// ==UserScript==
// @name FlatMMO Desktop Notifications
// @namespace com.pizza1337.flatmmo.desktopnotifications
// @version 1.0.3
// @description Adds desktop notifications for things such as: AFK detection, alien spawn and more
// @author Pizza1337
// @match *://flatmmo.com/play.php*
// @grant none
// @require https://update.greasyfork.org/scripts/544062/FlatMMOPlus.js
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const SOUND_LIBRARY = Object.freeze({
alien_ping: {
label: 'Alien Ping',
synth: {
duration: 0.85,
envelope: {
attack: 0.02,
hold: 0.15,
decay: 0.2,
sustain: 0.4,
release: 0.3
},
layers: [
{
type: 'square',
gain: 0.8,
sequence: [
{
time: 0,
freq: 520
},
{
time: 0.25,
freq: 680
},
{
time: 0.5,
freq: 460
}
],
vibrato: {
freq: 9,
depth: 12
}
},
{
type: 'triangle',
gain: 0.5,
sequence: [
{
time: 0,
freq: 260
},
{
time: 0.4,
freq: 330
}
]
}
]
}
},
glass_tinkle: {
label: 'Glass Tinkle',
synth: {
duration: 0.6,
envelope: {
attack: 0.004,
hold: 0.08,
decay: 0.18,
sustain: 0.35,
release: 0.22
},
layers: [
{
type: 'sine',
gain: 0.8,
sequence: [
{
time: 0,
freq: 1200
},
{
time: 0.12,
freq: 1450
},
{
time: 0.3,
freq: 1100
}
]
},
{
type: 'triangle',
gain: 0.3,
sequence: [
{
time: 0,
freq: 600
},
{
time: 0.2,
freq: 820
}
]
}
]
}
},
sparkle_drop: {
label: 'Sparkle Drop',
synth: {
duration: 0.65,
envelope: {
attack: 0.006,
hold: 0.05,
decay: 0.18,
sustain: 0.3,
release: 0.2
},
layers: [
{
type: 'square',
gain: 0.7,
sequence: [
{
time: 0,
freq: 980
},
{
time: 0.14,
freq: 1280
}
]
},
{
type: 'sine',
gain: 0.35,
sequence: [
{
time: 0,
freq: 490
},
{
time: 0.24,
freq: 640
}
]
}
]
}
},
comet_ping: {
label: 'Comet Ping',
synth: {
duration: 0.75,
envelope: {
attack: 0.008,
hold: 0.07,
decay: 0.22,
sustain: 0.32,
release: 0.28
},
layers: [
{
type: 'sawtooth',
gain: 0.6,
sequence: [
{
time: 0,
freq: 760
},
{
time: 0.3,
freq: 910
},
{
time: 0.5,
freq: 700
}
]
},
{
type: 'sine',
gain: 0.45,
sequence: [
{
time: 0,
freq: 380
},
{
time: 0.4,
freq: 520
}
]
}
]
}
},
ember_click: {
label: 'Ember Click',
synth: {
duration: 0.4,
envelope: {
attack: 0.003,
hold: 0.04,
decay: 0.12,
sustain: 0.25,
release: 0.18
},
layers: [
{
type: 'square',
gain: 0.8,
sequence: [
{
time: 0,
freq: 840
},
{
time: 0.1,
freq: 980
}
]
},
{
type: 'triangle',
gain: 0.35,
sequence: [
{
time: 0,
freq: 420
},
{
time: 0.16,
freq: 620
}
]
}
]
}
},
signal_tick: {
label: 'Signal Tick',
synth: {
duration: 0.45,
envelope: {
attack: 0.003,
hold: 0.03,
decay: 0.1,
sustain: 0.2,
release: 0.15
},
layers: [
{
type: 'square',
gain: 0.85,
sequence: [
{
time: 0,
freq: 920
},
{
time: 0.08,
freq: 1260
}
]
},
{
type: 'sine',
gain: 0.4,
sequence: [
{
time: 0,
freq: 310
},
{
time: 0.2,
freq: 520
}
]
}
]
}
}
});
const DEFAULT_SOUND_KEY = 'alien_ping';
const DEFAULT_AFK_SOUND_KEY = 'glass_tinkle';
const ALIEN_SOUND_OPTIONS = Object.freeze(
Object.entries(SOUND_LIBRARY).map(([value, meta]) => ({ value, label: meta.label }))
);
const NPC_TRACKING_NAMES = Object.freeze(['alien']);
const ALIEN_ICON = 'https://flatmmo.com/images/npcs/alien_stand1.png';
const AFK_ICON = 'https://flatmmo.com/images/ui/sleep.png';
const AFK_IDLE_ANIMATION = 'stand';
const AFK_CHECK_INTERVAL_MS = 1000;
const AFK_INTERACTION_EVENTS = Object.freeze(['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel']);
const AFK_INTERACTION_THROTTLE_MS = 250;
const SKILL_XP_VAR_KEYS = Object.freeze(['archery_xp', 'brewing_xp', 'cooking_xp', 'crafting_xp', 'enchantment_xp', 'farming_xp', 'firemake_xp', 'fishing_xp', 'forging_xp', 'health_xp', 'hunting_xp', 'magic_xp', 'melee_xp', 'mining_xp', 'woodcutting_xp', 'worship_xp']);
const DEFAULT_CONFIG = {
alienNotify: true,
alienSound: true,
alienSoundChoice: DEFAULT_SOUND_KEY,
alienSoundVolume: 100,
afkNotify: true,
afkSound: true,
afkSoundChoice: DEFAULT_AFK_SOUND_KEY,
afkSoundVolume: 100,
afkDurationValue: '30',
afkDurationUnits: 'seconds'
};
const DESPAWN_GRACE_MS = 600000;
class DesktopNotificationsPlugin extends FlatMMOPlusPlugin {
constructor() {
super('desktop-notifications', {
about: {
name: GM_info.script.name,
version: GM_info.script.version,
author: GM_info.script.author,
description: GM_info.script.description
},
config: [
{
type: 'label',
label: 'Alien spawn alerts'
},
{
id: 'alienNotify',
label: 'Notification',
type: 'boolean',
default: true
},
{
id: 'alienSound',
label: 'Sound',
type: 'boolean',
default: true
},
{
id: 'alienSoundChoice',
label: ' ',
type: 'select',
options: ALIEN_SOUND_OPTIONS,
default: DEFAULT_SOUND_KEY
},
{
id: 'alienSoundVolume',
label: 'Volume (%)',
type: 'range',
min: 0,
max: 100,
step: 10,
default: 100
},
{
type: 'label',
label: 'AFK detection alerts'
},
{
id: 'afkNotify',
label: 'Notification',
type: 'boolean',
default: true
},
{
id: 'afkSound',
label: 'Sound',
type: 'boolean',
default: true
},
{
id: 'afkSoundChoice',
label: ' ',
type: 'select',
options: ALIEN_SOUND_OPTIONS,
default: DEFAULT_AFK_SOUND_KEY
},
{
id: 'afkSoundVolume',
label: 'Volume (%)',
type: 'range',
min: 0,
max: 100,
step: 10,
default: 100
},
{
id: 'afkDurationValue',
label: 'AFK threshold',
type: 'select',
options: Array.from({ length: 60 }, function (_, i) { return { value: String(i + 1), label: String(i + 1) }; }),
default: '30'
},
{
id: 'afkDurationUnits',
label: 'AFK threshold units',
type: 'select',
options: [
{ value: 'seconds', label: 'Seconds' },
{ value: 'minutes', label: 'Minutes' }
],
default: 'seconds'
}
]
});
this.alienPresent = false;
this.lastSeenTimestamp = 0;
this.despawnTimer = null;
this.audioContext = null;
this.configCache = this.buildDefaultCache();
this._permissionPromise = null;
this.soundTesterObserver = null;
this.afkMonitorId = null;
this.afkState = this.createInitialAfkState();
this.afkAnimationWarningLogged = false;
this.afkXpWarningLogged = false;
this.xpVarWarningLogged = false;
this.lastTotalXpFromVars = NaN;
this.animationGuardWarningLogged = false;
this.npcLookupWarningLogged = false;
this.boundAfkInteractionHandler = null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.initSoundTester(), { once: true });
} else {
this.initSoundTester();
}
this.startAfkMonitor();
this.readTotalXpFromVars();
this.registerAfkInteractionGuards();
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('beforeunload', () => this.stopAfkMonitor(), { once: true });
}
}
onPaintNpcs() {
this.scanTrackedNpcPresence();
}
onConfigsChanged() {
this.configCache = this.buildConfigCache();
if (this.alienPresent) {
this.refreshPresenceTimer();
}
this.ensureSoundTester();
this.resetAfkState();
this.afkAnimationWarningLogged = false;
}
buildDefaultCache() {
return this.normalizeConfig(DEFAULT_CONFIG);
}
buildConfigCache() {
const afkDurationValue = (() => {
const value = this.getConfig('afkDurationValue');
return value !== undefined ? value : DEFAULT_CONFIG.afkDurationValue;
})();
const afkDurationUnits = this.getStringConfig('afkDurationUnits', DEFAULT_CONFIG.afkDurationUnits);
return this.normalizeConfig({
alienNotify: this.getBooleanConfig('alienNotify', DEFAULT_CONFIG.alienNotify),
alienSound: this.getBooleanConfig('alienSound', DEFAULT_CONFIG.alienSound),
alienSoundChoice: this.getStringConfig('alienSoundChoice', DEFAULT_CONFIG.alienSoundChoice),
alienSoundVolume: this.getNumberConfig('alienSoundVolume', DEFAULT_CONFIG.alienSoundVolume),
afkNotify: this.getBooleanConfig('afkNotify', DEFAULT_CONFIG.afkNotify),
afkSound: this.getBooleanConfig('afkSound', DEFAULT_CONFIG.afkSound),
afkSoundChoice: this.getStringConfig('afkSoundChoice', DEFAULT_CONFIG.afkSoundChoice),
afkSoundVolume: this.getNumberConfig('afkSoundVolume', DEFAULT_CONFIG.afkSoundVolume),
afkDurationValue,
afkDurationUnits
});
}
normalizeConfig(raw) {
const alienSoundKey = (typeof raw.alienSoundChoice === 'string' && SOUND_LIBRARY[raw.alienSoundChoice])
? raw.alienSoundChoice
: DEFAULT_SOUND_KEY;
if (raw.alienSoundChoice !== alienSoundKey) {
this.ensureConfigValue('alienSoundChoice', alienSoundKey);
}
const alienVolumePercent = this.clampPercent(raw.alienSoundVolume);
const afkSoundKey = (typeof raw.afkSoundChoice === 'string' && SOUND_LIBRARY[raw.afkSoundChoice])
? raw.afkSoundChoice
: DEFAULT_AFK_SOUND_KEY;
if (raw.afkSoundChoice !== afkSoundKey) {
this.ensureConfigValue('afkSoundChoice', afkSoundKey);
}
const afkVolumePercent = this.clampPercent(raw.afkSoundVolume ?? DEFAULT_CONFIG.afkSoundVolume);
let durationValue = null;
if (typeof raw.afkDurationValue === 'number' && Number.isFinite(raw.afkDurationValue)) {
durationValue = raw.afkDurationValue;
} else if (typeof raw.afkDurationValue === 'string' && raw.afkDurationValue.trim() !== '') {
const parsed = Number.parseInt(raw.afkDurationValue, 10);
if (!Number.isNaN(parsed)) {
durationValue = parsed;
}
}
if (!Number.isFinite(durationValue)) {
const fallback = Number.parseInt(DEFAULT_CONFIG.afkDurationValue, 10);
durationValue = Number.isNaN(fallback) ? 30 : fallback;
}
const clampedDurationValue = Math.min(60, Math.max(1, Math.round(durationValue)));
let durationUnits = (typeof raw.afkDurationUnits === 'string' && raw.afkDurationUnits.toLowerCase() === 'minutes')
? 'minutes'
: 'seconds';
const thresholdMs = clampedDurationValue * (durationUnits === 'minutes' ? 60000 : 1000);
const unitLabel = durationUnits === 'minutes'
? (clampedDurationValue === 1 ? 'minute' : 'minutes')
: (clampedDurationValue === 1 ? 'second' : 'seconds');
const thresholdLabel = `${clampedDurationValue} ${unitLabel}`;
return {
alien: {
notify: !!raw.alienNotify,
sound: !!raw.alienSound,
soundKey: alienSoundKey,
volumePercent: alienVolumePercent,
volumeLevel: alienVolumePercent / 1000
},
afk: {
notify: !!raw.afkNotify,
sound: !!raw.afkSound,
soundKey: afkSoundKey,
volumePercent: afkVolumePercent,
volumeLevel: afkVolumePercent / 1000,
durationValue: clampedDurationValue,
durationUnits,
thresholdMs,
thresholdLabel
}
};
}
clampPercent(value) {
const num = Number(value);
if (!Number.isFinite(num)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(num)));
}
getBooleanConfig(name, fallback) {
const value = this.getConfig(name);
if (typeof value === 'boolean') {
return value;
}
return fallback;
}
getNumberConfig(name, fallback) {
const value = this.getConfig(name);
if (typeof value === 'number' && !Number.isNaN(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return fallback;
}
getStringConfig(name, fallback) {
const value = this.getConfig(name);
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
return fallback;
}
ensureConfigValue(name, value) {
try {
if (typeof this.setConfig === 'function') {
this.setConfig(name, value);
return true;
}
if (typeof FlatMMOPlus !== 'undefined') {
if (typeof FlatMMOPlus.updateConfig === 'function') {
FlatMMOPlus.updateConfig(this.id, name, value);
return true;
}
if (typeof FlatMMOPlus.setConfig === 'function') {
FlatMMOPlus.setConfig(this.id, name, value);
return true;
}
}
} catch (err) {
// ignore inability to coerce stored config
}
return false;
}
createInitialAfkState() {
return {
standSince: null,
baselineXp: null,
lastXpValue: null,
lastXpTimestamp: 0,
lastInteraction: 0,
lastAnimation: null,
notified: false
};
}
resetAfkState() {
if (!this.afkState) {
this.afkState = this.createInitialAfkState();
return;
}
this.afkState.standSince = null;
this.afkState.baselineXp = null;
this.afkState.notified = false;
this.afkState.lastAnimation = null;
}
registerAfkInteractionGuards() {
if (typeof window === 'undefined') {
return;
}
if (this.boundAfkInteractionHandler) {
return;
}
const handler = () => this.handleUserInteraction();
AFK_INTERACTION_EVENTS.forEach((evt) => {
try {
window.addEventListener(evt, handler, true);
} catch (err) {
// ignore inability to attach interaction guards
}
});
this.boundAfkInteractionHandler = handler;
}
handleUserInteraction() {
const now = Date.now();
if (!this.afkState) {
this.afkState = this.createInitialAfkState();
}
const last = this.afkState.lastInteraction ?? 0;
if (now - last < AFK_INTERACTION_THROTTLE_MS) {
return;
}
this.afkState.lastInteraction = now;
this.afkState.standSince = null;
this.afkState.baselineXp = null;
this.afkState.notified = false;
}
startAfkMonitor() {
this.stopAfkMonitor();
if (typeof window === 'undefined' || typeof window.setInterval !== 'function') {
return;
}
this.afkMonitorId = window.setInterval(() => this.pollAfkState(), AFK_CHECK_INTERVAL_MS);
}
stopAfkMonitor() {
if (this.afkMonitorId !== null) {
if (typeof window !== 'undefined' && typeof window.clearInterval === 'function') {
window.clearInterval(this.afkMonitorId);
}
this.afkMonitorId = null;
}
}
pollAfkState() {
if (!this.afkState) {
this.afkState = this.createInitialAfkState();
}
const cache = this.configCache;
const afkConfig = cache?.afk;
if (!afkConfig) {
this.resetAfkState();
return;
}
const wantsNotification = !!afkConfig.notify;
const wantsSound = !!afkConfig.sound;
if (!wantsNotification && !wantsSound) {
this.resetAfkState();
return;
}
const thresholdMs = afkConfig.thresholdMs;
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) {
this.resetAfkState();
return;
}
if (typeof window === 'undefined') {
return;
}
const normalized = this.readLocalAnimationName();
if (!normalized) {
if (!this.afkAnimationWarningLogged) {
console.warn('[DesktopNotifications] Unable to determine local animation; AFK detection paused');
this.afkAnimationWarningLogged = true;
}
this.resetAfkState();
return;
}
this.afkAnimationWarningLogged = false;
const now = Date.now();
const totalXp = this.getCurrentTotalXp();
const hasXp = Number.isFinite(totalXp);
if (hasXp) {
this.afkXpWarningLogged = false;
this.afkState.lastXpValue = totalXp;
this.afkState.lastXpTimestamp = now;
}
const isStanding = normalized === AFK_IDLE_ANIMATION;
if (isStanding) {
if (!this.afkState.standSince) {
this.afkState.standSince = now;
}
if (!hasXp) {
this.afkState.baselineXp = null;
this.afkState.standSince = now;
this.afkState.notified = false;
} else if (!Number.isFinite(this.afkState.baselineXp)) {
this.afkState.baselineXp = totalXp;
this.afkState.standSince = now;
} else if (totalXp !== this.afkState.baselineXp) {
this.afkState.baselineXp = totalXp;
this.afkState.standSince = now;
this.afkState.notified = false;
}
const canDetermineAfk = hasXp && Number.isFinite(this.afkState.baselineXp);
if (canDetermineAfk && !this.afkState.notified && now - this.afkState.standSince >= thresholdMs && totalXp <= this.afkState.baselineXp) {
this.afkState.notified = true;
this.fireAfkAlert();
}
} else {
this.resetAfkState();
if (hasXp) {
this.afkState.lastXpValue = totalXp;
this.afkState.lastXpTimestamp = now;
}
}
if (!hasXp && !this.afkXpWarningLogged) {
console.warn('[DesktopNotifications] Unable to read total XP; AFK detection requires XP tracking.');
this.afkXpWarningLogged = true;
}
this.afkState.lastAnimation = normalized;
}
resolveGameWindow() {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow) {
return unsafeWindow;
}
return typeof window !== 'undefined' ? window : null;
}
readLocalAnimationName() {
const scope = this.resolveGameWindow();
if (!scope) {
return AFK_IDLE_ANIMATION;
}
try {
const globals = scope && scope.Globals ? scope.Globals : null;
const username = globals && typeof globals.local_username === 'string' ? globals.local_username : '';
const active = scope.active_animations;
if (username && active && typeof active === 'object') {
let entry = active[username];
if (!entry || typeof entry !== 'object') {
entry = active[username] = { animation_name: AFK_IDLE_ANIMATION };
return AFK_IDLE_ANIMATION;
}
const name = typeof entry.animation_name === 'string' ? entry.animation_name : '';
if (name) {
return name.toLowerCase();
}
entry.animation_name = AFK_IDLE_ANIMATION;
return AFK_IDLE_ANIMATION;
}
} catch (err) {
// ignore active animation lookup errors
}
const getter = scope.get_current_local_animation;
if (typeof getter === 'function') {
try {
const value = getter.call(scope);
if (typeof value === 'string' && value) {
const lower = value.toLowerCase();
try {
const globals = scope && scope.Globals ? scope.Globals : null;
const username = globals && typeof globals.local_username === 'string' ? globals.local_username : '';
if (username) {
if (!scope.active_animations || typeof scope.active_animations !== 'object') {
scope.active_animations = {};
}
const entry = scope.active_animations[username] || (scope.active_animations[username] = {});
entry.animation_name = lower;
}
} catch (innerErr) {
// ignore hydration errors
}
return lower;
}
} catch (err) {
// ignore errors from get_current_local_animation
}
}
return AFK_IDLE_ANIMATION;
}
guardAnimationGetter() {
if (DesktopNotificationsPlugin._animationGuarded) {
return;
}
const scope = this.resolveGameWindow();
if (!scope || typeof scope.get_current_local_animation !== 'function') {
return;
}
const original = scope.get_current_local_animation;
if (original.__desktopNotifyGuarded) {
DesktopNotificationsPlugin._animationGuarded = true;
return;
}
const plugin = this;
function guardedAnimationGetter() {
try {
const value = original.apply(this, arguments);
if (typeof value === 'string' && value) {
return value;
}
} catch (err) {
if (!plugin.animationGuardWarningLogged) {
console.debug('[DesktopNotifications] Guarded get_current_local_animation()', err);
plugin.animationGuardWarningLogged = true;
}
return plugin.recoverLocalAnimation(scope);
}
return plugin.recoverLocalAnimation(scope);
}
guardedAnimationGetter.__desktopNotifyGuarded = true;
scope.get_current_local_animation = guardedAnimationGetter;
DesktopNotificationsPlugin._animationGuarded = true;
}
recoverLocalAnimation(scope) {
try {
const globals = scope && scope.Globals ? scope.Globals : null;
const username = globals && typeof globals.local_username === 'string' ? globals.local_username : '';
if (!username) {
return AFK_IDLE_ANIMATION;
}
if (!scope.active_animations || typeof scope.active_animations !== 'object') {
scope.active_animations = {};
}
let entry = scope.active_animations[username];
if (!entry || typeof entry !== 'object') {
entry = scope.active_animations[username] = { animation_name: AFK_IDLE_ANIMATION };
} else if (typeof entry.animation_name !== 'string' || !entry.animation_name) {
entry.animation_name = AFK_IDLE_ANIMATION;
}
return entry.animation_name;
} catch (err) {
return AFK_IDLE_ANIMATION;
}
}
readTotalXpFromVars() {
const scope = this.resolveGameWindow();
if (!scope) {
return Number.isFinite(this.lastTotalXpFromVars) ? this.lastTotalXpFromVars : NaN;
}
const readVar = (key) => {
let value = NaN;
if (typeof scope.get_var === 'function') {
try {
value = scope.get_var(key);
} catch (err) {
// ignore get_var errors
}
}
if (!Number.isFinite(value)) {
try {
const loose = scope['var_' + key];
value = this.parseVarNumber(loose);
} catch (err) {
// ignore loose var errors
}
}
if (!Number.isFinite(value)) {
try {
const fallback = scope[key];
value = this.parseVarNumber(fallback);
} catch (err) {
// ignore fallback errors
}
}
return Number.isFinite(value) ? value : NaN;
};
let total = 0;
let foundAny = false;
for (const key of SKILL_XP_VAR_KEYS) {
const value = readVar(key);
if (Number.isFinite(value)) {
total += value;
foundAny = true;
}
}
if (foundAny) {
this.lastTotalXpFromVars = total;
this.xpVarWarningLogged = false;
return total;
}
if (typeof scope.get_var === 'function') {
try {
const fallbackGlobal = scope.get_var('global_xp');
if (Number.isFinite(fallbackGlobal)) {
this.lastTotalXpFromVars = fallbackGlobal;
this.xpVarWarningLogged = false;
return fallbackGlobal;
}
} catch (err) {
// ignore fallback errors
}
}
if (!this.xpVarWarningLogged) {
console.warn('[DesktopNotifications] Unable to read skill XP vars; relying on DOM totals.');
this.xpVarWarningLogged = true;
}
return Number.isFinite(this.lastTotalXpFromVars) ? this.lastTotalXpFromVars : NaN;
}
getCurrentTotalXp() {
const fromVars = this.readTotalXpFromVars();
if (Number.isFinite(fromVars)) {
return fromVars;
}
return this.readTotalXpFromDom();
}
readTotalXpFromDom() {
if (typeof document === 'undefined') {
return NaN;
}
try {
const globalEl = document.getElementById('ui-skill-global-xp');
if (globalEl) {
const value = this.extractFirstNumber(globalEl.textContent || globalEl.innerText || '');
if (Number.isFinite(value)) {
return value;
}
}
const xpNodes = document.querySelectorAll("span[id^='ui-skill-'][id$='-xp']");
let total = 0;
let found = false;
xpNodes.forEach((node) => {
if (node && typeof node.id === 'string' && node.id.indexOf('ui-skill-global-xp') !== -1) {
return;
}
const value = this.extractFirstNumber((node?.textContent) || (node?.innerText) || '');
if (Number.isFinite(value)) {
total += value;
found = true;
}
});
return found ? total : NaN;
} catch (err) {
return NaN;
}
}
parseVarNumber(raw) {
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : NaN;
}
if (typeof raw === 'string') {
const digits = raw.replace(/[^0-9-]/g, '');
if (!digits) {
return NaN;
}
const parsed = Number.parseInt(digits, 10);
return Number.isFinite(parsed) ? parsed : NaN;
}
return NaN;
}
extractFirstNumber(raw) {
if (typeof raw !== 'string') {
raw = raw == null ? '' : String(raw);
}
const match = raw.match(/(\d[\d,]*)/);
if (!match) {
return NaN;
}
const digits = match[1].replace(/[^0-9]/g, '');
if (!digits) {
return NaN;
}
const parsed = Number.parseInt(digits, 10);
return Number.isFinite(parsed) ? parsed : NaN;
}
fireAfkAlert() {
const cache = this.configCache;
const afkConfig = cache?.afk;
if (!afkConfig) {
return;
}
if (afkConfig.notify) {
const message = `You've been idle for ${afkConfig.thresholdLabel}.`;
this.showNotification('AFK detected', message, AFK_ICON);
}
if (afkConfig.sound) {
this.playLibrarySound(afkConfig.soundKey, afkConfig.volumeLevel);
}
}
listTrackedNpcNames() {
return NPC_TRACKING_NAMES.slice();
}
scanTrackedNpcPresence() {
const entries = this.readVisibleNpcEntries();
if (!entries.length) {
return;
}
if (entries.some((npc) => this.isTrackedAlienNpc(npc))) {
this.handleAlienSeen();
}
}
listVisibleNpcIdentifiers() {
return this.readVisibleNpcEntries().map((npc) => ({
uuid: typeof npc?.uuid === 'string' ? npc.uuid : '',
name: typeof npc?.name === 'string' ? npc.name : '',
label: typeof npc?.label === 'string' ? npc.label : ''
}));
}
readVisibleNpcEntries() {
const source = this.safeNpcTable();
if (!source) {
return [];
}
const entries = [];
for (const value of Object.values(source)) {
if (!value || typeof value !== 'object') {
continue;
}
if (value.is_hidden) {
continue;
}
entries.push(value);
}
return entries;
}
safeNpcTable() {
if (typeof window === 'undefined') {
return null;
}
try {
const scope = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
let table = null;
if (scope && typeof scope.npcs === 'object' && scope.npcs) {
table = scope.npcs;
} else if (typeof npcs !== 'undefined' && npcs && typeof npcs === 'object') {
table = npcs;
}
if (!table || typeof table !== 'object') {
return null;
}
this.npcLookupWarningLogged = false;
return table;
} catch (err) {
if (!this.npcLookupWarningLogged) {
console.warn('[DesktopNotifications] Unable to inspect NPC table', err);
this.npcLookupWarningLogged = true;
}
return null;
}
}
isTrackedAlienNpc(npc) {
const identifier = this.normalizeNpcIdentifier(npc);
return identifier ? NPC_TRACKING_NAMES.includes(identifier) : false;
}
normalizeNpcIdentifier(npc) {
if (!npc) {
return '';
}
if (typeof npc.name === 'string' && npc.name.trim() !== '') {
return npc.name.trim().toLowerCase().replace(/\s+/g, '_');
}
if (typeof npc.label === 'string' && npc.label.trim() !== '') {
return npc.label.trim().toLowerCase().replace(/\s+/g, '_');
}
return '';
}
handleAlienSeen() {
const now = Date.now();
this.lastSeenTimestamp = now;
if (!this.alienPresent) {
this.alienPresent = true;
this.fireAlienAlert();
}
this.refreshPresenceTimer();
}
refreshPresenceTimer() {
if (this.despawnTimer) {
clearTimeout(this.despawnTimer);
this.despawnTimer = null;
}
if (!this.alienPresent) {
return;
}
this.despawnTimer = window.setTimeout(() => this.checkDespawn(), DESPAWN_GRACE_MS + 100);
}
checkDespawn() {
if (!this.alienPresent) {
return;
}
if (Date.now() - this.lastSeenTimestamp >= DESPAWN_GRACE_MS) {
this.alienPresent = false;
this.lastSeenTimestamp = 0;
this.despawnTimer = null;
} else {
this.refreshPresenceTimer();
}
}
fireAlienAlert() {
const alienConfig = this.configCache?.alien;
if (!alienConfig) {
return;
}
if (alienConfig.notify) {
this.showNotification('Alien sighted!', 'An alien has appeared on your map.', ALIEN_ICON);
}
if (alienConfig.sound) {
this.playLibrarySound(alienConfig.soundKey, alienConfig.volumeLevel);
}
}
ensureNotificationPermission() {
if (!('Notification' in window)) {
return Promise.resolve('denied');
}
if (Notification.permission !== 'default') {
return Promise.resolve(Notification.permission);
}
if (this._permissionPromise) {
return this._permissionPromise;
}
this._permissionPromise = new Promise((resolve) => {
const finish = (permission) => {
this._permissionPromise = null;
resolve(permission);
};
try {
const result = Notification.requestPermission((permission) => finish(permission));
if (result && typeof result.then === 'function') {
result.then(finish).catch(() => finish('denied'));
} else if (typeof result === 'string') {
finish(result);
} else {
window.setTimeout(() => finish(Notification.permission), 0);
}
} catch (err) {
finish('denied');
}
});
return this._permissionPromise;
}
async showNotification(title, body, icon = ALIEN_ICON) {
try {
const permission = await this.ensureNotificationPermission();
if (permission !== 'granted') {
return;
}
const notification = new Notification(title, {
body,
icon
});
window.setTimeout(() => {
try {
notification.close();
} catch (err) {
// ignore inability to close notifications
}
}, 10000);
} catch (err) {
console.warn('[DesktopNotifications] Unable to show notification', err);
}
}
playLibrarySound(soundKey, volumeLevel) {
const sound = SOUND_LIBRARY[soundKey] || SOUND_LIBRARY[DEFAULT_SOUND_KEY];
if (!sound) {
return;
}
if (sound.synth) {
this.playSynthSound(sound.synth, volumeLevel);
} else if (sound.url) {
this.playAudioElement(sound.url, volumeLevel);
}
}
playAudioElement(url, volumeLevel) {
try {
const audio = new Audio(url);
audio.crossOrigin = 'anonymous';
audio.volume = Math.max(0, Math.min(1, volumeLevel));
audio.play().catch(() => {});
} catch (err) {
console.warn('[DesktopNotifications] Failed to play audio element', err);
}
}
initSoundTester() {
if (typeof MutationObserver === 'function' && !this.soundTesterObserver) {
const target = document.body || document.documentElement;
if (target) {
this.soundTesterObserver = new MutationObserver(() => this.ensureSoundTester());
this.soundTesterObserver.observe(target, { childList: true, subtree: true });
}
}
this.ensureSoundTester();
}
ensureSoundTester() {
let attached = false;
['alien', 'afk'].forEach((scope) => {
attached = this.attachSoundTester(scope) || attached;
});
return attached;
}
attachSoundTester(scope) {
const selectId = `flatmmoplus-config-${this.id}-${scope}SoundChoice`;
const select = document.getElementById(selectId);
if (!select) {
return false;
}
if (select.dataset.desktopNotifyTesterAttached === '1') {
return true;
}
const parent = select.parentElement;
if (!parent) {
return false;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Play';
button.style.marginLeft = '6px';
button.className = 'desktop-notify-play-button';
button.addEventListener('click', () => {
const selectEl = document.getElementById(selectId);
const volumeEl = document.getElementById(`flatmmoplus-config-${this.id}-${scope}SoundVolume`);
const configSegment = this.configCache?.[scope] || {};
const defaultSoundKey = scope === 'afk' ? DEFAULT_AFK_SOUND_KEY : DEFAULT_SOUND_KEY;
const selectedKey = (selectEl && selectEl.value) || configSegment.soundKey || defaultSoundKey;
let volumePercent = configSegment.volumePercent ?? (scope === 'afk' ? DEFAULT_CONFIG.afkSoundVolume : DEFAULT_CONFIG.alienSoundVolume);
if (volumeEl && volumeEl.value !== '') {
const parsed = Number(volumeEl.value);
if (!Number.isNaN(parsed)) {
volumePercent = this.clampPercent(parsed);
}
}
this.playLibrarySound(selectedKey, Math.max(0, Math.min(1, volumePercent / 1000)));
});
parent.appendChild(button);
select.dataset.desktopNotifyTesterAttached = '1';
return true;
}
ensureAudioContext() {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
return null;
}
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new Ctx();
}
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().catch(() => {});
}
return this.audioContext;
}
playSynthSound(descriptor, volumeLevel) {
const ctx = this.ensureAudioContext();
if (!ctx) {
return;
}
const now = ctx.currentTime;
const duration = Math.max(0.1, descriptor?.duration ?? 1);
const envelope = descriptor?.envelope || {};
const attack = Math.max(0.005, envelope.attack ?? 0.05);
const hold = Math.max(0, envelope.hold ?? 0.2);
const decay = Math.max(0, envelope.decay ?? 0.3);
const sustainLevel = Math.max(0.05, Math.min(1, envelope.sustain ?? 0.5));
const release = Math.max(0.05, envelope.release ?? 0.4);
const peak = Math.max(0.0001, Math.min(1, volumeLevel ?? 0.05));
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(0.0001, now);
masterGain.gain.exponentialRampToValueAtTime(peak, now + attack);
masterGain.gain.setValueAtTime(peak, now + attack + hold);
const decayTarget = Math.max(0.0001, peak * sustainLevel);
masterGain.gain.exponentialRampToValueAtTime(decayTarget, now + attack + hold + decay);
const stopTime = now + duration;
masterGain.gain.setValueAtTime(decayTarget, stopTime);
masterGain.gain.exponentialRampToValueAtTime(0.0001, stopTime + release);
masterGain.connect(ctx.destination);
const cleanupFns = [];
const scheduleCleanup = (() => {
let cleaned = false;
return () => {
if (cleaned) {
return;
}
cleaned = true;
cleanupFns.forEach((fn) => {
try {
fn();
} catch (err) {
// ignore cleanup errors
}
});
};
})();
cleanupFns.push(() => {
try {
masterGain.disconnect();
} catch (err) {
// ignore disconnect issues
}
});
const layers = Array.isArray(descriptor?.layers) ? descriptor.layers : [];
layers.forEach((layer) => {
try {
const osc = ctx.createOscillator();
osc.type = layer?.type || 'sine';
if (typeof layer?.detune === 'number') {
osc.detune.value = layer.detune;
}
const layerGain = ctx.createGain();
const layerScale = Math.max(0, Math.min(2, layer?.gain ?? 1));
layerGain.gain.setValueAtTime(layerScale, now);
layerGain.connect(masterGain);
osc.connect(layerGain);
const sequence = Array.isArray(layer?.sequence) && layer.sequence.length > 0
? layer.sequence
: [{ time: 0, freq: layer?.freq || 440 }];
sequence.forEach((step, index) => {
const clampedTime = now + Math.min(Math.max(step?.time ?? 0, 0), duration);
const freq = Math.max(20, step?.freq ?? layer?.freq ?? 440);
if (index === 0) {
osc.frequency.setValueAtTime(freq, clampedTime);
} else {
const glide = (layer?.glide || 'linear').toLowerCase();
if (glide === 'exponential') {
osc.frequency.exponentialRampToValueAtTime(freq, clampedTime);
} else {
osc.frequency.linearRampToValueAtTime(freq, clampedTime);
}
}
});
if (layer?.vibrato && typeof layer.vibrato.freq === 'number' && typeof layer.vibrato.depth === 'number') {
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = Math.max(0.1, layer.vibrato.freq);
const lfoGain = ctx.createGain();
lfoGain.gain.value = layer.vibrato.depth;
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
lfo.start(now);
const lfoStop = stopTime + release + 0.05;
lfo.stop(lfoStop);
cleanupFns.push(() => {
try {
lfo.disconnect();
lfoGain.disconnect();
} catch (err) {
// ignore cleanup issues
}
});
}
const oscStop = stopTime + release + 0.05;
osc.start(now);
osc.stop(oscStop);
osc.onended = scheduleCleanup;
cleanupFns.push(() => {
try {
osc.disconnect();
layerGain.disconnect();
} catch (err) {
// ignore disconnect issues
}
});
} catch (err) {
console.warn('[DesktopNotifications] Failed to create synth layer', err);
}
});
window.setTimeout(scheduleCleanup, Math.ceil((duration + release + 0.2) * 1000));
}
}
DesktopNotificationsPlugin._animationGuarded = false;
const plugin = new DesktopNotificationsPlugin();
FlatMMOPlus.registerPlugin(plugin);
})();