Enhanced features for IQRPG including notifications and alerts
当前为
// ==UserScript==
// @name IQRPG Enhanced
// @namespace https://iqrpg.com/
// @version 1.0.2
// @description Enhanced features for IQRPG including notifications and alerts
// @author Sanjin
// @license MIT
// @match https://iqrpg.com/game.html
// @match https://www.iqrpg.com/game.html
// @match http://iqrpg.com/game.html
// @match http://www.iqrpg.com/game.html
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// ============================================
// Configuration & State Management
// ============================================
const DEFAULT_CONFIG = {
notifications: {
globalEvents: {
sound: true,
desktop: true
},
clan: {
sound: true,
desktop: true,
watchtower: true,
clanChatGlobals: true
},
actionBonus: {
sound: true,
desktop: true
},
bossSpawn: {
sound: true,
desktop: true
},
tradeAlert: {
sound: true,
desktop: true,
sellingKeywords: [],
buyingKeywords: []
},
gatheringEvents: {
woodcutting: {
sound: true,
desktop: true
},
quarrying: {
sound: true,
desktop: true
},
mining: {
sound: true,
desktop: true
}
},
message: {
sound: true,
desktop: true
},
autos: {
sound: true,
desktop: true,
threshold: 100, // Alert when autos reach this number
repeatCount: 1, // Number of times to repeat alert while under threshold (1 = no repeat)
repeatInterval: 1 // Seconds between repeat alerts (0 = immediate repeats, no delay)
},
potions: {
sound: true,
desktop: true,
threshold: 100, // Alert when potions reach this number
repeatCount: 1, // Number of times to repeat alert while under threshold (1 = no repeat)
repeatInterval: 1 // Seconds between repeat alerts (0 = immediate repeats, no delay)
},
dungeon: {
sound: true,
desktop: true,
onlyWhenAllKeysComplete: false // Only notify when all dungeon keys are completed
},
mastery: {
sound: true,
desktop: true
},
land: {
sound: true,
desktop: true
},
skills: {
sound: true,
desktop: true
},
itemDrop: {
sound: true,
desktop: true,
itemKeywords: []
},
abyssBattles: {
sound: true,
desktop: true
}
},
sounds: {
globalEvent: 'https://audio.jukehost.co.uk/qyoNau6faKvNTt2NVyZ3Mcr8WDw1ueiv',
actionBonus: 'https://audio.jukehost.co.uk/wHRlgKNZdfDnXfLsoTqDjcluHENngS4b',
bossSpawn: 'https://audio.jukehost.co.uk/zL9Qk16xdxOKyJfMDUPwliTfsKAVJW6n',
rareBoss: 'https://audio.jukehost.co.uk/eFJ1jyr4Hf1tujq4E64lPXIGI0y3dYeY',
tradeAlert: 'https://audio.jukehost.co.uk/kbWqZVtOxyOB3Whq0o5Em6LLOGJjP2CY',
gatheringEvent: 'https://audio.jukehost.co.uk/yuTJytEhB55P1iFzAVwgX7wU4sr1h7cp',
message: 'https://audio.jukehost.co.uk/s3Nil94O25qt8bKUZp3CrL3z5YzIS1OE',
autos: 'https://audio.jukehost.co.uk/WKlTn6GvA0e3UGCAvW3IwaJj7vT7VBmL',
dungeon: 'https://audio.jukehost.co.uk/ccIvfx6WghmSymNDuZEeuGFFpMS84CY5',
mastery: 'https://audio.jukehost.co.uk/9DW0A6lxLQtstNHZwuiNoGcJciEJ5rdh',
land: 'https://audio.jukehost.co.uk/ccIvfx6WghmSymNDuZEeuGFFpMS84CY5',
skills: 'https://audio.jukehost.co.uk/9DW0A6lxLQtstNHZwuiNoGcJciEJ5rdh',
clanWatchtower: 'https://audio.jukehost.co.uk/wf7tdKTnzx1Kb0wQHqLDab9pfhEHk130',
clanGlobals: 'https://audio.jukehost.co.uk/qyoNau6faKvNTt2NVyZ3Mcr8WDw1ueiv',
itemDrop: 'https://audio.jukehost.co.uk/mlm4l3BkKXDT54NaogHDmt9buhGO4Sa3',
abyssBattles: 'https://audio.jukehost.co.uk/8WLa35FnrtIYNbBVhoiVdHKeFyLjwp0n',
potions: 'https://audio.jukehost.co.uk/DsgIJBKLrrZHdx7R3XE1kirUcMJKaDjo',
volume: 1.0 // Volume level (0.0 to 1.0)
},
gui: {
enabled: true
}
};
const CONFIG = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
// Constants
const CONSTANTS = {
DELAYS: {
BUTTON_CREATION: 100,
RETRY_SHORT: 500,
NOTIFICATION_AUTO_CLOSE: 10000,
SAVE_FEEDBACK: 2000
},
STRINGS: {
PREMIUM_STORE: 'premium store',
SOUND_GLOBAL: 'globalEvent',
SOUND_ACTION_BONUS: 'actionBonus',
SOUND_BOSS_SPAWN: 'bossSpawn',
SOUND_RARE_BOSS: 'rareBoss',
SOUND_TRADE_ALERT: 'tradeAlert',
SOUND_GATHERING_EVENT: 'gatheringEvent',
SOUND_MESSAGE: 'message',
SOUND_AUTOS: 'autos',
SOUND_DUNGEON: 'dungeon',
SOUND_MASTERY: 'mastery',
SOUND_LAND: 'land',
SOUND_SKILLS: 'skills',
SOUND_CLAN_WATCHTOWER: 'clanWatchtower',
SOUND_CLAN_GLOBALS: 'clanGlobals',
SOUND_ITEM_DROP: 'itemDrop',
SOUND_ABYSS_BATTLES: 'abyssBattles',
SOUND_POTIONS: 'potions',
MESSAGE_TYPE_MSG: 'msg',
MESSAGE_TYPE_ACTION_BONUS: 'actionBonus',
DATA_TYPE_GLOBAL: 'global',
DATA_TYPE_EVENT_GLOBAL: 'eventGlobal',
DATA_TYPE_CLAN_GLOBAL: 'clanGlobal',
DATA_TYPE_PM_FROM: 'pm-from',
CHANNEL_TRADE: 'trade',
CHANNEL_CLAN_PREFIX: 'clan-'
},
// Patterns to detect selling/buying intent in trade messages (case-insensitive)
TRADE_PATTERNS: {
SELLING: ['selling', 'wts', 'sell', 's>', '{s}'],
BUYING: ['buying', 'wtb', 'buy', 'b>', '{b}']
},
BOSS_SPAWN_PATTERNS: {
DEMON_HORN: ['demon horn', 'bosses start appearing']
},
// Mapping of item keys to their proper display names
ITEM_NAME_MAP: {
// Only dungeon keys need special names - all other items will be auto-converted
'dungeon_key_1': 'Goblin Cave Key',
'dungeon_key_2': 'Mountain Pass Key',
'dungeon_key_3': 'Desolate Tombs Key',
'dungeon_key_4': 'Dragonkin Lair Key',
'dungeon_key_5': 'Sunken Ruins Key',
'dungeon_key_6': 'Abandoned Tower Key',
'dungeon_key_7': 'Haunted Cells Key',
'dungeon_key_8': 'Hall of Dragons Key',
'dungeon_key_9': 'The Vault Key',
'dungeon_key_10': 'The Treasury Key'
},
SELECTORS: {
FIXED_TOP: '.fixed-top',
SECTION_3: '.section-3'
}
};
// Helper function to preload all sounds
function preloadAllSounds() {
AudioManager.preloadSound(CONFIG.sounds.globalEvent, CONSTANTS.STRINGS.SOUND_GLOBAL);
AudioManager.preloadSound(CONFIG.sounds.actionBonus, CONSTANTS.STRINGS.SOUND_ACTION_BONUS);
AudioManager.preloadSound(CONFIG.sounds.bossSpawn, CONSTANTS.STRINGS.SOUND_BOSS_SPAWN);
AudioManager.preloadSound(CONFIG.sounds.rareBoss, CONSTANTS.STRINGS.SOUND_RARE_BOSS);
AudioManager.preloadSound(CONFIG.sounds.tradeAlert, CONSTANTS.STRINGS.SOUND_TRADE_ALERT);
AudioManager.preloadSound(CONFIG.sounds.gatheringEvent, CONSTANTS.STRINGS.SOUND_GATHERING_EVENT);
AudioManager.preloadSound(CONFIG.sounds.message, CONSTANTS.STRINGS.SOUND_MESSAGE);
AudioManager.preloadSound(CONFIG.sounds.autos, CONSTANTS.STRINGS.SOUND_AUTOS);
AudioManager.preloadSound(CONFIG.sounds.dungeon, CONSTANTS.STRINGS.SOUND_DUNGEON);
AudioManager.preloadSound(CONFIG.sounds.mastery, CONSTANTS.STRINGS.SOUND_MASTERY);
AudioManager.preloadSound(CONFIG.sounds.land, CONSTANTS.STRINGS.SOUND_LAND);
AudioManager.preloadSound(CONFIG.sounds.skills, CONSTANTS.STRINGS.SOUND_SKILLS);
AudioManager.preloadSound(CONFIG.sounds.clanWatchtower, CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER);
AudioManager.preloadSound(CONFIG.sounds.clanGlobals, CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS);
AudioManager.preloadSound(CONFIG.sounds.itemDrop, CONSTANTS.STRINGS.SOUND_ITEM_DROP);
AudioManager.preloadSound(CONFIG.sounds.abyssBattles, CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES);
AudioManager.preloadSound(CONFIG.sounds.potions, CONSTANTS.STRINGS.SOUND_POTIONS);
}
// Load saved configuration
function loadConfig() {
let saved = null;
try {
const savedStr = localStorage.getItem('iqrpg_enhanced_config');
if (savedStr) {
saved = JSON.parse(savedStr);
}
} catch (e) {
// Silently fail - use default config
}
if (saved) {
// Deep merge instead of shallow assign
CONFIG.notifications.globalEvents = {
...CONFIG.notifications.globalEvents,
...(saved.notifications?.globalEvents || {})
};
CONFIG.notifications.clan = {
...CONFIG.notifications.clan,
...(saved.notifications?.clan || {})
};
CONFIG.notifications.actionBonus = {
...CONFIG.notifications.actionBonus,
...(saved.notifications?.actionBonus || {})
};
CONFIG.notifications.bossSpawn = {
...CONFIG.notifications.bossSpawn,
...(saved.notifications?.bossSpawn || {})
};
CONFIG.notifications.tradeAlert = {
...CONFIG.notifications.tradeAlert,
...(saved.notifications?.tradeAlert || {})
};
// Handle gathering events with backward compatibility
if (saved.notifications?.gatheringEvents) {
// Check if old format exists (top-level sound/desktop)
if (saved.notifications.gatheringEvents.sound !== undefined &&
!saved.notifications.gatheringEvents.woodcutting) {
// Old format detected - migrate to new structure
const oldConfig = saved.notifications.gatheringEvents;
CONFIG.notifications.gatheringEvents.woodcutting = {
sound: oldConfig.sound,
desktop: oldConfig.desktop
};
CONFIG.notifications.gatheringEvents.quarrying = {
sound: oldConfig.sound,
desktop: oldConfig.desktop
};
CONFIG.notifications.gatheringEvents.mining = {
sound: oldConfig.sound,
desktop: oldConfig.desktop
};
} else {
// New format - merge each event type
CONFIG.notifications.gatheringEvents.woodcutting = {
...CONFIG.notifications.gatheringEvents.woodcutting,
...(saved.notifications.gatheringEvents.woodcutting || {})
};
CONFIG.notifications.gatheringEvents.quarrying = {
...CONFIG.notifications.gatheringEvents.quarrying,
...(saved.notifications.gatheringEvents.quarrying || {})
};
CONFIG.notifications.gatheringEvents.mining = {
...CONFIG.notifications.gatheringEvents.mining,
...(saved.notifications.gatheringEvents.mining || {})
};
}
}
CONFIG.notifications.message = {
...CONFIG.notifications.message,
...(saved.notifications?.message || {})
};
CONFIG.notifications.autos = {
...CONFIG.notifications.autos,
...(saved.notifications?.autos || {})
};
CONFIG.notifications.dungeon = {
...CONFIG.notifications.dungeon,
...(saved.notifications?.dungeon || {})
};
CONFIG.notifications.mastery = {
...CONFIG.notifications.mastery,
...(saved.notifications?.mastery || {})
};
CONFIG.notifications.land = {
...CONFIG.notifications.land,
...(saved.notifications?.land || {})
};
CONFIG.notifications.skills = {
...CONFIG.notifications.skills,
...(saved.notifications?.skills || {})
};
CONFIG.notifications.itemDrop = {
...CONFIG.notifications.itemDrop,
...(saved.notifications?.itemDrop || {})
};
CONFIG.notifications.abyssBattles = {
...CONFIG.notifications.abyssBattles,
...(saved.notifications?.abyssBattles || {})
};
CONFIG.notifications.potions = {
...CONFIG.notifications.potions,
...(saved.notifications?.potions || {})
};
CONFIG.sounds = {
...CONFIG.sounds,
...(saved.sounds || {})
};
CONFIG.gui = {
...CONFIG.gui,
...(saved.gui || {})
};
}
}
// Save configuration
function saveConfig() {
try {
localStorage.setItem('iqrpg_enhanced_config', JSON.stringify(CONFIG));
} catch (e) {
// Silently fail
}
}
// Initialize config
loadConfig();
// ============================================
// Audio Management
// ============================================
const AudioManager = {
audioCache: new Map(),
// Preload and cache audio files
preloadSound(url, name) {
if (!url || url === 'default') return null;
if (this.audioCache.has(name)) {
return this.audioCache.get(name);
}
try {
const audio = new Audio(url);
audio.preload = 'auto';
this.audioCache.set(name, audio);
return audio;
} catch (e) {
return null;
}
},
// Play a sound
playSound(name, url = null) {
const volume = CONFIG.sounds.volume !== undefined ? CONFIG.sounds.volume : 1.0;
if (url) {
const audio = this.preloadSound(url, name);
if (audio) {
audio.volume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1
audio.currentTime = 0;
audio.play().catch(() => {
// Silently fail
});
}
} else if (this.audioCache.has(name)) {
const audio = this.audioCache.get(name);
audio.volume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1
audio.currentTime = 0;
audio.play().catch(() => {
// Silently fail
});
}
}
};
// Preload default sounds
preloadAllSounds();
// ============================================
// Notification System
// ============================================
// Helper function to convert item key to display name
// e.g., "undead_heart" -> "Undead Heart", "vial_of_orc_blood" -> "Vial Of Orc Blood"
function formatItemName(itemKey) {
if (!itemKey) return itemKey;
// Split by underscore, capitalize first letter of each word, join with spaces
return itemKey
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
// Helper function to clean game messages (remove HTML tags and game item formatting codes)
function cleanGameMessage(msg) {
if (!msg) return '';
// Remove HTML tags
let cleaned = msg.replace(/<[^>]*>/g, '');
// Remove game item formatting codes: [item:name] -> name
cleaned = cleaned.replace(/\[item:([^\]]+)\]/g, (match, itemKey) => {
// First check if this item key has a special mapping (only dungeon keys)
const properName = CONSTANTS.ITEM_NAME_MAP[itemKey];
if (properName) {
return properName;
}
// Auto-convert the item key to a display name
// e.g., "undead_heart" -> "Undead Heart", "runic_leather" -> "Runic Leather"
return formatItemName(itemKey);
});
// Remove game item formatting codes: [--type: {...}--]
// Parse JSON to extract item details, especially for trinkets
cleaned = cleaned.replace(/\[--([^:]+):([\s\S]*?)--\]/g, (match, itemType, jsonData) => {
// Try to parse the JSON data
try {
const data = JSON.parse(jsonData.trim());
// Handle trinkets specifically
if (itemType === 'trinket' && data.type !== undefined && data.tier !== undefined) {
// type: 1 = Battling Trinket, 2 = Gathering Trinket
const trinketType = data.type === 1 ? 'Battling' : data.type === 2 ? 'Gathering' : 'Trinket';
return `${trinketType} Trinket (T${data.tier})`;
}
// Handle jewels specifically
if (itemType === 'jewel' && data.gemType !== undefined && data.rarity !== undefined) {
// gemType: 1 = Sapphire, 2 = Ruby, 3 = Emerald, 4 = Diamond
const gemTypeMap = {
1: 'Sapphire',
2: 'Ruby',
3: 'Emerald',
4: 'Diamond'
};
const gemName = gemTypeMap[data.gemType] || 'Jewel';
return `${gemName} Jewel (T${data.rarity})`;
}
// For other item types, try to extract name from JSON if available
if (data.name) {
return data.name;
} else if (data.itemName) {
return data.itemName;
} else if (data.title) {
return data.title;
}
} catch (e) {
// JSON parsing failed, fall through to default handling
}
// Fallback: return the item type if we can't parse or construct a name
return itemType || '';
});
// Remove game item formatting codes: [name] -> name
// This catches any remaining bracket patterns that weren't handled above
cleaned = cleaned.replace(/\[([^\]]+)\]/g, (match, itemKey) => {
// First check if this is a direct key in the map (dungeon keys)
let properName = CONSTANTS.ITEM_NAME_MAP[itemKey];
if (properName) {
return properName;
}
// If it looks like an item key (contains underscores), auto-convert it
if (itemKey.includes('_')) {
return formatItemName(itemKey);
}
// If it's already a display name (no underscores), return as-is
return itemKey;
});
// Clean up any extra whitespace
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
// Helper function to convert emoji to data URL icon
function emojiToIcon(emoji) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const size = 128; // Icon size
canvas.width = size;
canvas.height = size;
// Set font size to render emoji large
ctx.font = `${size * 0.8}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Draw emoji on canvas
ctx.fillText(emoji, size / 2, size / 2);
// Convert to data URL
return canvas.toDataURL('image/png');
} catch (e) {
return null;
}
}
const NotificationManager = {
// Request notification permission
async requestPermission() {
if ('Notification' in window && Notification.permission === 'default') {
await Notification.requestPermission();
}
},
// Show desktop notification
showDesktopNotification(title, message, icon = null) {
if (!('Notification' in window)) {
return;
}
if (Notification.permission === 'granted') {
const options = {
body: message,
icon: icon || null,
badge: icon || null,
tag: 'iqrpg-enhanced',
requireInteraction: false
};
const notification = new Notification(title, options);
// Auto-close after delay
setTimeout(() => {
notification.close();
}, CONSTANTS.DELAYS.NOTIFICATION_AUTO_CLOSE);
// Handle click to focus window
notification.onclick = () => {
window.focus();
notification.close();
};
} else if (Notification.permission === 'default') {
this.requestPermission();
}
},
// Unified notification method
notify(title, message, options = {}) {
const { sound = false, desktop = false, soundName = null, soundUrl = null, emoji = null } = options;
// Play sound if enabled
if (sound && soundName) {
AudioManager.playSound(soundName, soundUrl);
}
// Show desktop notification if enabled
if (desktop) {
// Convert emoji to icon if provided
const icon = emoji ? emojiToIcon(emoji) : null;
this.showDesktopNotification(title, message, icon);
}
}
};
// Request notification permission on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
NotificationManager.requestPermission();
});
} else {
NotificationManager.requestPermission();
}
// ============================================
// WebSocket Interception
// ============================================
const WebSocketInterceptor = {
originalWebSocket: null,
interceptedSockets: new Set(),
init() {
try {
// Intercept WebSocket constructor
this.originalWebSocket = window.WebSocket;
const self = this;
window.WebSocket = function(url, protocols) {
const ws = new self.originalWebSocket(url, protocols);
self.interceptSocket(ws);
return ws;
};
// Copy static properties
Object.setPrototypeOf(window.WebSocket, self.originalWebSocket);
Object.defineProperty(window.WebSocket, 'CONNECTING', {
value: self.originalWebSocket.CONNECTING,
writable: false
});
Object.defineProperty(window.WebSocket, 'OPEN', {
value: self.originalWebSocket.OPEN,
writable: false
});
Object.defineProperty(window.WebSocket, 'CLOSING', {
value: self.originalWebSocket.CLOSING,
writable: false
});
Object.defineProperty(window.WebSocket, 'CLOSED', {
value: self.originalWebSocket.CLOSED,
writable: false
});
} catch (e) {
// Silently fail
}
},
interceptSocket(ws) {
if (this.interceptedSockets.has(ws)) return;
this.interceptedSockets.add(ws);
// Intercept messages
const originalAddEventListener = ws.addEventListener.bind(ws);
ws.addEventListener = function(type, listener, options) {
if (type === 'message') {
const wrappedListener = function(event) {
// Call original listener first
listener(event);
// Capture and log message
let messageData;
try {
messageData = JSON.parse(event.data);
} catch (e) {
messageData = typeof event.data === 'object' && event.data !== null && !Array.isArray(event.data)
? event.data
: { raw: event.data, _parseError: true };
}
// Process message for notifications
if (messageData && typeof messageData === 'object') {
MessageProcessor.process(messageData);
}
};
return originalAddEventListener(type, wrappedListener, options);
}
return originalAddEventListener(type, listener, options);
};
// Also intercept onmessage property
Object.defineProperty(ws, 'onmessage', {
get: function() {
return this._onmessage;
},
set: function(listener) {
this._onmessage = listener;
if (listener) {
ws.addEventListener('message', listener);
}
},
configurable: true
});
}
};
// ============================================
// Message Processing
// ============================================
// Track last action bonus to avoid duplicate notifications
let lastActionBonus = null;
const MessageProcessor = {
process(message) {
if (!message || typeof message !== 'object') return;
// Handle different message types
switch (message.type) {
case CONSTANTS.STRINGS.MESSAGE_TYPE_MSG:
this.handleMessageType(message);
break;
case CONSTANTS.STRINGS.MESSAGE_TYPE_ACTION_BONUS:
this.handleActionBonus(message);
break;
default:
// Unhandled message types - silently ignore
break;
}
},
handleMessageType(message) {
if (!message.data || !message.data.type) {
return;
}
// Handle global events (crafting notifications)
if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_GLOBAL) {
const config = CONFIG.notifications.globalEvents;
if (config.sound || config.desktop) {
const rawMsg = message.data.msg || 'A global event occurred';
const cleanMsg = cleanGameMessage(rawMsg);
NotificationManager.notify(
'Global',
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_GLOBAL,
soundUrl: CONFIG.sounds.globalEvent,
emoji: '🎉'
}
);
}
}
// Handle eventGlobal messages (gathering events and boss spawns)
if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_EVENT_GLOBAL) {
const rawMsg = message.data.msg || '';
// Clean message (remove HTML tags, game formatting codes, normalize whitespace)
const cleanMsg = cleanGameMessage(rawMsg);
const msgLower = cleanMsg.toLowerCase();
// Check if this is a boss spawn message (demon horn indicates multiple bosses spawning)
const isDemonHorn = CONSTANTS.BOSS_SPAWN_PATTERNS.DEMON_HORN.some(pattern =>
msgLower.includes(pattern)
);
if (isDemonHorn) {
// Check if this is a rare boss (rarity-4 or higher)
// Check rawMsg for text-rarity-4, text-rarity-5, etc.
const isRareBoss = /text-rarity-[4-9]|text-rarity-\d{2,}/.test(rawMsg);
// Always use bossSpawn config for toggles, but different sound URLs based on rarity
const config = CONFIG.notifications.bossSpawn;
const soundName = isRareBoss ? CONSTANTS.STRINGS.SOUND_RARE_BOSS : CONSTANTS.STRINGS.SOUND_BOSS_SPAWN;
const soundUrl = isRareBoss ? CONFIG.sounds.rareBoss : CONFIG.sounds.bossSpawn;
if (config.sound || config.desktop) {
NotificationManager.notify(
'Boss Event',
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: soundName,
soundUrl: soundUrl,
emoji: '👹'
}
);
}
} else {
// Gathering event (woodcutting, quarrying, mining)
// Determine specific event type and emoji
let eventType;
let eventEmoji;
let eventConfigKey = null;
if (msgLower.includes('spirit tree') || msgLower.includes('forest')) {
eventType = 'Woodcutting Event';
eventEmoji = '🌲';
eventConfigKey = 'woodcutting';
} else if (msgLower.includes('sinkhole') || msgLower.includes('ground shakes')) {
eventType = 'Quarrying Event';
eventEmoji = '🪨';
eventConfigKey = 'quarrying';
} else if (msgLower.includes('meteorite')) {
eventType = 'Mining Event';
eventEmoji = '☄️';
eventConfigKey = 'mining';
}
// Only notify if this specific event type is enabled
if (eventConfigKey) {
const config = CONFIG.notifications.gatheringEvents[eventConfigKey];
if (config && (config.sound || config.desktop)) {
NotificationManager.notify(
eventType,
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_GATHERING_EVENT,
soundUrl: CONFIG.sounds.gatheringEvent,
emoji: eventEmoji
}
);
}
}
}
}
// Handle private messages
if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_PM_FROM) {
const config = CONFIG.notifications.message;
if (config.sound || config.desktop) {
const rawMsg = message.data.msg || '';
const cleanMsg = cleanGameMessage(rawMsg);
const username = message.data.username || 'Unknown';
NotificationManager.notify(
`Message from ${username}`,
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_MESSAGE,
soundUrl: CONFIG.sounds.message,
emoji: '💬'
}
);
}
}
// Handle trade channel messages
if (message.channel === CONSTANTS.STRINGS.CHANNEL_TRADE && message.data.type === CONSTANTS.STRINGS.MESSAGE_TYPE_MSG) {
this.handleTradeMessage(message);
}
// Handle clan chat global messages
if (message.channel && message.channel.startsWith(CONSTANTS.STRINGS.CHANNEL_CLAN_PREFIX) &&
message.data.type === CONSTANTS.STRINGS.DATA_TYPE_CLAN_GLOBAL) {
const config = CONFIG.notifications.clan;
const rawMsg = message.data.msg || '';
const cleanMsg = cleanGameMessage(rawMsg);
// Check if this is a watchtower event
const isWatchtower = rawMsg.toLowerCase().includes('watchtower');
if (isWatchtower) {
// Handle watchtower events
if (config.watchtower && (config.sound || config.desktop)) {
NotificationManager.notify(
'Clan Watchtower',
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER,
soundUrl: CONFIG.sounds.clanWatchtower,
emoji: '🐉'
}
);
}
} else {
// Handle other clan global events
if (config.clanChatGlobals && (config.sound || config.desktop)) {
NotificationManager.notify(
'Clan Global',
cleanMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS,
soundUrl: CONFIG.sounds.clanGlobals,
emoji: '🎉'
}
);
}
}
}
},
handleTradeMessage(message) {
const config = CONFIG.notifications.tradeAlert;
if (!config.sound && !config.desktop) return;
const msgText = (message.data.msg || '').toLowerCase();
const username = message.data.username || 'Unknown';
// Normalize patterns to lowercase for case-insensitive matching
const sellingPatterns = CONSTANTS.TRADE_PATTERNS.SELLING.map(p => p.toLowerCase());
const buyingPatterns = CONSTANTS.TRADE_PATTERNS.BUYING.map(p => p.toLowerCase());
// Check if message contains selling indicators (case-insensitive)
const isSelling = sellingPatterns.some(pattern => msgText.includes(pattern));
// Check if message contains buying indicators (case-insensitive)
const isBuying = buyingPatterns.some(pattern => msgText.includes(pattern));
// Get keywords to check based on message type
let keywordsToCheck = [];
let tradeType = '';
if (isSelling && config.sellingKeywords && config.sellingKeywords.length > 0) {
keywordsToCheck = config.sellingKeywords;
tradeType = 'Selling';
} else if (isBuying && config.buyingKeywords && config.buyingKeywords.length > 0) {
keywordsToCheck = config.buyingKeywords;
tradeType = 'Buying';
}
if (keywordsToCheck.length === 0) return;
// Check if any of the keywords match (case-insensitive)
const matchedKeywords = keywordsToCheck.filter(keyword => {
// Normalize keyword to lowercase for case-insensitive matching
const kw = (keyword || '').toLowerCase().trim();
return kw && msgText.includes(kw);
});
if (matchedKeywords.length > 0) {
const rawMsg = message.data.msg || '';
const cleanMsg = cleanGameMessage(rawMsg);
const notificationMsg = `${username}: ${cleanMsg}`;
NotificationManager.notify(
'Trade Alert',
notificationMsg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_TRADE_ALERT,
soundUrl: CONFIG.sounds.tradeAlert,
emoji: '💱'
}
);
}
},
handleActionBonus(message) {
const config = CONFIG.notifications.actionBonus;
if ((config.sound || config.desktop) && message.data && message.data.actionBonus) {
const actionBonus = message.data.actionBonus;
// Only notify if the action bonus has actually changed (not on initial load)
if (lastActionBonus !== null && actionBonus !== lastActionBonus) {
const msg = `The action bonus is now active whilst ${actionBonus}`;
NotificationManager.notify(
'Action Bonus Changed',
msg,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_ACTION_BONUS,
soundUrl: CONFIG.sounds.actionBonus,
emoji: '✨'
}
);
}
// Always update the tracked value
lastActionBonus = actionBonus;
}
}
};
// ============================================
// DOM Observation for UI-Based Events
// ============================================
const DOMMonitor = {
checkInterval: null,
lastAutosCount: null,
autosRepeatCount: 0,
isUnderThreshold: false,
lastAutosNotificationTime: null,
lastDungeonText: null,
pendingDungeonText: null,
lastMasteryLevel: null,
lastMasteryName: null,
lastRaidTimer: null,
skillLevels: new Map(),
processedItemDrops: new Set(),
lastAbyssBattlesCount: null,
lastPotionCounts: new Map(),
potionRepeatCounts: new Map(),
potionUnderThreshold: new Map(),
lastPotionNotificationTimes: new Map(),
init() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.startObserving());
} else {
this.startObserving();
}
},
startObserving() {
// Check periodically for autos changes, dungeon completion, mastery level increases, land/raid completion, skill level increases, and item drops
this.checkInterval = setInterval(() => {
this.checkForAutos();
this.checkForDungeonCompletion();
this.checkForMasteryLevelIncrease();
this.checkForLandCompletion();
this.checkForSkillLevelIncrease();
this.checkForItemDrops();
this.checkForAbyssBattlesCompletion();
this.checkForPotionThreshold();
}, 1000); // Check every second
},
checkForAutos() {
const config = CONFIG.notifications.autos;
if (!config.sound && !config.desktop) return;
const autoElement = document.querySelector('.action-timer__text');
if (!autoElement) return;
const text = autoElement.textContent || '';
const match = text.match(/Autos Remaining:\s*(\d+)/i);
if (match) {
const currentAutos = parseInt(match[1], 10);
const threshold = config.threshold || 0;
const repeatCount = config.repeatCount || 1;
const wasUnderThreshold = this.isUnderThreshold;
const isCurrentlyUnder = currentAutos <= threshold;
// Check if we've crossed the threshold (went from above to at/below threshold)
if (this.lastAutosCount !== null &&
this.lastAutosCount > threshold &&
currentAutos <= threshold) {
// Just crossed threshold - trigger first notification
this.autosRepeatCount = 1;
this.isUnderThreshold = true;
this.lastAutosNotificationTime = Date.now();
NotificationManager.notify(
'Autos Alert',
text.trim(),
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_AUTOS,
soundUrl: CONFIG.sounds.autos,
emoji: '🪫'
}
);
}
// Check if we're still under threshold and need to repeat
else if (isCurrentlyUnder && wasUnderThreshold && this.autosRepeatCount < repeatCount) {
const repeatInterval = config.repeatInterval !== undefined ? config.repeatInterval : 0; // Default to 0 (immediate) if not set
const now = Date.now();
const timeSinceLastNotification = this.lastAutosNotificationTime
? (now - this.lastAutosNotificationTime) / 1000
: Infinity; // If no previous notification, allow it
// Check if enough time has passed (or interval is 0 for immediate repeats)
if (repeatInterval === 0 || timeSinceLastNotification >= repeatInterval) {
// Still under threshold and haven't reached repeat limit
this.autosRepeatCount++;
this.lastAutosNotificationTime = now;
NotificationManager.notify(
'Autos Alert',
text.trim(),
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_AUTOS,
soundUrl: CONFIG.sounds.autos,
emoji: '🪫'
}
);
}
}
// Check if we've gone back above threshold
else if (wasUnderThreshold && !isCurrentlyUnder) {
// Reset repeat counter and notification time when we go back above threshold
this.autosRepeatCount = 0;
this.isUnderThreshold = false;
this.lastAutosNotificationTime = null;
}
// Update state if we're currently under threshold
else if (isCurrentlyUnder) {
this.isUnderThreshold = true;
}
this.lastAutosCount = currentAutos;
}
},
checkForDungeonCompletion() {
const config = CONFIG.notifications.dungeon;
if (!config.sound && !config.desktop) return;
const dungeonChest = document.querySelector('.d_chest');
// If chest exists, track it but don't notify yet if we need to check progress
if (dungeonChest) {
// Find the green text paragraph with the completion message
const greenText = dungeonChest.querySelector('.green-text');
if (!greenText) return;
const text = greenText.textContent || '';
// Only proceed if this is a new dungeon completion (different text)
if (text && text !== this.lastDungeonText) {
// If we need to check for all keys complete, wait for progress text to update
if (config.onlyWhenAllKeysComplete) {
// Store the text and check progress when chest disappears
this.pendingDungeonText = text;
return;
}
// Otherwise, notify immediately
this.sendDungeonNotification(text, config);
}
}
// When chest disappears, check progress text if we have a pending notification
else if (this.pendingDungeonText && config.onlyWhenAllKeysComplete) {
// Query .progress__text directly
const progressText = document.querySelector('.progress__text');
if (progressText) {
const progressTextContent = (progressText.textContent || '').trim().toLowerCase();
// If it contains "dungeoneering", user has more keys - don't notify
if (progressTextContent.includes('dungeoneering')) {
// Still has keys left, skip notification
this.pendingDungeonText = null;
return;
}
// Otherwise, all keys are completed - send notification
this.sendDungeonNotification(this.pendingDungeonText, config);
this.pendingDungeonText = null;
} else {
// Progress text not found, clear pending
this.pendingDungeonText = null;
}
} else {
// Reset tracking when chest disappears and no pending notification
this.lastDungeonText = null;
this.pendingDungeonText = null;
}
},
sendDungeonNotification(text, config) {
this.lastDungeonText = text;
NotificationManager.notify(
'Dungeon Complete',
text.trim(),
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_DUNGEON,
soundUrl: CONFIG.sounds.dungeon,
emoji: '🗝️'
}
);
},
checkForMasteryLevelIncrease() {
const config = CONFIG.notifications.mastery;
if (!config.sound && !config.desktop) return;
try {
// Navigate to masteries panel: .game-grid > first div > second .main-section
const gameGrid = document.querySelector('.game-grid');
if (!gameGrid) return;
const leftSidebar = gameGrid.children[0]; // First div (left sidebar)
if (!leftSidebar) return;
// Find all .main-section divs in left sidebar
const mainSections = leftSidebar.querySelectorAll('.main-section');
if (mainSections.length < 2) return; // Need at least 2 sections
const masteriesPanel = mainSections[1]; // Second .main-section (masteries panel)
if (!masteriesPanel) return;
// Find .main-section__body
const body = masteriesPanel.querySelector('.main-section__body');
if (!body) return;
// Find the unnamed div (first child of body)
const unnamedDiv = body.children[0];
if (!unnamedDiv) return;
// Find all .relative.clickable divs (all masteries)
const masteryDivs = unnamedDiv.querySelectorAll('.relative.clickable');
if (!masteryDivs || masteryDivs.length === 0) return;
// Find the active mastery (the one that's green/selected)
// The active one should have a .flex.space-between with span.activeText and span.green-text
let activeMastery = null;
let masteryName = null;
let masteryLevel = null;
for (const masteryDiv of masteryDivs) {
const flexDiv = masteryDiv.querySelector('.flex.space-between');
if (!flexDiv) continue;
const activeText = flexDiv.querySelector('span.activeText');
const greenText = flexDiv.querySelector('span.green-text');
// If both exist, this is likely the active mastery
if (activeText && greenText) {
activeMastery = masteryDiv;
masteryName = (activeText.textContent || '').trim();
const levelText = (greenText.textContent || '').trim();
// Parse level number directly (green-text is just a number)
masteryLevel = parseInt(levelText, 10);
// Validate that we got a valid number
if (isNaN(masteryLevel)) {
continue; // Skip this mastery if level is not a valid number
}
break;
}
}
// If we found an active mastery with valid data
if (activeMastery && masteryName && masteryLevel !== null && !isNaN(masteryLevel)) {
// Check if this is a new mastery or level increase
if (this.lastMasteryName !== masteryName) {
// User switched to a different mastery - reset tracking
this.lastMasteryName = masteryName;
this.lastMasteryLevel = masteryLevel;
return;
}
// Same mastery - check if level increased
if (this.lastMasteryLevel !== null && masteryLevel > this.lastMasteryLevel) {
// Level increased!
NotificationManager.notify(
'Mastery Level Up!',
`${masteryName} reached level ${masteryLevel}`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_MASTERY,
soundUrl: CONFIG.sounds.mastery,
emoji: '⭐'
}
);
}
// Update tracking
this.lastMasteryName = masteryName;
this.lastMasteryLevel = masteryLevel;
}
} catch (error) {
// Silently fail if DOM structure changes or element not found
}
},
checkForLandCompletion() {
const config = CONFIG.notifications.land;
if (!config.sound && !config.desktop) return;
try {
// Use direct selector approach - find the flex container containing "Raid:" text
const flexContainers = document.querySelectorAll('.flex.space-between');
let raidContainer = null;
// Find the container that contains "Raid:" text
for (const container of flexContainers) {
const text = container.textContent || '';
if (text.includes('Raid:')) {
raidContainer = container;
break;
}
}
if (!raidContainer) return;
// Find the div containing "Raid:" text (first child div)
const raidTextDiv = Array.from(raidContainer.children).find(child => {
return child.tagName === 'DIV' && (child.textContent || '').includes('Raid:');
});
if (!raidTextDiv) return;
// Extract raid name from the span inside the raid text div
const raidNameSpan = raidTextDiv.querySelector('span');
const raidName = raidNameSpan ? (raidNameSpan.textContent || '').trim() : '';
if (!raidName) return;
// Find the status div - it should be the second child div (not the raid text div)
const statusDiv = Array.from(raidContainer.children).find(child => {
return child.tagName === 'DIV' && child !== raidTextDiv &&
child.textContent && child.textContent.trim();
});
if (!statusDiv) return;
// Get the text from the <a> tag inside the status div, or fallback to div text
const statusLink = statusDiv.querySelector('a');
const statusText = (statusLink ? statusLink.textContent : statusDiv.textContent || '').trim();
// Check if status changed to "Returned"
if (statusText.toLowerCase() === 'returned') {
// Only notify when transitioning from NOT "returned" to "returned"
if (this.lastRaidTimer !== 'returned') {
NotificationManager.notify(
'Land',
`Raid: ${raidName} Returned!`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_LAND,
soundUrl: CONFIG.sounds.land,
emoji: '🏰'
}
);
}
// Update tracking
this.lastRaidTimer = 'returned';
} else {
// Status is not "Returned" - update tracking
this.lastRaidTimer = statusText;
}
} catch (error) {
// Silently fail if DOM structure changes or element not found
}
},
checkForSkillLevelIncrease() {
const config = CONFIG.notifications.skills;
if (!config.sound && !config.desktop) return;
try {
// Use direct selector approach - find all skill-bar divs
const skillBars = document.querySelectorAll('.skill-bar');
if (!skillBars || skillBars.length === 0) return;
// Process each skill-bar
for (const skillBar of skillBars) {
// Find the progress__text element (try both variations)
const progressText = skillBar.querySelector('.progress__text') ||
skillBar.querySelector('.progress_text');
if (!progressText) continue;
const text = (progressText.textContent || '').trim();
// Parse text like "Battling (120)" to extract skill name and level
// Match pattern: "Skill Name (level)"
const match = text.match(/^(.+?)\s*\((\d+)\)$/);
if (!match || match.length < 3) continue;
const skillName = match[1].trim();
const currentLevel = parseInt(match[2], 10);
if (!skillName || isNaN(currentLevel)) continue;
// Check if we've seen this skill before
const previousLevel = this.skillLevels.get(skillName);
if (previousLevel !== undefined) {
// Skill exists in tracking - check if level increased
if (currentLevel > previousLevel) {
// Level increased!
NotificationManager.notify(
'Skills',
`Skill leveled up! ${skillName} (${currentLevel})`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_SKILLS,
soundUrl: CONFIG.sounds.skills,
emoji: '⏫'
}
);
}
}
// Update tracking (always update to current level)
this.skillLevels.set(skillName, currentLevel);
}
} catch (error) {
// Silently fail if DOM structure changes or element not found
}
},
checkForItemDrops() {
const config = CONFIG.notifications.itemDrop;
if (!config.sound && !config.desktop) return;
if (!config.itemKeywords || config.itemKeywords.length === 0) return;
// Filter out any empty or whitespace-only keywords
const validKeywords = config.itemKeywords.filter(kw => kw && typeof kw === 'string' && kw.trim().length > 0);
if (validKeywords.length === 0) return;
try {
// Find the log div in the right sidebar
const logDiv = document.querySelector('#log-div') || document.querySelector('.log-div');
if (!logDiv) return;
// Find all item clickable divs within the log
const itemDivs = logDiv.querySelectorAll('.item.clickable');
if (!itemDivs || itemDivs.length === 0) return;
// Process each item drop entry
for (const itemDiv of itemDivs) {
// Find the paragraph containing the item name
const itemNamePara = itemDiv.querySelector('p');
if (!itemNamePara) continue;
// Extract item name (remove brackets if present)
let itemName = (itemNamePara.textContent || '').trim();
itemName = itemName.replace(/^\[|\]$/g, '').trim();
if (!itemName) continue;
// Check if this item matches any tracked keyword (case-insensitive)
const itemNameLower = itemName.toLowerCase();
const matchedKeyword = validKeywords.find(keyword => {
const kw = (keyword || '').toLowerCase().trim();
return kw && itemNameLower.includes(kw);
});
if (!matchedKeyword) continue;
// Find the amount and timestamp - look for it in the log entry container
// The structure is: log entry container -> timestamp span -> amount span -> item div
let amount = '1'; // Default to 1 if not found
let timestamp = ''; // Track timestamp for unique identification
// Try to find the log entry container (parent of item div, or parent's parent)
let container = itemDiv.parentElement;
while (container && container !== logDiv) {
// Look for spans that might contain the amount or timestamp
const spans = container.querySelectorAll('span');
for (const span of spans) {
const spanText = (span.textContent || '').trim();
// Check if this span contains a number pattern like "+1 " or "+5 "
if (spanText.match(/\+?\d+/)) {
const amountMatch = spanText.match(/\+?(\d+)/);
if (amountMatch && amountMatch[1]) {
amount = amountMatch[1];
}
}
// Try to capture timestamp (usually first span in log entry, not a number)
if (!timestamp && spanText && spanText.length > 0 && !spanText.match(/^\+?\d+$/)) {
timestamp = spanText;
}
}
if (amount !== '1' && timestamp) break; // Found both, exit loop
// Move up to parent container
container = container.parentElement;
}
// Create unique identifier: item name + amount + timestamp
// This prevents duplicate notifications even if DOM elements are recreated
const uniqueId = `${itemNameLower}|${amount}|${timestamp}`;
// Skip if already processed
if (this.processedItemDrops.has(uniqueId)) continue;
// Mark as processed
this.processedItemDrops.add(uniqueId);
// Send notification
NotificationManager.notify(
'Item Log',
`Found ${amount}x ${itemName}`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_ITEM_DROP,
soundUrl: CONFIG.sounds.itemDrop,
emoji: '💰'
}
);
}
} catch (error) {
// Silently fail if DOM structure changes or element not found
}
},
checkForAbyssBattlesCompletion() {
const config = CONFIG.notifications.abyssBattles;
if (!config.sound && !config.desktop) return;
try {
// Find the div with classes "margin-top-small grey-text" containing " Battles Remaining"
const mainGameSection = document.querySelector('.main-game-section');
if (!mainGameSection) return;
// Find all divs with the specific classes
const battleDivs = mainGameSection.querySelectorAll('.margin-top-small.grey-text');
let battlesDiv = null;
// Find the one containing " Battles Remaining"
for (const div of battleDivs) {
const text = div.textContent || '';
if (text.includes('Battles Remaining')) {
battlesDiv = div;
break;
}
}
// If battlesDiv doesn't exist, we're not in abyss battles section
// Reset tracking and don't assume completion
if (!battlesDiv) {
this.lastAbyssBattlesCount = null;
return;
}
// Find the span with class "green-text" inside it
const battlesSpan = battlesDiv.querySelector('span.green-text');
if (!battlesSpan) {
// If span doesn't exist but div does, reset tracking
this.lastAbyssBattlesCount = null;
return;
}
const battlesText = (battlesSpan.textContent || '').trim();
const battlesCount = parseInt(battlesText, 10);
// Check if count is valid
if (isNaN(battlesCount)) {
// If text is not a number, reset tracking
this.lastAbyssBattlesCount = null;
return;
}
// Detect when battles reach 0 (completed)
// Only notify when transitioning from > 0 to 0
if (battlesCount === 0) {
// Only notify if we haven't already notified for this completion
// and we were previously tracking a count > 0
if (this.lastAbyssBattlesCount !== null && this.lastAbyssBattlesCount > 0) {
NotificationManager.notify(
'Abyss Battles',
'All Abyss Battles Completed!',
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES,
soundUrl: CONFIG.sounds.abyssBattles,
emoji: '🌀'
}
);
}
}
// Update tracking (always update, even if 0, to prevent duplicate notifications)
this.lastAbyssBattlesCount = battlesCount;
} catch (error) {
// Silently fail if DOM structure changes or element not found
this.lastAbyssBattlesCount = null;
}
},
checkForPotionThreshold() {
const config = CONFIG.notifications.potions;
if (!config.sound && !config.desktop) return;
try {
// Find the Effects panel - look for divs with class "flex space-between" that contain potions
// The structure is: .flex.space-between > span (potion name) > span.green-text (count)
const allFlexDivs = document.querySelectorAll('.flex.space-between');
if (!allFlexDivs || allFlexDivs.length === 0) return;
const threshold = config.threshold || 0;
const repeatCount = config.repeatCount || 1;
// Process each flex div to find potion entries
for (const flexDiv of allFlexDivs) {
// Check if this div contains a potion (has an item clickable with a potion name and a green-text span)
const itemDiv = flexDiv.querySelector('.item.clickable');
if (!itemDiv) continue;
const potionNamePara = itemDiv.querySelector('p');
if (!potionNamePara) continue;
// Extract potion name (remove brackets if present)
let potionName = (potionNamePara.textContent || '').trim();
potionName = potionName.replace(/^\[|\]$/g, '').trim();
if (!potionName) continue;
// Check if this is actually a potion (contains "Potion" in the name)
if (!potionName.toLowerCase().includes('potion')) continue;
// Find the green-text span with the count
const countSpan = flexDiv.querySelector('span.green-text');
if (!countSpan) continue;
// Parse the count (remove commas)
const countText = (countSpan.textContent || '').trim().replace(/,/g, '');
const currentCount = parseInt(countText, 10);
if (isNaN(currentCount)) continue;
// Get previous count for this potion
const previousCount = this.lastPotionCounts.get(potionName);
const wasUnderThreshold = this.potionUnderThreshold.get(potionName) || false;
const isCurrentlyUnder = currentCount <= threshold;
// Check if we've crossed the threshold (went from above to at/below threshold)
if (previousCount !== undefined &&
previousCount > threshold &&
currentCount <= threshold) {
// Just crossed threshold - trigger first notification
this.potionRepeatCounts.set(potionName, 1);
this.potionUnderThreshold.set(potionName, true);
this.lastPotionNotificationTimes.set(potionName, Date.now());
NotificationManager.notify(
'Potion Alert',
`${potionName}: ${countText.toLocaleString()} remaining`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_POTIONS,
soundUrl: CONFIG.sounds.potions,
emoji: '🧪'
}
);
}
// Check if we're still under threshold and need to repeat
else if (isCurrentlyUnder && wasUnderThreshold) {
const currentRepeatCount = this.potionRepeatCounts.get(potionName) || 0;
if (currentRepeatCount < repeatCount) {
const repeatInterval = config.repeatInterval !== undefined ? config.repeatInterval : 0; // Default to 0 (immediate) if not set
const now = Date.now();
const lastNotificationTime = this.lastPotionNotificationTimes.get(potionName);
const timeSinceLastNotification = lastNotificationTime
? (now - lastNotificationTime) / 1000
: Infinity; // If no previous notification, allow it
// Check if enough time has passed (or interval is 0 for immediate repeats)
if (repeatInterval === 0 || timeSinceLastNotification >= repeatInterval) {
// Still under threshold and haven't reached repeat limit
this.potionRepeatCounts.set(potionName, currentRepeatCount + 1);
this.lastPotionNotificationTimes.set(potionName, now);
NotificationManager.notify(
'Potion Alert',
`${potionName}: ${countText.toLocaleString()} remaining`,
{
sound: config.sound,
desktop: config.desktop,
soundName: CONSTANTS.STRINGS.SOUND_POTIONS,
soundUrl: CONFIG.sounds.potions,
emoji: '🧪'
}
);
}
}
}
// Check if we've gone back above threshold
else if (wasUnderThreshold && !isCurrentlyUnder) {
// Reset repeat counter and notification time when we go back above threshold
this.potionRepeatCounts.set(potionName, 0);
this.potionUnderThreshold.set(potionName, false);
this.lastPotionNotificationTimes.delete(potionName);
}
// Update state if we're currently under threshold
else if (isCurrentlyUnder) {
this.potionUnderThreshold.set(potionName, true);
}
// Update tracking
this.lastPotionCounts.set(potionName, currentCount);
}
// Clean up tracking for potions that no longer exist
const currentPotionNames = new Set();
for (const flexDiv of allFlexDivs) {
const itemDiv = flexDiv.querySelector('.item.clickable');
if (!itemDiv) continue;
const potionNamePara = itemDiv.querySelector('p');
if (!potionNamePara) continue;
let potionName = (potionNamePara.textContent || '').trim();
potionName = potionName.replace(/^\[|\]$/g, '').trim();
if (potionName && potionName.toLowerCase().includes('potion')) {
currentPotionNames.add(potionName);
}
}
// Remove tracking for potions that are no longer active
for (const [potionName] of this.lastPotionCounts) {
if (!currentPotionNames.has(potionName)) {
this.lastPotionCounts.delete(potionName);
this.potionRepeatCounts.delete(potionName);
this.potionUnderThreshold.delete(potionName);
this.lastPotionNotificationTimes.delete(potionName);
}
}
} catch (error) {
// Silently fail if DOM structure changes or element not found
}
},
cleanup() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
}
};
// ============================================
// Image Modal Manager
// ============================================
const ImageModalManager = {
modalOverlay: null,
modal: null,
image: null,
videoFrame: null,
initialized: false,
escHandler: null,
observer: null,
processedNodes: new WeakSet(), // Track processed nodes to avoid reprocessing
init() {
if (this.initialized) return;
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.startObserving());
} else {
this.startObserving();
}
this.initialized = true;
},
startObserving() {
// Use MutationObserver with debouncing to avoid interfering with chat
let timeout;
this.observer = new MutationObserver((mutations) => {
// Debounce processing to avoid interfering with rapid DOM changes
clearTimeout(timeout);
timeout = setTimeout(() => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // Element node
// Process new nodes for plain text image and video URLs
this.processNodeForImageUrls(node);
}
});
});
}, 200); // Small delay to let chat finish its DOM manipulation
});
// Observe the chat container
const chatContainer = document.querySelector('.chat-content');
if (chatContainer) {
this.observer.observe(chatContainer, {
childList: true,
subtree: true
});
// Process existing messages
this.processNodeForImageUrls(chatContainer);
} else {
// Retry if chat container not found yet
setTimeout(() => this.startObserving(), 1000);
}
},
processNodeForImageUrls(node) {
// Skip if already processed
if (this.processedNodes.has(node)) return;
// Skip if this is an input, form, or interactive element
if (node.tagName === 'INPUT' ||
node.tagName === 'TEXTAREA' ||
node.tagName === 'FORM' ||
node.tagName === 'BUTTON' ||
node.isContentEditable ||
node.contentEditable === 'true') {
return;
}
// Skip if inside a form or input
if (node.closest('form') ||
node.closest('input') ||
node.closest('textarea') ||
node.closest('[contenteditable="true"]')) {
return;
}
// Skip if this is the chat container itself (we want to process its children)
if (node.classList && node.classList.contains('chat-content')) {
// Process all child nodes instead
if (node.children) {
Array.from(node.children).forEach(child => {
this.processNodeForImageUrls(child);
});
}
return;
}
// Process any node that has text content and is likely a message
// Be more flexible - process any element with text that contains URLs
if (node.textContent && node.nodeType === 1) {
const text = node.textContent;
const urlPattern = /(https?:\/\/[^\s<>"']+)/gi;
const urls = [...new Set(text.match(urlPattern) || [])];
if (urls && urls.length > 0) {
// Filter for image and video URLs
const imageUrls = urls.filter(url => {
return this.isImageUrl(url);
});
const videoUrls = urls.filter(url => {
return this.isVideoUrl(url);
});
if (imageUrls.length > 0 || videoUrls.length > 0) {
// Mark node as processed
this.processedNodes.add(node);
// Convert text URLs to clickable links
this.convertTextUrlsToClickable(node, imageUrls, videoUrls);
}
}
}
},
convertTextUrlsToClickable(node, imageUrls, videoUrls = []) {
// Use TreeWalker to find and replace text nodes containing URLs
const allMediaUrls = [...imageUrls, ...videoUrls];
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
{
acceptNode: (textNode) => {
// Only process text nodes that contain our image or video URLs
const text = textNode.textContent;
const hasMediaUrl = allMediaUrls.some(url => text.includes(url));
return hasMediaUrl ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
},
false
);
const textNodesToReplace = [];
let textNode;
while (textNode = walker.nextNode()) {
textNodesToReplace.push(textNode);
}
// Process text nodes in reverse order to maintain indices
textNodesToReplace.reverse().forEach((textNode) => {
const text = textNode.textContent;
const urlPattern = /(https?:\/\/[^\s<>"']+)/gi;
const parts = [];
let lastIndex = 0;
let match;
// Split text by URLs
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
const index = match.index;
// Add text before URL
if (index > lastIndex) {
const beforeText = text.substring(lastIndex, index);
if (beforeText) {
parts.push({ type: 'text', content: beforeText });
}
}
// Add URL (check if it's an image or video)
if (imageUrls.includes(url)) {
parts.push({ type: 'imageUrl', content: url });
} else if (videoUrls.includes(url)) {
parts.push({ type: 'videoUrl', content: url });
} else {
parts.push({ type: 'text', content: url });
}
lastIndex = index + url.length;
}
// Add remaining text
if (lastIndex < text.length) {
const remainingText = text.substring(lastIndex);
if (remainingText) {
parts.push({ type: 'text', content: remainingText });
}
}
// If we found URLs to replace, create new elements
if (parts.some(p => p.type === 'imageUrl' || p.type === 'videoUrl')) {
const fragment = document.createDocumentFragment();
parts.forEach((part) => {
if (part.type === 'imageUrl' || part.type === 'videoUrl') {
// Create clickable span for image or video URL
const linkSpan = document.createElement('span');
linkSpan.textContent = part.content;
linkSpan.className = part.type === 'imageUrl' ? 'iqrpg-image-link' : 'iqrpg-video-link';
linkSpan.style.cssText = `
color: #4CAF50;
text-decoration: underline;
cursor: pointer;
user-select: text;
`;
linkSpan.setAttribute('data-url', part.content);
linkSpan.setAttribute('data-type', part.type === 'imageUrl' ? 'image' : 'video');
// Add click handler
linkSpan.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.openModal(part.content, part.type === 'imageUrl' ? 'image' : 'video');
}, true);
// Add hover effect
linkSpan.addEventListener('mouseenter', () => {
linkSpan.style.color = '#66BB6A';
});
linkSpan.addEventListener('mouseleave', () => {
linkSpan.style.color = '#4CAF50';
});
fragment.appendChild(linkSpan);
} else {
// Add plain text
fragment.appendChild(document.createTextNode(part.content));
}
});
// Replace the text node with the fragment
const parent = textNode.parentNode;
if (parent) {
parent.replaceChild(fragment, textNode);
}
}
});
},
isImageUrl(url) {
if (!url) return false;
// Check for common image file extensions
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i;
if (imageExtensions.test(url)) return true;
// Check for common image hosting services
const imageHosts = [
/imgur\.com/i,
/giphy\.com/i,
/tenor\.com/i,
/gfycat\.com/i,
/i\.redd\.it/i,
/i\.imgur\.com/i,
/media\.giphy\.com/i,
/cdn\.discordapp\.com/i,
/discord\.com\/attachments/i
];
return imageHosts.some(pattern => pattern.test(url));
},
isVideoUrl(url) {
if (!url) return false;
// Check for YouTube URL patterns (including Shorts)
const youtubePatterns = [
/youtube\.com\/watch\?v=([\w-]+)/i,
/youtu\.be\/([\w-]+)/i,
/youtube\.com\/embed\/([\w-]+)/i,
/youtube\.com\/v\/([\w-]+)/i,
/youtube\.com\/shorts\/([\w-]+)/i // YouTube Shorts
];
return youtubePatterns.some(pattern => pattern.test(url));
},
getYouTubeEmbedUrl(url) {
if (!url) return null;
// Check if it's a Short
const isShort = /youtube\.com\/shorts\/([\w-]+)/i.test(url);
// Extract video ID from various YouTube URL formats
let videoId = null;
const patterns = [
{ regex: /youtube\.com\/watch\?v=([\w-]+)/i, index: 1 },
{ regex: /youtu\.be\/([\w-]+)/i, index: 1 },
{ regex: /youtube\.com\/embed\/([\w-]+)/i, index: 1 },
{ regex: /youtube\.com\/v\/([\w-]+)/i, index: 1 },
{ regex: /youtube\.com\/shorts\/([\w-]+)/i, index: 1 } // Shorts pattern
];
for (const { regex, index } of patterns) {
const match = url.match(regex);
if (match) {
videoId = match[index];
break;
}
}
if (videoId) {
return {
embedUrl: `https://www.youtube.com/embed/${videoId}`,
isShort: isShort || /youtube\.com\/shorts\//i.test(url) // Double-check
};
}
return null;
},
openModal(url, type = 'image') {
// Create overlay if it doesn't exist
if (!this.modalOverlay) {
this.createModal();
}
if (type === 'video') {
// Handle video (YouTube)
const videoInfo = this.getYouTubeEmbedUrl(url);
if (videoInfo && this.videoFrame) {
this.videoFrame.src = videoInfo.embedUrl;
// Set aspect ratio based on video type
if (videoInfo.isShort) {
// YouTube Shorts: 9:16 (portrait)
this.videoFrame.style.aspectRatio = '9 / 16';
this.videoFrame.style.width = 'min(85vw, 405px)'; // 85% viewport width, capped at 405px
this.videoFrame.style.maxWidth = '405px';
this.videoFrame.style.maxHeight = '720px';
} else {
// Regular YouTube videos: 16:9 (landscape)
this.videoFrame.style.aspectRatio = '16 / 9';
this.videoFrame.style.width = 'min(85vw, 1280px)'; // 85% viewport width, capped at 1280px
this.videoFrame.style.maxWidth = '1280px';
this.videoFrame.style.maxHeight = '720px';
}
this.videoFrame.style.display = 'block';
if (this.image) this.image.style.display = 'none';
}
} else {
// Handle image
if (this.image) {
this.image.src = url;
this.image.alt = 'Image';
this.image.style.display = 'block';
}
if (this.videoFrame) this.videoFrame.style.display = 'none';
}
// Show modal
if (this.modalOverlay) {
this.modalOverlay.classList.add('active');
document.body.style.overflow = 'hidden';
}
},
createModal() {
// Create overlay
this.modalOverlay = document.createElement('div');
this.modalOverlay.className = 'iqrpg-image-modal-overlay';
this.modalOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
cursor: pointer;
pointer-events: none;
`;
// Create modal container
this.modal = document.createElement('div');
this.modal.className = 'iqrpg-image-modal';
this.modal.style.cssText = `
position: relative;
max-width: 90%;
max-height: 90%;
cursor: default;
pointer-events: auto;
`;
// Create close button
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.className = 'iqrpg-image-modal-close';
closeBtn.style.cssText = `
position: absolute;
top: -40px;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 32px;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
pointer-events: auto;
`;
closeBtn.onmouseover = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.3)';
closeBtn.onmouseout = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.2)';
// Create image element
this.image = document.createElement('img');
this.image.style.cssText = `
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
display: block;
`;
// Create video iframe element
this.videoFrame = document.createElement('iframe');
this.videoFrame.style.cssText = `
aspect-ratio: 16 / 9; /* Default to regular video ratio */
border: none;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
display: none;
`;
this.videoFrame.setAttribute('allowfullscreen', 'true');
this.videoFrame.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
// Assemble modal
this.modal.appendChild(closeBtn);
this.modal.appendChild(this.image);
this.modal.appendChild(this.videoFrame);
this.modalOverlay.appendChild(this.modal);
document.body.appendChild(this.modalOverlay);
// Add CSS for active state
if (!document.getElementById('iqrpg-image-modal-styles')) {
const style = document.createElement('style');
style.id = 'iqrpg-image-modal-styles';
style.textContent = `
.iqrpg-image-modal-overlay.active {
opacity: 1 !important;
pointer-events: auto !important;
}
.iqrpg-image-link:hover,
.iqrpg-video-link:hover {
color: #66BB6A !important;
}
`;
document.head.appendChild(style);
}
// Event listeners
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeModal();
});
this.modalOverlay.addEventListener('click', (e) => {
if (e.target === this.modalOverlay) {
this.closeModal();
}
});
// ESC key to close
this.escHandler = (e) => {
if (e.key === 'Escape' && this.modalOverlay.classList.contains('active')) {
this.closeModal();
}
};
document.addEventListener('keydown', this.escHandler);
},
closeModal() {
if (this.modalOverlay) {
this.modalOverlay.classList.remove('active');
document.body.style.overflow = '';
// Remove image source to free memory
if (this.image) {
this.image.src = '';
this.image.style.display = 'block';
}
// Remove video iframe source to stop playback and free memory
if (this.videoFrame) {
this.videoFrame.src = '';
this.videoFrame.style.display = 'none';
}
}
},
cleanup() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.escHandler) {
document.removeEventListener('keydown', this.escHandler);
this.escHandler = null;
}
if (this.modalOverlay) {
this.modalOverlay.remove();
this.modalOverlay = null;
this.modal = null;
this.image = null;
this.videoFrame = null;
}
document.body.style.overflow = '';
}
};
// ============================================
// GUI Manager - Settings Button & Modal
// ============================================
const GUIManager = {
initialized: false,
// Section name mapping for navigation
sectionNames: {
volume: 'Volume',
autos: 'Autos',
globalEvents: 'Globals',
bossSpawn: 'Bosses',
gatheringEvents: 'Gathering',
actionBonus: 'Action Bonus',
clan: 'Clan',
dungeon: 'Dungeon',
land: 'Raid',
mastery: 'Mastery',
skills: 'Skills',
tradeAlert: 'Trade',
itemDrop: 'Log',
message: 'Message',
abyssBattles: 'Abyss',
potions: 'Potions'
},
settingsButton: null,
modal: null,
modalOverlay: null,
escKeyHandler: null,
init() {
if (this.initialized || !CONFIG.gui.enabled) return;
this.initialized = true;
// Wait for DOM to be ready
const initGUI = () => {
this.injectStyles();
// Delay button creation slightly to ensure header content is loaded
setTimeout(() => {
this.createSettingsButton();
}, CONSTANTS.DELAYS.BUTTON_CREATION);
this.createModal();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGUI);
} else {
initGUI();
}
},
injectStyles() {
if (document.getElementById('iqrpg-enhanced-styles')) return;
const style = document.createElement('style');
style.id = 'iqrpg-enhanced-styles';
style.textContent = `
/* Settings Button */
.iqrpg-settings-btn {
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
box-shadow: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
font-size: 16px;
color: white;
padding: 0;
margin-right: 8px;
vertical-align: middle;
}
.iqrpg-settings-btn:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.iqrpg-settings-btn:active {
transform: scale(0.98);
}
/* Modal Overlay */
.iqrpg-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.iqrpg-modal-overlay.active {
display: flex;
}
/* Modal */
.iqrpg-modal {
background: #1e1e1e;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
z-index: 2001;
position: relative;
}
.iqrpg-modal-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* Modal Header */
.iqrpg-modal-header {
padding: 20px;
border-bottom: 2px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #2a9d2a 0%, #227E22 100%);
border-radius: 10px 10px 0 0;
flex-shrink: 0;
}
.iqrpg-modal-title {
margin: 0;
color: white;
font-size: 24px;
font-weight: bold;
}
.iqrpg-modal-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.iqrpg-modal-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
/* Modal Navigation Bar */
.iqrpg-modal-nav {
position: sticky;
top: 0;
z-index: 10;
background: #252525;
border-bottom: 2px solid #444;
padding: 6px 8px;
min-height: 60px;
flex-shrink: 0;
}
.iqrpg-nav-buttons {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
}
.iqrpg-nav-btn {
padding: 5px 10px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 11.5px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
flex: 0 0 auto;
min-height: 26px;
display: inline-block;
}
.iqrpg-nav-btn:hover {
background: #333;
border-color: #227E22;
color: #227E22;
}
.iqrpg-nav-btn:active {
transform: scale(0.95);
}
/* Modal Body */
.iqrpg-modal-body {
padding: 20px;
padding-bottom: 80px; /* Space for sticky footer */
}
/* Modal Footer */
.iqrpg-modal-footer {
position: sticky;
bottom: 0;
z-index: 10;
background: #1e1e1e;
border-top: 2px solid #333;
padding: 15px 20px;
border-radius: 0 0 10px 10px;
}
/* Section */
.iqrpg-section {
margin-bottom: 30px;
padding: 20px;
background: #2a2a2a;
border-radius: 8px;
}
.iqrpg-section-header {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.iqrpg-section-title {
margin: 0;
color: #fff;
font-size: 18px;
font-weight: bold;
}
.iqrpg-section-content {
overflow: hidden;
}
/* Toggle Switch */
.iqrpg-toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px;
background: #1e1e1e;
border-radius: 6px;
}
.iqrpg-toggle-label {
color: #ccc;
font-size: 14px;
flex: 1;
}
.iqrpg-toggle-switch {
position: relative;
width: 50px;
height: 26px;
background: #555;
border-radius: 13px;
cursor: pointer;
transition: background 0.3s;
}
.iqrpg-toggle-switch.active {
background: #227E22;
}
.iqrpg-toggle-switch::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
top: 3px;
left: 3px;
transition: left 0.3s;
}
.iqrpg-toggle-switch.active::after {
left: 27px;
}
/* Input Group */
.iqrpg-input-group {
margin-bottom: 15px;
}
.iqrpg-input-label {
display: block;
color: #ccc;
font-size: 14px;
margin-bottom: 5px;
}
.iqrpg-input {
width: 100%;
padding: 10px;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 14px;
box-sizing: border-box;
}
.iqrpg-input:focus {
outline: none;
border-color: #227E22;
}
/* Volume Slider */
.iqrpg-volume-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px;
background: #1e1e1e;
border-radius: 6px;
}
.iqrpg-volume-label {
color: #ccc;
font-size: 14px;
flex: 1;
margin-right: 15px;
}
.iqrpg-volume-slider {
flex: 2;
height: 6px;
background: #333;
border-radius: 3px;
outline: none;
-webkit-appearance: none;
appearance: none;
margin: 0;
padding: 0;
}
.iqrpg-volume-slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: linear-gradient(to right, #227E22 0%, #227E22 var(--volume-percent, 0%), #333 var(--volume-percent, 0%), #333 100%);
border-radius: 3px;
}
.iqrpg-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #227E22;
border-radius: 50%;
cursor: pointer;
transition: background 0.2s;
margin-top: -6px;
position: relative;
z-index: 1;
}
.iqrpg-volume-slider::-webkit-slider-thumb:hover {
background: #2a9d2a;
}
.iqrpg-volume-slider::-moz-range-track {
width: 100%;
height: 6px;
background: #333;
border-radius: 3px;
border: none;
}
.iqrpg-volume-slider::-moz-range-progress {
background: #227E22;
height: 6px;
border-radius: 3px 0 0 3px;
}
.iqrpg-volume-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: #227E22;
border-radius: 50%;
cursor: pointer;
border: none;
transition: background 0.2s;
}
.iqrpg-volume-slider::-moz-range-thumb:hover {
background: #2a9d2a;
}
.iqrpg-volume-value {
color: #227E22;
font-size: 14px;
font-weight: bold;
min-width: 45px;
text-align: right;
margin-left: 15px;
}
/* Buttons */
.iqrpg-button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.iqrpg-button {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s;
}
.iqrpg-button-primary {
background: linear-gradient(135deg, #2a9d2a 0%, #227E22 100%);
color: white;
}
.iqrpg-button-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(34, 126, 34, 0.4);
}
.iqrpg-button-secondary {
background: #444;
color: white;
}
.iqrpg-button-secondary:hover {
background: #555;
}
`;
document.head.appendChild(style);
},
findButtonContainer() {
// Find .fixed-top first (this is the actual header bar)
const fixedTop = document.querySelector(CONSTANTS.SELECTORS.FIXED_TOP);
if (!fixedTop) {
// DOM not ready yet - return null to trigger retry
return null;
}
// Find .section-3 inside .fixed-top (this is where Premium Store is)
const section3 = fixedTop.querySelector(CONSTANTS.SELECTORS.SECTION_3);
if (!section3) {
// section-3 not ready yet - return null to trigger retry
return null;
}
// Find the "Premium Store" element - it's in a <p> with an <a> tag
// We want to insert before the <p> element that contains "Premium Store"
let premiumStore = null;
// First, try to find the <p> element containing "Premium Store"
const paragraphs = section3.querySelectorAll('p');
for (const p of paragraphs) {
const text = (p.textContent || p.innerText || '').trim().toLowerCase();
if (text.includes(CONSTANTS.STRINGS.PREMIUM_STORE)) {
premiumStore = p;
break;
}
}
// If not found in <p>, try <a> tags and get their parent <p>
if (!premiumStore) {
const links = section3.querySelectorAll('a');
for (const a of links) {
const text = (a.textContent || a.innerText || '').trim().toLowerCase();
if (text.includes(CONSTANTS.STRINGS.PREMIUM_STORE)) {
// Find the parent <p> element
let parent = a.parentElement;
while (parent && parent !== section3 && parent.tagName !== 'P') {
parent = parent.parentElement;
}
if (parent && parent.tagName === 'P') {
premiumStore = parent;
} else {
premiumStore = a;
}
break;
}
}
}
// If Premium Store not found yet, return null to trigger retry
if (!premiumStore) {
return null;
}
return {
premiumStore: premiumStore,
insertParent: section3
};
},
createSettingsButton() {
if (this.settingsButton) return;
const button = document.createElement('button');
button.className = 'iqrpg-settings-btn';
button.innerHTML = '⚙️';
button.title = 'IQRPG Enhanced Settings';
button.setAttribute('aria-label', 'Open IQRPG Enhanced Settings');
button.addEventListener('click', (e) => {
e.stopPropagation();
this.openModal();
});
// Try to create button with retry logic (DOM might not be fully loaded)
let retryCount = 0;
const maxRetries = 10;
const tryCreateButton = () => {
// Skip if already successfully placed
if (this.settingsButton) return true;
const container = this.findButtonContainer();
// Container not ready yet - retry
if (!container) {
retryCount++;
if (retryCount < maxRetries) {
setTimeout(tryCreateButton, CONSTANTS.DELAYS.RETRY_SHORT);
}
return false;
}
try {
// Insert before Premium Store element in section-3
container.insertParent.insertBefore(button, container.premiumStore);
this.settingsButton = button;
return true;
} catch (e) {
retryCount++;
if (retryCount < maxRetries) {
setTimeout(tryCreateButton, CONSTANTS.DELAYS.RETRY_SHORT);
}
return false;
}
};
tryCreateButton();
},
createModal() {
if (this.modal) return;
// Overlay
const overlay = document.createElement('div');
overlay.className = 'iqrpg-modal-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.closeModal();
}
});
// Modal
const modal = document.createElement('div');
modal.className = 'iqrpg-modal';
// Header
const header = document.createElement('div');
header.className = 'iqrpg-modal-header';
header.innerHTML = `
<h2 class="iqrpg-modal-title">IQRPG Enhanced Settings</h2>
<button class="iqrpg-modal-close" aria-label="Close">×</button>
`;
const closeBtn = header.querySelector('.iqrpg-modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.closeModal());
}
// Navigation Bar
const navBar = document.createElement('div');
navBar.className = 'iqrpg-modal-nav';
navBar.innerHTML = `
<div class="iqrpg-nav-buttons">
${Object.entries(this.sectionNames).map(([type, name]) =>
`<button class="iqrpg-nav-btn" data-section="${type}" title="${name}">${name}</button>`
).join('')}
</div>
`;
// Add ESC key listener once in createModal
this.escKeyHandler = (e) => {
if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.classList.contains('active')) {
this.closeModal();
}
};
document.addEventListener('keydown', this.escKeyHandler);
// Body
const body = document.createElement('div');
body.className = 'iqrpg-modal-body';
body.innerHTML = this.generateSettingsHTML();
// Footer (sticky action buttons)
const footer = document.createElement('div');
footer.className = 'iqrpg-modal-footer';
footer.innerHTML = `
<div class="iqrpg-button-group">
<button class="iqrpg-button iqrpg-button-primary" id="iqrpg-save-btn">Save Settings</button>
<button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-reset-btn">Reset to Defaults</button>
<button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-toggle-sound-btn">Toggle All Sound Alerts</button>
<button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-toggle-notifications-btn">Toggle All Notifications</button>
</div>
`;
modal.appendChild(header);
modal.appendChild(navBar);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this.modalOverlay = overlay;
this.modal = modal;
// Attach event listeners
this.attachEventListeners();
},
generateNotificationSection(type, label) {
const config = CONFIG.notifications[type];
// Map notification type to sound key
const soundKeyMap = {
'globalEvents': CONSTANTS.STRINGS.SOUND_GLOBAL,
'actionBonus': CONSTANTS.STRINGS.SOUND_ACTION_BONUS,
'tradeAlert': CONSTANTS.STRINGS.SOUND_TRADE_ALERT,
'gatheringEvents': CONSTANTS.STRINGS.SOUND_GATHERING_EVENT,
'itemDrop': CONSTANTS.STRINGS.SOUND_ITEM_DROP,
'message': CONSTANTS.STRINGS.SOUND_MESSAGE,
'abyssBattles': CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES,
'potions': CONSTANTS.STRINGS.SOUND_POTIONS
};
const soundKey = soundKeyMap[type] || type;
return `
<div class="iqrpg-section" id="section-${type}" data-section-type="${type}">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">${label} Notifications</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.${type}.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.${type}.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateBossSection() {
const config = CONFIG.notifications.bossSpawn;
const bossSpawnSoundKey = CONSTANTS.STRINGS.SOUND_BOSS_SPAWN;
const rareBossSoundKey = CONSTANTS.STRINGS.SOUND_RARE_BOSS;
return `
<div class="iqrpg-section" id="section-bossSpawn" data-section-type="bossSpawn">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Bosses Notifications</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.bossSpawn.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.bossSpawn.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Boss Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${bossSpawnSoundKey}"
value="${CONFIG.sounds[bossSpawnSoundKey] || ''}"
placeholder="Leave empty for default">
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Rare Boss Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${rareBossSoundKey}"
value="${CONFIG.sounds[rareBossSoundKey] || ''}"
placeholder="Leave empty for default">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Sound for rarity-4+ bosses
</small>
</div>
</div>
</div>
`;
},
generateClanSection() {
const config = CONFIG.notifications.clan;
const watchtowerSoundKey = CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER;
const globalsSoundKey = CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS;
return `
<div class="iqrpg-section" id="section-clan" data-section-type="clan">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Clan</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.clan.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.clan.desktop"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Watchtower</label>
<div class="iqrpg-toggle-switch ${config.watchtower ? 'active' : ''}"
data-setting="notifications.clan.watchtower"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Watchtower Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${watchtowerSoundKey}"
value="${CONFIG.sounds[watchtowerSoundKey] || ''}"
placeholder="Leave empty for default">
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Clan Chat Globals</label>
<div class="iqrpg-toggle-switch ${config.clanChatGlobals ? 'active' : ''}"
data-setting="notifications.clan.clanChatGlobals"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Clan Globals Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${globalsSoundKey}"
value="${CONFIG.sounds[globalsSoundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateItemDropSection() {
const config = CONFIG.notifications.itemDrop;
const soundKey = CONSTANTS.STRINGS.SOUND_ITEM_DROP;
const itemKeywords = (config.itemKeywords || []).join(', ');
return `
<div class="iqrpg-section" id="section-itemDrop" data-section-type="itemDrop">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Log (Item Drops)</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.itemDrop.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.itemDrop.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Item Keywords (comma-separated)</label>
<input type="text" class="iqrpg-input"
data-setting="notifications.itemDrop.itemKeywords"
data-type="array"
value="${itemKeywords}"
placeholder="e.g. golden egg, diamond, malachite">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Alert when these items are found in the log panel (case-insensitive)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateGatheringEventsSection() {
const woodcutting = CONFIG.notifications.gatheringEvents.woodcutting;
const quarrying = CONFIG.notifications.gatheringEvents.quarrying;
const mining = CONFIG.notifications.gatheringEvents.mining;
const soundKey = CONSTANTS.STRINGS.SOUND_GATHERING_EVENT;
return `
<div class="iqrpg-section" id="section-gatheringEvents" data-section-type="gatheringEvents">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Gathering</h3>
</div>
<div class="iqrpg-section-content">
<!-- Woodcutting Event -->
<div style="margin-bottom: 20px; padding: 15px; background: #1e1e1e; border-radius: 6px;">
<h4 style="margin: 0 0 10px 0; color: #fff; font-size: 16px;">🌲 Woodcutting Event</h4>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${woodcutting.sound ? 'active' : ''}"
data-setting="notifications.gatheringEvents.woodcutting.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${woodcutting.desktop ? 'active' : ''}"
data-setting="notifications.gatheringEvents.woodcutting.desktop"></div>
</div>
</div>
<!-- Quarrying Event -->
<div style="margin-bottom: 20px; padding: 15px; background: #1e1e1e; border-radius: 6px;">
<h4 style="margin: 0 0 10px 0; color: #fff; font-size: 16px;">🪨 Quarrying Event</h4>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${quarrying.sound ? 'active' : ''}"
data-setting="notifications.gatheringEvents.quarrying.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${quarrying.desktop ? 'active' : ''}"
data-setting="notifications.gatheringEvents.quarrying.desktop"></div>
</div>
</div>
<!-- Mining Event -->
<div style="margin-bottom: 20px; padding: 15px; background: #1e1e1e; border-radius: 6px;">
<h4 style="margin: 0 0 10px 0; color: #fff; font-size: 16px;">☄️ Mining Event</h4>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${mining.sound ? 'active' : ''}"
data-setting="notifications.gatheringEvents.mining.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${mining.desktop ? 'active' : ''}"
data-setting="notifications.gatheringEvents.mining.desktop"></div>
</div>
</div>
<!-- Sound URL (shared for all gathering events) -->
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateTradeAlertSection() {
const config = CONFIG.notifications.tradeAlert;
const soundKey = CONSTANTS.STRINGS.SOUND_TRADE_ALERT;
const sellingKeywords = (config.sellingKeywords || []).join(', ');
const buyingKeywords = (config.buyingKeywords || []).join(', ');
return `
<div class="iqrpg-section" id="section-tradeAlert" data-section-type="tradeAlert">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Trade</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.tradeAlert.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.tradeAlert.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Selling Keywords (comma-separated)</label>
<input type="text" class="iqrpg-input"
data-setting="notifications.tradeAlert.sellingKeywords"
data-type="array"
value="${sellingKeywords}"
placeholder="e.g. iq, mana, qs2">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Alert when someone is selling these items (detects: selling, wts, sell, s>)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Buying Keywords (comma-separated)</label>
<input type="text" class="iqrpg-input"
data-setting="notifications.tradeAlert.buyingKeywords"
data-type="array"
value="${buyingKeywords}"
placeholder="e.g. iq, mana, qs2">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Alert when someone is buying these items (detects: buying, wtb, buy, b>)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateAutosSection() {
const config = CONFIG.notifications.autos;
const soundKey = CONSTANTS.STRINGS.SOUND_AUTOS;
const threshold = config.threshold || 100;
const repeatCount = config.repeatCount || 1;
return `
<div class="iqrpg-section" id="section-autos" data-section-type="autos">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Autos Alert</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.autos.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.autos.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Alert Threshold</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.autos.threshold"
value="${threshold}"
min="0"
placeholder="100">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Alert when autos remaining reaches this number or below
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Repeat Count</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.autos.repeatCount"
value="${repeatCount}"
min="1"
placeholder="1">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Number of times to repeat alert while autos stay under threshold (1 = no repeat)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Repeat Interval (seconds)</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.autos.repeatInterval"
value="${config.repeatInterval !== undefined ? config.repeatInterval : 1}"
min="0"
placeholder="1">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Seconds between repeat alerts (0 = immediate repeats, no delay)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generatePotionSection() {
const config = CONFIG.notifications.potions;
const soundKey = CONSTANTS.STRINGS.SOUND_POTIONS;
const threshold = config.threshold || 100;
const repeatCount = config.repeatCount || 1;
return `
<div class="iqrpg-section" id="section-potions" data-section-type="potions">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Potion Alert</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.potions.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.potions.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Alert Threshold</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.potions.threshold"
value="${threshold}"
min="0"
placeholder="100">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Alert when potion remaining reaches this number or below
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Repeat Count</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.potions.repeatCount"
value="${repeatCount}"
min="1"
placeholder="1">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Number of times to repeat alert while potion stays under threshold (1 = no repeat)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Repeat Interval (seconds)</label>
<input type="number" class="iqrpg-input"
data-setting="notifications.potions.repeatInterval"
value="${config.repeatInterval !== undefined ? config.repeatInterval : 1}"
min="0"
placeholder="1">
<small style="color: #888; font-size: 12px; display: block; margin-top: 5px;">
Seconds between repeat alerts (0 = immediate repeats, no delay)
</small>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateDungeonSection() {
const config = CONFIG.notifications.dungeon;
const soundKey = CONSTANTS.STRINGS.SOUND_DUNGEON;
return `
<div class="iqrpg-section" id="section-dungeon" data-section-type="dungeon">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Dungeon Completion</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.dungeon.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.dungeon.desktop"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Only When All Keys Complete</label>
<div class="iqrpg-toggle-switch ${config.onlyWhenAllKeysComplete ? 'active' : ''}"
data-setting="notifications.dungeon.onlyWhenAllKeysComplete"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateMasterySection() {
const config = CONFIG.notifications.mastery;
const soundKey = CONSTANTS.STRINGS.SOUND_MASTERY;
return `
<div class="iqrpg-section" id="section-mastery" data-section-type="mastery">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Mastery Level Increases</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.mastery.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.mastery.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateLandSection() {
const config = CONFIG.notifications.land;
const soundKey = CONSTANTS.STRINGS.SOUND_LAND;
return `
<div class="iqrpg-section" id="section-land" data-section-type="land">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Raid Completion</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.land.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.land.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateSkillsSection() {
const config = CONFIG.notifications.skills;
const soundKey = CONSTANTS.STRINGS.SOUND_SKILLS;
return `
<div class="iqrpg-section" id="section-skills" data-section-type="skills">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Skill Level Increases</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.skills.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.skills.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateAbyssBattlesSection() {
const config = CONFIG.notifications.abyssBattles;
const soundKey = CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES;
return `
<div class="iqrpg-section" id="section-abyssBattles" data-section-type="abyssBattles">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Abyss Battles Completion</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Sound Alerts</label>
<div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}"
data-setting="notifications.abyssBattles.sound"></div>
</div>
<div class="iqrpg-toggle-group">
<label class="iqrpg-toggle-label">Desktop Notifications</label>
<div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}"
data-setting="notifications.abyssBattles.desktop"></div>
</div>
<div class="iqrpg-input-group">
<label class="iqrpg-input-label">Sound URL (optional)</label>
<input type="text" class="iqrpg-input"
data-setting="sounds.${soundKey}"
value="${CONFIG.sounds[soundKey] || ''}"
placeholder="Leave empty for default">
</div>
</div>
</div>
`;
},
generateSettingsHTML() {
const volume = (CONFIG.sounds.volume !== undefined ? CONFIG.sounds.volume : 1.0) * 100;
return `
<div class="iqrpg-section" id="section-volume" data-section-type="volume">
<div class="iqrpg-section-header">
<h3 class="iqrpg-section-title">Volume</h3>
</div>
<div class="iqrpg-section-content">
<div class="iqrpg-volume-group">
<label class="iqrpg-volume-label">Sound Volume</label>
<input type="range"
class="iqrpg-volume-slider"
id="iqrpg-volume-slider"
data-setting="sounds.volume"
min="0"
max="100"
value="${volume}"
step="1">
<span class="iqrpg-volume-value" id="iqrpg-volume-value">${Math.round(volume)}%</span>
</div>
</div>
</div>
${this.generateAutosSection()}
${this.generateNotificationSection('globalEvents', 'Globals')}
${this.generateBossSection()}
${this.generateGatheringEventsSection()}
${this.generateNotificationSection('actionBonus', 'Action Bonus')}
${this.generateClanSection()}
${this.generateDungeonSection()}
${this.generateLandSection()}
${this.generateMasterySection()}
${this.generateSkillsSection()}
${this.generateTradeAlertSection()}
${this.generateItemDropSection()}
${this.generateNotificationSection('message', 'Message')}
${this.generateAbyssBattlesSection()}
${this.generatePotionSection()}
`;
},
attachEventListeners() {
if (!this.modal) return;
// Use event delegation - attach listeners once to modal container
// This works with dynamically regenerated HTML and prevents duplicate listeners
// Navigation button click handlers
this.modal.addEventListener('click', (e) => {
if (e.target.classList.contains('iqrpg-nav-btn')) {
const sectionType = e.target.getAttribute('data-section');
const targetSection = this.modal.querySelector(`#section-${sectionType}`);
if (targetSection) {
targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
e.target.style.background = '#227E22';
setTimeout(() => {
e.target.style.background = '';
}, 300);
}
}
});
// Toggle switches
this.modal.addEventListener('click', (e) => {
if (e.target.classList.contains('iqrpg-toggle-switch')) {
e.target.classList.toggle('active');
const setting = e.target.getAttribute('data-setting');
this.updateSetting(setting, e.target.classList.contains('active'));
}
});
// Volume slider
this.modal.addEventListener('input', (e) => {
if (e.target.id === 'iqrpg-volume-slider') {
const volumeValue = this.modal.querySelector('#iqrpg-volume-value');
if (volumeValue) {
const value = parseFloat(e.target.value);
const volumePercent = Math.round(value);
volumeValue.textContent = `${volumePercent}%`;
const volumeDecimal = value / 100;
this.updateSetting('sounds.volume', volumeDecimal);
// Update the filled portion of the slider
e.target.style.setProperty('--volume-percent', `${value}%`);
}
}
});
// Input fields
this.modal.addEventListener('change', (e) => {
if (e.target.classList.contains('iqrpg-input')) {
const setting = e.target.getAttribute('data-setting');
const dataType = e.target.getAttribute('data-type');
let value = e.target.value;
if (e.target.type === 'number') {
value = parseInt(value, 10) || 0;
}
if (dataType === 'array') {
value = value.split(',')
.map(v => v.trim())
.filter(v => v.length > 0);
}
this.updateSetting(setting, value);
}
});
// Save button
this.modal.addEventListener('click', (e) => {
if (e.target.id === 'iqrpg-save-btn') {
this.saveSettings();
}
});
// Reset button
this.modal.addEventListener('click', (e) => {
if (e.target.id === 'iqrpg-reset-btn') {
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
this.resetSettings();
}
}
});
// Toggle all sound alerts button
this.modal.addEventListener('click', (e) => {
if (e.target.id === 'iqrpg-toggle-sound-btn') {
this.toggleAllSoundAlerts();
}
});
// Toggle all notifications button
this.modal.addEventListener('click', (e) => {
if (e.target.id === 'iqrpg-toggle-notifications-btn') {
this.toggleAllDesktopNotifications();
}
});
},
updateSetting(path, value) {
const keys = path.split('.');
let obj = CONFIG;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
},
reloadAudio() {
AudioManager.audioCache.clear();
preloadAllSounds();
},
saveSettings() {
saveConfig();
// If item keywords exist, mark all current items in log as processed
// This prevents alerts for items that were already in the log before keywords were set
const itemKeywords = CONFIG.notifications.itemDrop?.itemKeywords || [];
if (itemKeywords.length > 0) {
const validKeywords = itemKeywords.filter(kw => kw && typeof kw === 'string' && kw.trim().length > 0);
if (validKeywords.length > 0) {
try {
const logDiv = document.querySelector('#log-div') || document.querySelector('.log-div');
if (logDiv) {
const itemDivs = logDiv.querySelectorAll('.item.clickable');
for (const itemDiv of itemDivs) {
const itemNamePara = itemDiv.querySelector('p');
if (!itemNamePara) continue;
let itemName = (itemNamePara.textContent || '').trim();
itemName = itemName.replace(/^\[|\]$/g, '').trim();
if (!itemName) continue;
const itemNameLower = itemName.toLowerCase();
const matchedKeyword = validKeywords.find(keyword => {
const kw = (keyword || '').toLowerCase().trim();
return kw && itemNameLower.includes(kw);
});
if (!matchedKeyword) continue;
// Extract amount and timestamp to create unique ID
let amount = '1';
let timestamp = '';
let container = itemDiv.parentElement;
while (container && container !== logDiv) {
const spans = container.querySelectorAll('span');
for (const span of spans) {
const spanText = (span.textContent || '').trim();
if (spanText.match(/\+?\d+/)) {
const amountMatch = spanText.match(/\+?(\d+)/);
if (amountMatch && amountMatch[1]) {
amount = amountMatch[1];
}
}
if (!timestamp && spanText && spanText.length > 0 && !spanText.match(/^\+?\d+$/)) {
timestamp = spanText;
}
}
if (amount !== '1' && timestamp) break;
container = container.parentElement;
}
// Mark current items as processed using unique ID
const uniqueId = `${itemNameLower}|${amount}|${timestamp}`;
DOMMonitor.processedItemDrops.add(uniqueId);
}
}
} catch (e) {
// Silently fail if DOM structure changes
}
}
}
this.reloadAudio();
// Show feedback
const saveBtn = this.modal?.querySelector('#iqrpg-save-btn');
if (saveBtn) {
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saved!';
saveBtn.style.background = '#4caf50';
setTimeout(() => {
saveBtn.textContent = originalText;
saveBtn.style.background = '';
}, CONSTANTS.DELAYS.SAVE_FEEDBACK);
}
},
resetSettings() {
// Reset to defaults
Object.assign(CONFIG, JSON.parse(JSON.stringify(DEFAULT_CONFIG)));
saveConfig();
this.reloadAudio();
// Recreate modal to reflect new settings
if (this.modalOverlay) {
this.modalOverlay.remove();
}
this.modal = null;
this.modalOverlay = null;
this.createModal();
},
toggleAllSoundAlerts() {
// Get current state - check if all are enabled or disabled
const allNotificationTypes = [
'globalEvents',
'actionBonus',
'bossSpawn',
'tradeAlert',
'message',
'autos',
'potions',
'dungeon',
'mastery',
'land',
'skills',
'itemDrop',
'abyssBattles',
'clan'
];
// Check gathering events
const gatheringTypes = ['woodcutting', 'quarrying', 'mining'];
// Determine if we should enable or disable (if any are enabled, disable all; otherwise enable all)
let anyEnabled = false;
for (const type of allNotificationTypes) {
if (CONFIG.notifications[type]?.sound) {
anyEnabled = true;
break;
}
}
if (!anyEnabled) {
for (const type of gatheringTypes) {
if (CONFIG.notifications.gatheringEvents[type]?.sound) {
anyEnabled = true;
break;
}
}
}
const newValue = !anyEnabled;
// Toggle all notification types
for (const type of allNotificationTypes) {
if (CONFIG.notifications[type]) {
CONFIG.notifications[type].sound = newValue;
}
}
// Toggle gathering events
for (const type of gatheringTypes) {
if (CONFIG.notifications.gatheringEvents[type]) {
CONFIG.notifications.gatheringEvents[type].sound = newValue;
}
}
// Update UI
this.refreshSettingsUI();
},
toggleAllDesktopNotifications() {
// Get current state - check if all are enabled or disabled
const allNotificationTypes = [
'globalEvents',
'actionBonus',
'bossSpawn',
'tradeAlert',
'message',
'autos',
'potions',
'dungeon',
'mastery',
'land',
'skills',
'itemDrop',
'abyssBattles',
'clan'
];
// Check gathering events
const gatheringTypes = ['woodcutting', 'quarrying', 'mining'];
// Determine if we should enable or disable (if any are enabled, disable all; otherwise enable all)
let anyEnabled = false;
for (const type of allNotificationTypes) {
if (CONFIG.notifications[type]?.desktop) {
anyEnabled = true;
break;
}
}
if (!anyEnabled) {
for (const type of gatheringTypes) {
if (CONFIG.notifications.gatheringEvents[type]?.desktop) {
anyEnabled = true;
break;
}
}
}
const newValue = !anyEnabled;
// Toggle all notification types
for (const type of allNotificationTypes) {
if (CONFIG.notifications[type]) {
CONFIG.notifications[type].desktop = newValue;
}
}
// Toggle gathering events
for (const type of gatheringTypes) {
if (CONFIG.notifications.gatheringEvents[type]) {
CONFIG.notifications.gatheringEvents[type].desktop = newValue;
}
}
// Update UI
this.refreshSettingsUI();
},
refreshSettingsUI() {
// Regenerate settings HTML to reflect current config
const body = this.modal.querySelector('.iqrpg-modal-body');
if (body) {
body.innerHTML = this.generateSettingsHTML();
// Re-initialize volume slider CSS variable
const volumeSlider = this.modal.querySelector('#iqrpg-volume-slider');
if (volumeSlider) {
const currentVolume = parseFloat(volumeSlider.value);
volumeSlider.style.setProperty('--volume-percent', `${currentVolume}%`);
}
}
},
openModal() {
if (this.modalOverlay) {
this.modalOverlay.classList.add('active');
// Update modal content with current config
const body = this.modal.querySelector('.iqrpg-modal-body');
body.innerHTML = this.generateSettingsHTML();
// Initialize volume slider CSS variable
const volumeSlider = this.modal.querySelector('#iqrpg-volume-slider');
if (volumeSlider) {
const currentVolume = parseFloat(volumeSlider.value);
volumeSlider.style.setProperty('--volume-percent', `${currentVolume}%`);
}
// No need to re-attach listeners - event delegation handles dynamic content
}
},
closeModal() {
if (this.modalOverlay) {
this.modalOverlay.classList.remove('active');
}
},
cleanup() {
if (this.escKeyHandler) {
document.removeEventListener('keydown', this.escKeyHandler);
this.escKeyHandler = null;
}
}
};
// ============================================
// Initialization
// ============================================
function init() {
// Initialize WebSocket interception
WebSocketInterceptor.init();
// Initialize GUI
GUIManager.init();
// Initialize DOM monitoring
DOMMonitor.init();
// Initialize image modal manager
ImageModalManager.init();
}
// Start initialization
init();
})();