// ==UserScript==
// @name Enhanced Osprey's Eye
// @namespace https://github.com/HomieWrecker/Osprey-s-Eye
// @version 2.0.2
// @description Custom Estimation Enguine
// @author Homiewrecker
// @match https://www.torn.com/profiles.php?XID=*
// @match https://www.torn.com/profiles.php*
// @match https://www.torn.com/factions.php*
// @match https://www.torn.com/joblist.php*
// @match https://www.torn.com/index.php?page=people*
// @match https://www.torn.com/pmarket.php
// @match https://www.torn.com/loader.php?sid=attack&user2ID=*
// @match https://www.torn.com/hospitalview.php*
// @match https://www.torn.com/companies.php*
// @match https://www.torn.com/bounties.php*
// @match https://www.torn.com/forums.php*
// @match https://www.torn.com/page.php?sid=hof*
// @match https://www.torn.com/messages.php*
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect tsc.diicot.cc
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// ========== Constants & Configuration ==========
const VERSION = '2.0';
const STORAGE_KEY_API = 'osprey_api_key';
const STORAGE_KEY_CONFIG = 'osprey_config';
const STORAGE_KEY_ESTIMATES = 'osprey_player_estimates';
const CACHE_EXPIRY = 12 * 60 * 60 * 1000; // 12 hours
// Default configuration options
const DEFAULT_CONFIG = {
showStatBreakdown: true,
colorScheme: 'dark',
estimationMethod: 'advanced',
uiPosition: 'top',
compactMode: false,
debugMode: false,
// Feature configuration
showInlineStats: true, // Show inline stat estimations for players
enableFairFightIndicator: true, // Show fair fight color indicators
showFFGauge: true, // Show FF gauge/arrow on profiles
storeSavedEstimates: true, // Store estimations for future reference
maxStoredEstimates: 500, // Limit stored estimates to prevent excessive storage usage
showEstimationBox: true, // Show the main estimation box
};
// Default styles - combined from both scripts
const STYLES = `
body {
--osprey-bg-color: #f0f0f0;
--osprey-alt-bg-color: #fff;
--osprey-border-color: #ccc;
--osprey-input-color: #ccc;
--osprey-text-color: #000;
--osprey-hover-color: #ddd;
--osprey-glow-color: #ffb6c1;
--osprey-excellent-color: #00cc00;
--osprey-good-color: #66cc00;
--osprey-fair-color: #cccc00;
--osprey-poor-color: #ff9900;
--osprey-very-poor-color: #ff0000;
}
body.dark-mode {
--osprey-bg-color: #333;
--osprey-alt-bg-color: #383838;
--osprey-border-color: #444;
--osprey-input-color: #504f4f;
--osprey-text-color: #ccc;
--osprey-hover-color: #555;
--osprey-glow-color: #ffb6c1;
}
.osprey-loader {
content: url(https://www.torn.com/images/v2/main/ajax-loader.gif);
}
body.dark-mode .osprey-loader {
content: url(https://www.torn.com/images/v2/main/ajax-loader-white.gif);
}
table.osprey-stat-table {
border-collapse: collapse;
width: 100%;
background-color: var(--osprey-bg-color);
color: var(--osprey-text-color);
}
table.osprey-stat-table th,
table.osprey-stat-table td {
padding: 4px;
border: 1px solid var(--osprey-border-color);
color: var(--osprey-text-color);
text-align: center;
}
table.osprey-stat-table th {
background-color: var(--osprey-bg-color);
color: var(--osprey-text-color);
border: 1px solid var(--osprey-border-color);
}
.osprey-stat-table>tbody>tr>td {
padding: 5px;
border: 1px solid var(--osprey-border-color);
}
.osprey-faction-spy {
margin-right: 2px;
margin-left: auto;
padding: 3px 5px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
color: var(--osprey-text-color);
text-wrap: nowrap;
}
.osprey-chain-spy {
display: flex;
align-items: center;
height: 0.7rem;
margin-left: 2px;
padding: 3px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
font-size: 0.6rem;
color: var(--osprey-text-color);
text-wrap: nowrap;
}
.osprey-company-spy {
display: inline;
margin-left: 5px;
padding: 3px 5px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
color: var(--osprey-text-color);
text-wrap: nowrap;
}
.osprey-faction-war {
display: flex;
justify-content: space-around;
align-items: center;
height: 15px;
padding: 3px;
background-color: var(--osprey-alt-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
color: var(--osprey-text-color);
text-wrap: nowrap;
vertical-align: middle;
}
.osprey-abroad-spy {
display: inline;
margin-left: 2px;
padding: 3px 4px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
color: var(--osprey-text-color);
text-wrap: nowrap;
}
.osprey-points-market-spy {
display: inline;
margin-left: 5px;
padding: 3px 5px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
font-size: 0.6rem;
color: var(--osprey-text-color);
text-wrap: nowrap;
vertical-align: middle;
}
.osprey-attack-spy {
display: flex;
align-items: center;
height: 0.44rem;
margin-left: 2px;
padding: 3px;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
font-size: 0.6rem;
color: var(--osprey-text-color);
text-wrap: nowrap;
}
.osprey-attack-mobile {
flex: none !important;
margin-right: 3px !important;
}
.osprey-accordion {
margin: 10px 0;
padding: 10px;
background-color: var(--osprey-bg-color);
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
}
.osprey-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
font-size: 1.2em;
font-weight: 700;
}
.osprey-header-username {
display: inline;
font-style: italic;
}
.osprey-setting-entry {
display: flex;
align-items: center;
gap: 5px;
margin-top: 10px;
margin-bottom: 5px;
}
.osprey-key-input {
width: 120px;
padding-left: 5px;
background-color: var(--osprey-input-color);
color: var(--osprey-text-color);
}
.osprey-button {
padding: 5px 10px;
transition: background-color 0.5s;
background-color: var(--osprey-bg-color);
cursor: pointer;
border: 1px solid var(--osprey-border-color);
border-radius: 5px;
color: var(--osprey-text-color);
}
.osprey-button:hover {
transition: background-color 0.5s;
background-color: var(--osprey-hover-color);
}
.osprey-blur {
filter: blur(3px);
transition: filter 2s;
}
.osprey-blur:focus,
.osprey-blur:active {
filter: blur(0);
transition-duration: 0.5s;
}
.osprey-glow {
animation: glow 1s infinite alternate;
border-width: 3px;
}
/* FF Gauge Styles */
.osprey-ff-gauge {
position: relative;
display: block;
padding: 0;
}
.osprey-vertical-line-low,
.osprey-vertical-line-high {
content: '';
position: absolute;
width: 2px;
height: 60%;
background-color: var(--osprey-border-color);
margin-left: -1px;
top: 20%;
}
.osprey-vertical-line-low {
left: calc(var(--arrow-width) / 2 + 33 * (100% - var(--arrow-width)) / 100);
}
.osprey-vertical-line-high {
left: calc(var(--arrow-width) / 2 + 66 * (100% - var(--arrow-width)) / 100);
}
.osprey-ff-arrow {
position: absolute;
transform: translate(-50%, -50%);
padding: 0;
top: 50%;
left: calc(var(--arrow-width) / 2 + var(--band-percent) * (100% - var(--arrow-width)) / 100);
width: var(--arrow-width);
object-fit: cover;
pointer-events: none;
}
@keyframes glow {
0% {
border-color: var(--osprey-border-color);
}
to {
border-color: var(--osprey-glow-color);
}
}
`;
// Add styles to page
GM_addStyle(STYLES);
// ========== Helper Classes & Utils ==========
// Storage utilities
class Storage {
constructor(prefix) {
this.prefix = prefix;
}
// Get value with key
get(key) {
return localStorage.getItem(`${this.prefix}-${key}`);
}
// Set value with key
set(key, value) {
localStorage.setItem(`${this.prefix}-${key}`, value);
}
// Get value as boolean
getBoolean(key) {
return this.get(key) === 'true';
}
// Get value as JSON
getJSON(key) {
const value = this.get(key);
return value === null ? null : JSON.parse(value);
}
// Set value as JSON
setJSON(key, value) {
this.set(key, JSON.stringify(value));
}
// Clear all storage with prefix
clear() {
let count = 0;
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(this.prefix)) {
keys.push(key);
}
}
for (const key of keys) {
localStorage.removeItem(key);
count++;
}
return count;
}
// Clear spy cache
clearCache() {
let count = 0;
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(`${this.prefix}-spy`)) {
keys.push(key);
}
}
for (const key of keys) {
localStorage.removeItem(key);
count++;
}
return count;
}
}
// Logging utilities
class Logger {
constructor() {
this.colors = {
info: '#05668D',
warn: '#EDDEA4',
error: '#ff0000',
debug: '#5C415D'
};
// Check if we're in TornPDA
const apikey = '###PDA-APIKEY###';
this.inPDA = apikey.includes('PDA-APIKEY') === false;
}
info(message, ...args) {
let prefix = '%c';
let style = `color: ${this.colors.info}`;
if (this.inPDA) {
args = args.map(a => JSON.stringify(a));
prefix = '';
style = '';
}
console.info(`${prefix}[Osprey's Eye] ${message}`, style, ...args);
}
warn(message, ...args) {
let prefix = '%c';
let style = `color: ${this.colors.warn}`;
if (this.inPDA) {
args = args.map(a => JSON.stringify(a));
prefix = '';
style = '';
}
console.warn(`${prefix}[Osprey's Eye] ${message}`, style, ...args);
}
error(message, ...args) {
let prefix = '%c';
let style = `color: ${this.colors.error}`;
if (this.inPDA) {
args = args.map(a => JSON.stringify(a));
prefix = '';
style = '';
}
console.error(`${prefix}[Osprey's Eye] ${message}`, style, ...args);
}
debug(message, ...args) {
// Only log debug messages if debug mode is enabled
if (!storage.getBoolean('debug-logs')) return;
let prefix = '%c';
let style = `color: ${this.colors.debug}`;
if (this.inPDA) {
args = args.map(a => JSON.stringify(a));
prefix = '';
style = '';
}
console.log(`${prefix}[Osprey's Eye] ${message}`, style, ...args);
}
}
// DOM utilities
class DOMHelpers {
// Wait for element to appear in DOM
static waitForElement(selector, timeout = 15000) {
return new Promise((resolve) => {
if (document.querySelectorAll(selector).length) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(() => {
if (document.querySelectorAll(selector).length) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, { childList: true, subtree: true });
if (timeout) {
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
}
});
}
// Get current user ID from page
static async getCurrentUserID() {
const settingsLink = await this.waitForElement(".settings-menu > .link > a:first-child", 15000);
if (!settingsLink) return "";
const match = settingsLink.href.match(/XID=(\d+)/);
return match?.[1] ?? "";
}
// Format number for display
static formatNumber(number, decimals = 2) {
return Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: decimals,
minimumFractionDigits: decimals
}).format(number);
}
// Format time difference
static formatTimeDiff(timestamp) {
const now = new Date().getTime();
const then = new Date(timestamp).getTime();
const diff = now - then;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) {
const remainingMonths = months % 12;
return `${years} year${years > 1 ? 's' : ''}, ${remainingMonths} month${remainingMonths > 1 ? 's' : ''}`;
} else if (months > 0) {
const remainingDays = days % 30;
return `${months} month${months > 1 ? 's' : ''}, ${remainingDays} day${remainingDays > 1 ? 's' : ''}`;
} else if (days > 0) {
const remainingHours = hours % 24;
return `${days} day${days > 1 ? 's' : ''}, ${remainingHours} hour${remainingHours > 1 ? 's' : ''}`;
} else if (hours > 0) {
const remainingMinutes = minutes % 60;
return `${hours} hour${hours > 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes > 1 ? 's' : ''}`;
} else {
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
}
}
// ========== API and Data Fetching ==========
class APIService {
constructor(storage, logger) {
this.storage = storage;
this.logger = logger;
// Check if we're in TornPDA
const apikey = '###PDA-APIKEY###';
this.inPDA = apikey.includes('PDA-APIKEY') === false;
// Setup XHR wrapper based on environment
this.httpRequest = this.inPDA ? this.pdaRequest : this.gmRequest;
}
// Make request for TornPDA
pdaRequest(details) {
this.logger.debug("Using PDA HTTP request");
if (details.method.toLowerCase() === "get") {
return PDA_httpGet(details.url)
.then(details.onload)
.catch(details.onerror || ((e) => console.error(e)));
} else if (details.method.toLowerCase() === "post") {
return PDA_httpPost(details.url, details.headers || {}, details.body || details.data || "")
.then(details.onload)
.catch(details.onerror || ((e) => console.error(e)));
}
}
// Make request for standard userscript managers
gmRequest(details) {
return (GM.xmlHttpRequest || GM_xmlhttpRequest)(details);
}
// Get API error message
getErrorMessage(code) {
switch (code) {
case 1: return "Invalid request";
case 2: return "Maintenance";
case 3: return "Invalid API Key";
case 4: return "Internal Error";
case 5: return "User Disabled";
case 6: return "Cached Only";
case 999: return "Service Down";
default: return "Unknown error";
}
}
// Fetch spy data for a user
async fetchSpy(userId) {
const cachedData = this.storage.getJSON(`spy-${userId}`);
const now = Date.now();
if (cachedData) {
const cachedTime = new Date(cachedData.insertedAt).getTime();
if (cachedData.insertedAt && now - cachedTime < CACHE_EXPIRY) {
this.logger.debug("Using cached spy data");
return Promise.resolve(cachedData);
}
this.logger.debug("Spy cache expired, fetching new data");
this.storage.setJSON(`spy-${userId}`, null);
}
const apiKey = this.storage.get("tsc-key") || "";
const requestData = {
apiKey: apiKey,
userId: userId
};
return new Promise((resolve, reject) => {
this.httpRequest({
method: "POST",
url: "https://tsc.diicot.cc/next",
timeout: 30000,
headers: {
"Authorization": "10000000-6000-0000-0009-000000000001",
"x-requested-with": "XMLHttpRequest",
"Content-Type": "application/json"
},
data: JSON.stringify(requestData),
onload: (response) => {
const data = JSON.parse(response.responseText);
// Save valid responses to cache
if (!("error" in data) && data.success) {
this.storage.setJSON(`spy-${userId}`, {
...data,
insertedAt: new Date().getTime()
});
}
resolve(data);
},
onerror: (error) => {
this.logger.debug("Error fetching spy data", requestData);
resolve({
error: true,
message: `Failed to fetch spy: ${error.statusText}`
});
},
onabort: () => {
resolve({
error: true,
message: "Request aborted"
});
},
ontimeout: () => {
resolve({
error: true,
message: "Request timed out"
});
}
});
});
}
// Fetch user data from Torn API
async fetchUserData() {
if (!this.storage.get("tsc-key")) {
return {
error: true,
message: "API Key not set"
};
}
const cachedData = this.storage.getJSON("user-data");
const now = Date.now();
if (cachedData) {
const cachedTime = new Date(cachedData.insertedAt).getTime();
if (cachedData.insertedAt && now - cachedTime < CACHE_EXPIRY) {
this.logger.debug("Using cached user data");
return cachedData;
}
this.logger.debug("User data cache expired, fetching new data");
this.storage.setJSON("user-data", null);
}
try {
const response = await fetch(
`https://api.torn.com/user/?selections=basic&key=${this.storage.get("tsc-key")}&comment=Osprey-Eye`
);
if (!response.ok) {
return {
error: true,
message: response.statusText
};
}
const data = await response.json();
if (data.error) {
return {
error: true,
message: data.error.error
};
}
// Save to cache
this.storage.setJSON("user-data", {
...data,
insertedAt: new Date().getTime()
});
return data;
} catch (error) {
return {
error: true,
message: error.message
};
}
}
// Calculate FF value from stats ratio
calculateFairFight(playerStats, enemyStats) {
// Get average values for calculations
const myAvgStats = typeof playerStats === 'object' ?
Math.floor((playerStats.low + playerStats.high) / 2) : playerStats;
const enemyAvgStats = typeof enemyStats === 'object' ?
Math.floor((enemyStats.low + enemyStats.high) / 2) : enemyStats;
if (!myAvgStats || !enemyAvgStats) {
return {
percentage: 100,
color: '#888888',
description: 'Unknown',
ratio: 1
};
}
// Calculate ratio
const ratio = myAvgStats / enemyAvgStats;
// Calculate fair fight percentage using Torn's formula (approximated)
let fairFightPercentage;
if (ratio >= 4) {
fairFightPercentage = 25; // Minimum fair fight (25%)
} else if (ratio <= 0.25) {
fairFightPercentage = 100; // Maximum fair fight (100%)
} else {
// Calculate fair fight percentage based on ratio
// Approximation of Torn's formula, may need tweaking
if (ratio > 1) {
fairFightPercentage = 100 - (75 * (ratio - 1) / 3);
} else {
fairFightPercentage = 100;
}
}
// Round to integer
fairFightPercentage = Math.round(fairFightPercentage);
// Determine color and description based on fair fight percentage
let color;
let description;
if (fairFightPercentage >= 95) {
color = '#00cc00'; // Bright green - excellent
description = 'Excellent';
} else if (fairFightPercentage >= 75) {
color = '#66cc00'; // Green - very good
description = 'Very Good';
} else if (fairFightPercentage >= 60) {
color = '#cccc00'; // Yellow - good
description = 'Good';
} else if (fairFightPercentage >= 45) {
color = '#ff9900'; // Orange - fair
description = 'Fair';
} else if (fairFightPercentage >= 35) {
color = '#ff6600'; // Dark orange - poor
description = 'Poor';
} else {
color = '#ff0000'; // Red - very poor
description = 'Very Poor';
}
return {
percentage: fairFightPercentage,
color,
description,
ratio
};
}
// Convert FF value to gauge position
ffToGaugePercent(ff) {
// FF gauge has 3 key areas: low (1-2), medium (2-4), high (4+)
// This maps to gauge ranges: 0-33%, 33-66%, 66-100%
const lowFF = 2;
const highFF = 4;
const lowMidPercent = 33;
const midHighPercent = 66;
const maxFF = 8; // Clip high values for display
ff = Math.min(ff, maxFF);
let percent;
if (ff < lowFF) {
percent = (ff - 1) / (lowFF - 1) * lowMidPercent;
} else if (ff < highFF) {
percent = (((ff - lowFF) / (highFF - lowFF)) * (midHighPercent - lowMidPercent)) + lowMidPercent;
} else {
percent = (((ff - highFF) / (maxFF - highFF)) * (100 - midHighPercent)) + midHighPercent;
}
return percent;
}
// Get FF arrow image based on position
getFFArrowImage(percent) {
const blueArrow = "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/blue-arrow.svg";
const greenArrow = "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/green-arrow.svg";
const redArrow = "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/red-arrow.svg";
if (percent < 33) {
return blueArrow; // Low FF (75-100%)
} else if (percent < 66) {
return greenArrow; // Medium FF (50-75%)
} else {
return redArrow; // High FF (25-50%)
}
}
}
// ========== Stats Estimation ==========
class StatsEstimator {
constructor(storage, logger) {
this.storage = storage;
this.logger = logger;
}
// Basic estimate based on account age
basicEstimate(ageDays) {
if (!ageDays || ageDays < 1) return { low: 0, high: 0 };
// Adjusted algorithm with diminishing returns for older accounts
// and more realistic progression rates
let baseMultiplierLow = 150000;
let baseMultiplierHigh = 400000;
// Apply scaling factors based on account age
if (ageDays > 2000) {
baseMultiplierLow = 120000;
baseMultiplierHigh = 350000;
} else if (ageDays > 1000) {
baseMultiplierLow = 135000;
baseMultiplierHigh = 380000;
}
// Calculate ranges
const low = Math.floor(ageDays * baseMultiplierLow);
const high = Math.floor(ageDays * baseMultiplierHigh);
return { low, high };
}
// Advanced estimate using account age and other factors
advancedEstimate(ageDays, level = null, networth = null) {
const base = this.basicEstimate(ageDays);
// Apply adjustments if we have level information
if (level) {
const levelMultiplier = 1 + (Math.log10(level) / 10);
base.low = Math.floor(base.low * levelMultiplier);
base.high = Math.floor(base.high * levelMultiplier);
}
// Apply networth adjustments if available
if (networth) {
// Wealthy players might have higher stats due to gym upgrades
const networthAdjustment = Math.min(1.2, Math.max(1, Math.log10(networth/1000000) / 10));
base.high = Math.floor(base.high * networthAdjustment);
}
return base;
}
// Format the estimate range as a string
formatEstimate(estimate) {
return `${DOMHelpers.formatNumber(estimate.low)} - ${DOMHelpers.formatNumber(estimate.high)}`;
}
// Format the estimate as single number (average) - useful for inline display
formatEstimateCompact(estimate) {
const avg = Math.floor((estimate.low + estimate.high) / 2);
return DOMHelpers.formatNumber(avg);
}
// Generate a stat breakdown (estimates of individual stats)
generateStatBreakdown(totalStats) {
// Calculate average total stat
const avgTotal = Math.floor((totalStats.low + totalStats.high) / 2);
// Generate breakdown distribution (approximately equal for now)
// In a more advanced implementation, this could analyze activity patterns
const statBreakdown = {
strength: {
low: Math.floor(totalStats.low * 0.24),
high: Math.floor(totalStats.high * 0.28)
},
speed: {
low: Math.floor(totalStats.low * 0.23),
high: Math.floor(totalStats.high * 0.26)
},
defense: {
low: Math.floor(totalStats.low * 0.24),
high: Math.floor(totalStats.high * 0.27)
},
dexterity: {
low: Math.floor(totalStats.low * 0.22),
high: Math.floor(totalStats.high * 0.26)
}
};
return statBreakdown;
}
// Format spy data for display
formatSpyForDisplay(spyData) {
const { estimate, statInterval } = spyData.spy;
let spyText = DOMHelpers.formatNumber(estimate.stats, 1);
let tooltipText = `Estimate: ${DOMHelpers.formatNumber(estimate.stats, 2)} (${DOMHelpers.formatTimeDiff(new Date(estimate.lastUpdated))})`;
// Add interval data if available
if (statInterval && statInterval.battleScore) {
spyText = `${DOMHelpers.formatNumber(BigInt(statInterval.min), 1)} - ${DOMHelpers.formatNumber(BigInt(statInterval.max), 1)}`;
tooltipText += `<br>Interval: ${DOMHelpers.formatNumber(BigInt(statInterval.min), 2)} - ${DOMHelpers.formatNumber(BigInt(statInterval.max), 2)} (${DOMHelpers.formatTimeDiff(new Date(statInterval.lastUpdated))})<br>Battle Score: ${DOMHelpers.formatNumber(statInterval.battleScore, 2)}`;
}
return { spyText, tooltipText };
}
// Format spy data for faction page
formatSpyForFaction(spyData) {
const { estimate, statInterval } = spyData.spy;
let longTextInterval = "";
const longTextEstimate = `Estimate: ${DOMHelpers.formatNumber(estimate.stats)}`;
let toolTipText = `Estimate: ${new Date(estimate.lastUpdated).toLocaleDateString()}`;
// Add interval and fair fight if available
if (statInterval && statInterval.battleScore) {
longTextInterval = `${DOMHelpers.formatNumber(BigInt(statInterval.min))} - ${DOMHelpers.formatNumber(BigInt(statInterval.max))} / FF: ${statInterval.fairFight}`;
toolTipText += `<br>Interval: ${new Date(statInterval.lastUpdated).toLocaleDateString()}`;
}
return { longTextInterval, longTextEstimate, toolTipText };
}
// Get FF HTML for display
getFFHTML(fairFight) {
return `<span style="color:${fairFight.color}; font-weight:bold;">${fairFight.percentage}%</span>`;
}
// Save player estimate to storage
savePlayerEstimate(playerId, estimate, playerName = null) {
if (!this.storage.getBoolean("storeSavedEstimates")) return false;
const estimateData = {
totalStats: estimate,
playerName: playerName,
lastUpdated: Date.now()
};
try {
this.storage.setJSON(`player-${playerId}`, estimateData);
return true;
} catch (e) {
this.logger.error("Failed to save player estimate", e);
return false;
}
}
}
// ========== Module Implementation ==========
class PageModule {
constructor(name, description, handler, enabledByDefault = true) {
this.name = name;
this.description = description;
this.handler = handler;
this.enabledByDefault = enabledByDefault;
this.logger = new Logger();
this.storage = new Storage('osprey');
this.api = new APIService(this.storage, this.logger);
this.estimator = new StatsEstimator(this.storage, this.logger);
}
// Check if module should run
async shouldRun() {
// Check if module is enabled in settings
const enabled = this.storage.get(`module-${this.name}`) ??
(this.enabledByDefault ? 'true' : 'false');
if (enabled !== 'true') {
this.logger.debug(`Module ${this.name} is disabled, skipping`);
return false;
}
return true;
}
// Start the module
async start() {
try {
if (await this.shouldRun()) {
this.logger.info(`Starting module: ${this.name}`);
await this.handler(this);
this.logger.debug(`Module ${this.name} started successfully`);
}
} catch (error) {
this.logger.error(`Error running module ${this.name}:`, error);
}
}
}
// ========== Initialize ==========
const storage = new Storage('osprey');
const logger = new Logger();
// Ensure default settings are set
if (!storage.get(STORAGE_KEY_CONFIG)) {
storage.setJSON(STORAGE_KEY_CONFIG, DEFAULT_CONFIG);
}
// ========== UI Components ==========
class UIComponents {
constructor(storage, logger, api, estimator) {
this.storage = storage;
this.logger = logger;
this.api = api;
this.estimator = estimator;
}
// Create tooltip with stat breakdown
createStatBreakdownTooltip(element, statEstimate, playerId, playerName) {
// Remove any existing tooltips
const existingTooltip = document.getElementById('osprey-tooltip');
if (existingTooltip) {
existingTooltip.remove();
}
// Generate breakdown
const breakdown = this.estimator.generateStatBreakdown(statEstimate);
// Create tooltip
const tooltip = document.createElement('div');
tooltip.id = 'osprey-tooltip';
tooltip.style.position = 'absolute';
tooltip.style.zIndex = '9999';
tooltip.style.backgroundColor = '#1e2129';
tooltip.style.border = '2px solid #00bfff';
tooltip.style.borderRadius = '8px';
tooltip.style.padding = '12px';
tooltip.style.color = '#fff';
tooltip.style.boxShadow = '0 2px 10px rgba(0,0,0,0.3)';
tooltip.style.width = '220px';
tooltip.style.fontSize = '12px';
tooltip.innerHTML = `
<div style="font-weight:bold; font-size:14px; margin-bottom:8px; color:#00bfff;">
${playerName} - Stat Estimate
</div>
<div style="margin-bottom:6px;">
<span style="font-weight:bold;">Total:</span>
<span style="color:#66a3ff; float:right;">${this.estimator.formatEstimate(statEstimate)}</span>
</div>
<div style="margin-bottom:6px;">
<span style="font-weight:bold;">Strength:</span>
<span style="color:#ff6666; float:right;">${this.estimator.formatEstimate(breakdown.strength)}</span>
</div>
<div style="margin-bottom:6px;">
<span style="font-weight:bold;">Defense:</span>
<span style="color:#66cc66; float:right;">${this.estimator.formatEstimate(breakdown.defense)}</span>
</div>
<div style="margin-bottom:6px;">
<span style="font-weight:bold;">Speed:</span>
<span style="color:#ffcc00; float:right;">${this.estimator.formatEstimate(breakdown.speed)}</span>
</div>
<div style="margin-bottom:6px;">
<span style="font-weight:bold;">Dexterity:</span>
<span style="color:#cc66ff; float:right;">${this.estimator.formatEstimate(breakdown.dexterity)}</span>
</div>
<div style="color:#999; font-size:10px; margin-top:10px; text-align:center;">
Osprey's Eye Estimation
</div>
`;
// Position tooltip near the element
const rect = element.getBoundingClientRect();
tooltip.style.left = `${rect.left}px`;
tooltip.style.top = `${rect.bottom + 5}px`;
// Add to document
document.body.appendChild(tooltip);
// Close tooltip when clicking outside
const closeTooltip = (e) => {
if (e.target !== element && !tooltip.contains(e.target)) {
tooltip.remove();
document.removeEventListener('click', closeTooltip);
}
};
// Use timeout to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', closeTooltip);
}, 100);
}
// Add estimation box to profile
addEstimationBox(container, stats, verified, breakdown) {
if (!this.storage.getBoolean("showEstimationBox")) {
this.logger.debug("Estimation box disabled in config");
return;
}
// Look for the best place to insert the estimation box
const targetSelectors = [
'.profile-buttons',
'.user-profile-buttons',
'.profile-status',
'.basic-info',
'.user-profile-info'
];
let targetElement = null;
for (const selector of targetSelectors) {
const element = container.querySelector(selector);
if (element) {
targetElement = element;
break;
}
}
if (!targetElement) {
// Fallback - use the container itself
targetElement = container;
}
// Check if we already added an estimation box
if (container.querySelector('#osprey-estimate-box')) {
this.logger.debug("Estimation box already exists, not adding again");
return;
}
// Create the estimation box
const estimateBox = document.createElement('div');
estimateBox.id = 'osprey-estimate-box';
estimateBox.className = 'osprey-estimate-box';
estimateBox.style.marginTop = '15px';
estimateBox.style.marginBottom = '15px';
estimateBox.style.padding = '12px';
estimateBox.style.backgroundColor = '#1e2129';
estimateBox.style.border = '1px solid #3c5875';
estimateBox.style.borderRadius = '5px';
estimateBox.style.color = '#fff';
estimateBox.style.fontSize = '13px';
let content = `
<div style="font-weight:bold; color:#66a3ff; margin-bottom:8px; display:flex; align-items:center;">
<span style="flex:1;">Osprey's Eye - Stats Estimate</span>
${verified ? '<span style="color:#00cc00; font-size:11px; margin-left:5px;">✓ Verified</span>' : ''}
</div>
<div style="margin-bottom:8px;">
<span style="font-weight:bold;">Total Battle Stats: </span>
<span style="color:#66a3ff;">${this.estimator.formatEstimate(stats)}</span>
</div>
`;
// Add breakdown if available
if (breakdown) {
content += `
<div style="display:flex; margin-top:10px;">
<div style="flex:1;">
<div style="color:#ff6666; margin-bottom:4px;">
<span style="font-weight:bold;">STR: </span>
<span>${this.estimator.formatEstimate(breakdown.strength)}</span>
</div>
<div style="color:#66cc66; margin-bottom:4px;">
<span style="font-weight:bold;">DEF: </span>
<span>${this.estimator.formatEstimate(breakdown.defense)}</span>
</div>
</div>
<div style="flex:1;">
<div style="color:#ffcc00; margin-bottom:4px;">
<span style="font-weight:bold;">SPD: </span>
<span>${this.estimator.formatEstimate(breakdown.speed)}</span>
</div>
<div style="color:#cc66ff; margin-bottom:4px;">
<span style="font-weight:bold;">DEX: </span>
<span>${this.estimator.formatEstimate(breakdown.dexterity)}</span>
</div>
</div>
</div>
`;
}
// Add fair fight details if user has personal data available
const userData = this.storage.getJSON('user-data');
if (userData && userData.stats) {
const userTotalStats = userData.strength + userData.speed + userData.defense + userData.dexterity;
const userStats = { low: userTotalStats, high: userTotalStats };
const fairFight = this.api.calculateFairFight(userStats, stats);
content += `
<div style="margin-top:10px; padding-top:10px; border-top:1px solid #3c5875;">
<span style="font-weight:bold;">Fair Fight: </span>
<span style="color:${fairFight.color};">${fairFight.percentage}% (${fairFight.description})</span>
</div>
`;
}
estimateBox.innerHTML = content;
// Add to container
try {
targetElement.appendChild(estimateBox);
} catch (error) {
this.logger.error("Failed to add estimation box", error);
}
}
// Add inline stats and estimate button
addInlineStatsAndButton(profileContainer, statEstimate, xid) {
try {
if (!this.storage.getBoolean("showInlineStats")) {
this.logger.debug("Inline stats disabled in config");
return;
}
// Find the profile picture area - several possible selectors
const pfpSelectors = [
'.user-profile-pic',
'.profile-pic',
'.user-info img',
'.player-info img',
'.avatar',
'.profile-container img',
// Add more if needed
];
// Also check for parent containers
const containerSelectors = [
'.basic-information',
'.user-info',
'.user-profile-info',
'.profile-container',
'.avatar-container',
'.user-data'
];
// First try direct picture selectors
let targetElement = null;
for (const selector of pfpSelectors) {
const element = profileContainer.querySelector(selector);
if (element) {
targetElement = element.parentElement;
break;
}
}
// If not found, try container selectors
if (!targetElement) {
for (const selector of containerSelectors) {
const element = profileContainer.querySelector(selector);
if (element) {
targetElement = element;
break;
}
}
}
// Fallback to any valid container
if (!targetElement) {
targetElement = profileContainer.querySelector('.profile-status, .status, .info');
}
if (!targetElement) {
this.logger.error("Could not find profile picture area");
return;
}
// Check if we already added the inline stats
if (targetElement.querySelector('.osprey-inline-profile-stats')) {
return;
}
// Create container for inline stats + button
const inlineContainer = document.createElement('div');
inlineContainer.className = 'osprey-inline-profile-stats';
inlineContainer.style.marginTop = '10px';
inlineContainer.style.padding = '5px 0';
inlineContainer.style.borderTop = '1px solid rgba(255, 255, 255, 0.1)';
inlineContainer.style.color = '#fff';
inlineContainer.style.fontSize = '13px';
inlineContainer.style.textAlign = 'center';
// Add stat estimation
const statsText = document.createElement('div');
statsText.style.fontWeight = 'bold';
statsText.style.color = '#66a3ff';
statsText.style.marginBottom = '5px';
statsText.innerHTML = `Stats: ${this.estimator.formatEstimateCompact(statEstimate)}`;
inlineContainer.appendChild(statsText);
// Add the estimate button
const estimateBtn = document.createElement('button');
estimateBtn.className = 'osprey-profile-estimate-btn';
estimateBtn.textContent = 'Detailed Estimate';
estimateBtn.style.backgroundColor = '#1e2129';
estimateBtn.style.border = '1px solid #00bfff';
estimateBtn.style.color = '#00bfff';
estimateBtn.style.padding = '4px 8px';
estimateBtn.style.fontSize = '11px';
estimateBtn.style.borderRadius = '3px';
estimateBtn.style.cursor = 'pointer';
estimateBtn.style.transition = 'background-color 0.2s';
// Add hover effect
estimateBtn.onmouseover = () => {
estimateBtn.style.backgroundColor = '#2a3241';
};
estimateBtn.onmouseout = () => {
estimateBtn.style.backgroundColor = '#1e2129';
};
// Add click handler to show breakdown tooltip
estimateBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// Get player name if available
const playerInfo = this.storage.getJSON(`player-${xid}`);
let playerName = playerInfo?.playerName;
// If no stored name, try to get from page
if (!playerName) {
const nameElement = profileContainer.querySelector('.user-name, .name, .player-name, .title');
if (nameElement) {
playerName = nameElement.textContent.trim();
} else {
playerName = `Player ${xid}`;
}
}
this.createStatBreakdownTooltip(estimateBtn, statEstimate, xid, playerName);
};
inlineContainer.appendChild(estimateBtn);
// Add to page
targetElement.appendChild(inlineContainer);
} catch (e) {
this.logger.error(`Error adding inline stats: ${e.message}`);
}
}
// Add FF gauge to a UI element
addFFGauge(element, playerId, fairFightValue) {
if (!this.storage.getBoolean("showFFGauge")) {
return;
}
try {
// Set up the gauge container
element.classList.add('osprey-ff-gauge');
element.style.setProperty("--arrow-width", "20px");
// Calculate the position percentage
const percent = this.api.ffToGaugePercent(fairFightValue);
element.style.setProperty("--band-percent", percent);
// Remove any existing components
$(element).find('.osprey-ff-arrow, .osprey-vertical-line-low, .osprey-vertical-line-high').remove();
// Add vertical lines
$(element).append($("<div>", { class: "osprey-vertical-line-low" }));
$(element).append($("<div>", { class: "osprey-vertical-line-high" }));
// Add arrow with appropriate color
const arrowSrc = this.api.getFFArrowImage(percent);
const img = $('<img>', {
src: arrowSrc,
class: "osprey-ff-arrow",
});
$(element).append(img);
// Add tooltip with more detail
const ffValue = fairFightValue.toFixed(2);
const ffDetail = `Fair Fight Value: ${ffValue}
(${percent < 33 ? 'High' : percent < 66 ? 'Medium' : 'Low'} reward)`;
element.title = ffDetail;
} catch (error) {
this.logger.error(`Error adding FF gauge: ${error.message}`);
}
}
// Show settings modal
showSettingsModal() {
// Remove any existing modal
const existingModal = document.getElementById('osprey-settings-modal');
if (existingModal) {
existingModal.remove();
}
// Create modal container
const modal = document.createElement('div');
modal.id = 'osprey-settings-modal';
modal.style.position = 'fixed';
modal.style.top = '0';
modal.style.left = '0';
modal.style.width = '100%';
modal.style.height = '100%';
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
modal.style.zIndex = '10000';
modal.style.display = 'flex';
modal.style.justifyContent = 'center';
modal.style.alignItems = 'center';
// Create modal content
const modalContent = document.createElement('div');
modalContent.style.backgroundColor = '#1e2129';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '600px';
modalContent.style.width = '80%';
modalContent.style.maxHeight = '80vh';
modalContent.style.overflowY = 'auto';
modalContent.style.color = '#fff';
// Create header
const header = document.createElement('div');
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.style.marginBottom = '20px';
header.style.borderBottom = '1px solid #3c5875';
header.style.paddingBottom = '10px';
const title = document.createElement('h2');
title.textContent = "Osprey's Eye Settings";
title.style.margin = '0';
title.style.color = '#66a3ff';
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.backgroundColor = 'transparent';
closeButton.style.border = 'none';
closeButton.style.color = '#fff';
closeButton.style.fontSize = '24px';
closeButton.style.cursor = 'pointer';
closeButton.onclick = () => {
modal.remove();
};
header.appendChild(title);
header.appendChild(closeButton);
modalContent.appendChild(header);
// Create API key section
const apiKeySection = document.createElement('div');
apiKeySection.style.marginBottom = '20px';
const apiKeyLabel = document.createElement('label');
apiKeyLabel.textContent = 'API Key:';
apiKeyLabel.style.display = 'block';
apiKeyLabel.style.marginBottom = '5px';
apiKeyLabel.style.fontWeight = 'bold';
const apiKeyInput = document.createElement('input');
apiKeyInput.type = 'text';
apiKeyInput.value = this.storage.get('tsc-key') || '';
apiKeyInput.className = 'osprey-blur';
apiKeyInput.style.width = '100%';
apiKeyInput.style.padding = '8px';
apiKeyInput.style.marginBottom = '10px';
apiKeyInput.style.backgroundColor = '#333';
apiKeyInput.style.color = '#fff';
apiKeyInput.style.border = '1px solid #3c5875';
apiKeyInput.style.borderRadius = '3px';
const saveApiKeyButton = document.createElement('button');
saveApiKeyButton.textContent = 'Save API Key';
saveApiKeyButton.className = 'osprey-button';
saveApiKeyButton.onclick = () => {
this.storage.set('tsc-key', apiKeyInput.value);
this.showMessage(modalContent, 'API Key saved!', 'success');
};
apiKeySection.appendChild(apiKeyLabel);
apiKeySection.appendChild(apiKeyInput);
apiKeySection.appendChild(saveApiKeyButton);
modalContent.appendChild(apiKeySection);
// Create feature toggles section
const featureSection = document.createElement('div');
featureSection.style.marginBottom = '20px';
const featureTitle = document.createElement('h3');
featureTitle.textContent = 'Features';
featureTitle.style.borderBottom = '1px solid #3c5875';
featureTitle.style.paddingBottom = '5px';
featureSection.appendChild(featureTitle);
// Get current config
const config = this.storage.getJSON(STORAGE_KEY_CONFIG) || DEFAULT_CONFIG;
// Add toggle for each feature
const features = [
{ id: 'showInlineStats', name: 'Show Inline Stats' },
{ id: 'enableFairFightIndicator', name: 'Enable Fair Fight Indicator' },
{ id: 'showFFGauge', name: 'Show FF Gauge on Profiles' },
{ id: 'showEstimationBox', name: 'Show Estimation Box' },
{ id: 'storeSavedEstimates', name: 'Store Saved Estimates' },
{ id: 'debugMode', name: 'Debug Mode' }
];
features.forEach(feature => {
const toggleContainer = document.createElement('div');
toggleContainer.style.display = 'flex';
toggleContainer.style.alignItems = 'center';
toggleContainer.style.marginBottom = '10px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `toggle-${feature.id}`;
checkbox.checked = config[feature.id] || false;
checkbox.style.marginRight = '10px';
const label = document.createElement('label');
label.htmlFor = `toggle-${feature.id}`;
label.textContent = feature.name;
checkbox.onchange = () => {
config[feature.id] = checkbox.checked;
this.storage.setJSON(STORAGE_KEY_CONFIG, config);
};
toggleContainer.appendChild(checkbox);
toggleContainer.appendChild(label);
featureSection.appendChild(toggleContainer);
});
modalContent.appendChild(featureSection);
// Add cache management section
const cacheSection = document.createElement('div');
cacheSection.style.marginBottom = '20px';
const cacheTitle = document.createElement('h3');
cacheTitle.textContent = 'Cache Management';
cacheTitle.style.borderBottom = '1px solid #3c5875';
cacheTitle.style.paddingBottom = '5px';
cacheSection.appendChild(cacheTitle);
const clearCacheButton = document.createElement('button');
clearCacheButton.textContent = 'Clear Spy Cache';
clearCacheButton.className = 'osprey-button';
clearCacheButton.style.marginRight = '10px';
clearCacheButton.onclick = () => {
const count = this.storage.clearCache();
this.showMessage(modalContent, `Cleared ${count} cached items!`, 'success');
};
const clearAllButton = document.createElement('button');
clearAllButton.textContent = 'Reset All Settings';
clearAllButton.className = 'osprey-button';
clearAllButton.style.backgroundColor = '#c62828';
clearAllButton.onclick = () => {
if (confirm('Are you sure you want to reset all settings? This will clear your API key and all cached data.')) {
const count = this.storage.clear();
this.storage.setJSON(STORAGE_KEY_CONFIG, DEFAULT_CONFIG);
this.showMessage(modalContent, `Reset all settings! Cleared ${count} items.`, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
}
};
cacheSection.appendChild(clearCacheButton);
cacheSection.appendChild(clearAllButton);
modalContent.appendChild(cacheSection);
// Add about section
const aboutSection = document.createElement('div');
const aboutTitle = document.createElement('h3');
aboutTitle.textContent = 'About';
aboutTitle.style.borderBottom = '1px solid #3c5875';
aboutTitle.style.paddingBottom = '5px';
aboutSection.appendChild(aboutTitle);
const aboutText = document.createElement('p');
aboutText.innerHTML = `Osprey's Eye v${VERSION}<br>
Created by Homiewrecker [2687547]<br>
An enhanced version combining features from multiple stat estimators and fair fight calculators.`;
aboutSection.appendChild(aboutText);
modalContent.appendChild(aboutSection);
// Add modal to body
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Close modal when clicking outside
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove();
}
};
}
// Show message in settings modal
showMessage(container, message, type = 'info') {
// Remove any existing message
const existingMessage = container.querySelector('.osprey-message');
if (existingMessage) {
existingMessage.remove();
}
// Create message
const messageEl = document.createElement('div');
messageEl.className = 'osprey-message';
messageEl.textContent = message;
messageEl.style.padding = '10px';
messageEl.style.marginTop = '10px';
messageEl.style.borderRadius = '3px';
messageEl.style.textAlign = 'center';
// Set colors based on type
if (type === 'success') {
messageEl.style.backgroundColor = '#2e7d32';
messageEl.style.color = '#fff';
} else if (type === 'error') {
messageEl.style.backgroundColor = '#c62828';
messageEl.style.color = '#fff';
} else {
messageEl.style.backgroundColor = '#1565c0';
messageEl.style.color = '#fff';
}
// Add to container
container.appendChild(messageEl);
// Remove after a delay
setTimeout(() => {
messageEl.remove();
}, 3000);
}
}
// ========== Page Modules ==========
// Helper function to extract player ID from element
function getPlayerIdFromElement(element) {
// Try to find player ID in href
const profileLinks = element.querySelectorAll('a[href*="profiles.php"]');
for (const link of profileLinks) {
const match = link.href.match(/XID=(\d+)/);
if (match) {
return match[1];
}
}
// Try parent elements if link is found but without ID
if (element.parentElement) {
const parentLinks = element.parentElement.querySelectorAll('a[href*="profiles.php"]');
for (const link of parentLinks) {
const match = link.href.match(/XID=(\d+)/);
if (match) {
return match[1];
}
}
}
return null;
}
// Module: Profile Page
const profileModule = new PageModule(
'ProfilePage',
'Shows stat estimates on profile pages',
async function(module) {
const profileContainer = await DOMHelpers.waitForElement('.profile-container', 15000);
if (!profileContainer) {
module.logger.warn('Could not find profile container');
return;
}
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('XID');
if (!targetId) {
module.logger.error('Could not find target ID in URL');
return;
}
// Fetch spy data
const spyData = await module.api.fetchSpy(targetId);
if ('error' in spyData || spyData.success !== true) {
module.logger.error('Failed to fetch spy data', spyData);
return;
}
// Create UI components
const ui = new UIComponents(module.storage, module.logger, module.api, module.estimator);
// Process the stats
const { estimate } = spyData.spy;
const statEstimate = { low: estimate.stats, high: estimate.stats * 1.1 }; // Add a small range
const breakdown = module.estimator.generateStatBreakdown(statEstimate);
// Add the estimation box
ui.addEstimationBox(profileContainer, statEstimate, false, breakdown);
// Add inline stats and estimate button
ui.addInlineStatsAndButton(profileContainer, statEstimate, targetId);
// Save to storage for future use
const playerName = document.querySelector('.user-name, .name')?.textContent.trim();
module.estimator.savePlayerEstimate(targetId, statEstimate, playerName);
}
);
// Module: Attack Page
const attackModule = new PageModule(
'AttackPage',
'Shows fair fight estimations on attack pages',
async function(module) {
// Extract the target ID from the URL
const urlMatch = window.location.href.match(/user2ID=(\d+)/);
if (!urlMatch) {
module.logger.warn('Could not find target ID in attack URL');
return;
}
const targetId = urlMatch[1];
// Create or find a place to show FF info
const h4List = document.querySelectorAll('h4');
let infoContainer = null;
for (const h4 of h4List) {
if (h4.textContent === 'Attacking') {
infoContainer = document.createElement('div');
infoContainer.className = 'osprey-ff-info';
infoContainer.style.margin = '10px 0';
infoContainer.style.padding = '10px';
infoContainer.style.backgroundColor = '#1e2129';
infoContainer.style.border = '1px solid #3c5875';
infoContainer.style.borderRadius = '5px';
infoContainer.style.color = '#fff';
infoContainer.style.textAlign = 'center';
infoContainer.innerHTML = '<img class="osprey-loader">';
h4.parentNode.parentNode.after(infoContainer);
break;
}
}
if (!infoContainer) {
module.logger.warn('Could not find place to add FF info');
return;
}
// Fetch spy data
const spyData = await module.api.fetchSpy(targetId);
if ('error' in spyData || spyData.success !== true) {
module.logger.error('Failed to fetch spy data', spyData);
infoContainer.textContent = 'Error: Could not fetch target stats';
return;
}
// Get user's own stats
const userData = await module.api.fetchUserData();
if ('error' in userData) {
module.logger.error('Failed to fetch user data', userData);
infoContainer.innerHTML = `
<strong>Target Estimate:</strong> ${module.estimator.formatEstimateCompact(spyData.spy.estimate.stats)}<br>
<em>Set your API key to see Fair Fight calculations</em>
`;
return;
}
// Calculate FF
const userTotalStats = userData.strength + userData.speed + userData.defense + userData.dexterity;
const userStats = { low: userTotalStats, high: userTotalStats };
const targetStats = { low: spyData.spy.estimate.stats, high: spyData.spy.estimate.stats * 1.1 };
const fairFight = module.api.calculateFairFight(userStats, targetStats);
// Update the info container
infoContainer.innerHTML = `
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<div style="text-align:left;">
<strong>Your Stats:</strong> ${DOMHelpers.formatNumber(userTotalStats)}
</div>
<div style="text-align:right;">
<strong>Target Est:</strong> ${module.estimator.formatEstimateCompact(targetStats)}
</div>
</div>
<div style="margin:10px 0; height:20px; background-color:#333; border-radius:10px; overflow:hidden; position:relative;">
<div style="width:${fairFight.percentage}%; height:100%; background-color:${fairFight.color};"></div>
</div>
<div style="font-weight:bold; color:${fairFight.color};">
Fair Fight: ${fairFight.percentage}% (${fairFight.description})
</div>
`;
}
);
// Module: Faction Page
const factionModule = new PageModule(
'FactionPage',
'Shows stat estimates on faction pages',
async function(module) {
// Check if we're on a faction page
if (!window.location.href.includes('factions.php?step=profile')) {
return;
}
// Find faction member list
const memberList = await DOMHelpers.waitForElement('.faction-info-wrap .table-body > li:nth-child(1)', 15000);
if (!memberList) {
module.logger.warn('Could not find faction member list');
return;
}
// Process all members
const tableRows = document.querySelectorAll('.faction-info-wrap .table-body > li');
for (const row of tableRows) {
// Find member box
const memberBox = row.querySelector('[class*="userInfoBox"]');
if (!memberBox) continue;
// Adjust style for space
memberBox.style.width = '169px';
memberBox.style.overflow = 'hidden';
memberBox.style.textOverflow = 'ellipsis';
// Find profile link
const profileLink = memberBox.querySelector('a[href*="profiles.php"]');
if (!profileLink) continue;
// Extract ID
const playerId = profileLink.href.split('XID=')[1];
if (!playerId) continue;
// Fetch spy data
module.api.fetchSpy(playerId).then(spyData => {
if ('error' in spyData || spyData.success !== true) {
module.logger.warn(`Failed to fetch spy for ${playerId}`, spyData);
return;
}
// Format for display
const { spyText, tooltipText } = module.estimator.formatSpyForDisplay(spyData);
// Create and add the spy element
const spyElement = document.createElement('div');
spyElement.className = 'osprey-faction-spy';
spyElement.textContent = spyText;
spyElement.title = tooltipText;
memberBox.after(spyElement);
});
}
}
);
// Module: Faction War Page
const factionWarModule = new PageModule(
'FactionWarPage',
'Shows stat estimates on faction war pages',
async function(module) {
// Check if we're on a faction war page
if (!window.location.href.includes('factions.php?step=') ||
(!window.location.href.includes('/war/rank') && !window.location.href.includes('/war/raid'))) {
return;
}
// Function to process members
const processMembers = async () => {
const memberElements = document.querySelectorAll('div.member.icons.left');
if (!memberElements.length) {
module.logger.warn('Could not find faction war members');
return;
}
for (const memberElement of memberElements) {
// Find profile link
const profileLink = memberElement.querySelector('a[href*="profiles.php"]');
if (!profileLink) continue;
// Extract ID
const playerId = profileLink.href.split('XID=')[1];
if (!playerId) continue;
// Skip if already processed
if (memberElement.parentElement.querySelector('.osprey-faction-war')) continue;
// Create placeholder
const spyElement = document.createElement('div');
spyElement.className = 'osprey-faction-war';
spyElement.innerHTML = '<img class="osprey-loader">';
memberElement.parentElement.appendChild(spyElement);
// Fetch spy data
module.api.fetchSpy(playerId).then(spyData => {
// Clear loader
spyElement.innerHTML = '';
if ('error' in spyData || spyData.success !== true) {
module.logger.warn(`Failed to fetch spy for ${playerId}`, spyData);
spyElement.textContent = 'Error: ' + (spyData.message || module.api.getErrorMessage(spyData.code));
return;
}
// Format for display
const { longTextInterval, longTextEstimate, toolTipText } = module.estimator.formatSpyForFaction(spyData);
// Set content
spyElement.title = toolTipText;
spyElement.appendChild(document.createElement('span')).textContent = longTextEstimate;
if (longTextInterval) {
spyElement.appendChild(document.createElement('span')).textContent = longTextInterval;
}
});
}
};
// Process immediately
await processMembers();
// Set up observer for dynamic content
const observer = new MutationObserver(() => {
processMembers();
});
observer.observe(document.body, { childList: true, subtree: true });
}
);
// Module: FF Gauge
const ffGaugeModule = new PageModule(
'FFGauge',
'Shows fair fight gauge/arrow on various pages',
async function(module) {
// Skip if FF gauge is disabled
if (!module.storage.getBoolean('showFFGauge')) {
return;
}
// Create UI components
const ui = new UIComponents(module.storage, module.logger, module.api, module.estimator);
// Function to find elements with user profiles on the page
const findProfileElements = () => {
const elements = [];
const possibleSelectors = [
'.honor-text-wrap',
'.member',
'.employee',
'.name',
'.listed',
'.target',
'.last-poster',
'.starter',
'.last-post',
'.poster',
'[class^="userInfoBox__"]'
];
for (const selector of possibleSelectors) {
const found = document.querySelectorAll(selector);
if (found.length) {
elements.push(...found);
}
}
return elements;
};
// Function to process elements and add FF gauges
const processElements = async (elements) => {
// Filter out elements that already have a gauge
elements = elements.filter(e => !e.classList.contains('osprey-ff-gauge'));
// Extract player IDs
const elementMap = [];
for (const element of elements) {
const playerId = getPlayerIdFromElement(element);
if (playerId) {
elementMap.push({ element, playerId });
}
}
// Batch fetch spy data
for (const { element, playerId } of elementMap) {
// First try to get from cache
const cachedSpyData = module.storage.getJSON(`spy-${playerId}`);
if (cachedSpyData && cachedSpyData.spy) {
const fairFightValue = cachedSpyData.spy.estimate.fairFight ||
(cachedSpyData.spy.statInterval ? cachedSpyData.spy.statInterval.fairFight : null);
if (fairFightValue) {
ui.addFFGauge(element, playerId, fairFightValue);
}
}
// Fetch fresh data if needed
module.api.fetchSpy(playerId).then(spyData => {
if ('error' in spyData || !spyData.success) return;
const fairFightValue = spyData.spy.estimate.fairFight ||
(spyData.spy.statInterval ? spyData.spy.statInterval.fairFight : null);
if (fairFightValue) {
ui.addFFGauge(element, playerId, fairFightValue);
}
});
}
};
// Initial processing
const initialElements = findProfileElements();
await processElements(initialElements);
// Observer for dynamic content
const observer = new MutationObserver(() => {
const newElements = findProfileElements();
processElements(newElements);
});
observer.observe(document.body, { childList: true, subtree: true });
}
);
// Module: Settings Button
const settingsModule = new PageModule(
'SettingsButton',
'Adds a settings button to the page',
async function(module) {
// Find a good place to add the settings button
const areas = [
'.header-wrapper .delimiter',
'.overview .delimiter',
'#top-page-links-list'
];
let targetElement = null;
for (const selector of areas) {
const element = document.querySelector(selector);
if (element) {
targetElement = element;
break;
}
}
if (!targetElement) {
// Try to add to the bottom of the page
targetElement = document.querySelector('.content-wrapper');
}
if (!targetElement) {
module.logger.warn('Could not find a place to add settings button');
return;
}
// Create the button
const settingsButton = document.createElement('div');
settingsButton.innerHTML = `
<span style="cursor:pointer; margin: 0 5px; color:#66a3ff;">
<i class="fas fa-eye"></i> Osprey
</span>
`;
settingsButton.style.display = 'inline-block';
settingsButton.style.marginLeft = '5px';
// Add click handler
settingsButton.onclick = () => {
const ui = new UIComponents(module.storage, module.logger, module.api, module.estimator);
ui.showSettingsModal();
};
// Add to page
targetElement.appendChild(settingsButton);
}
);
// Start all modules
const modules = [
profileModule,
attackModule,
factionModule,
factionWarModule,
ffGaugeModule,
settingsModule
];
modules.forEach(module => module.start());
// Log initialization
logger.info(`Enhanced Osprey's Eye v${VERSION} initialized`);
})();