MWI Lab Ping

Plays a sound when your Labyrinth run goes idle.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         MWI Lab Ping
// @namespace    https://github.com/Jukkales/MWI-Scripts
// @version      1.0.0
// @description  Plays a sound when your Labyrinth run goes idle.
// @author       Jukkales
// @homepageURL  https://github.com/Jukkales/MWI-Scripts
// @supportURL   https://github.com/Jukkales/MWI-Scripts/issues
// @license      MIT
// @icon         https://www.milkywayidle.com/favicon.svg
// @match        https://www.milkywayidle.com/*
// @match        https://milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @match        https://www.milkywayidlecn.com/*
// @match        https://test.milkywayidlecn.com/*
// @run-at       document-start
// @noframes
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const NS = 'mwi-lab-ping';
    const SETTINGS_KEY_PREFIX = 'mwiLabPing:settings:';
    const DB_NAME = 'mwiLabPing';
    const DB_STORE = 'sounds';

    const TONE_VOLUME = 0.15;
    const FILE_VOLUME = 0.8;
    const COOLDOWN_MS = 3000;
    const STEP_GAP_MS = 70;
    const REPEAT_GAP_MS = 350;

    const CUSTOM_TONE = 'custom';
    const DEFAULT_SETTINGS = { soundEnabled: true, tone: 'alarm' };

    // Each step is [frequency in Hz, duration in ms].
    const TONES = {
        alarm: { label: 'Alarm', repeat: 2, steps: [[988, 160], [740, 160], [988, 160], [740, 320]] },
        chime: { label: 'Chime', repeat: 1, steps: [[523, 110], [659, 110], [784, 110], [1047, 380]] },
        ping: { label: 'Ping', repeat: 1, steps: [[1175, 90], [1175, 90]] },
        descending: { label: 'Descending', repeat: 1, steps: [[1175, 130], [880, 130], [659, 280]] },
        buzzer: { label: 'Buzzer', repeat: 2, steps: [[220, 260], [180, 380]] },
    };

    const LAB_ACTION_HRID = '/actions/labyrinth/explore';
    const INIT_MARKER = '"type":"init_character_data"';
    const ACTIONS_MARKER = '"type":"actions_updated"';

    // Class names carry a per-build hash suffix, so only the prefix is stable.
    const ANCHOR_SELECTOR = '[class*="LabyrinthPanel_chargeLeft"]';
    const GAME_BUTTON_SELECTOR = 'button[class*="Button_button"]';

    const warn = (...args) => console.warn('[LabPing]', ...args);

    // --- Storage -------------------------------------------------------------

    function openDb() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, 1);
            request.onupgradeneeded = () => request.result.createObjectStore(DB_STORE);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function dbRun(mode, action) {
        const db = await openDb();
        try {
            return await new Promise((resolve, reject) => {
                const tx = db.transaction(DB_STORE, mode);
                const request = action(tx.objectStore(DB_STORE));
                tx.onerror = () => reject(tx.error);
                tx.oncomplete = () => resolve(request ? request.result : undefined);
            });
        } finally {
            db.close();
        }
    }

    // --- State ---------------------------------------------------------------

    let characterId = null;
    let settings = { ...DEFAULT_SETTINGS };
    let customSound = null;

    function loadSettings() {
        settings = { ...DEFAULT_SETTINGS };
        if (!characterId) return;
        try {
            const stored = localStorage.getItem(SETTINGS_KEY_PREFIX + characterId);
            if (stored) Object.assign(settings, JSON.parse(stored));
        } catch (error) {
            warn('could not read settings:', error);
        }
    }

    function saveSettings() {
        if (!characterId) return;
        try {
            localStorage.setItem(SETTINGS_KEY_PREFIX + characterId, JSON.stringify(settings));
        } catch (error) {
            warn('could not persist settings:', error);
        }
    }

    // Audio files are far too large for localStorage once base64 encoded.
    async function loadCustomSound() {
        if (customSound) URL.revokeObjectURL(customSound.url);
        customSound = null;
        if (!characterId) return;
        try {
            const record = await dbRun('readonly', (store) => store.get(characterId));
            if (record?.blob) customSound = { name: record.name, url: URL.createObjectURL(record.blob) };
        } catch (error) {
            warn('could not read custom sound:', error);
        }
    }

    async function saveCustomSound(file) {
        if (!characterId) return;
        await dbRun('readwrite', (store) => store.put({ name: file.name, blob: file }, characterId));
        await loadCustomSound();
    }

    async function removeCustomSound() {
        if (!characterId) return;
        await dbRun('readwrite', (store) => store.delete(characterId));
        await loadCustomSound();
    }

    // --- Playback ------------------------------------------------------------

    let audioContext = null;
    let lastPlayedAt = 0;

    function scheduleStep(frequency, startAt, duration) {
        const oscillator = audioContext.createOscillator();
        const gain = audioContext.createGain();

        oscillator.type = 'sine';
        oscillator.frequency.value = frequency;

        // Ramps instead of hard edges, which would click.
        gain.gain.setValueAtTime(0.0001, startAt);
        gain.gain.exponentialRampToValueAtTime(TONE_VOLUME, startAt + 0.01);
        gain.gain.setValueAtTime(TONE_VOLUME, startAt + duration - 0.03);
        gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);

        oscillator.connect(gain).connect(audioContext.destination);
        oscillator.start(startAt);
        oscillator.stop(startAt + duration + 0.02);
    }

    function playTone(tone) {
        audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)();
        if (audioContext.state === 'suspended') audioContext.resume();

        let at = audioContext.currentTime + 0.03;
        for (let pass = 0; pass < tone.repeat; pass++) {
            for (const [frequency, duration] of tone.steps) {
                scheduleStep(frequency, at, duration / 1000);
                at += (duration + STEP_GAP_MS) / 1000;
            }
            at += REPEAT_GAP_MS / 1000;
        }
    }

    function playFile() {
        const audio = new Audio(customSound.url);
        audio.volume = FILE_VOLUME;
        audio.play()?.catch((error) => warn('playback blocked:', error));
    }

    function play(toneKey) {
        try {
            if (toneKey === CUSTOM_TONE && customSound) playFile();
            else playTone(TONES[toneKey] || TONES[DEFAULT_SETTINGS.tone]);
        } catch (error) {
            warn('audio failed:', error);
        }
    }

    function notify() {
        if (!settings.soundEnabled) return;
        const now = Date.now();
        if (now - lastPlayedAt < COOLDOWN_MS) return;
        lastPlayedAt = now;
        play(settings.tone);
    }

    // --- Game state ----------------------------------------------------------

    const runningActions = new Set();
    let wasRunning = false;

    function trackActions(actions, replaceAll) {
        if (!Array.isArray(actions)) return;
        if (replaceAll) runningActions.clear();

        for (const action of actions) {
            if (action?.actionHrid !== LAB_ACTION_HRID) continue;
            if (action.isDone === false) runningActions.add(action.id);
            else runningActions.delete(action.id);
        }
    }

    function handleMessage(raw) {
        // Substring test first: market and chat payloads are huge and frequent,
        // and must never reach JSON.parse.
        const isInit = raw.includes(INIT_MARKER);
        const isActions = !isInit && raw.includes(ACTIONS_MARKER);
        if (!isInit && !isActions) return;

        let message;
        try {
            message = JSON.parse(raw);
        } catch {
            return;
        }

        if (isInit) {
            const id = message.character?.id;
            if (id && id !== characterId) {
                characterId = id;
                loadSettings();
                loadCustomSound();
            }
            // Seed only, so reloading mid-run stays silent.
            trackActions(message.characterActions, true);
            wasRunning = runningActions.size > 0;
            return;
        }

        trackActions(message.endCharacterActions, false);

        const isRunning = runningActions.size > 0;
        if (wasRunning && !isRunning) notify();
        wasRunning = isRunning;
    }

    // --- Game socket ---------------------------------------------------------

    const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    const seenEvents = new WeakSet();

    const isGameSocket = (socket) =>
        typeof socket?.url === 'string' && socket.url.includes('milkywayidle') && socket.url.includes('/ws');

    // Hooking the event getter rather than the WebSocket constructor keeps this
    // working regardless of which script owns window.WebSocket, and regardless of
    // load order.
    function hookSocketMessages() {
        const descriptor = Object.getOwnPropertyDescriptor(pageWindow.MessageEvent.prototype, 'data');
        if (!descriptor?.get || descriptor.get.hooked) return;

        const original = descriptor.get;

        descriptor.get = function () {
            const data = original.call(this);
            if (!seenEvents.has(this) && typeof data === 'string' && isGameSocket(this.currentTarget)) {
                seenEvents.add(this);
                try {
                    handleMessage(data);
                } catch (error) {
                    warn('handler failed:', error);
                }
            }
            return data;
        };

        descriptor.get.hooked = true;
        Object.defineProperty(pageWindow.MessageEvent.prototype, 'data', descriptor);
    }

    // --- Styles --------------------------------------------------------------

    const STYLE = `
.${NS}-button {
    margin-left: 8px;
    vertical-align: middle;
}
.${NS}-button-fallback {
    padding: 2px 8px;
    font: inherit;
    font-size: 0.85em;
    color: inherit;
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.25);
    border-radius: 4px;
    cursor: pointer;
}
.${NS}-popup {
    color-scheme: dark;
    position: fixed;
    z-index: 10000;
    display: flex;
    flex-direction: column;
    gap: 10px;
    min-width: 240px;
    padding: 12px;
    font-size: 0.9em;
    color: #eee;
    background: #23252b;
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 6px;
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.${NS}-popup h4 {
    margin: 0;
    font-size: 1em;
    font-weight: 600;
}
.${NS}-popup label {
    cursor: pointer;
}
.${NS}-row {
    display: flex;
    gap: 8px;
    align-items: center;
}
.${NS}-row input[type='checkbox'] {
    margin: 0;
    cursor: pointer;
}
.${NS}-popup select {
    flex: 1;
    min-width: 0;
    padding: 3px 22px 3px 6px;
    font: inherit;
    /* Native selects ignore background-color on WebKit until appearance is off. */
    appearance: none;
    -webkit-appearance: none;
    color: #eee !important;
    background-color: #31343c !important;
    background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%23cccccc' d='M0 1h12L6 8z'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 6px center;
    background-size: 9px 6px;
    border: 1px solid rgba(255, 255, 255, 0.25);
    border-radius: 4px;
    cursor: pointer;
}
/* The open dropdown is drawn by the OS and inherits nothing from the select. */
.${NS}-popup select option {
    color: #eee;
    background-color: #31343c;
}
.${NS}-play {
    flex: 0 0 auto;
    width: 24px;
    height: 24px;
    padding: 0;
    font-size: 0.8em;
    line-height: 1;
    color: #eee;
    background-color: #31343c;
    border: 1px solid rgba(255, 255, 255, 0.25);
    border-radius: 4px;
    cursor: pointer;
}
.${NS}-play:hover {
    background-color: #3c404a;
}
.${NS}-file {
    display: flex;
    gap: 8px;
    align-items: center;
    justify-content: space-between;
}
.${NS}-file-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 150px;
    font-size: 0.85em;
    opacity: 0.75;
}
.${NS}-link {
    padding: 0;
    font: inherit;
    font-size: 0.85em;
    color: #7fb3ff;
    background: none;
    border: none;
    cursor: pointer;
    text-decoration: underline;
}
.${NS}-hidden {
    display: none;
}
`;

    function injectStyle() {
        if (document.getElementById(`${NS}-style`)) return;
        const style = document.createElement('style');
        style.id = `${NS}-style`;
        style.textContent = STYLE;
        document.head.appendChild(style);
    }

    // --- Settings popup ------------------------------------------------------

    let popup = null;

    function closePopup() {
        if (!popup) return;
        popup.remove();
        popup = null;
        document.removeEventListener('mousedown', onOutsideClick, true);
        document.removeEventListener('keydown', onKeyDown, true);
        window.removeEventListener('scroll', closePopup, true);
        window.removeEventListener('resize', closePopup);
    }

    function onOutsideClick(event) {
        const target = event.target;
        if (popup.contains(target)) return;
        if (target?.closest?.(`.${NS}-button`)) return;
        closePopup();
    }

    function onKeyDown(event) {
        if (event.key === 'Escape') closePopup();
    }

    function buildPopup() {
        const element = document.createElement('div');
        element.className = `${NS}-popup`;

        const options = Object.entries(TONES)
            .map(([key, tone]) => `<option value="${key}">${tone.label}</option>`)
            .join('');

        element.innerHTML = `
            <h4>Notification Settings</h4>
            <label class="${NS}-row">
                <input type="checkbox" data-role="enabled">
                <span>Play sound when lab is idle</span>
            </label>
            <div class="${NS}-row">
                <label for="${NS}-tone">Sound</label>
                <select id="${NS}-tone" data-role="tone">
                    ${options}
                    <option value="${CUSTOM_TONE}">Custom file…</option>
                </select>
                <button type="button" class="${NS}-play" data-role="play" title="Play">&#9654;</button>
            </div>
            <div class="${NS}-file ${NS}-hidden" data-role="file-row">
                <span class="${NS}-file-name" data-role="file-name"></span>
                <span class="${NS}-row">
                    <button type="button" class="${NS}-link" data-role="pick">Choose…</button>
                    <button type="button" class="${NS}-link ${NS}-hidden" data-role="clear">Remove</button>
                </span>
            </div>
            <input type="file" accept="audio/*" class="${NS}-hidden" data-role="file-input">
        `;

        const find = (role) => element.querySelector(`[data-role="${role}"]`);

        function renderFileRow() {
            find('file-row').classList.toggle(`${NS}-hidden`, settings.tone !== CUSTOM_TONE);
            find('file-name').textContent = customSound ? customSound.name : 'No file selected';
            find('clear').classList.toggle(`${NS}-hidden`, !customSound);
        }

        find('enabled').checked = settings.soundEnabled;
        find('enabled').addEventListener('change', (event) => {
            settings.soundEnabled = event.target.checked;
            saveSettings();
        });

        find('tone').value = settings.tone;
        find('tone').addEventListener('change', (event) => {
            settings.tone = event.target.value;
            saveSettings();
            renderFileRow();
            if (settings.tone !== CUSTOM_TONE || customSound) play(settings.tone);
        });

        find('play').addEventListener('click', () => play(settings.tone));
        find('pick').addEventListener('click', () => find('file-input').click());

        find('file-input').addEventListener('change', async (event) => {
            const file = event.target.files?.[0];
            event.target.value = '';
            if (!file) return;
            try {
                await saveCustomSound(file);
                renderFileRow();
                play(CUSTOM_TONE);
            } catch (error) {
                warn('could not store sound:', error);
            }
        });

        find('clear').addEventListener('click', async () => {
            await removeCustomSound();
            renderFileRow();
        });

        renderFileRow();
        return element;
    }

    function openPopup(button) {
        closePopup();
        popup = buildPopup();
        document.body.appendChild(popup);

        // Fixed positioning so the panel's overflow cannot clip it.
        const rect = button.getBoundingClientRect();
        popup.style.top = `${rect.bottom + 6}px`;
        popup.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - popup.offsetWidth - 8))}px`;

        document.addEventListener('mousedown', onOutsideClick, true);
        document.addEventListener('keydown', onKeyDown, true);
        window.addEventListener('scroll', closePopup, true);
        window.addEventListener('resize', closePopup);
    }

    // --- Button --------------------------------------------------------------

    function mountButton() {
        const anchor = document.querySelector(ANCHOR_SELECTOR);
        if (!anchor) {
            closePopup();
            return;
        }
        if (anchor.querySelector(`.${NS}-button`)) return;

        injectStyle();

        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = 'Notification Settings';

        // Borrow the game's own button classes, hash suffix and all.
        const template = anchor.querySelector(GAME_BUTTON_SELECTOR) || document.querySelector(GAME_BUTTON_SELECTOR);
        button.className = template ? `${template.className} ${NS}-button` : `${NS}-button ${NS}-button-fallback`;

        button.addEventListener('click', (event) => {
            event.stopPropagation();
            if (popup) closePopup();
            else openPopup(button);
        });

        anchor.appendChild(button);
    }

    // The panel is re-rendered by React, so the button needs re-mounting.
    function watchDom() {
        let scheduled = false;
        const observer = new MutationObserver(() => {
            if (scheduled) return;
            scheduled = true;
            requestAnimationFrame(() => {
                scheduled = false;
                mountButton();
            });
        });

        observer.observe(document.body, { childList: true, subtree: true });
        mountButton();
    }

    // --- Start ---------------------------------------------------------------

    hookSocketMessages();

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', watchDom, { once: true });
    } else {
        watchDom();
    }

    pageWindow.__labPing = {
        play: () => play(settings.tone),
        state: () => ({ characterId, settings, customSound: customSound?.name, running: [...runningActions] }),
    };
})();