OpenFront.io - Audio Notifications

Plays audio notifications for game events: incoming threats (MIRV, Nuke, Hydrogen Bomb, Naval Invasion), chat/emoji messages, troop capacity warnings (64%/82%), alliance expiry & endings, betrayals, and boat arrivals

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

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         OpenFront.io - Audio Notifications
// @namespace    https://github.com/antigrid/openfront-audio-notifications
// @version      0.0.1
// @description  Plays audio notifications for game events: incoming threats (MIRV, Nuke, Hydrogen Bomb, Naval Invasion), chat/emoji messages, troop capacity warnings (64%/82%), alliance expiry & endings, betrayals, and boat arrivals
// @author       antigrid (Discord: webdev.js)
// @match        https://*.openfront.io/*
// @match        https://*.openfront.dev/*
// @icon         https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f50a.svg
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license      MIT
// @homepageURL  https://github.com/antigrid/openfront-audio-notifications
// @supportURL   https://github.com/antigrid/openfront-audio-notifications/issues
// ==/UserScript==

(function () {
  "use strict";

  const CONFIG = {
    STORAGE_KEY: "ofio-audio-notifications-settings",
    POSITION_STORAGE_KEY: "ofio-audio-notifications-position",
    VISIBILITY_STORAGE_KEY: "ofio-audio-notifications-visibility",
    DEFAULT_VOLUME: 100,
    DEFAULT_ENABLED: true,
    DEFAULT_PANEL_VISIBLE: true,
  };

  const SOUND_CATEGORIES = [
    {
      name: "Incoming Threats",
      sounds: [
        { key: "mirv", label: "MIRV" },
        { key: "nuke", label: "Nuke" },
        { key: "hydrogenBomb", label: "Hydrogen Bomb" },
        { key: "navalInvasion", label: "Naval Invasion" },
      ],
    },
    {
      name: "Communication",
      sounds: [{ key: "chat", label: "Chat & Emoji" }],
    },
    {
      name: "Troop Management",
      sounds: [
        { key: "troopCapacityWarning", label: "Capacity Warning (64%)" },
        { key: "troopCapacityCritical", label: "Capacity Critical (82%)" },
      ],
    },
    {
      name: "Alliance Events",
      sounds: [
        { key: "allianceRequest", label: "Alliance Request" },
        { key: "allianceRequestAccepted", label: "Request Accepted" },
        { key: "allianceRequestRejected", label: "Request Rejected" },
        { key: "allianceExpiringSoon", label: "Expiring Soon" },
        { key: "allianceEnded", label: "Alliance Ended" },
        { key: "betrayed", label: "Betrayed" },
        { key: "allyDisconnected", label: "Ally Disconnected" },
      ],
    },
    {
      name: "Game Events",
      sounds: [
        { key: "boatArrival", label: "Boat Arrival" },
        { key: "tradeShipCaptured", label: "Trade Ship Captured" },
        { key: "warshipCombat", label: "Warship Combat" },
      ],
    },
  ];

  let panelState = {
    enabled: CONFIG.DEFAULT_ENABLED,
    volume: CONFIG.DEFAULT_VOLUME,
    minimized: false,
    settingsOpen: false,
    panelVisible: true,
    sounds: {
      mirv: true,
      nuke: true,
      hydrogenBomb: true,
      navalInvasion: true,
      chat: true,
      troopCapacityWarning: false,
      troopCapacityCritical: true,
      allianceRequest: true,
      allianceRequestAccepted: true,
      allianceRequestRejected: true,
      allianceExpiringSoon: true,
      allianceEnded: true,
      betrayed: true,
      allyDisconnected: true,
      boatArrival: true,
      tradeShipCaptured: true,
      warshipCombat: true,
    },
  };

  function loadSettings() {
    try {
      const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
      if (saved) {
        const parsed = JSON.parse(saved);
        panelState.enabled = parsed.enabled ?? CONFIG.DEFAULT_ENABLED;
        panelState.volume = Math.max(0, Math.min(100, parsed.volume ?? CONFIG.DEFAULT_VOLUME));
        panelState.minimized = parsed.minimized ?? false;
        panelState.settingsOpen = parsed.settingsOpen ?? false;
        if (parsed.sounds) {
          Object.keys(panelState.sounds).forEach((key) => {
            if (typeof parsed.sounds[key] === "boolean") {
              panelState.sounds[key] = parsed.sounds[key];
            }
          });
        }
      }
      panelState.panelVisible = loadPanelVisibility();
    } catch (_) {}
  }

  function saveSettings() {
    try {
      localStorage.setItem(
        CONFIG.STORAGE_KEY,
        JSON.stringify({
          enabled: panelState.enabled,
          volume: panelState.volume,
          minimized: panelState.minimized,
          settingsOpen: panelState.settingsOpen,
          sounds: panelState.sounds,
        }),
      );
    } catch (_) {}
  }

  function loadPanelPosition() {
    try {
      const saved = localStorage.getItem(CONFIG.POSITION_STORAGE_KEY);
      if (!saved) return null;
      const parsed = JSON.parse(saved);
      if (typeof parsed?.x !== "number" || typeof parsed?.y !== "number") return null;
      return { x: parsed.x, y: parsed.y };
    } catch (_) {
      return null;
    }
  }

  function savePanelPosition(x, y) {
    try {
      localStorage.setItem(CONFIG.POSITION_STORAGE_KEY, JSON.stringify({ x, y }));
    } catch (_) {}
  }

  function loadPanelVisibility() {
    try {
      const saved = localStorage.getItem(CONFIG.VISIBILITY_STORAGE_KEY);
      if (saved !== null) {
        return saved === "true";
      }
      return CONFIG.DEFAULT_PANEL_VISIBLE;
    } catch (_) {
      return CONFIG.DEFAULT_PANEL_VISIBLE;
    }
  }

  function savePanelVisibility(visible) {
    try {
      localStorage.setItem(CONFIG.VISIBILITY_STORAGE_KEY, String(visible));
    } catch (_) {}
  }

  let menuCommandId = null;

  function registerMenuCommand() {
    if (menuCommandId !== null) {
      try {
        GM_unregisterMenuCommand(menuCommandId);
      } catch (_) {}
    }

    const menuLabel = panelState.panelVisible ? "Hide Audio Panel" : "Show Audio Panel";
    menuCommandId = GM_registerMenuCommand(menuLabel, togglePanelVisibility);
  }

  function togglePanelVisibility() {
    panelState.panelVisible = !panelState.panelVisible;
    savePanelVisibility(panelState.panelVisible);
    applyPanelVisibility();
    registerMenuCommand();
  }

  function applyPanelVisibility() {
    const panel = document.getElementById("ofio-audio-panel");
    if (panel) {
      panel.style.display = panelState.panelVisible ? "" : "none";
    }
  }

  function clampPanelPosition(panel, x, y) {
    const rect = panel.getBoundingClientRect();
    const maxX = Math.max(0, window.innerWidth - rect.width);
    const maxY = Math.max(0, window.innerHeight - rect.height);
    return {
      x: Math.min(Math.max(0, x), maxX),
      y: Math.min(Math.max(0, y), maxY),
    };
  }

  function isSoundEnabled(soundKey) {
    return panelState.enabled && panelState.sounds[soundKey];
  }

  const HOOK_FLAG = "__ofioAudioHooked";

  const GameUpdateType = {
    Unit: 1,
    Player: 2,
    DisplayChatEvent: 4,
    AllianceRequest: 5,
    AllianceRequestReply: 6,
    BrokeAlliance: 7,
    AllianceExpired: 8,
    Emoji: 11,
    UnitIncoming: 14,
  };

  const UnitType = {
    TransportShip: "Transport",
    TradeShip: "Trade Ship",
    Warship: "Warship",
  };

  const MessageType = {
    MIRV_INBOUND: 4,
    NUKE_INBOUND: 5,
    HYDROGEN_BOMB_INBOUND: 6,
    NAVAL_INVASION_INBOUND: 7,
  };

  const TROOP_CAPACITY_WARNING_THRESHOLD = 0.64;
  const TROOP_CAPACITY_CRITICAL_THRESHOLD = 0.82;

  // Alliance expiring prompt offset in ticks (30 seconds = 300 ticks at 10 ticks/sec)
  const ALLIANCE_EXTENSION_PROMPT_OFFSET = 300;

  let audioContext = null;

  function getAudioContext() {
    if (!audioContext) {
      audioContext = new (window.AudioContext || window.webkitAudioContext)();
    }
    return audioContext;
  }

  function playTone({
    type = "sine",
    startFreq,
    endFreq,
    volume = 0.12,
    duration = 0.1,
    delay = 0,
    attack = 0.004,
    release = 0.04,
    ignoreSettings = false,
  }) {
    try {
      if (!ignoreSettings) {
        volume = volume * (panelState.volume / 100);
      }

      const ctx = getAudioContext();
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();

      osc.type = type;
      osc.connect(gain);
      gain.connect(ctx.destination);

      const startTime = ctx.currentTime + delay;
      const endTime = startTime + Math.max(0.001, duration);

      osc.frequency.setValueAtTime(Math.max(1, startFreq || 440), startTime);
      const targetEnd = endFreq ?? startFreq;
      if (targetEnd && targetEnd !== startFreq) {
        osc.frequency.exponentialRampToValueAtTime(Math.max(1, targetEnd), endTime);
      }

      const a = Math.min(attack, duration * 0.4);
      const r = Math.min(release, duration * 0.7);

      gain.gain.setValueAtTime(0.0001, startTime);
      gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, volume), startTime + a);
      gain.gain.setValueAtTime(Math.max(0.0002, volume), Math.max(startTime + a, endTime - r));
      gain.gain.exponentialRampToValueAtTime(0.0001, endTime);

      osc.start(startTime);
      osc.stop(endTime + 0.01);
    } catch (_) {}
  }

  function playClick({ freq = 900, volume = 0.1, duration = 0.045, delay = 0 }) {
    playTone({
      type: "square",
      startFreq: freq,
      endFreq: freq * 0.985,
      volume,
      duration,
      delay,
      attack: 0.002,
      release: 0.02,
    });
  }

  function arpeggio({ notes, type = "sine", volume = 0.1, noteDur = 0.08, gap = 0.03, delay = 0 }) {
    notes.forEach((f, i) => {
      playTone({
        type,
        startFreq: f,
        endFreq: f * 1.01,
        volume: volume * (1 - i * 0.06),
        duration: noteDur,
        delay: delay + i * (noteDur + gap),
        attack: 0.004,
        release: 0.04,
      });
    });
  }

  function burst({ count = 3, interval = 0.06, fn }) {
    for (let i = 0; i < count; i++) fn(i, i * interval);
  }

  const SOUNDS = {
    mirv() {
      for (let i = 0; i < 5; i++) {
        playTone({
          type: "sawtooth",
          startFreq: 2000 - i * 200,
          endFreq: 300 - i * 20,
          volume: 0.15,
          duration: 0.4,
          delay: i * 0.12,
        });
      }
    },
    nuke() {
      playTone({ type: "triangle", startFreq: 1800, endFreq: 120, volume: 0.25, duration: 1 });
    },
    hydrogenBomb() {
      playTone({ type: "sine", startFreq: 1500, endFreq: 100, volume: 0.3, duration: 0.8 });
      playTone({ type: "sawtooth", startFreq: 100, endFreq: 50, volume: 0.2, duration: 0.8 });
    },
    navalInvasion() {
      playTone({ type: "triangle", startFreq: 260, volume: 0.3, duration: 0.5 });
    },
    chat() {
      playTone({ type: "sine", startFreq: 740, volume: 0.12, duration: 0.05 });
      playTone({ type: "sine", startFreq: 988, volume: 0.1, duration: 0.06, delay: 0.06 });
    },
    troopCapacityWarning() {
      playClick({ freq: 900, volume: 0.15, duration: 0.06, delay: 0.0 });
      playClick({ freq: 1050, volume: 0.16, duration: 0.06, delay: 0.1 });
      playClick({ freq: 1200, volume: 0.17, duration: 0.06, delay: 0.2 });
    },
    troopCapacityCritical() {
      playClick({ freq: 900, volume: 0.16, duration: 0.06, delay: 0.0 });
      playClick({ freq: 1050, volume: 0.17, duration: 0.06, delay: 0.1 });
      playClick({ freq: 1350, volume: 0.18, duration: 0.06, delay: 0.2 });
    },
    allianceRequest() {
      playTone({
        type: "sine",
        startFreq: 520,
        endFreq: 520,
        volume: 0.075,
        duration: 0.12,
        attack: 0.004,
        release: 0.11,
      });
      playTone({
        type: "sine",
        startFreq: 1040,
        endFreq: 1040,
        volume: 0.045,
        duration: 0.18,
        delay: 0.02,
        attack: 0.004,
        release: 0.16,
      });
    },
    allianceExpiringSoon() {
      playTone({
        type: "sine",
        startFreq: 520,
        endFreq: 520,
        volume: 0.075,
        duration: 0.12,
        attack: 0.004,
        release: 0.11,
      });
      playTone({
        type: "sine",
        startFreq: 1040,
        endFreq: 1040,
        volume: 0.045,
        duration: 0.18,
        delay: 0.02,
        attack: 0.004,
        release: 0.16,
      });
    },
    allianceRequestAccepted() {
      burst({
        count: 5,
        interval: 0.055,
        fn: (i, d) =>
          playTone({
            type: "triangle",
            startFreq: 260 + i * 140,
            endFreq: 260 + i * 140 * 1.06,
            volume: 0.09,
            duration: 0.05,
            delay: d,
            attack: 0.002,
            release: 0.03,
          }),
      });
    },
    allianceRequestRejected() {
      burst({
        count: 5,
        interval: 0.07,
        fn: (i, d) => {
          const base = 980 - i * 150;
          const wobble = i % 2 === 0 ? 1 : 0.97;

          playTone({
            type: "triangle",
            startFreq: base * wobble,
            endFreq: base * 0.9,
            volume: 0.085,
            duration: 0.055,
            delay: d,
            attack: 0.002,
            release: 0.035,
          });
        },
      });
    },
    allianceEnded() {
      for (let i = 0; i < 3; i++) {
        playClick({ freq: 600 - i * 50, volume: 0.13, duration: 0.05, delay: i * 0.35 });
      }
    },
    betrayed() {
      playTone({
        type: "sine",
        startFreq: 80,
        volume: 0.3,
        duration: 0.08,
      });
      playTone({
        type: "sine",
        startFreq: 80,
        volume: 0.3,
        duration: 0.08,
        delay: 0.12,
      });
    },
    allyDisconnected() {
      playTone({
        type: "sine",
        startFreq: 660,
        endFreq: 620,
        volume: 0.1,
        duration: 0.12,
        attack: 0.003,
        release: 0.08,
      });
      playTone({
        type: "sine",
        startFreq: 520,
        endFreq: 480,
        volume: 0.09,
        duration: 0.12,
        delay: 0.14,
        attack: 0.003,
        release: 0.08,
      });
      playTone({
        type: "sine",
        startFreq: 390,
        endFreq: 350,
        volume: 0.08,
        duration: 0.15,
        delay: 0.28,
        attack: 0.003,
        release: 0.1,
      });
    },
    boatArrival() {
      playTone({
        type: "sine",
        startFreq: 1568,
        endFreq: 1660,
        volume: 0.09,
        duration: 0.06,
        attack: 0.002,
        release: 0.05,
      });
      playTone({
        type: "sine",
        startFreq: 2093,
        endFreq: 1976,
        volume: 0.07,
        duration: 0.07,
        delay: 0.03,
        attack: 0.002,
        release: 0.06,
      });
    },
    tradeShipCaptured() {
      burst({
        count: 1,
        interval: 0.08,
        fn: (i, d) =>
          playTone({
            type: "sawtooth",
            startFreq: 200 + i * 30,
            endFreq: 180 + i * 30,
            volume: 0.08,
            duration: 0.05,
            delay: d,
            attack: 0.002,
            release: 0.03,
          }),
      });
    },
    warshipCombat() {
      burst({
        count: 3,
        interval: 0.08,
        fn: (i, d) =>
          playTone({
            type: "sawtooth",
            startFreq: 200 + i * 30,
            endFreq: 180 + i * 30,
            volume: 0.08,
            duration: 0.05,
            delay: d,
            attack: 0.002,
            release: 0.03,
          }),
      });
    },
    infoPing() {
      arpeggio({ notes: [880, 1175], type: "sine", volume: 0.09, noteDur: 0.06, gap: 0.04 });
    },
    select() {
      playClick({ freq: 1100, volume: 0.1, duration: 0.04 });
      playClick({ freq: 1320, volume: 0.08, duration: 0.03, delay: 0.04 });
    },
    deselect() {
      playClick({ freq: 1100, volume: 0.08, duration: 0.04 });
      playClick({ freq: 880, volume: 0.06, duration: 0.03, delay: 0.04 });
    },
    dialogOpen() {
      playTone({ type: "sine", startFreq: 523, volume: 0.12, duration: 0.15 });
      playTone({ type: "sine", startFreq: 659, volume: 0.1, duration: 0.12, delay: 0.06 });
    },

    dialogClose() {
      playTone({ type: "sine", startFreq: 659, volume: 0.09, duration: 0.1 });
      playTone({ type: "sine", startFreq: 523, volume: 0.08, duration: 0.12, delay: 0.04 });
    },
  };

  const threatState = {
    processedUnitIds: new Set(),
    lastSoundTime: 0,
    soundCooldown: 500,
  };

  function handleIncomingThreats(game, updates) {
    if (!updates) return;

    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const incomingUnits = updates[GameUpdateType.UnitIncoming];
    if (!incomingUnits?.length) return;

    const now = Date.now();

    for (const event of incomingUnits) {
      if (event.playerID !== mySmallID) continue;
      if (threatState.processedUnitIds.has(event.unitID)) continue;

      threatState.processedUnitIds.add(event.unitID);

      if (now - threatState.lastSoundTime >= threatState.soundCooldown) {
        let soundPlayed = false;
        switch (event.messageType) {
          case MessageType.MIRV_INBOUND:
            if (isSoundEnabled("mirv")) {
              SOUNDS.mirv();
              soundPlayed = true;
            }
            break;
          case MessageType.NUKE_INBOUND:
            if (isSoundEnabled("nuke")) {
              SOUNDS.nuke();
              soundPlayed = true;
            }
            break;
          case MessageType.HYDROGEN_BOMB_INBOUND:
            if (isSoundEnabled("hydrogenBomb")) {
              SOUNDS.hydrogenBomb();
              soundPlayed = true;
            }
            break;
          case MessageType.NAVAL_INVASION_INBOUND:
            if (isSoundEnabled("navalInvasion")) {
              SOUNDS.navalInvasion();
              soundPlayed = true;
            }
            break;
        }
        if (soundPlayed) threatState.lastSoundTime = now;
      }
    }

    if (threatState.processedUnitIds.size > 100) {
      const ids = Array.from(threatState.processedUnitIds);
      threatState.processedUnitIds = new Set(ids.slice(-50));
    }
  }

  const chatState = {
    lastSoundTime: 0,
    soundCooldown: 300,
  };

  function handleChatNotifications(game, updates) {
    if (!updates) return;

    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();
    if (now - chatState.lastSoundTime < chatState.soundCooldown) return;

    // Chat: playerID is who sees the message, isFrom=true means it's FROM another player (incoming)
    const chatEvents = updates[GameUpdateType.DisplayChatEvent] ?? [];
    for (const event of chatEvents) {
      if (event.playerID === mySmallID && event.isFrom) {
        if (isSoundEnabled("chat")) {
          SOUNDS.chat();
          chatState.lastSoundTime = now;
        }
        return;
      }
    }

    const emojiEvents = updates[GameUpdateType.Emoji] ?? [];
    for (const event of emojiEvents) {
      const recipientID = event.emoji?.recipientID;
      const senderID = event.emoji?.senderID;
      if (recipientID === mySmallID && senderID !== mySmallID) {
        if (isSoundEnabled("chat")) {
          SOUNDS.chat();
          chatState.lastSoundTime = now;
        }
        return;
      }
    }
  }

  const allianceRequestState = {
    lastSoundTime: 0,
    soundCooldown: 300,
  };

  function handleAllianceRequestNotifications(game, updates) {
    if (!updates) return;

    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();

    const allianceRequests = updates[GameUpdateType.AllianceRequest] ?? [];
    for (const event of allianceRequests) {
      if (event.recipientID === mySmallID && event.requestorID !== mySmallID) {
        try {
          const requestor = game.playerBySmallID?.(event.requestorID);
          if (!requestor?.isAlive?.()) {
            continue;
          }
        } catch (_) {
          continue;
        }

        if (now - allianceRequestState.lastSoundTime >= allianceRequestState.soundCooldown) {
          if (isSoundEnabled("allianceRequest")) {
            SOUNDS.allianceRequest();
            allianceRequestState.lastSoundTime = now;
          }
        }
        return;
      }
    }

    const allianceReplies = updates[GameUpdateType.AllianceRequestReply] ?? [];
    for (const event of allianceReplies) {
      if (event.request?.requestorID === mySmallID && event.request?.recipientID !== mySmallID) {
        if (now - allianceRequestState.lastSoundTime >= allianceRequestState.soundCooldown) {
          if (event.accepted) {
            if (isSoundEnabled("allianceRequestAccepted")) {
              SOUNDS.allianceRequestAccepted();
              allianceRequestState.lastSoundTime = now;
            }
          } else {
            if (isSoundEnabled("allianceRequestRejected")) {
              SOUNDS.allianceRequestRejected();
              allianceRequestState.lastSoundTime = now;
            }
          }
        }
        return;
      }
    }
  }

  const troopCapacityState = {
    lastLevel: null, // null = unknown, 'normal', 'warning', 'critical'
    lastSoundTime: 0,
    soundCooldown: 5000,
  };

  function handleTroopCapacityNotifications(game) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) {
      troopCapacityState.lastLevel = null;
      return;
    }

    const troops = myPlayer.troops?.();
    const config = game?.config?.();
    if (troops === undefined || !config) return;

    const maxTroops = config.maxTroops?.(myPlayer);
    if (!maxTroops || maxTroops <= 0) return;

    const ratio = troops / maxTroops;
    const now = Date.now();

    let currentLevel = "normal";
    if (ratio >= TROOP_CAPACITY_CRITICAL_THRESHOLD) {
      currentLevel = "critical";
    } else if (ratio >= TROOP_CAPACITY_WARNING_THRESHOLD) {
      currentLevel = "warning";
    }

    // Only play sound when transitioning to a higher level
    const levelPriority = { normal: 0, warning: 1, critical: 2 };
    const lastPriority = levelPriority[troopCapacityState.lastLevel] ?? -1;
    const currentPriority = levelPriority[currentLevel];

    if (currentPriority > lastPriority && now - troopCapacityState.lastSoundTime >= troopCapacityState.soundCooldown) {
      if (currentLevel === "critical" && isSoundEnabled("troopCapacityCritical")) {
        SOUNDS.troopCapacityCritical();
        troopCapacityState.lastSoundTime = now;
      } else if (currentLevel === "warning" && isSoundEnabled("troopCapacityWarning")) {
        SOUNDS.troopCapacityWarning();
        troopCapacityState.lastSoundTime = now;
      }
    }

    troopCapacityState.lastLevel = currentLevel;
  }

  const allianceExpiringState = {
    notifiedAllianceIds: new Set(),
    lastSoundTime: 0,
    soundCooldown: 1000,
  };

  function handleAllianceExpiringNotifications(game) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) {
      allianceExpiringState.notifiedAllianceIds.clear();
      return;
    }

    const alliances = myPlayer.alliances?.();
    if (!alliances?.length) return;

    const currentTicks = game?.ticks?.();
    if (currentTicks === undefined) return;

    const now = Date.now();

    for (const alliance of alliances) {
      try {
        const otherPlayer = game.playerBySmallID?.(alliance.other);
        if (!otherPlayer?.isAlive?.()) {
          continue;
        }
      } catch (_) {
        continue;
      }

      if (alliance.expiresAt > currentTicks + ALLIANCE_EXTENSION_PROMPT_OFFSET) {
        allianceExpiringState.notifiedAllianceIds.delete(alliance.id);
        continue;
      }

      if (allianceExpiringState.notifiedAllianceIds.has(alliance.id)) {
        continue;
      }

      allianceExpiringState.notifiedAllianceIds.add(alliance.id);

      if (now - allianceExpiringState.lastSoundTime >= allianceExpiringState.soundCooldown) {
        if (isSoundEnabled("allianceExpiringSoon")) {
          SOUNDS.allianceRequest();
          allianceExpiringState.lastSoundTime = now;
        }
      }
    }

    const activeAllianceIds = new Set(alliances.map((a) => a.id));
    for (const id of allianceExpiringState.notifiedAllianceIds) {
      if (!activeAllianceIds.has(id)) {
        allianceExpiringState.notifiedAllianceIds.delete(id);
      }
    }
  }

  const allianceEndedState = {
    lastSoundTime: 0,
    soundCooldown: 500,
  };

  function handleAllianceEndedNotifications(game, updates) {
    if (!updates) return;

    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const allianceExpiredEvents = updates[GameUpdateType.AllianceExpired] ?? [];
    if (!allianceExpiredEvents.length) return;

    const now = Date.now();

    for (const event of allianceExpiredEvents) {
      if (event.player1ID !== mySmallID && event.player2ID !== mySmallID) {
        continue;
      }

      const otherPlayerID = event.player1ID === mySmallID ? event.player2ID : event.player1ID;
      try {
        const otherPlayer = game.playerBySmallID?.(otherPlayerID);
        if (!otherPlayer?.isAlive?.()) {
          continue;
        }
      } catch (_) {
        continue;
      }

      if (now - allianceEndedState.lastSoundTime >= allianceEndedState.soundCooldown) {
        if (isSoundEnabled("allianceEnded")) {
          SOUNDS.allianceEnded();
          allianceEndedState.lastSoundTime = now;
        }
        return;
      }
    }
  }

  const betrayalState = {
    lastSoundTime: 0,
    soundCooldown: 300,
  };

  function handleBetrayalNotifications(game, updates) {
    if (!updates) return;

    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const brokeAllianceEvents = updates[GameUpdateType.BrokeAlliance] ?? [];
    if (!brokeAllianceEvents.length) return;

    const now = Date.now();

    for (const event of brokeAllianceEvents) {
      const traitorID = event.traitorID;
      const betrayedID = event.betrayedID;

      if (traitorID === mySmallID) {
        continue;
      }

      try {
        const traitor = game.playerBySmallID?.(traitorID);
        if (traitor && myPlayer.isOnSameTeam(traitor)) {
          continue;
        }

        const betrayed = game.playerBySmallID?.(betrayedID);
        if (betrayed?.isDisconnected?.() || betrayed?.isTraitor?.()) {
          continue;
        }
      } catch (_) {
        // If we can't lookup players, still play the sound
      }

      if (now - betrayalState.lastSoundTime >= betrayalState.soundCooldown) {
        if (isSoundEnabled("betrayed")) {
          SOUNDS.betrayed();
          betrayalState.lastSoundTime = now;
        }
        return;
      }
    }
  }

  const boatArrivalState = {
    trackedBoats: new Set(),
    lastSoundTime: 0,
    soundCooldown: 300,
  };

  function handleBoatArrivalNotifications(game, updates) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) return;

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();

    const unitUpdates = updates?.[GameUpdateType.Unit];
    if (unitUpdates?.length) {
      for (const unitUpdate of unitUpdates) {
        if (unitUpdate.unitType !== UnitType.TransportShip) continue;
        if (unitUpdate.ownerID !== mySmallID) continue;

        if (unitUpdate.isActive) {
          boatArrivalState.trackedBoats.add(unitUpdate.id);
        }
      }
    }

    for (const unitId of boatArrivalState.trackedBoats) {
      try {
        const unit = game.unit?.(unitId);

        if (!unit || !unit.data?.isActive) {
          boatArrivalState.trackedBoats.delete(unitId);
          if (now - boatArrivalState.lastSoundTime >= boatArrivalState.soundCooldown) {
            if (isSoundEnabled("boatArrival")) {
              SOUNDS.boatArrival();
              boatArrivalState.lastSoundTime = now;
            }
          }
        }
      } catch (_) {
        boatArrivalState.trackedBoats.delete(unitId);
      }
    }
  }

  const allyDisconnectedState = {
    notifiedDisconnected: new Map(),
    lastSoundTime: 0,
    soundCooldown: 500,
  };

  function handleAllyDisconnectedNotifications(game) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) {
      allyDisconnectedState.notifiedDisconnected.clear();
      return;
    }

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();

    try {
      const players = game.players?.();
      if (!players) return;

      const currentFriendlyIds = new Set();

      for (const player of players) {
        const playerId = player.smallID?.();
        if (playerId === undefined || playerId === mySmallID) continue;

        if (!myPlayer.isFriendly?.(player, true)) continue;

        currentFriendlyIds.add(playerId);

        if (!player.isAlive?.()) {
          allyDisconnectedState.notifiedDisconnected.delete(playerId);
          continue;
        }

        const isDisconnected = player.isDisconnected?.();
        const wasNotified = allyDisconnectedState.notifiedDisconnected.get(playerId);

        if (isDisconnected && !wasNotified) {
          allyDisconnectedState.notifiedDisconnected.set(playerId, true);

          if (now - allyDisconnectedState.lastSoundTime >= allyDisconnectedState.soundCooldown) {
            if (isSoundEnabled("allyDisconnected")) {
              SOUNDS.allyDisconnected();
              allyDisconnectedState.lastSoundTime = now;
            }
          }
        } else if (!isDisconnected && wasNotified) {
          allyDisconnectedState.notifiedDisconnected.set(playerId, false);
        }
      }

      for (const trackedId of allyDisconnectedState.notifiedDisconnected.keys()) {
        if (!currentFriendlyIds.has(trackedId)) {
          allyDisconnectedState.notifiedDisconnected.delete(trackedId);
        }
      }
    } catch (_) {
    }
  }

  const tradeShipCapturedState = {
    trackedTradeShips: new Set(),
    notifiedCapturedShips: new Set(),
    lastSoundTime: 0,
    soundCooldown: 500,
  };

  function handleTradeShipCapturedNotifications(game, updates) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) {
      tradeShipCapturedState.trackedTradeShips.clear();
      tradeShipCapturedState.notifiedCapturedShips.clear();
      return;
    }

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();

    const unitUpdates = updates?.[GameUpdateType.Unit];
    if (!unitUpdates?.length) return;

    for (const unitUpdate of unitUpdates) {
      if (unitUpdate.unitType !== UnitType.TradeShip) continue;

      if (!unitUpdate.isActive) {
        tradeShipCapturedState.trackedTradeShips.delete(unitUpdate.id);
        tradeShipCapturedState.notifiedCapturedShips.delete(unitUpdate.id);
        continue;
      }

      if (
        unitUpdate.lastOwnerID === mySmallID &&
        unitUpdate.ownerID !== mySmallID &&
        !tradeShipCapturedState.notifiedCapturedShips.has(unitUpdate.id)
      ) {
        tradeShipCapturedState.notifiedCapturedShips.add(unitUpdate.id);

        if (now - tradeShipCapturedState.lastSoundTime >= tradeShipCapturedState.soundCooldown) {
          if (isSoundEnabled("tradeShipCaptured")) {
            SOUNDS.tradeShipCaptured();
            tradeShipCapturedState.lastSoundTime = now;
          }
        }
      }

      if (unitUpdate.ownerID === mySmallID) {
        tradeShipCapturedState.trackedTradeShips.add(unitUpdate.id);
        tradeShipCapturedState.notifiedCapturedShips.delete(unitUpdate.id);
      } else {
        tradeShipCapturedState.trackedTradeShips.delete(unitUpdate.id);
      }
    }
  }

  const warshipCombatState = {
    notifiedWarships: new Set(),
    lastSoundTime: 0,
    soundCooldown: 1000,
  };

  function handleWarshipCombatNotifications(game, updates) {
    const myPlayer = game?.myPlayer?.();
    if (!myPlayer?.isAlive?.()) {
      warshipCombatState.notifiedWarships.clear();
      return;
    }

    const mySmallID = myPlayer.smallID?.();
    if (mySmallID === undefined) return;

    const now = Date.now();

    const unitUpdates = updates?.[GameUpdateType.Unit];
    if (!unitUpdates?.length) return;

    for (const unitUpdate of unitUpdates) {
      if (unitUpdate.unitType !== UnitType.Warship) continue;
      if (unitUpdate.ownerID !== mySmallID) continue;
      if (!unitUpdate.isActive) {
        warshipCombatState.notifiedWarships.delete(unitUpdate.id);
        continue;
      }

      // Only trigger for actual warship-vs-warship combat.
      // `targetUnitId` can point at any unit type; verify the target is an active Warship.
      const targetUnitId = unitUpdate.targetUnitId;
      let isTargetWarship = false;
      if (targetUnitId !== undefined) {
        try {
          const targetUnit = game?.unit?.(targetUnitId);
          const targetActive =
            (typeof targetUnit?.isActive === "function" ? targetUnit.isActive() : targetUnit?.data?.isActive) ?? true;
          const targetType =
            (typeof targetUnit?.type === "function" ? targetUnit.type() : targetUnit?.data?.unitType) ?? undefined;

          isTargetWarship = targetActive && targetType === UnitType.Warship;
        } catch (_) {
          isTargetWarship = false;
        }
      }

      const isInWarshipCombat = targetUnitId !== undefined && isTargetWarship;
      const wasNotified = warshipCombatState.notifiedWarships.has(unitUpdate.id);

      if (isInWarshipCombat && !wasNotified) {
        warshipCombatState.notifiedWarships.add(unitUpdate.id);

        if (now - warshipCombatState.lastSoundTime >= warshipCombatState.soundCooldown) {
          if (isSoundEnabled("warshipCombat")) {
            SOUNDS.warshipCombat();
            warshipCombatState.lastSoundTime = now;
          }
        }
      } else if (!isInWarshipCombat && wasNotified) {
        warshipCombatState.notifiedWarships.delete(unitUpdate.id);
      }
    }
  }

  function waitForElement(selector) {
    return new Promise((resolve) => {
      const el = document.querySelector(selector);
      if (el) {
        resolve(el);
        return;
      }

      const observer = new MutationObserver((_, obs) => {
        const found = document.querySelector(selector);
        if (found) {
          obs.disconnect();
          resolve(found);
        }
      });

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

  function waitForGame(controlPanel) {
    return new Promise((resolve) => {
      if (controlPanel.game) {
        resolve(controlPanel.game);
        return;
      }

      const check = setInterval(() => {
        if (controlPanel.game) {
          clearInterval(check);
          resolve(controlPanel.game);
        }
      }, 50);
    });
  }

  function hookGameUpdates(game) {
    const proto = Object.getPrototypeOf(game);
    if (!proto?.update || proto[HOOK_FLAG]) return;

    const originalUpdate = proto.update;
    proto.update = function (viewData, ...rest) {
      const result = originalUpdate.call(this, viewData, ...rest);
      try {
        const updates = viewData?.updates;
        handleIncomingThreats(this, updates);
        handleChatNotifications(this, updates);
        handleAllianceRequestNotifications(this, updates);
        handleTroopCapacityNotifications(this);
        handleAllianceExpiringNotifications(this);
        handleAllianceEndedNotifications(this, updates);
        handleBetrayalNotifications(this, updates);
        handleBoatArrivalNotifications(this, updates);
        handleAllyDisconnectedNotifications(this);
        handleTradeShipCapturedNotifications(this, updates);
        handleWarshipCombatNotifications(this, updates);
      } catch (_) {}
      return result;
    };

    Object.defineProperty(proto, HOOK_FLAG, { value: true, configurable: true });
  }

  function createPanel() {
    if (document.getElementById("ofio-audio-panel")) return;

    const panel = document.createElement("div");
    panel.id = "ofio-audio-panel";

    const generateSoundToggles = () => {
      const tooltipBySoundKey = {
        troopCapacityWarning:
          "Notifies when troop capacity reaches 64%. At this threshold, your troop income rate begins to decrease, making it an optimal time to expand territory or launch attacks.",
        troopCapacityCritical:
          "Notifies when troop capacity reaches 82%. At this threshold, your troop income rate is severely reduced, indicating you should expand territory or attack to avoid wasting potential troop growth.",
      };

      return SOUND_CATEGORIES.map(
        (category) => `
        <div class="ofio-audio-category">
          <div class="ofio-audio-category-title">${category.name}</div>
          ${category.sounds
            .map((sound) => {
              const tooltip = tooltipBySoundKey[sound.key];
              const tooltipAttr = tooltip ? ` title="${tooltip}"` : "";
              return `
            <div class="ofio-audio-sound-row">
              <label class="ofio-audio-toggle-label"${tooltipAttr}>
                <input type="checkbox" class="ofio-audio-sound-toggle" data-sound="${sound.key}" ${panelState.sounds[sound.key] ? "checked" : ""} />
                <span>${sound.label}</span>
              </label>
              <button type="button" class="ofio-audio-preview-btn" data-sound="${sound.key}" title="Preview">▶</button>
            </div>
          `;
            })
            .join("")}
        </div>
      `,
      ).join("");
    };

    panel.innerHTML = `
      <style>
        #ofio-audio-panel {
          position: fixed;
          top: 64px;
          right: 16px;
          background: rgba(31, 41, 55, 0.8);
          border-radius: 8px;
          padding: 8px 12px 8px 18px;
          z-index: 10000;
          font-family: system-ui, -apple-system, sans-serif;
          font-size: 13px;
          color: #e5e7eb;
          backdrop-filter: blur(8px);
          display: flex;
          flex-direction: column;
          gap: 8px;
          box-sizing: border-box;
          min-width: 180px;
        }
        #ofio-audio-panel.ofio-dragging {
          user-select: none;
          cursor: grabbing;
        }
        #ofio-audio-panel.ofio-minimized {
          min-width: auto;
          padding: 8px 12px 8px 18px;
        }
        #ofio-audio-panel.ofio-minimized .ofio-audio-header,
        #ofio-audio-panel.ofio-minimized .ofio-audio-settings {
          display: none !important;
        }
        #ofio-audio-panel .ofio-audio-minimized-view {
          display: none;
        }
        #ofio-audio-panel.ofio-minimized .ofio-audio-minimized-view {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        #ofio-audio-panel .ofio-audio-restore-btn {
          background: transparent;
          border: none;
          font-size: 13px;
          cursor: pointer;
          padding: 2px 4px;
          opacity: 0.9;
          transition: opacity 0.15s, transform 0.15s;
        }
        #ofio-audio-panel .ofio-audio-restore-btn:hover {
          opacity: 1;
          transform: scale(1.1);
        }
        #ofio-audio-panel .ofio-audio-restore-btn.is-muted {
          opacity: 0.5;
        }
        #ofio-audio-panel .ofio-drag-handle {
          position: absolute;
          left: 0;
          top: 0;
          bottom: 0;
          width: 12px;
          cursor: grab;
          border-right: 1px solid rgba(75, 85, 99, 0.6);
        }
        #ofio-audio-panel .ofio-drag-handle::after {
          content: "";
          position: absolute;
          left: 3px;
          top: 50%;
          width: 4px;
          height: 16px;
          transform: translateY(-50%);
          border-radius: 999px;
          background: rgba(229, 231, 235, 0.6);
          box-shadow: 0 -6px 0 rgba(229, 231, 235, 0.6), 0 6px 0 rgba(229, 231, 235, 0.6);
        }
        #ofio-audio-panel .ofio-audio-header {
          display: flex;
          align-items: center;
          gap: 8px;
        }
        #ofio-audio-panel .ofio-audio-title {
          display: flex;
          align-items: center;
          gap: 6px;
          font-weight: 600;
        }
        #ofio-audio-panel .ofio-audio-title input[type="checkbox"] {
          width: 16px;
          height: 16px;
          cursor: pointer;
        }
        #ofio-audio-panel .ofio-audio-controls {
          margin-left: auto;
          display: flex;
          align-items: center;
          gap: 6px;
        }
        #ofio-audio-panel .ofio-audio-btn {
          padding: 4px;
          border: 1px solid #4b5563;
          border-radius: 4px;
          background: #374151;
          color: #e5e7eb;
          font-size: 12px;
          cursor: pointer;
          display: flex;
          align-items: center;
          justify-content: center;
          width: 24px;
          height: 24px;
        }
        #ofio-audio-panel .ofio-audio-btn:hover {
          background: #4b5563;
        }
        #ofio-audio-panel .ofio-audio-btn.is-active {
          border-color: #7dd3fc;
          box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.6);
        }
        #ofio-audio-panel .ofio-audio-volume {
          display: flex;
          align-items: center;
          gap: 8px;
        }
        #ofio-audio-panel .ofio-audio-volume input[type="range"] {
          flex: 1;
          accent-color: #7dd3fc;
          -webkit-appearance: none;
          appearance: none;
          height: 4px;
          background: transparent;
        }
        #ofio-audio-panel .ofio-audio-volume input[type="range"]::-webkit-slider-runnable-track {
          height: 4px;
          background: #4b5563;
          border-radius: 999px;
        }
        #ofio-audio-panel .ofio-audio-volume input[type="range"]::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          width: 14px;
          height: 14px;
          margin-top: -5px;
          border-radius: 50%;
          background: #e5e7eb;
          border: 1px solid #9ca3af;
        }
        #ofio-audio-panel .ofio-audio-volume input[type="range"]::-moz-range-track {
          height: 4px;
          background: #4b5563;
          border-radius: 999px;
        }
        #ofio-audio-panel .ofio-audio-volume input[type="range"]::-moz-range-thumb {
          width: 14px;
          height: 14px;
          border-radius: 50%;
          background: #e5e7eb;
          border: 1px solid #9ca3af;
        }
        #ofio-audio-panel .ofio-audio-volume-value {
          min-width: 36px;
          text-align: right;
          font-variant-numeric: tabular-nums;
          font-size: 12px;
        }
        #ofio-audio-panel .ofio-audio-settings {
          display: none;
          flex-direction: column;
          gap: 8px;
          padding-top: 8px;
          border-top: 1px solid rgba(75, 85, 99, 0.6);
          max-height: 300px;
          overflow-y: auto;
        }
        #ofio-audio-panel .ofio-audio-settings.is-open {
          display: flex;
        }
        #ofio-audio-panel .ofio-audio-category {
          display: flex;
          flex-direction: column;
          gap: 4px;
        }
        #ofio-audio-panel .ofio-audio-category-title {
          font-size: 11px;
          font-weight: 600;
          color: #9ca3af;
          text-transform: uppercase;
          letter-spacing: 0.5px;
        }
        #ofio-audio-panel .ofio-audio-sound-row {
          display: flex;
          align-items: center;
          gap: 8px;
          padding: 2px 0;
        }
        #ofio-audio-panel .ofio-audio-toggle-label {
          display: flex;
          align-items: center;
          gap: 6px;
          cursor: pointer;
          flex: 1;
        }
        #ofio-audio-panel .ofio-audio-toggle-label input[type="checkbox"] {
          width: 14px;
          height: 14px;
          cursor: pointer;
        }
        #ofio-audio-panel .ofio-audio-toggle-label span {
          font-size: 12px;
        }
        #ofio-audio-panel .ofio-audio-preview-btn {
          padding: 2px 6px;
          border: 1px solid #4b5563;
          border-radius: 4px;
          background: #374151;
          color: #e5e7eb;
          font-size: 10px;
          cursor: pointer;
        }
        #ofio-audio-panel .ofio-audio-preview-btn:hover {
          background: #4b5563;
        }
      </style>
      <div class="ofio-drag-handle" title="Drag panel"></div>
      <div class="ofio-audio-minimized-view">
        <button type="button" class="ofio-audio-restore-btn" id="ofio-audio-restore" title="Expand Audio Panel">${panelState.enabled ? "🔊" : "🔇"}</button>
      </div>
      <div class="ofio-audio-header">
        <div class="ofio-audio-title">
          <input type="checkbox" id="ofio-audio-master-toggle" ${panelState.enabled ? "checked" : ""} title="Enable/Disable all sounds" />
          <span>🔊 Audio</span>
        </div>
        <div class="ofio-audio-controls">
          <button type="button" class="ofio-audio-btn" id="ofio-audio-minimize" title="Minimize">−</button>
          <button type="button" class="ofio-audio-btn" id="ofio-audio-settings-toggle" title="Settings">⚙</button>
        </div>
      </div>
      <div class="ofio-audio-settings" id="ofio-audio-settings">
        <div class="ofio-audio-volume">
          <span>🔉</span>
          <input type="range" id="ofio-audio-volume" min="0" max="100" value="${panelState.volume}" />
          <span class="ofio-audio-volume-value" id="ofio-audio-volume-value">${panelState.volume}%</span>
        </div>
        ${generateSoundToggles()}
      </div>
    `;

    const appendPanel = () => {
      document.body.appendChild(panel);
      restorePanelPosition(panel);
      attachDragHandlers(panel);
      attachPanelEventListeners(panel);

      if (panelState.minimized) {
        panel.classList.add("ofio-minimized");
      }

      if (panelState.settingsOpen) {
        document.getElementById("ofio-audio-settings")?.classList.add("is-open");
        document.getElementById("ofio-audio-settings-toggle")?.classList.add("is-active");
      }
    };

    if (document.body) {
      appendPanel();
    } else {
      document.addEventListener("DOMContentLoaded", appendPanel, { once: true });
    }
  }

  function restorePanelPosition(panel) {
    const saved = loadPanelPosition();
    if (!saved) return;
    const clamped = clampPanelPosition(panel, saved.x, saved.y);
    panel.style.left = `${clamped.x}px`;
    panel.style.top = `${clamped.y}px`;
    panel.style.right = "auto";
  }

  function attachDragHandlers(panel) {
    const handle = panel.querySelector(".ofio-drag-handle");
    if (!handle) return;

    let isDragging = false;
    let offsetX = 0;
    let offsetY = 0;

    const onMouseMove = (event) => {
      if (!isDragging) return;
      const nextX = event.clientX - offsetX;
      const nextY = event.clientY - offsetY;
      const clamped = clampPanelPosition(panel, nextX, nextY);
      panel.style.left = `${clamped.x}px`;
      panel.style.top = `${clamped.y}px`;
      panel.style.right = "auto";
    };

    const onMouseUp = () => {
      if (!isDragging) return;
      isDragging = false;
      panel.classList.remove("ofio-dragging");
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("mouseup", onMouseUp);
      const rect = panel.getBoundingClientRect();
      savePanelPosition(rect.left, rect.top);
    };

    handle.addEventListener("mousedown", (event) => {
      if (event.button !== 0) return;
      const rect = panel.getBoundingClientRect();
      offsetX = event.clientX - rect.left;
      offsetY = event.clientY - rect.top;
      panel.style.left = `${rect.left}px`;
      panel.style.top = `${rect.top}px`;
      panel.style.right = "auto";
      isDragging = true;
      panel.classList.add("ofio-dragging");
      document.addEventListener("mousemove", onMouseMove);
      document.addEventListener("mouseup", onMouseUp);
      event.preventDefault();
    });

    window.addEventListener("resize", () => {
      const rect = panel.getBoundingClientRect();
      const clamped = clampPanelPosition(panel, rect.left, rect.top);
      panel.style.left = `${clamped.x}px`;
      panel.style.top = `${clamped.y}px`;
      panel.style.right = "auto";
    });
  }

  function attachPanelEventListeners(panel) {
    const masterToggle = document.getElementById("ofio-audio-master-toggle");
    const volumeSlider = document.getElementById("ofio-audio-volume");
    const volumeValue = document.getElementById("ofio-audio-volume-value");
    const minimizeBtn = document.getElementById("ofio-audio-minimize");
    const settingsToggle = document.getElementById("ofio-audio-settings-toggle");
    const settingsSection = document.getElementById("ofio-audio-settings");

    const restoreBtn = document.getElementById("ofio-audio-restore");

    masterToggle?.addEventListener("change", (e) => {
      panelState.enabled = e.target.checked;
      if (restoreBtn) {
        restoreBtn.textContent = panelState.enabled ? "🔊" : "🔇";
        restoreBtn.classList.toggle("is-muted", !panelState.enabled);
      }
      saveSettings();
    });

    restoreBtn?.addEventListener("click", () => {
      panelState.minimized = false;
      panel.classList.remove("ofio-minimized");
      saveSettings();
    });

    volumeSlider?.addEventListener("input", (e) => {
      panelState.volume = parseInt(e.target.value, 10);
      if (volumeValue) volumeValue.textContent = `${panelState.volume}%`;
      saveSettings();
    });

    minimizeBtn?.addEventListener("click", () => {
      panelState.minimized = !panelState.minimized;
      panel.classList.toggle("ofio-minimized", panelState.minimized);
      saveSettings();
    });

    settingsToggle?.addEventListener("click", () => {
      panelState.settingsOpen = !panelState.settingsOpen;
      settingsSection?.classList.toggle("is-open", panelState.settingsOpen);
      settingsToggle.classList.toggle("is-active", panelState.settingsOpen);
      saveSettings();
    });

    panel.querySelectorAll(".ofio-audio-sound-toggle").forEach((toggle) => {
      toggle.addEventListener("change", (e) => {
        const soundKey = e.target.dataset.sound;
        if (soundKey && panelState.sounds.hasOwnProperty(soundKey)) {
          panelState.sounds[soundKey] = e.target.checked;
          saveSettings();
        }
      });
    });

    panel.querySelectorAll(".ofio-audio-preview-btn").forEach((btn) => {
      btn.addEventListener("click", (e) => {
        const soundKey = e.target.dataset.sound;
        if (soundKey && SOUNDS[soundKey]) {
          SOUNDS[soundKey]();
        }
      });
    });
  }

  async function init() {
    try {
      loadSettings();
      registerMenuCommand();
      const controlPanel = await waitForElement("control-panel");
      const game = await waitForGame(controlPanel);
      hookGameUpdates(game);
      createPanel();
      applyPanelVisibility();
    } catch (_) {}
  }

  init();
})();