MWI Lab Ping

Plays a sound when your Labyrinth run goes idle.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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] }),
    };
})();