MWI Lab Ping

Plays a sound when your Labyrinth run goes idle.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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] }),
    };
})();