GROS

Status panel for OGame running in Tampermonkey.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GROS
// @namespace    local.ogame.status-panel
// @version      0.5.0
// @description  Status panel for OGame running in Tampermonkey.
// @author       GR
// @license      MIT
// @match        https://*.ogame.gameforge.com/game/index.php*
// @grant        GM_addStyle
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const APP_ID = 'ogame-status-panel';
  const SETTINGS = {
    // Language for panel labels and tooltips: 'auto', 'pl', or 'en'.
    language: 'auto',
  };
  const SESSION_ID = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
  const MOON_ACTIVITY_RUN_LIMIT_MS = 60 * 1000;
  const INITIAL_SCAN_RETRY_LIMIT = 5;
  const INITIAL_SCAN_RETRY_DELAY_MS = 1000;
  const EMPIRE_CACHE_KEY = `${APP_ID}:empire-ships:v1`;
  const LOCATION_CACHE_KEY = `${APP_ID}:location-ships:v1`;
  const EXPEDITION_SLOTS_CACHE_KEY = `${APP_ID}:expedition-slots:v1`;
  const PANEL_CACHE_KEY = `${APP_ID}:panel:v1`;
  const FLEET_INVENTORY_CACHE_KEY = `${APP_ID}:fleet-inventory:v1`;
  const FLEET_INVENTORY_CACHE_VERSION = 4;
  const EMPIRE_CACHE_VERSION = 3;
  const LOCATION_CACHE_VERSION = 3;
  const LOCALIZED_SHIP_NAMES_CACHE_KEY = `${APP_ID}:localized-ship-names:v1`;
  const MOON_ACTIVITY_CACHE_KEY = `${APP_ID}:moon-activity:v1`;
  const DISCOVERY_MISSION_TYPE = '18';
  const EXPEDITION_MISSION_TYPES = new Set(['15', DISCOVERY_MISSION_TYPE]);
  const MOON_ACTIVITY_FRESH_MS = 15 * 60 * 1000;
  const MOON_ACTIVITY_VISIBLE_MS = 60 * 60 * 1000;
  const SHIP_IDS = new Set(['202', '203', '204', '205', '206', '207', '208', '209', '210', '211', '213', '214', '215', '218', '219']);
  const AGO_CIVIL_SHIP_BY_ROW = ['202', '203', '208', '209', '210'];
  const AGO_COMBAT_SHIP_BY_ROW = ['204', '205', '206', '207', '211', '213', '214', '215', '218', '219'];
  const SHIP_NAME_PATTERNS = [
    { id: '202', pattern: /\bsmall cargo\b/ },
    { id: '203', pattern: /\blarge cargo\b/ },
    { id: '204', pattern: /\blight fighter\b/ },
    { id: '205', pattern: /\bheavy fighter\b/ },
    { id: '206', pattern: /\bcruiser\b/ },
    { id: '207', pattern: /\bbattleship\b/ },
    { id: '208', pattern: /\bcolony ship\b/ },
    { id: '209', pattern: /\brecycler\b/ },
    { id: '210', pattern: /\bespionage probe\b/ },
    { id: '211', pattern: /\bbomber\b/ },
    { id: '213', pattern: /\bdestroyer\b/ },
    { id: '214', pattern: /\bdeathstar\b/ },
    { id: '215', pattern: /\bbattlecruiser\b/ },
    { id: '218', pattern: /\breaper\b/ },
    { id: '219', pattern: /\bpathfinder\b/ },
  ];
  const STATUS_COLORS = {
    good: '#86c977',
    caution: '#d7c35a',
    warn: '#d79a4a',
    bad: '#c96a62',
    unknown: '#7f8790',
    timerBlue: '#57ddff',
  };
  const TIMER_TONES = {
    nextExpedition: [
      { maxMs: 5 * 60 * 1000, tone: 'caution' },
    ],
    fleetSave: [
      { maxMs: 5 * 60 * 1000, tone: 'bad' },
      { maxMs: 15 * 60 * 1000, tone: 'warn' },
      { maxMs: 30 * 60 * 1000, tone: 'caution' },
    ],
  };
  let pageObserver = null;
  let fleetObserver = null;
  let moonDotsObserver = null;
  let scheduledMoonDotsRender = null;
  let scheduledDomScan = null;
  let panelTooltip = null;
  let panelTooltipTarget = null;

  const Storage = {
    read(key, fallback = null) {
      try {
        const raw = window.localStorage.getItem(key);
        if (!raw) {
          return fallback;
        }

        const parsed = JSON.parse(raw);
        return parsed === null || parsed === undefined ? fallback : parsed;
      } catch {
        return fallback;
      }
    },
    write(key, value) {
      try {
        window.localStorage.setItem(key, JSON.stringify(value));
        return true;
      } catch {
        return false;
      }
    },
    remove(key) {
      try {
        window.localStorage.removeItem(key);
        return true;
      } catch {
        return false;
      }
    },
  };

  const I18N = {
    pl: {
      sectionExpeditions: 'Ekspedycje',
      sectionFleet: 'Flota',
      sectionMoonActivity: 'Aktywność księżyców',
      labelReturn: 'Powrót:',
      labelSlots: 'Sloty:',
      labelInFlight: 'W powietrzu:',
      labelFleetSave: 'FS:',
      labelLast: 'Ostatnia:',
      dataQualityMissing: 'Jakość danych: brak pełnych danych.',
      dataQualityNeedsAttention: 'Jakość danych: wynik wymaga uwagi.',
      dataQualityStale: 'Jakość danych: dane są starsze niż 15 minut.',
      dataQualityGood: 'Jakość danych: wynik wiarygodny.',
      fleetSaveLanding: 'Lądowanie',
      fleetSaveReturn: 'Powrót',
      location: 'Lokalizacja',
      noFleetRowsData: 'Brak danych o pojedynczych flotach w locie.',
      largestFleet: 'Najwieksza flota',
      time: 'Czas',
      arrival: 'Dotarcie',
      direction: 'Kierunek',
      returnDirection: 'powrot',
      outboundDirection: 'dolot do celu',
      mission: 'Misja',
      route: 'Trasa',
      shareTotal: 'Udzial w calosci',
      shareInFlight: 'Udzial we flocie w locie',
      description: 'Opis',
      noFullShipData: 'Brak pelnych danych o statkach.',
      inFlight: 'W locie',
      coverage: 'Pokrycie',
      totalKnown: 'Znane lacznie',
      sources: 'Zrodla',
      none: 'brak',
      stationary: 'Na miejscu',
      audit: 'Audyt',
      flight: 'lot',
      rows: 'wiersze',
      ships: 'statki',
      occupiedExpeditions: 'Zajete ekspedycje',
      source: 'Zrodlo',
      lastUpdate: 'Ostatnia aktualizacja',
      empireRead: 'Imperium pobrane',
      empireParsed: 'Imperium sparsowane',
      missionsInFlight: 'Misje w powietrzu',
      empireCache: 'Cache Imperium',
      empireFleetStatus: 'Status Imperium/Flota',
      eventlistStatus: 'Status eventlisty',
      unknownRoute: 'trasa nieznana',
      returnPrefix: 'Powrót',
      activeMoons: 'Aktywowane moony',
      runStart: 'Start obchodu',
      fullRun: 'Pelny obchod',
      oldestActivity: 'Najstarsza aktywnosc',
      missingMoons: 'Brakujace moony:',
      noData: 'Brak danych.',
      unparsedFlightRowsKept: 'Wykryto floty w powietrzu, ale nie odczytano liczby statkow. Zachowano ostatni poprawny odczyt.',
      unparsedFlightRows: 'Wykryto floty w powietrzu, ale nie odczytano liczby statkow.',
      incompleteEmpireWarning: 'Nie odczytano jeszcze kompletnego Imperium, wiec procent jest ukryty.',
      insufficientEmpireRead: 'Odczyt {source} nie wystarcza do policzenia procentu.',
      sourceView: 'Widok',
      locationCacheStationary: 'Cache lokalizacji: statki stojace',
      locationCache: 'Cache lokalizacji',
      empireCacheLabel: 'Imperium cache',
      cacheEmpty: 'cache pusty',
      cacheInvalid: 'cache niepoprawny',
      cacheOk: 'cache OK: {total}',
      cacheSaved: 'cache zapisany: {total}',
      cacheWriteError: 'cache zapis blad',
    },
    en: {
      sectionExpeditions: 'Expeditions',
      sectionFleet: 'Fleet',
      sectionMoonActivity: 'Moon activity',
      labelReturn: 'Return:',
      labelSlots: 'Slots:',
      labelInFlight: 'In flight:',
      labelFleetSave: 'FS:',
      labelLast: 'Last:',
      dataQualityMissing: 'Data quality: missing complete data.',
      dataQualityNeedsAttention: 'Data quality: result needs attention.',
      dataQualityStale: 'Data quality: data is older than 15 minutes.',
      dataQualityGood: 'Data quality: result is reliable.',
      fleetSaveLanding: 'Landing',
      fleetSaveReturn: 'Return',
      location: 'Location',
      noFleetRowsData: 'No data for individual fleets in flight.',
      largestFleet: 'Largest fleet',
      time: 'Time',
      arrival: 'Arrival',
      direction: 'Direction',
      returnDirection: 'return',
      outboundDirection: 'outbound',
      mission: 'Mission',
      route: 'Route',
      shareTotal: 'Share of total',
      shareInFlight: 'Share of in-flight fleet',
      description: 'Description',
      noFullShipData: 'Missing complete ship data.',
      inFlight: 'In flight',
      coverage: 'Coverage',
      totalKnown: 'Known total',
      sources: 'Sources',
      none: 'none',
      stationary: 'Stationary',
      audit: 'Audit',
      flight: 'flight',
      rows: 'rows',
      ships: 'ships',
      occupiedExpeditions: 'Occupied expeditions',
      source: 'Source',
      lastUpdate: 'Last update',
      empireRead: 'Empire read',
      empireParsed: 'Empire parsed',
      missionsInFlight: 'Missions in flight',
      empireCache: 'Empire cache',
      empireFleetStatus: 'Empire/Fleet status',
      eventlistStatus: 'Event list status',
      unknownRoute: 'unknown route',
      returnPrefix: 'Return',
      activeMoons: 'Activated moons',
      runStart: 'Round start',
      fullRun: 'Full round',
      oldestActivity: 'Oldest activity',
      missingMoons: 'Missing moons:',
      noData: 'No data.',
      unparsedFlightRowsKept: 'Fleets in flight were detected, but ship counts were not read. The last valid reading was kept.',
      unparsedFlightRows: 'Fleets in flight were detected, but ship counts were not read.',
      incompleteEmpireWarning: 'Complete Empire data has not been read yet, so the percentage is hidden.',
      insufficientEmpireRead: '{source} read is not enough to calculate the percentage.',
      sourceView: 'View',
      locationCacheStationary: 'Location cache: stationary ships',
      locationCache: 'Location cache',
      empireCacheLabel: 'Empire cache',
      cacheEmpty: 'cache empty',
      cacheInvalid: 'cache invalid',
      cacheOk: 'cache OK: {total}',
      cacheSaved: 'cache saved: {total}',
      cacheWriteError: 'cache write error',
    },
  };

  function t(key, params = {}) {
    const dictionary = I18N[getUiLanguage()] || I18N.en;
    const fallback = I18N.en[key] || key;
    return String(dictionary[key] || fallback).replace(/\{(\w+)\}/g, (_, name) => (
      params[name] === undefined || params[name] === null ? '' : String(params[name])
    ));
  }

  function getUiLanguage() {
    if (SETTINGS.language === 'pl' || SETTINGS.language === 'en') {
      return SETTINGS.language;
    }

    return detectUiLanguage();
  }

  function detectUiLanguage() {
    const candidates = [
      document.documentElement?.lang,
      document.querySelector('html')?.getAttribute('lang'),
      navigator.language,
      ...(navigator.languages || []),
    ].filter(Boolean).map((value) => String(value).toLowerCase());

    return candidates.some((value) => value.startsWith('pl')) ? 'pl' : 'en';
  }

  const state = {
    expeditions: [],
    expeditionSlots: {
      used: 0,
      total: null,
      source: '',
      lastUpdatedAt: null,
    },
    fleetInventory: {
      inFlight: 0,
      totalKnown: 0,
      stationary: 0,
      flightPercent: null,
      complete: false,
      coverage: 'unknown',
      warning: 'No data.',
      sourceLabels: [],
      sourceAudit: [],
      inFlightCounts: {},
      stationaryCounts: {},
      largestInFlight: null,
      lastUpdatedAt: null,
    },
    lastScanAt: null,
    lastEmpireReadAt: null,
    lastEmpireParsedAt: null,
    lastMissionsReadAt: null,
    lastCacheStatus: '',
    missionScanReliable: false,
    lastRowsSeen: 0,
    lastReturnCandidates: 0,
    serverTimeOffsetMs: readServerTimeOffset(),
    localizedShipNames: readLocalizedShipNamesCache(),
    moonActivity: readMoonActivityCache(),
  };

  function init() {
    state.fleetInventory.warning = t('noData');
    hydrateStateFromPanelCache();
    startEarlyMoonDotsRender();
    updateMoonActivity();
    scanCurrentShipPage();
    startEarlyPanelMount();
    installPanelTooltips();

    window.setInterval(refreshPanel, 1000);
    runInitialScansWithRetry();
  }

  function startEarlyPanelMount() {
    ensurePanel();
    refreshPanel();

    if (document.body) {
      window.setTimeout(startInitialScans, 0);
      return;
    }

    const observer = new MutationObserver(() => {
      if (!document.body) {
        return;
      }

      observer.disconnect();
      ensurePanel();
      refreshPanel();
      window.setTimeout(startInitialScans, 0);
    });

    observer.observe(document.documentElement, { childList: true });
    document.addEventListener('DOMContentLoaded', () => {
      observer.disconnect();
      ensurePanel();
      refreshPanel();
      window.setTimeout(startInitialScans, 0);
    }, { once: true });
  }

  function startInitialScans() {
    updateMoonActivity();
    scanCurrentShipPage();
    observePageChanges();

    if (!isOverviewPage()) {
      return;
    }

    observeFleetEvents();
    scanFleetEvents();
    scanShipInventory();
    refreshPanel();
  }

  function runInitialScansWithRetry(attempt = 1) {
    ensurePanel();
    updateMoonActivity();
    scanCurrentShipPage();
    scanFleetEvents();
    scanShipInventory();
    refreshPanel();

    if (isDomReadyForCurrentPage() || attempt >= INITIAL_SCAN_RETRY_LIMIT) {
      return;
    }

    window.setTimeout(() => runInitialScansWithRetry(attempt + 1), INITIAL_SCAN_RETRY_DELAY_MS);
  }

  function startEarlyMoonDotsRender() {
    renderMoonActivityDots(state.moonActivity);

    if (document.querySelector('#planetList .moonlink')) {
      scheduleMoonDotsRender();
      return;
    }

    if (moonDotsObserver || !document.documentElement) {
      return;
    }

    moonDotsObserver = new MutationObserver(() => {
      if (!document.querySelector('#planetList .moonlink')) {
        return;
      }

      moonDotsObserver.disconnect();
      moonDotsObserver = null;
      scheduleMoonDotsRender();
    });

    moonDotsObserver.observe(document.documentElement, {
      childList: true,
      subtree: true,
    });
  }

  function scheduleMoonDotsRender() {
    if (scheduledMoonDotsRender) {
      return;
    }

    scheduledMoonDotsRender = window.setTimeout(() => {
      scheduledMoonDotsRender = null;
      updateMoonActivity();
      renderMoonActivityDots(state.moonActivity);
    }, 0);
  }

  function isDomReadyForCurrentPage() {
    const component = getCurrentComponent();
    if (isOverviewPage()) {
      return Boolean(document.getElementById(APP_ID));
    }

    if (component === 'empire') {
      return document.querySelectorAll('.planet[id^="planet"], .values.ships.groupships').length > 0;
    }

    if (component === 'fleet' || component === 'fleetdispatch') {
      return Boolean(document.querySelector('#slots, #technologies, [name="ogame-planet-id"]'));
    }

    return Boolean(document.body);
  }

  function ensurePanel() {
    if (!isOverviewPage()) {
      removePanel();
      return;
    }

    const panel = document.getElementById(APP_ID) || createPanel();
    const target = getPanelTarget();

    if (panel && target && !isPanelPlaced(panel, target)) {
      insertPanel(panel, target);
    }

    updatePanelLanguage(panel);
    updatePanelOffset(panel);
  }

  function createPanel() {
    if (!isOverviewPage()) {
      return null;
    }

    const target = getPanelTarget();
    if (!target) {
      return null;
    }

    const existing = document.getElementById(APP_ID);
    if (existing) {
      return existing;
    }

    const panel = document.createElement('div');
    panel.id = APP_ID;
    panel.className = 'ogh-production-row';
    const next = state.expeditions[0] || null;
    const displayFleetInventory = getDisplayFleetInventory();
    panel.innerHTML = `
      <div class="ogh-status-column">
        <div class="ogh-status-component injectedComponent parent overview">
          <div class="content-box-s">
            <div class="header">
              <h3 data-i18n="sectionExpeditions">${escapeHtml(t('sectionExpeditions'))}</h3>
            </div>
            <div class="content">
              <table cellspacing="0" cellpadding="0" class="construction active ogh-status-table">
                <tbody>
                  <tr>
                    <td colspan="2" class="idle">
                      <div class="ogh-panel-main">
                        <table cellspacing="0" cellpadding="0" class="ogh-metrics-table">
                          <tbody>
                            <tr>
                              <td class="ogh-label-cell"><span class="ogh-label" data-i18n="labelReturn">${escapeHtml(t('labelReturn'))}</span></td>
                              <td class="ogh-value-cell"><span class="ogh-time ogh-timer-blue" data-role="next-expedition-time">${next ? escapeHtml(formatCountdown(next.arrivalAt - getNow())) : '-'}</span></td>
                            </tr>
                            <tr>
                              <td class="ogh-label-cell"><span class="ogh-label" data-i18n="labelSlots">${escapeHtml(t('labelSlots'))}</span></td>
                              <td class="ogh-value-cell"><span class="ogh-time" data-role="expedition-slots">${escapeHtml(formatExpeditionSlots(state.expeditionSlots))}</span></td>
                            </tr>
                          </tbody>
                        </table>
                      </div>
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>
            <div class="footer"></div>
          </div>
        </div>
      </div>
      <div class="ogh-status-column">
        <div class="ogh-status-component injectedComponent parent overview">
          <div class="content-box-s">
            <div class="header">
              <h3 data-i18n="sectionFleet">${escapeHtml(t('sectionFleet'))}</h3>
            </div>
            <div class="content">
              <table cellspacing="0" cellpadding="0" class="construction active ogh-status-table">
                <tbody>
                  <tr>
                    <td colspan="2" class="idle">
                      <div class="ogh-panel-main">
                        <table cellspacing="0" cellpadding="0" class="ogh-metrics-table">
                          <tbody>
                            <tr>
                              <td class="ogh-label-cell"><span class="ogh-label" data-i18n="labelInFlight">${escapeHtml(t('labelInFlight'))}</span></td>
                              <td class="ogh-value-cell"><span class="ogh-time" data-role="fleet-flight-percent">${escapeHtml(formatFleetFlightPercent(displayFleetInventory))}</span><span class="ogh-data-quality" data-role="fleet-data-quality">●</span></td>
                            </tr>
                            <tr>
                              <td class="ogh-label-cell"><span class="ogh-label" data-i18n="labelFleetSave">${escapeHtml(t('labelFleetSave'))}</span></td>
                              <td class="ogh-value-cell"><span class="ogh-time ogh-timer-blue" data-role="largest-fleet-percent">${escapeHtml(formatLargestInFlightTime(displayFleetInventory))}</span></td>
                            </tr>
                          </tbody>
                        </table>
                      </div>
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>
            <div class="footer"></div>
          </div>
        </div>
      </div>
      <div class="ogh-status-column">
        <div class="ogh-status-component injectedComponent parent overview">
          <div class="content-box-s">
            <div class="header">
              <h3 data-i18n="sectionMoonActivity">${escapeHtml(t('sectionMoonActivity'))}</h3>
            </div>
            <div class="content">
              <table cellspacing="0" cellpadding="0" class="construction active ogh-status-table">
                <tbody>
                  <tr>
                    <td colspan="2" class="idle">
                      <div class="ogh-panel-main">
                        <table cellspacing="0" cellpadding="0" class="ogh-metrics-table">
                          <tbody>
                            <tr>
                              <td class="ogh-label-cell"><span class="ogh-label" data-i18n="labelLast">${escapeHtml(t('labelLast'))}</span></td>
                              <td class="ogh-value-cell"><span class="ogh-time" data-role="moon-activity-timer" data-ogh-tooltip="${escapeHtml(buildMoonActivityTitleV2(state.moonActivity))}">${escapeHtml(formatMoonActivityTimer(state.moonActivity))}</span></td>
                            </tr>
                          </tbody>
                        </table>
                      </div>
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>
            <div class="footer"></div>
          </div>
        </div>
      </div>
    `;

    insertPanel(panel, target);
    return panel;
  }

  function updatePanelLanguage(panel) {
    if (!panel) {
      return;
    }

    for (const element of panel.querySelectorAll('[data-i18n]')) {
      element.textContent = t(element.dataset.i18n);
    }
  }

  function getPanelTarget() {
    return document.querySelector('#overviewcomponent')
      || document.querySelector('#productionboxBottom')
      || null;
  }

  function insertPanel(panel, target) {
    if (!target) {
      return;
    }

    if (target?.id === 'overviewcomponent' || target?.id === 'productionboxBottom') {
      target.insertAdjacentElement('afterend', panel);
    } else {
      target.appendChild(panel);
    }
  }

  function isPanelPlaced(panel, target) {
    if (target?.id === 'overviewcomponent' || target?.id === 'productionboxBottom') {
      return target.nextElementSibling === panel;
    }

    return panel.parentElement === target;
  }

  function updatePanelOffset(panel) {
    const overview = document.querySelector('#overviewcomponent');
    if (!overview || !panel || panel.previousElementSibling !== overview || getCurrentPlanetType() !== 'moon') {
      panel?.style.removeProperty('--ogh-overview-offset');
      return;
    }

    const overviewBottom = overview.getBoundingClientRect().bottom;
    const visibleBottom = getOverviewVisibleBottom(overview);
    const emptyBottomSpace = Math.min(60, Math.max(0, Math.round(overviewBottom - visibleBottom)));
    const correctedSpace = Math.max(0, emptyBottomSpace - 27);

    if (correctedSpace > 2) {
      panel.style.setProperty('--ogh-overview-offset', `${-correctedSpace}px`);
    } else {
      panel.style.removeProperty('--ogh-overview-offset');
    }
  }

  function getOverviewVisibleBottom(overview) {
    let bottom = overview.getBoundingClientRect().top;

    for (const element of overview.querySelectorAll('*')) {
      if (!isVisibleOverviewContentElement(element)) {
        continue;
      }

      const rect = element.getBoundingClientRect();
      bottom = Math.max(bottom, rect.bottom);
    }

    return bottom;
  }

  function isVisibleOverviewContentElement(element) {
    if (element.id === APP_ID || ['SCRIPT', 'STYLE', 'TEMPLATE'].includes(element.tagName)) {
      return false;
    }

    const style = window.getComputedStyle(element);
    if (style.display === 'none' || style.visibility === 'hidden' || style.position === 'fixed') {
      return false;
    }

    const rect = element.getBoundingClientRect();
    if (rect.width <= 0 || rect.height <= 0) {
      return false;
    }

    if (hasVisibleElementChild(element)) {
      return false;
    }

    return element.textContent.trim() !== ''
      || ['IMG', 'CANVAS', 'SVG', 'FIGURE'].includes(element.tagName);
  }

  function hasVisibleElementChild(element) {
    for (const child of element.children) {
      const style = window.getComputedStyle(child);
      const rect = child.getBoundingClientRect();

      if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0) {
        return true;
      }
    }

    return false;
  }

  function removePanel() {
    document.getElementById(APP_ID)?.remove();
  }

  function isOverviewPage() {
    const pageValue = typeof currentPage !== 'undefined' ? currentPage : '';
    const params = new URLSearchParams(window.location.search);
    const wantsOverview = pageValue === 'overview' || params.get('component') === 'overview';
    return wantsOverview && Boolean(document.querySelector('#overviewcomponent') || document.querySelector('#productionboxBottom'));
  }

  function getCurrentPlanetType() {
    return document.querySelector('[name="ogame-planet-type"]')?.content || '';
  }

  function observeFleetEvents() {
    if (fleetObserver) {
      fleetObserver.disconnect();
    }

    const target = document.querySelector('#eventboxContent') || document.querySelector('#message-wrapper') || document.body;
    fleetObserver = new MutationObserver(() => {
      scanFleetEvents();
      refreshPanel();
    });

    fleetObserver.observe(target, {
      childList: true,
      subtree: true,
      characterData: true,
      attributes: true,
      attributeFilter: ['data-time', 'data-arrival-time', 'class'],
    });
  }

  function observePageChanges() {
    if (!document.body) {
      return;
    }

    if (pageObserver) {
      pageObserver.disconnect();
    }

    pageObserver = new MutationObserver((mutations) => {
      if (mutations.every((mutation) => isOwnPanelMutation(mutation))) {
        return;
      }

      scheduleDomScan();
    });

    pageObserver.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true,
      attributes: true,
      attributeFilter: ['class', 'data-time', 'data-arrival-time', 'data-return-time', 'data-end-time'],
    });
  }

  function isOwnPanelMutation(mutation) {
    const target = mutation.target?.nodeType === Node.ELEMENT_NODE
      ? mutation.target
      : mutation.target?.parentElement;
    return Boolean(target?.closest?.(`#${APP_ID}, .ogh-moon-activity-dot, .ogh-tooltip`));
  }

  function installPanelTooltips() {
    document.addEventListener('mouseover', handlePanelTooltipEnter, true);
    document.addEventListener('focusin', handlePanelTooltipEnter, true);
    document.addEventListener('mousemove', handlePanelTooltipMove, true);
    document.addEventListener('mouseout', handlePanelTooltipLeave, true);
    document.addEventListener('focusout', handlePanelTooltipLeave, true);
  }

  function handlePanelTooltipEnter(event) {
    const target = findPanelTooltipTarget(event.target);
    if (!target || target === panelTooltipTarget) {
      return;
    }

    const title = target.getAttribute('title') || target.dataset.oghTooltip || '';
    if (!title) {
      return;
    }

    target.dataset.oghTooltip = title;
    target.removeAttribute('title');
    showPanelTooltip(target, title, event);
  }

  function handlePanelTooltipMove(event) {
    if (panelTooltipTarget && event.type === 'mousemove') {
      positionPanelTooltip(event);
    }
  }

  function handlePanelTooltipLeave(event) {
    const target = findPanelTooltipTarget(event.target);
    if (!target || target !== panelTooltipTarget) {
      return;
    }

    hidePanelTooltip();
  }

  function findPanelTooltipTarget(target) {
    const element = target?.nodeType === Node.ELEMENT_NODE ? target : target?.parentElement;
    return element?.closest?.(`#${APP_ID} [title], #${APP_ID} [data-ogh-tooltip]`) || null;
  }

  function showPanelTooltip(target, title, event) {
    panelTooltipTarget = target;
    if (!panelTooltip) {
      panelTooltip = document.createElement('div');
      panelTooltip.className = 'ogh-tooltip';
      document.body.appendChild(panelTooltip);
    }

    panelTooltip.textContent = title;
    panelTooltip.style.display = 'block';
    positionPanelTooltip(event);
  }

  function hidePanelTooltip() {
    if (panelTooltip) {
      panelTooltip.style.display = 'none';
    }
    panelTooltipTarget = null;
  }

  function positionPanelTooltip(event) {
    if (!panelTooltip) {
      return;
    }

    const offset = 12;
    const rect = panelTooltip.getBoundingClientRect();
    const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
    let left = event.clientX + offset;
    let top = event.clientY + offset;

    if (left + rect.width > viewportWidth - 8) {
      left = Math.max(8, event.clientX - rect.width - offset);
    }
    if (top + rect.height > viewportHeight - 8) {
      top = Math.max(8, event.clientY - rect.height - offset);
    }

    panelTooltip.style.left = `${left}px`;
    panelTooltip.style.top = `${top}px`;
  }

  function scheduleDomScan() {
    if (scheduledDomScan) {
      return;
    }

    scheduledDomScan = window.setTimeout(() => {
      scheduledDomScan = null;
      updateMoonActivity();
      scanCurrentShipPage();
      scanFleetEvents();
      scanShipInventory();
      refreshPanel();
    }, 100);
  }

  function scanFleetEvents() {
    if (!isOverviewPage()) {
      return;
    }

    const rows = getFleetEventRows(document);
    state.lastRowsSeen = rows.length;
    if (rows.length <= 0 && preserveCachedNextExpedition()) {
      state.lastScanAt = new Date();
      updateExpeditionSlots();
      return;
    }

    if (rows.length > 0) {
      state.missionScanReliable = true;
    }
    state.expeditions = rows
      .map(readFleetEvent)
      .filter(isActiveExpedition);
    state.expeditions = mergeExpeditions(state.expeditions.filter(isActiveExpedition));
    state.lastReturnCandidates = state.expeditions.length;
    state.lastScanAt = new Date();
    state.lastMissionsReadAt = state.lastScanAt;
    updateExpeditionSlots();
  }

  function scanCurrentShipPage() {
    const component = getCurrentComponent();
    if (!['empire', 'fleet', 'fleetdispatch'].includes(component)) {
      return;
    }

    learnLocalizedShipNames(document);
    updateLocationShipCacheFromCurrentSources();

    if (component === 'fleet' || component === 'fleetdispatch') {
      updateExpeditionSlots();
    }

    if (component === 'empire') {
      const locationCache = readLocationShipCache();
      if (locationCache?.isComplete) {
        const readAt = new Date();
        state.lastEmpireReadAt = readAt;
        state.lastEmpireParsedAt = locationCache.updatedAt || readAt;
        writeEmpireShipCache(locationCache.counts, state.lastEmpireParsedAt);
      }
    }
  }

  function getCurrentComponent() {
    const params = new URLSearchParams(window.location.search);
    return params.get('component') || '';
  }

  function getFleetEventRows(root) {
    const rows = new Set([
      ...root.querySelectorAll('#eventContent tr, #eventboxContent tr, #eventListWrap tr, tr.eventFleet, .eventFleet'),
    ]);

    for (const arrivalTime of root.querySelectorAll('.arrivalTime, td[class*="arrival"]')) {
      const row = arrivalTime.closest('tr');
      if (row) {
        rows.add(row);
      }
    }

    for (const countdown of root.querySelectorAll('.countDown, [id^="counter-eventlist-"]')) {
      const row = countdown.closest('tr');
      if (row) {
        rows.add(row);
      }
    }

    return [...rows];
  }

  function scanShipInventory() {
    scanCurrentShipPage();

    if (!isOverviewPage()) {
      return;
    }

    updateLocationShipCacheFromCurrentSources();
    const cachedEmpire = readEmpireShipCache();
    const locationCache = readLocationShipCache();
    const knownLocationCount = readKnownFleetLocationCount(document);
    const sources = [
      { label: t('sourceView'), type: getCurrentComponent() || 'current', doc: document, fetchedAt: new Date() },
      ...(cachedEmpire ? [cachedEmpire] : []),
    ];
    let bestInFlight = { label: '', counts: createShipCounts(), total: 0, fleets: [] };
    let bestEmpireStationary = { label: '', type: '', counts: createShipCounts(), total: 0, fetchedAt: null };
    const sourceAudit = [];

    for (const source of sources) {
      if (source.doc) {
        learnLocalizedShipNames(source.doc);
      }

      const flightData = source.doc ? scrapeInFlightFleetData(source.doc, source.label) : { counts: createShipCounts(), fleets: [], total: 0, rowsSeen: 0 };
      const flightCounts = flightData.counts;
      const pageCounts = scrapeStationaryShipCounts(source);
      const flightTotal = flightData.total;
      const pageTotal = sumShipCounts(pageCounts);
      const isEmpire = isCompleteEmpireSource(source);

      sourceAudit.push({
        label: source.label,
        type: source.type,
        fetchedAt: source.fetchedAt || null,
        inFlight: flightTotal,
        fleetRows: flightData.rowsSeen || 0,
        stationary: pageTotal,
        parsed: pageTotal > 0,
        coverage: isEmpire && pageTotal > 0 ? 'empire-full' : 'partial',
      });

      if (flightTotal > bestInFlight.total) {
        bestInFlight = { label: `${source.label}: lot`, counts: flightCounts, total: flightTotal, fleets: flightData.fleets };
      }

      if (isEmpire && pageTotal > bestEmpireStationary.total) {
        bestEmpireStationary = {
          label: `${source.label}: ${t('stationary').toLowerCase()}`,
          type: source.type,
          counts: pageCounts,
          total: pageTotal,
          fetchedAt: source.fetchedAt || null,
        };
      }
    }

    if (locationCache && shouldUseLocationShipCache(locationCache, bestEmpireStationary.total, knownLocationCount, bestEmpireStationary.fetchedAt)) {
      bestEmpireStationary = {
        label: t('locationCacheStationary'),
        type: 'location-cache',
        counts: locationCache.counts,
        total: locationCache.total,
        fetchedAt: locationCache.updatedAt,
      };
      sourceAudit.push({
        label: t('locationCache'),
        type: 'location-cache',
        fetchedAt: locationCache.updatedAt,
        inFlight: 0,
        stationary: locationCache.total,
        parsed: true,
        coverage: 'empire-full',
        locations: locationCache.locationCount,
        expectedLocations: locationCache.expectedLocationCount || knownLocationCount || null,
      });
    } else if (locationCache) {
      if (isLocationShipCacheImplausible(locationCache, bestEmpireStationary.total)) {
        clearLocationShipCache();
      }
      sourceAudit.push({
        label: t('locationCache'),
        type: 'location-cache',
        fetchedAt: locationCache.updatedAt,
        inFlight: 0,
        stationary: locationCache.total,
        parsed: false,
        coverage: 'ignored',
        locations: locationCache.locationCount,
        expectedLocations: locationCache.expectedLocationCount || knownLocationCount || null,
      });
    }

    if (bestEmpireStationary.total > 0) {
      if (bestEmpireStationary.type === 'location-cache') {
        state.lastEmpireParsedAt = locationCache?.empireParsedAt || state.lastEmpireParsedAt;
      } else {
        state.lastEmpireParsedAt = bestEmpireStationary.fetchedAt || new Date();
      }
      if (bestEmpireStationary.type === 'location-cache') {
        writeEmpireShipCache(bestEmpireStationary.counts, state.lastEmpireParsedAt);
      }
    }

    const inFlight = bestInFlight.total;
    const stationary = bestEmpireStationary.total;
    const hasCompleteEmpire = stationary > 0;
    const cachedFleetInventory = readFleetInventoryCache();
    const hasReliableZeroInFlight = inFlight > 0 || state.missionScanReliable;
    const hasUnparsedFlightRows = inFlight === 0 && sourceAudit.some((source) => source.type !== 'empire-cache' && source.fleetRows > 0);

    if (hasCompleteEmpire && hasUnparsedFlightRows) {
      state.fleetInventory = cachedFleetInventory?.complete
        ? {
          ...state.fleetInventory,
          ...cachedFleetInventory,
        warning: t('unparsedFlightRowsKept'),
          sourceAudit,
          lastUpdatedAt: new Date(),
        }
        : {
          ...state.fleetInventory,
          complete: false,
          coverage: 'incomplete',
          warning: t('unparsedFlightRows'),
          sourceAudit,
          lastUpdatedAt: new Date(),
        };
      updateExpeditionSlots();
      return;
    }

    if (hasCompleteEmpire && inFlight === 0 && !hasReliableZeroInFlight && cachedFleetInventory?.complete) {
      state.fleetInventory = {
        ...state.fleetInventory,
        ...cachedFleetInventory,
      warning: cachedFleetInventory.warning || '',
      largestInFlight: cachedFleetInventory.largestInFlight || null,
      sourceAudit,
      lastUpdatedAt: new Date(),
      };
      updateExpeditionSlots();
      return;
    }

    const totalKnown = hasCompleteEmpire ? inFlight + stationary : 0;
    const sourceLabels = [bestInFlight.label, bestEmpireStationary.label].filter(Boolean);
    const warning = buildFleetInventoryWarning(inFlight, stationary, bestEmpireStationary.label);
    const largestInFlight = getLargestInFlightFleet(bestInFlight.fleets, hasCompleteEmpire ? totalKnown : inFlight, inFlight);

    if (!hasCompleteEmpire && state.fleetInventory.complete) {
      state.fleetInventory = {
        ...state.fleetInventory,
        inFlight,
        inFlightCounts: bestInFlight.counts,
        largestInFlight: largestInFlight || state.fleetInventory.largestInFlight,
        sourceAudit,
        lastUpdatedAt: new Date(),
      };
      updateExpeditionSlots();
      return;
    }

    state.fleetInventory = {
      inFlight,
      totalKnown,
      stationary,
      flightPercent: hasCompleteEmpire ? (inFlight / totalKnown) * 100 : null,
      complete: hasCompleteEmpire,
      coverage: hasCompleteEmpire ? 'empire-full' : 'incomplete',
      warning,
      sourceLabels,
      sourceAudit,
      inFlightCounts: bestInFlight.counts,
      stationaryCounts: bestEmpireStationary.counts,
      largestInFlight,
      lastUpdatedAt: new Date(),
    };
    updateExpeditionSlots();
  }

  function buildFleetInventoryWarning(inFlight, stationary, stationarySourceLabel) {
    if (stationary > 0) {
      return '';
    }

    if (inFlight > 0) {
      return t('incompleteEmpireWarning');
    }

    return t('insufficientEmpireRead', { source: stationarySourceLabel || 'Empire' });
  }

  function updateExpeditionSlots() {
    const sources = [
      { label: t('sourceView'), doc: document },
    ];
    const countedUsed = countActiveExpeditionSlots(sources);
    const parsedLimit = readBestExpeditionSlotLimit(sources);
    const cachedLimit = readExpeditionSlotsCache();

    if (!parsedLimit && countedUsed === 0 && state.expeditionSlots.lastUpdatedAt) {
      return;
    }

    const used = parsedLimit?.used ?? countedUsed;
    const total = parsedLimit?.total ?? cachedLimit?.total ?? state.expeditionSlots.total;

    if (parsedLimit?.total) {
      writeExpeditionSlotsCache(parsedLimit);
    }

    state.expeditionSlots = {
      used,
      total: total ?? null,
      source: parsedLimit?.source || (cachedLimit ? 'cache' : '') || (countedUsed > 0 ? 'eventlist' : state.expeditionSlots.source || ''),
      lastUpdatedAt: new Date(),
    };
  }

  function updateMoonActivity() {
    const cache = readMoonActivityCache();
    const moons = readKnownMoons(document);
    const now = getNow();
    const pageKey = getMoonActivityPageKey();
    let changed = false;

    for (const moon of moons) {
      const current = cache.moons[moon.id] || {};
      if (!cache.moons[moon.id] && cache.lastCompletedAt) {
        cache.lastCompletedAt = null;
        cache.currentRunCompleted = false;
      }
      cache.moons[moon.id] = {
        ...current,
        ...moon,
      };
      changed = true;
    }

    if (isMoonActivityRunExpired(cache, now)) {
      expireMoonActivityRun(cache);
      changed = true;
    }

    const currentLocation = readCurrentMoonActivityLocation(document, cache);
    if (currentLocation.type === 'moon' && currentLocation.id) {
      const currentMoon = cache.moons[currentLocation.id] || {
        id: currentLocation.id,
        name: currentLocation.name || '',
        coordinates: currentLocation.coordinates || '',
      };
      if (!cache.currentRunStartedAt || isMoonActivityRunExpired(cache, now) || shouldStartNewMoonActivityRun(cache, currentLocation.id, pageKey)) {
        startMoonActivityRun(cache, now);
      }
      if (!cache.currentRunCompleted) {
        cache.moons[currentLocation.id] = {
          ...currentMoon,
          id: currentLocation.id,
          name: currentLocation.name || currentMoon.name || '',
          coordinates: currentLocation.coordinates || currentMoon.coordinates || '',
          lastActivityAt: now,
          lastSeenAt: now,
          lastRunId: cache.currentRunId,
        };
        cache.currentRunVisits = cache.currentRunVisits || {};
        cache.currentRunVisits[currentLocation.id] = now;
        cache.lastVisitedMoonId = currentLocation.id;
        cache.lastPageKey = pageKey;
        cache.lastSessionId = SESSION_ID;
        changed = true;
      }
    }

    const completedAt = calculateMoonActivityCompletedAt(cache);
    if (completedAt) {
      cache.lastCompletedAt = completedAt;
      cache.currentRunCompleted = true;
    }
    cache.updatedAt = now;
    state.moonActivity = cache;
    renderMoonActivityDots(cache);

    if (changed) {
      writeMoonActivityCache(cache);
    }
  }

  function renderMoonActivityDots(cache = state.moonActivity) {
    const now = getNow();
    for (const link of document.querySelectorAll('#planetList .moonlink')) {
      const href = link.getAttribute('href') || link.getAttribute('data-link') || '';
      const id = href ? new URL(href, window.location.origin).searchParams.get('cp') || '' : '';
      if (!id) {
        continue;
      }

      const target = link;
      for (const staleDot of link.querySelectorAll(':scope > .ogh-moon-activity-dot')) {
        if (staleDot.parentElement !== target) {
          staleDot.remove();
        }
      }
      for (const staleDot of link.querySelectorAll('.planetBarSpaceObjectContainer > .ogh-moon-activity-dot')) {
        staleDot.remove();
      }

      let dot = target.querySelector(':scope > .ogh-moon-activity-dot');
      if (!dot) {
        dot = document.createElement('span');
        dot.className = 'ogh-moon-activity-dot';
        target.insertAdjacentElement('afterbegin', dot);
      }
      positionMoonActivityDot(link, dot);

      const moon = cache?.moons?.[id] || null;
      const lastSeenAt = Number(moon?.lastSeenAt) || null;
      dot.classList.remove('ogh-moon-activity-dot-fresh', 'ogh-moon-activity-dot-stale', 'ogh-moon-activity-dot-unknown');

      if (!lastSeenAt || now - lastSeenAt > MOON_ACTIVITY_VISIBLE_MS) {
        dot.classList.add('ogh-moon-activity-dot-unknown');
        dot.title = '';
      } else if (now - lastSeenAt <= MOON_ACTIVITY_FRESH_MS) {
        dot.classList.add('ogh-moon-activity-dot-fresh');
        dot.title = formatElapsedDuration(now - lastSeenAt);
      } else {
        dot.classList.add('ogh-moon-activity-dot-stale');
        dot.title = formatElapsedDuration(now - lastSeenAt);
      }
    }
  }

  function positionMoonActivityDot(moonLink, dot) {
    const image = moonLink.querySelector('img.icon-moon, img[id^="planetBarSpaceObjectImg_"]');
    if (!image?.getBoundingClientRect || !moonLink.getBoundingClientRect) {
      dot.style.removeProperty('--ogh-moon-activity-dot-left');
      dot.style.removeProperty('--ogh-moon-activity-dot-top');
      return;
    }

    const linkRect = moonLink.getBoundingClientRect();
    const imageRect = image.getBoundingClientRect();
    if (linkRect.width <= 0 || linkRect.height <= 0 || imageRect.width <= 0 || imageRect.height <= 0) {
      dot.style.removeProperty('--ogh-moon-activity-dot-left');
      dot.style.removeProperty('--ogh-moon-activity-dot-top');
      return;
    }

    dot.style.setProperty('--ogh-moon-activity-dot-left', `${Math.round(imageRect.left - linkRect.left - 9)}px`);
    dot.style.setProperty('--ogh-moon-activity-dot-top', `${Math.round(imageRect.top - linkRect.top + imageRect.height / 2)}px`);
  }

  function resetMoonActivityRun(cache) {
    cache.currentRunVisits = {};
    for (const moon of Object.values(cache.moons || {})) {
      moon.lastActivityAt = null;
    }
  }

  function expireMoonActivityRun(cache) {
    resetMoonActivityRun(cache);
    cache.currentRunStartedAt = null;
    cache.currentRunCompleted = false;
    cache.currentRunVisits = {};
  }

  function startMoonActivityRun(cache, now) {
    cache.currentRunId = (Number(cache.currentRunId) || 0) + 1;
    cache.currentRunStartedAt = now;
    cache.currentRunCompleted = false;
    resetMoonActivityRun(cache);
  }

  function shouldStartNewMoonActivityRun(cache, currentMoonId, pageKey) {
    if (!cache.currentRunCompleted) {
      return false;
    }

    return cache.lastVisitedMoonId !== currentMoonId
      || cache.lastPageKey !== pageKey
      || cache.lastSessionId !== SESSION_ID;
  }

  function isMoonActivityRunExpired(cache, now) {
    return !cache.currentRunCompleted
      && Number(cache.currentRunStartedAt) > 0
      && now - Number(cache.currentRunStartedAt) > MOON_ACTIVITY_RUN_LIMIT_MS;
  }

  function getMoonActivityPageKey() {
    const params = new URLSearchParams(window.location.search);
    return [
      params.get('component') || '',
      params.get('cp') || '',
      window.location.pathname,
    ].join('|');
  }

  function readCurrentMoonActivityLocation(root, cache = null) {
    const info = readCurrentLocationInfo(root);
    const params = new URLSearchParams(window.location.search);
    const cp = params.get('cp') || '';
    if (info.type === 'planet') {
      return info;
    }

    const cachedMoon = cp ? cache?.moons?.[cp] : null;
    if (!info.type && cp && cachedMoon) {
      return {
        ...info,
        ...cachedMoon,
        id: cp,
        type: 'moon',
        name: info.name || cachedMoon.name || '',
        coordinates: info.coordinates || cachedMoon.coordinates || '',
      };
    }

    if (info.type === 'moon' && cp) {
      return { ...info, id: cp };
    }

    return info;
  }

  function readKnownMoons(root) {
    const moons = [];
    const seen = new Set();

    for (const link of root.querySelectorAll('#planetList .moonlink')) {
      const href = link.getAttribute('href') || link.getAttribute('data-link') || '';
      const id = new URL(href, window.location.origin).searchParams.get('cp') || '';
      if (!id || seen.has(id)) {
        continue;
      }

      const title = stripHtml(link.getAttribute('data-tooltip-title') || '');
      const nameMatch = title.match(/^([^[]+)/);
      const coordinatesMatch = title.match(/\[(\d+:\d+:\d+)\]/);
      seen.add(id);
      moons.push({
        id,
        name: cleanText(nameMatch?.[1] || ''),
        coordinates: coordinatesMatch?.[1] || '',
      });
    }

    return moons;
  }

  function calculateMoonActivityCompletedAt(cache) {
    const moons = Object.values(cache.moons || {});
    const currentRunId = Number(cache.currentRunId) || 0;
    const startedAt = Number(cache.currentRunStartedAt) || null;
    if (!startedAt || !currentRunId || !moons.length || moons.some((moon) => Number(moon.lastRunId) !== currentRunId || !moon.lastActivityAt || Number(moon.lastActivityAt) < startedAt)) {
      return null;
    }

    return Math.max(...moons.map((moon) => Number(moon.lastActivityAt) || 0));
  }

  function hydrateStateFromPanelCache() {
    const cache = readPanelCache();
    const fleetInventoryCache = readFleetInventoryCache();
    const panelFleetInventory = normalizeFleetInventoryCache(cache?.fleetInventory);

    if (panelFleetInventory) {
      state.fleetInventory = {
        ...state.fleetInventory,
        ...panelFleetInventory,
        warning: panelFleetInventory.warning || '',
      };
    } else if (fleetInventoryCache?.complete) {
      state.fleetInventory = {
        ...state.fleetInventory,
        ...fleetInventoryCache,
        warning: fleetInventoryCache.warning || '',
      };
    }

    if (cache?.expeditionSlots) {
      state.expeditionSlots = {
        ...state.expeditionSlots,
        ...cache.expeditionSlots,
      };
    }

    const cachedNext = readCachedNextExpedition(cache);
    if (cachedNext) {
      state.expeditions = [cachedNext];
    }
  }

  function preserveCachedNextExpedition() {
    const currentNext = state.expeditions[0];
    if (currentNext?.arrivalAt > getNow()) {
      return true;
    }

    const cachedNext = readCachedNextExpedition(readPanelCache());
    if (!cachedNext) {
      return false;
    }

    state.expeditions = [cachedNext];
    return true;
  }

  function readCachedNextExpedition(cache) {
    const arrivalAt = Number(cache?.nextExpeditionArrivalAt);
    if (!Number.isFinite(arrivalAt) || arrivalAt <= getNow()) {
      return null;
    }

    return {
      id: 'panel-cache-next-expedition',
      isExpedition: true,
      arrivalAt,
      returning: true,
      label: 'cache',
    };
  }

  function readPanelCache() {
    const parsed = Storage.read(PANEL_CACHE_KEY, null);
    if (!parsed || typeof parsed !== 'object') {
      return null;
    }

    return parsed;
  }

  function writePanelCache() {
    try {
      const previous = readPanelCache();
      const currentNextArrivalAt = state.expeditions[0]?.arrivalAt || null;
      const previousNextArrivalAt = Number(previous?.nextExpeditionArrivalAt) || null;
      const nextExpeditionArrivalAt = currentNextArrivalAt
        || (previousNextArrivalAt && previousNextArrivalAt > getNow() ? previousNextArrivalAt : null);
      const previousFleetInventory = normalizeFleetInventoryCache(previous?.fleetInventory);
      const fleetInventory = state.fleetInventory.complete ? serializeFleetInventory(state.fleetInventory) : previousFleetInventory || readFleetInventoryCache();
      const expeditionSlots = state.expeditionSlots.lastUpdatedAt
        ? state.expeditionSlots
        : previous?.expeditionSlots || state.expeditionSlots;

      if (state.fleetInventory.complete) {
        writeFleetInventoryCache(state.fleetInventory);
      }

      Storage.write(PANEL_CACHE_KEY, {
        savedAt: new Date().toISOString(),
        nextExpeditionArrivalAt,
        expeditionSlots,
        fleetInventory,
      });
    } catch {
      // Panel cache is only used to paint the initial state faster.
    }
  }

  function readFleetInventoryCache() {
    return normalizeFleetInventoryCache(Storage.read(FLEET_INVENTORY_CACHE_KEY, null));
  }

  function normalizeFleetInventoryCache(parsed) {
    if (!parsed?.complete
      || parsed.cacheVersion !== FLEET_INVENTORY_CACHE_VERSION
      || parsed.coverage !== 'empire-full'
      || parsed.flightPercent === null
      || parsed.inFlight <= 0) {
      return null;
    }

    return parsed;
  }

  function readLocalizedShipNamesCache() {
    const parsed = Storage.read(LOCALIZED_SHIP_NAMES_CACHE_KEY, null);
    if (!parsed?.names || typeof parsed.names !== 'object') {
      return {};
    }

    return Object.fromEntries(
      Object.entries(parsed.names)
        .filter(([name, shipId]) => isUsefulShipName(name) && SHIP_IDS.has(String(shipId)))
        .map(([name, shipId]) => [name, String(shipId)]),
    );
  }

  function writeLocalizedShipNamesCache() {
    Storage.write(LOCALIZED_SHIP_NAMES_CACHE_KEY, {
      savedAt: new Date().toISOString(),
      names: state.localizedShipNames,
    });
  }

  function readMoonActivityCache() {
    const parsed = Storage.read(MOON_ACTIVITY_CACHE_KEY, null);
    if (!parsed || typeof parsed !== 'object') {
      return createEmptyMoonActivityCache();
    }

    const moons = {};
    for (const [id, moon] of Object.entries(parsed.moons || {})) {
      if (!id || !moon || typeof moon !== 'object') {
        continue;
      }

      moons[id] = {
        id,
        name: cleanText(moon.name || ''),
        coordinates: cleanText(moon.coordinates || ''),
        lastActivityAt: Number(moon.lastActivityAt) || null,
        lastSeenAt: Number(moon.lastSeenAt) || null,
        lastRunId: Number(moon.lastRunId) || 0,
      };
    }

    return {
      version: 1,
      moons,
      currentRunId: Number(parsed.currentRunId) || 0,
      currentRunStartedAt: Number(parsed.currentRunStartedAt) || null,
      currentRunVisits: normalizeMoonActivityVisits(parsed.currentRunVisits),
      currentRunCompleted: Boolean(parsed.currentRunCompleted),
      lastVisitedMoonId: cleanText(parsed.lastVisitedMoonId || ''),
      lastPageKey: cleanText(parsed.lastPageKey || ''),
      lastSessionId: cleanText(parsed.lastSessionId || ''),
      lastCompletedAt: Number(parsed.lastCompletedAt) || null,
      updatedAt: Number(parsed.updatedAt) || null,
    };
  }

  function writeMoonActivityCache(cache) {
    Storage.write(MOON_ACTIVITY_CACHE_KEY, {
      version: 1,
      moons: cache.moons || {},
      currentRunId: cache.currentRunId || 0,
      currentRunStartedAt: cache.currentRunStartedAt || null,
      currentRunVisits: cache.currentRunVisits || {},
      currentRunCompleted: Boolean(cache.currentRunCompleted),
      lastVisitedMoonId: cache.lastVisitedMoonId || '',
      lastPageKey: cache.lastPageKey || '',
      lastSessionId: cache.lastSessionId || '',
      lastCompletedAt: cache.lastCompletedAt || null,
      updatedAt: cache.updatedAt || getNow(),
    });
  }

  function createEmptyMoonActivityCache() {
    return {
      version: 1,
      moons: {},
      currentRunId: 0,
      currentRunStartedAt: null,
      currentRunVisits: {},
      currentRunCompleted: false,
      lastVisitedMoonId: '',
      lastPageKey: '',
      lastSessionId: '',
      lastCompletedAt: null,
      updatedAt: null,
    };
  }

  function normalizeMoonActivityVisits(value) {
    const visits = {};
    for (const [id, visitedAt] of Object.entries(value || {})) {
      const timestamp = Number(visitedAt) || null;
      if (id && timestamp) {
        visits[id] = timestamp;
      }
    }
    return visits;
  }

  function writeFleetInventoryCache(inventory) {
    Storage.write(FLEET_INVENTORY_CACHE_KEY, serializeFleetInventory(inventory));
  }

  function serializeFleetInventory(inventory) {
    return {
      cacheVersion: FLEET_INVENTORY_CACHE_VERSION,
      inFlight: inventory.inFlight,
      totalKnown: inventory.totalKnown,
      stationary: inventory.stationary,
      flightPercent: inventory.flightPercent,
      complete: inventory.complete,
      coverage: inventory.coverage,
      warning: inventory.warning,
      sourceLabels: inventory.sourceLabels,
      largestInFlight: inventory.largestInFlight || null,
      lastUpdatedAt: inventory.lastUpdatedAt,
      savedAt: new Date().toISOString(),
    };
  }

  function readExpeditionSlotsCache() {
    const parsed = Storage.read(EXPEDITION_SLOTS_CACHE_KEY, null);
    const total = Number.parseInt(parsed?.total, 10);
    if (!Number.isFinite(total) || total <= 0) {
      return null;
    }

    return {
      total,
      updatedAt: normalizeDate(parsed.updatedAt),
    };
  }

  function writeExpeditionSlotsCache(slots) {
    Storage.write(EXPEDITION_SLOTS_CACHE_KEY, {
      total: slots.total,
      updatedAt: new Date().toISOString(),
    });
  }

  function countActiveExpeditionSlots(sources) {
    const byKey = new Map();
    const events = [];
    const outboundEventRowIds = new Set();

    for (const source of sources) {
      for (const row of getFleetEventRows(source.doc)) {
        const event = readFleetEvent(row);
        if (!event?.isExpedition || event.arrivalAt <= getNow()) {
          continue;
        }

        const eventRowId = readEventRowId(row, event);
        if (eventRowId !== null && !event.returning) {
          outboundEventRowIds.add(eventRowId);
        }

        events.push({ row, event, eventRowId });
      }
    }

    for (const entry of events) {
      byKey.set(getExpeditionSlotKey(entry, outboundEventRowIds), entry.event);
    }

    return byKey.size;
  }

  function getExpeditionSlotKey(entry, outboundEventRowIds) {
    const { row, event, eventRowId } = entry;
    const agoPair = row.getAttribute('ago-events-pair');
    if (agoPair) {
      return `ago:${agoPair}`;
    }

    const fleetId = row.getAttribute('data-fleet-id')
      || row.dataset?.fleetId
      || row.querySelector('[data-fleet-id]')?.getAttribute('data-fleet-id');
    if (fleetId) {
      return `fleet:${fleetId}`;
    }

    if (eventRowId !== null) {
      if (event.returning && outboundEventRowIds.has(eventRowId - 1)) {
        return `event-row-pair:${eventRowId - 1}`;
      }

      return event.returning ? `event-row:${eventRowId}` : `event-row-pair:${eventRowId}`;
    }

    return [
      event.id || '',
      Math.floor(event.arrivalAt / 1000),
      event.origin || '',
      event.destination || '',
      event.returning ? 'R' : 'O',
    ].join('|');
  }

  function readEventRowId(row, event = null) {
    const raw = row.id || event?.id || '';
    const match = String(raw).match(/^eventRow-(\d+)$/);
    return match ? Number(match[1]) : null;
  }

  function readBestExpeditionSlotLimit(sources) {
    for (const source of sources) {
      const slots = readExpeditionSlotsFromDocument(source.doc);
      if (slots) {
        return { ...slots, source: source.label };
      }
    }

    return null;
  }

  function readExpeditionSlotsFromDocument(root) {
    const slots = root.querySelector('#slots');
    const text = cleanText(slots?.textContent || root.body?.textContent || '');
    const match = normalizeText(text).match(/(?:ekspedycje|expeditions):\s*(\d+)\s*\/\s*(\d+)/);

    if (match) {
      return {
        used: Number(match[1]),
        total: Number(match[2]),
      };
    }

    const usedFromScript = readScriptNumber(root, 'expeditionCount');
    const totalFromScript = readScriptNumber(root, 'maxExpeditionCount');

    if (totalFromScript > 0) {
      return {
        used: Math.max(0, usedFromScript),
        total: totalFromScript,
      };
    }

    return null;
  }

  function readEmpireShipCache() {
    const parsed = Storage.read(EMPIRE_CACHE_KEY, null);
    if (!parsed) {
      state.lastCacheStatus = t('cacheEmpty');
      return null;
    }

    const coverage = parsed.coverage || '';
    const counts = normalizeShipCounts(parsed.counts);
    const total = sumShipCounts(counts);
    const parsedAt = normalizeDate(parsed.parsedAt);

    if (parsed.cacheVersion !== EMPIRE_CACHE_VERSION || coverage !== 'empire-full' || total <= 0 || !parsedAt) {
      state.lastCacheStatus = t('cacheInvalid');
      return null;
    }

    state.lastCacheStatus = t('cacheOk', { total: formatInteger(total) });
    return {
      label: t('empireCacheLabel'),
      type: 'empire-cache',
      coverage,
      counts,
      fetchedAt: parsedAt,
    };
  }

  function writeEmpireShipCache(counts, parsedAt) {
    const normalizedCounts = normalizeShipCounts(counts);
    const total = sumShipCounts(normalizedCounts);
    if (total <= 0) {
      return;
    }

    const saved = Storage.write(EMPIRE_CACHE_KEY, {
      counts: normalizedCounts,
      cacheVersion: EMPIRE_CACHE_VERSION,
      coverage: 'empire-full',
      parsedAt: normalizeDate(parsedAt)?.toISOString() || new Date().toISOString(),
    });
    if (saved) {
      state.lastCacheStatus = t('cacheSaved', { total: formatInteger(total) });
    } else {
      state.lastCacheStatus = t('cacheWriteError');
    }
  }

  function normalizeShipCounts(value) {
    const counts = createShipCounts();
    const source = value || {};

    for (const key of Object.keys(counts)) {
      const numeric = Number.parseInt(source[key], 10);
      counts[key] = Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
    }

    return counts;
  }

  function updateLocationShipCacheFromCurrentSources() {
    const now = new Date();
    const cache = readRawLocationShipCache();
    let changed = false;

    for (const source of [
      { label: t('sourceView'), type: getCurrentComponent() || 'current', doc: document, fetchedAt: now },
    ]) {
      if (source.type === 'empire') {
        const locations = scrapeEmpireLocationShipCounts(source.doc, source.fetchedAt || now);
        if (locations.length > 0) {
          mergeEmpireLocationCache(cache, locations, source.doc, source.fetchedAt || now);
          changed = true;
        }
        continue;
      }

      if (source.type === 'fleet' || source.type === 'fleetdispatch') {
        const location = scrapeFleetDispatchLocationShipCounts(source.doc, source.fetchedAt || now);
        if (location) {
          setLocationCacheEntry(cache, location);
          changed = true;
        }
      }
    }

    if (changed) {
      writeRawLocationShipCache(cache);
    }
  }

  function readLocationShipCache() {
    const cache = readRawLocationShipCache();
    const counts = createShipCounts();
    let updatedAt = null;
    let locationCount = 0;

    for (const location of Object.values(cache.locations || {})) {
      const locationCounts = normalizeShipCounts(location.counts);
      locationCount += 1;
      if (sumShipCounts(locationCounts) <= 0) {
        continue;
      }

      addShipCounts(counts, locationCounts);
      const locationDate = normalizeDate(location.updatedAt);
      if (locationDate && (!updatedAt || locationDate > updatedAt)) {
        updatedAt = locationDate;
      }
    }

    const total = sumShipCounts(counts);
    if (total <= 0 || locationCount <= 0) {
      return null;
    }

    return {
      counts,
      total,
      locationCount,
      updatedAt,
      empireParsedAt: normalizeDate(cache.empireParsedAt),
      expectedLocationCount: getExpectedLocationCacheCount(cache),
      expectedPlanets: Number(cache.empireParts?.expectedPlanets) || 0,
      expectedMoons: Number(cache.empireParts?.expectedMoons) || 0,
      planetLocationsReadAt: normalizeDate(cache.empireParts?.planetsParsedAt),
      moonLocationsReadAt: normalizeDate(cache.empireParts?.moonsParsedAt),
      hasFullEmpire: isRawLocationCacheComplete(cache, locationCount),
      isComplete: isRawLocationCacheComplete(cache, locationCount),
      locations: cache.locations,
    };
  }

  function shouldUseLocationShipCache(locationCache, referenceTotal, expectedLocationCount = 0, referenceFetchedAt = null) {
    if (!locationCache || locationCache.total <= 0) {
      return false;
    }

    if (!isCompleteLocationShipCache(locationCache, expectedLocationCount)) {
      return false;
    }

    if (isLocationShipCacheImplausible(locationCache, referenceTotal)) {
      return false;
    }

    const cacheDate = normalizeDate(locationCache.updatedAt) || normalizeDate(locationCache.empireParsedAt);
    const referenceDate = normalizeDate(referenceFetchedAt);
    if (cacheDate && referenceDate) {
      return cacheDate >= referenceDate;
    }

    return referenceTotal <= 0 || Boolean(cacheDate);
  }

  function isCompleteLocationShipCache(locationCache, expectedLocationCount = 0) {
    if (!locationCache) {
      return false;
    }

    return Boolean(locationCache.isComplete);
  }

  function isLocationShipCacheImplausible(locationCache, referenceTotal) {
    return Boolean(locationCache && referenceTotal > 0 && locationCache.total > referenceTotal * 5);
  }

  function readKnownFleetLocationCount(root) {
    const counts = readKnownPlanetMoonCounts(root);
    return counts.planets + counts.moons;
  }

  function clearLocationShipCache() {
    Storage.remove(LOCATION_CACHE_KEY);
  }

  function readRawLocationShipCache() {
    const parsed = Storage.read(LOCATION_CACHE_KEY, null);
    if (!parsed || parsed.version !== LOCATION_CACHE_VERSION) {
      return createEmptyLocationShipCache();
    }

    return {
      version: LOCATION_CACHE_VERSION,
      empireParsedAt: parsed.empireParsedAt || null,
      empireParts: normalizeEmpireParts(parsed.empireParts),
      locations: parsed.locations && typeof parsed.locations === 'object' ? parsed.locations : {},
    };
  }

  function writeRawLocationShipCache(cache) {
    Storage.write(LOCATION_CACHE_KEY, {
      version: LOCATION_CACHE_VERSION,
      empireParsedAt: cache.empireParsedAt || null,
      empireParts: cache.empireParts || normalizeEmpireParts(null),
      locations: cache.locations || {},
    });
  }

  function createEmptyLocationShipCache() {
    return {
      version: LOCATION_CACHE_VERSION,
      empireParsedAt: null,
      empireParts: normalizeEmpireParts(null),
      locations: {},
    };
  }

  function normalizeEmpireParts(parts) {
    return {
      planetsParsedAt: parts?.planetsParsedAt || null,
      moonsParsedAt: parts?.moonsParsedAt || null,
      expectedPlanets: Number(parts?.expectedPlanets) || 0,
      expectedMoons: Number(parts?.expectedMoons) || 0,
    };
  }

  function mergeEmpireLocationCache(cache, locations, root, parsedAt) {
    const empireInfo = readEmpireViewInfo(root);
    const parts = normalizeEmpireParts(cache.empireParts);
    const parsedAtIso = normalizeDate(parsedAt)?.toISOString() || new Date().toISOString();
    const locationTypes = new Set(locations.map((location) => location.locationType).filter(Boolean));

    if (empireInfo.expectedPlanets > 0) {
      parts.expectedPlanets = Math.max(parts.expectedPlanets, empireInfo.expectedPlanets);
    }
    if (empireInfo.expectedMoons > 0) {
      parts.expectedMoons = Math.max(parts.expectedMoons, empireInfo.expectedMoons);
    }

    if (empireInfo.viewKind) {
      locationTypes.add(empireInfo.viewKind === 'moons' ? 'moon' : 'planet');
    }

    for (const type of locationTypes) {
      for (const [key, location] of Object.entries(cache.locations || {})) {
        if (location?.source === 'empire' && location.locationType === type) {
          delete cache.locations[key];
        }
      }
    }

    cache.locations = cache.locations || {};
    for (const location of locations) {
      cache.locations[location.key] = location;
    }

    if (locationTypes.has('planet')) {
      parts.planetsParsedAt = parsedAtIso;
    }
    if (locationTypes.has('moon')) {
      parts.moonsParsedAt = parsedAtIso;
    }

    cache.empireParts = parts;
    cache.empireParsedAt = isRawLocationCacheComplete(cache, Object.keys(cache.locations || {}).length) ? parsedAtIso : null;
  }

  function getExpectedLocationCacheCount(cache) {
    const parts = normalizeEmpireParts(cache?.empireParts);
    return parts.expectedPlanets + parts.expectedMoons;
  }

  function isRawLocationCacheComplete(cache, locationCount = 0) {
    const parts = normalizeEmpireParts(cache?.empireParts);
    const expected = getExpectedLocationCacheCount(cache);
    const hasPlanets = parts.expectedPlanets <= 0 || Boolean(parts.planetsParsedAt);
    const hasMoons = parts.expectedMoons <= 0 || Boolean(parts.moonsParsedAt);

    return expected > 0 && locationCount >= expected && hasPlanets && hasMoons;
  }

  function setLocationCacheEntry(cache, location) {
    const current = cache.locations[location.key];
    const currentDate = normalizeDate(current?.updatedAt);
    const nextDate = normalizeDate(location.updatedAt) || new Date();

    if (!currentDate || nextDate >= currentDate) {
      cache.locations[location.key] = location;
    }
  }

  function scrapeEmpireLocationShipCounts(root, updatedAt) {
    const payloadLocations = scrapeEmpirePayloadLocationShipCounts(root, updatedAt);
    if (payloadLocations.length > 0) {
      return payloadLocations;
    }

    const locations = [];

    for (const planet of root.querySelectorAll('.planet[id^="planet"]')) {
      const shipRows = planet.querySelectorAll('.values.ships.groupships');
      if (!shipRows.length) {
        continue;
      }

      const counts = createShipCounts();
      for (const row of shipRows) {
        addShipCounts(counts, readEmpireShipCountsFromRow(row));
      }

      const id = String(planet.id || '').replace(/^planet/, '');
      const name = cleanText(planet.querySelector('.planetname')?.textContent);
      const key = id ? `id:${id}` : `empire:${locations.length}`;
      const locationType = readEmpireDomLocationType(planet);
      locations.push({
        key,
        id,
        name,
        type: 'empire-location',
        locationType,
        source: 'empire',
        updatedAt: normalizeDate(updatedAt)?.toISOString() || new Date().toISOString(),
        counts,
      });
    }

    return locations;
  }

  function scrapeEmpirePayloadLocationShipCounts(root, updatedAt) {
    const payload = readEmpirePayload(root);
    const ships = payload?.groups?.ships || [];
    if (!Array.isArray(payload?.planets) || !Array.isArray(ships)) {
      return [];
    }

    const updatedAtIso = normalizeDate(updatedAt)?.toISOString() || new Date().toISOString();
    const locations = [];

    for (const entry of payload.planets) {
      const counts = readEmpirePayloadShipCounts(entry, ships);
      const id = String(entry.id || '');
      const locationType = Number(entry.type) === 3 ? 'moon' : 'planet';
      locations.push({
        key: id ? `id:${id}` : `empire:${locationType}:${locations.length}`,
        id,
        parentId: entry.planetID ? String(entry.planetID) : '',
        name: cleanText(entry.name || ''),
        coordinates: cleanText(String(entry.coordinates || '').replace(/^\[|\]$/g, '')),
        type: 'empire-location',
        locationType,
        source: 'empire',
        updatedAt: updatedAtIso,
        counts,
      });
    }

    return locations;
  }

  function readEmpirePayloadShipCounts(entry, shipIds) {
    const counts = createShipCounts();
    for (const shipId of shipIds) {
      const id = String(shipId);
      if (!SHIP_IDS.has(id)) {
        continue;
      }

      const amount = Number.parseInt(entry?.[id], 10);
      if (Number.isFinite(amount) && amount > 0) {
        counts[id] += amount;
      }
    }

    return counts;
  }

  function readEmpireDomLocationType(planet) {
    const text = normalizeText(`${planet.className || ''} ${planet.textContent || ''}`);
    if (text.includes('ksiezyc') || text.includes('moon')) {
      return 'moon';
    }

    return 'planet';
  }

  function readEmpireViewInfo(root) {
    const payload = readEmpirePayload(root);
    const counts = readKnownPlanetMoonCounts(root);
    const viewKind = readEmpireViewKind(root, payload);
    const expectedFromPayload = Array.isArray(payload?.planets) ? payload.planets.length : 0;
    const expectedPlanetsFromMoonParents = countEmpirePayloadMoonParents(payload);
    const expectedMoonsFromScript = readScriptNumber(root, 'moonCount');

    return {
      viewKind,
      expectedPlanets: Math.max(
        counts.planets,
        expectedPlanetsFromMoonParents,
        viewKind === 'planets' ? expectedFromPayload : 0,
      ),
      expectedMoons: Math.max(
        counts.moons,
        expectedMoonsFromScript,
        viewKind === 'moons' ? expectedFromPayload : 0,
      ),
    };
  }

  function countEmpirePayloadMoonParents(payload) {
    if (!Array.isArray(payload?.planets)) {
      return 0;
    }

    const parentIds = new Set();
    for (const entry of payload.planets) {
      if (Number(entry?.type) !== 3 || !entry?.planetID) {
        continue;
      }

      parentIds.add(String(entry.planetID));
    }

    return parentIds.size;
  }

  function readEmpireViewKind(root, payload = null) {
    const firstType = Number(payload?.planets?.[0]?.type);
    if (firstType === 3) {
      return 'moons';
    }
    if (firstType === 1) {
      return 'planets';
    }

    const planetType = readScriptNumber(root, 'planetType');
    if (planetType === 1) {
      return 'moons';
    }
    if (planetType === 0) {
      return 'planets';
    }

    return '';
  }

  function readKnownPlanetMoonCounts(root) {
    const planets = new Set();
    const moons = new Set();

    for (const link of root.querySelectorAll('#planetList .planetlink[href*="cp="], #planetList .planetlink[data-link*="cp="]')) {
      const cp = readCpFromLink(link);
      if (cp) {
        planets.add(cp);
      }
    }

    for (const link of root.querySelectorAll('#planetList .moonlink[href*="cp="], #planetList .moonlink[data-link*="cp="]')) {
      const cp = readCpFromLink(link);
      if (cp) {
        moons.add(cp);
      }
    }

    return {
      planets: planets.size,
      moons: moons.size,
    };
  }

  function readCpFromLink(link) {
    const href = link.getAttribute('href') || link.getAttribute('data-link') || '';
    return href.match(/[?&]cp=(\d+)/)?.[1] || '';
  }

  function readScriptNumber(root, variableName) {
    const scriptsText = [...root.querySelectorAll('script')].map((script) => script.textContent || '').join('\n');
    const match = scriptsText.match(new RegExp(`var\\s+${variableName}\\s*=\\s*(\\d+)\\s*;`));
    return match ? Number(match[1]) : 0;
  }

  function readEmpirePayload(root) {
    const scripts = [...root.querySelectorAll('script')];
    for (const script of scripts) {
      const text = script.textContent || '';
      const markerIndex = text.indexOf('createImperiumHtml(');
      if (markerIndex < 0) {
        continue;
      }

      const objectStart = text.indexOf('{', markerIndex);
      const jsonText = extractBalancedObject(text, objectStart);
      if (!jsonText) {
        continue;
      }

      try {
        return JSON.parse(jsonText);
      } catch {
        return null;
      }
    }

    return null;
  }

  function extractBalancedObject(text, start) {
    if (start < 0 || text[start] !== '{') {
      return '';
    }

    let depth = 0;
    let quote = '';
    let escaped = false;
    for (let index = start; index < text.length; index += 1) {
      const char = text[index];

      if (quote) {
        if (escaped) {
          escaped = false;
        } else if (char === '\\') {
          escaped = true;
        } else if (char === quote) {
          quote = '';
        }
        continue;
      }

      if (char === '"' || char === "'") {
        quote = char;
        continue;
      }

      if (char === '{') {
        depth += 1;
      } else if (char === '}') {
        depth -= 1;
        if (depth === 0) {
          return text.slice(start, index + 1);
        }
      }
    }

    return '';
  }

  function scrapeFleetDispatchLocationShipCounts(root, updatedAt) {
    const info = readCurrentLocationInfo(root);
    const counts = readFleetDispatchCurrentShipCounts(root);
    if (!info.key) {
      return null;
    }

    return {
      ...info,
      source: 'fleet',
      updatedAt: normalizeDate(updatedAt)?.toISOString() || new Date().toISOString(),
      counts,
    };
  }

  function readCurrentLocationInfo(root) {
    const id = root.querySelector('meta[name="ogame-planet-id"]')?.content || '';
    const name = root.querySelector('meta[name="ogame-planet-name"]')?.content || '';
    const coordinates = root.querySelector('meta[name="ogame-planet-coordinates"]')?.content || '';
    const type = root.querySelector('meta[name="ogame-planet-type"]')?.content || '';
    const key = id ? `id:${id}` : (coordinates && type ? `${coordinates}:${type}` : '');

    return { key, id, name, coordinates, type };
  }

  function readFleetDispatchCurrentShipCounts(root) {
    const fromShipsOnPlanet = readShipCountsFromScriptArray(root, 'shipsOnPlanet', 'id', 'number');
    if (sumShipCounts(fromShipsOnPlanet) > 0) {
      return fromShipsOnPlanet;
    }

    const fromApiBase = readShipCountsFromApiShipBaseData(root);
    if (sumShipCounts(fromApiBase) > 0) {
      return fromApiBase;
    }

    return createShipCounts();
  }

  function readShipCountsFromScriptArray(root, variableName, idKey, amountKey) {
    const counts = createShipCounts();
    const scriptsText = [...root.querySelectorAll('script')].map((script) => script.textContent || '').join('\n');
    const match = scriptsText.match(new RegExp(`var\\s+${variableName}\\s*=\\s*(\\[[\\s\\S]*?\\]);`));
    if (!match) {
      return counts;
    }

    try {
      const rows = JSON.parse(match[1]);
      for (const row of rows) {
        const id = String(row[idKey] || '');
        if (SHIP_IDS.has(id)) {
          counts[id] += Number(row[amountKey]) || 0;
        }
      }
    } catch {
      return createShipCounts();
    }

    return counts;
  }

  function readShipCountsFromApiShipBaseData(root) {
    const counts = createShipCounts();
    const scriptsText = [...root.querySelectorAll('script')].map((script) => script.textContent || '').join('\n');
    const match = scriptsText.match(/var\s+apiShipBaseData\s*=\s*(\[[\s\S]*?\]);/);
    if (!match) {
      return counts;
    }

    try {
      const rows = JSON.parse(match[1]);
      for (const row of rows) {
        const id = String(row[0] || '');
        if (SHIP_IDS.has(id)) {
          counts[id] += Number(row[1]) || 0;
        }
      }
    } catch {
      return createShipCounts();
    }

    return counts;
  }

  function scrapeInFlightShipCounts(root) {
    return scrapeInFlightFleetData(root).counts;
  }

  function scrapeInFlightFleetData(root, sourceLabel = '') {
    const rowsSeen = getFleetEventRows(root).length + root.querySelectorAll('.ago_eventlist_fleet').length;
    const agoFleets = scrapeAgoEventlistFleets(root, sourceLabel);
    const detailsFleets = scrapeDetailsFleetRows(root, sourceLabel);
    if (agoFleets.length > 0) {
      const agoData = buildFlightFleetData(agoFleets, rowsSeen);
      const detailsData = buildFlightFleetData(detailsFleets, rowsSeen);
      return detailsData.total > agoData.total ? detailsData : agoData;
    }

    const fleets = [];
    const rows = getFleetEventRows(root);

    for (const row of rows) {
      const counts = readShipCountsFromElement(row);
      const total = sumShipCounts(counts);
      if (total <= 0) {
        continue;
      }

      fleets.push({
        key: row.id || row.getAttribute('data-fleet-id') || cleanText(row.textContent).slice(0, 80),
        label: buildFleetEntryLabel(row, sourceLabel),
        ...readGenericFleetEvent(row),
        counts,
        total,
      });
    }

    const rowData = buildFlightFleetData(fleets, rowsSeen);
    const detailsData = buildFlightFleetData(detailsFleets, rowsSeen);
    return detailsData.total > rowData.total ? detailsData : rowData;
  }

  function scrapeDetailsFleetRows(root, sourceLabel = '') {
    const fleets = [];
    const seen = new Set();

    for (const row of getFleetEventRows(root)) {
      const amount = readTotalShipAmount(row);
      if (amount <= 0) {
        continue;
      }

      const key = row.getAttribute('ago-events-pair')
        || row.getAttribute('data-fleet-id')
        || row.id
        || cleanText(row.textContent).slice(0, 80);
      if (seen.has(key)) {
        continue;
      }

      seen.add(key);
      const counts = createShipCounts();
      counts.unknown = amount;
      fleets.push({
        key,
        label: buildFleetEntryLabel(row, sourceLabel),
        ...readGenericFleetEvent(row),
        counts,
        total: amount,
      });
    }

    return fleets;
  }

  function buildFlightFleetData(fleets, rowsSeen = 0) {
    const counts = createShipCounts();

    for (const fleet of fleets) {
      addShipCounts(counts, fleet.counts);
    }

    return {
      counts,
      fleets,
      total: sumShipCounts(counts),
      rowsSeen,
    };
  }

  function scrapeAgoEventlistShipCounts(root) {
    return buildFlightFleetData(scrapeAgoEventlistFleets(root)).counts;
  }

  function scrapeAgoEventlistFleets(root, sourceLabel = '') {
    const fleets = [];
    const seenPairs = new Set();
    let fallbackIndex = 0;

    for (const fleetDetails of root.querySelectorAll('.ago_eventlist_fleet')) {
      const row = fleetDetails.closest('tr');
      const eventRow = findAgoEventRow(fleetDetails);
      const pair = row?.getAttribute('ago-events-pair') || eventRow?.getAttribute('ago-events-pair') || '';
      const key = pair || `single-${fallbackIndex++}`;
      if (seenPairs.has(key)) {
        continue;
      }

      const detailCounts = readShipCountsFromAgoFleetDetails(fleetDetails);
      if (sumShipCounts(detailCounts) <= 0) {
        continue;
      }

      seenPairs.add(key);
      fleets.push({
        key,
        label: buildFleetEntryLabel(eventRow || row || fleetDetails, sourceLabel),
        ...readGenericFleetEvent(eventRow || row || fleetDetails),
        counts: detailCounts,
        total: sumShipCounts(detailCounts),
      });
    }

    return fleets;
  }

  function findAgoEventRow(fleetDetails) {
    const row = fleetDetails.closest('tr');
    const pair = row?.getAttribute('ago-events-pair') || row?.previousElementSibling?.getAttribute('ago-events-pair') || '';
    if (!pair) {
      return findNearbyEventFleetRow(row) || row;
    }

    const table = row?.closest('table') || fleetDetails.ownerDocument;
    for (const candidate of table.querySelectorAll(`tr[ago-events-pair="${cssEscape(pair)}"]`)) {
      if (candidate.querySelector('.arrivalTime, [id^="counter-eventlist-"], .countDown, .missionFleet, [data-mission-type]')) {
        return candidate;
      }
    }

    return findNearbyEventFleetRow(row) || row;
  }

  function findNearbyEventFleetRow(row) {
    for (let current = row?.previousElementSibling; current; current = current.previousElementSibling) {
      if (isEventFleetRow(current)) {
        return current;
      }
      if (!current.classList?.contains('ago_eventlist')) {
        break;
      }
    }

    for (let current = row?.nextElementSibling; current; current = current.nextElementSibling) {
      if (isEventFleetRow(current)) {
        return current;
      }
      if (!current.classList?.contains('ago_eventlist')) {
        break;
      }
    }

    return null;
  }

  function isEventFleetRow(row) {
    return Boolean(row?.matches?.('tr.eventFleet, .eventFleet'))
      || Boolean(row?.querySelector?.('.arrivalTime, [id^="counter-eventlist-"], .countDown, .missionFleet, [data-mission-type]'));
  }

  function readGenericFleetEvent(row) {
    if (!row) {
      return {};
    }

    const searchableText = getSearchableText(row);
    const mission = readMissionTitle(row) || readMissionCellText(row);
    const missionType = row.getAttribute('data-mission-type')
      || row.dataset?.missionType
      || row.querySelector('[data-mission-type]')?.getAttribute('data-mission-type')
      || '';
    const normalizedText = normalizeText(`${searchableText} ${mission}`);
    const coordinates = readAllCoordinates(searchableText);

    return {
      arrivalAt: readAgoArrivalTimestamp(row) || readArrivalTimestamp(row) || null,
      mission,
      missionType,
      returning: isReturnFlight(row, normalizedText),
      origin: readCoordinates(row, ['coordsOrigin', 'origin', 'originFleet', 'start', 'from']) || coordinates[0] || null,
      destination: readCoordinates(row, ['destCoords', 'destination', 'destFleet', 'target', 'dest', 'to']) || coordinates[1] || null,
    };
  }

  function buildFleetEntryLabel(row, sourceLabel = '') {
    const mission = readMissionTitle(row) || readMissionCellText(row);
    const coordinates = readAllCoordinates(getSearchableText(row));
    const route = coordinates.length ? coordinates.join(' -> ') : '';
    return [sourceLabel, cleanText(mission), route].filter(Boolean).join(' | ') || cleanText(row?.textContent).slice(0, 80);
  }

  function getLargestInFlightFleet(fleets, totalKnown, inFlightTotal) {
    if (!Array.isArray(fleets) || fleets.length <= 0 || !Number.isFinite(totalKnown) || totalKnown <= 0) {
      return null;
    }

    const largest = fleets.reduce((best, fleet) => (fleet.total > (best?.total || 0) ? fleet : best), null);
    if (!largest || largest.total <= 0) {
      return null;
    }

    return {
      key: largest.key || '',
      label: largest.label || '',
      total: largest.total,
      percentOfTotal: (largest.total / totalKnown) * 100,
      percentOfInFlight: inFlightTotal > 0 ? (largest.total / inFlightTotal) * 100 : null,
      arrivalAt: largest.arrivalAt || null,
      mission: largest.mission || '',
      missionType: largest.missionType || '',
      returning: Boolean(largest.returning),
      origin: largest.origin || null,
      destination: largest.destination || null,
      counts: largest.counts,
    };
  }

  function readShipCountsFromAgoFleetDetails(fleetDetails) {
    const counts = createShipCounts();
    const rows = [...fleetDetails.querySelectorAll('tr')];
    const aggregateTotal = readAgoFleetAggregateTotal(rows[0]);

    for (const [rowIndex, row] of rows.entries()) {
      if (rowIndex === 0) {
        continue;
      }

      for (const [cellIndex, cell] of [...row.querySelectorAll('td')].entries()) {
        const labelElement = cell.querySelector('.ago_eventlist_label');
        if (!labelElement) {
          continue;
        }

        const shipId = findShipIdByName(labelElement.textContent) || inferAgoShipIdByPosition(rowIndex - 1, cellIndex);
        if (!shipId) {
          continue;
        }

        const valueText = cleanText(cell.textContent).replace(cleanText(labelElement.textContent), '');
        const amount = parseLocalizedInteger(valueText);
        if (amount !== null) {
          counts[shipId] += amount;
        }
      }
    }

    const parsedTotal = sumShipCounts(counts);
    if (aggregateTotal > parsedTotal) {
      counts.unknown += aggregateTotal - parsedTotal;
    }

    return counts;
  }

  function readAgoFleetAggregateTotal(row) {
    if (!row) {
      return 0;
    }

    let total = 0;
    for (const cell of [...row.querySelectorAll('td')].slice(0, 2)) {
      const labelElement = cell.querySelector('.ago_eventlist_label');
      if (!labelElement) {
        continue;
      }

      const valueText = cleanText(cell.textContent).replace(cleanText(labelElement.textContent), '');
      total += parseLocalizedInteger(valueText) || 0;
    }

    return total;
  }

  function inferAgoShipIdByPosition(rowIndex, cellIndex) {
    if (cellIndex === 0) {
      return AGO_CIVIL_SHIP_BY_ROW[rowIndex] || null;
    }

    if (cellIndex === 1) {
      return AGO_COMBAT_SHIP_BY_ROW[rowIndex] || null;
    }

    return null;
  }

  function scrapeStationaryShipCounts(source) {
    if (source.type === 'empire-cache') {
      return source.counts || createShipCounts();
    }

    if (source.type === 'empire') {
      const empireCounts = scrapeEmpireShipCounts(source.doc);
      if (sumShipCounts(empireCounts) > 0) {
        return empireCounts;
      }
    }

    return scrapePageShipCounts(source.doc);
  }

  function isCompleteEmpireSource(source) {
    return source.type === 'empire-cache' || source.type === 'location-cache';
  }

  function scrapeEmpireShipCounts(root) {
    const counts = createShipCounts();
    const payload = readEmpirePayload(root);
    if (Array.isArray(payload?.planets) && Array.isArray(payload?.groups?.ships)) {
      for (const entry of payload.planets) {
        addShipCounts(counts, readEmpirePayloadShipCounts(entry, payload.groups.ships));
      }

      if (sumShipCounts(counts) > 0) {
        return counts;
      }
    }

    const empireRows = root.querySelectorAll('.values.ships.groupships');

    for (const row of empireRows) {
      addShipCounts(counts, readEmpireShipCountsFromRow(row));
    }

    if (sumShipCounts(counts) > 0) {
      return counts;
    }

    const rows = root.querySelectorAll('tr, li, .technology, .ship, .building, [class*="ship"], [class*="technology"]');

    for (const row of rows) {
      if (hasNestedShipCountCandidate(row)) {
        continue;
      }

      const shipId = readShipId(row);
      if (shipId) {
        const amount = sumNumericCells(row);
        if (amount > 0) {
          counts[shipId] += amount;
          continue;
        }
      }

      addShipCounts(counts, readShipCountsFromText(getSearchableText(row)));
    }

    return counts;
  }

  function readEmpireShipCountsFromRow(row) {
    const counts = createShipCounts();

    for (const cell of row.children) {
      const shipId = readShipId(cell);
      if (!shipId) {
        continue;
      }

      const amount = parseLocalizedInteger(cleanText(cell.textContent));
      if (amount !== null) {
        counts[shipId] += amount;
      }
    }

    return counts;
  }

  function hasNestedShipCountCandidate(element) {
    if (element.matches?.('tr')) {
      return false;
    }

    for (const child of element.children) {
      if (child.matches?.('tr, li, .technology, .ship, .building, [class*="ship"], [class*="technology"]')) {
        return true;
      }
    }

    return false;
  }

  function scrapePageShipCounts(root) {
    const structuredCounts = createShipCounts();

    for (const element of root.querySelectorAll('[data-technology], [data-tech-id], [data-ship-id], [rel], [ref], [class*="technology"], [class*="ship"]')) {
      const shipId = readShipId(element);
      if (!shipId) {
        continue;
      }

      const amount = readShipAmountNearElement(element);
      if (amount > 0) {
        structuredCounts[shipId] += amount;
      }
    }

    if (sumShipCounts(structuredCounts) > 0) {
      return structuredCounts;
    }

    const counts = createShipCounts();
    for (const row of root.querySelectorAll('tr, li, .technology, .ship, .building')) {
      const rowCounts = readShipCountsFromText(getSearchableText(row));
      addShipCounts(counts, rowCounts);
    }

    return counts;
  }

  function readShipCountsFromElement(element) {
    const tooltipCounts = readShipCountsFromFleetInfoTooltip(element);
    if (sumShipCounts(tooltipCounts) > 0) {
      return tooltipCounts;
    }

    const counts = readShipCountsFromText(getSearchableText(element));
    if (sumShipCounts(counts) > 0) {
      return counts;
    }

    const exactAmount = readTotalShipAmount(element);
    if (exactAmount > 0) {
      counts.unknown += exactAmount;
    }

    return counts;
  }

  function readShipCountsFromFleetInfoTooltip(element) {
    const counts = createShipCounts();
    const tooltipHtml = getSearchableHtml(element);
    if (!tooltipHtml || !tooltipHtml.includes('fleetinfo')) {
      return counts;
    }

    const doc = new DOMParser().parseFromString(tooltipHtml, 'text/html');
    let readingShips = false;
    for (const row of doc.querySelectorAll('.fleetinfo tr')) {
      if (row.querySelector('th')) {
        if (readingShips) {
          break;
        }
        readingShips = true;
        continue;
      }

      if (!readingShips) {
        continue;
      }

      const label = cleanText(row.querySelector('td')?.textContent).replace(/:$/, '');
      const amount = parseLocalizedInteger(row.querySelector('.value')?.textContent);
      if (!label && amount === null) {
        break;
      }
      if (!label || amount === null) {
        continue;
      }

      const shipId = findShipIdByName(label);
      if (shipId) {
        counts[shipId] += amount;
      } else if (!isResourceName(label)) {
        counts.unknown += amount;
      }
    }

    return counts;
  }

  function isResourceName(value) {
    const text = normalizeText(value);
    return /\b(metal|crystal|cristal|krysztal|deuterium|deuterio|deuter|deut|food|alimento|comida|population|poblacion|energia|energy|dark matter|materia oscura|antymateria|resources|recursos|surowce)\b/.test(text);
  }

  function getSearchableHtml(element) {
    const parts = [];
    const attributes = ['title', 'data-title', 'data-tooltip-title'];

    for (const node of [element, ...element.querySelectorAll('*')]) {
      for (const attribute of attributes) {
        const value = node.getAttribute?.(attribute);
        if (value) {
          parts.push(value);
        }
      }
    }

    return parts.join('\n');
  }

  function readTotalShipAmount(element) {
    const value = readNumericAttribute(element, [
      'data-fleet-amount',
      'data-fleet-size',
      'data-ship-count',
      'data-ships',
    ]);
    if (value !== null) {
      return value;
    }

    const fleetAmountElement = element.querySelector(
      '.detailsFleet span, .detailsFleet, .fleetAmount, .fleet_amount, .shipAmount, .ships, [class*="fleetAmount"], [class*="shipAmount"]',
    );
    return parseLocalizedInteger(cleanText(fleetAmountElement?.textContent)) || 0;
  }

  function readShipCountsFromText(text) {
    const counts = createShipCounts();
    const normalized = normalizeText(text);

    for (const ship of SHIP_NAME_PATTERNS) {
      if (!ship.pattern.test(normalized)) {
        continue;
      }

      const amount = readAmountForShipName(normalized, ship.pattern);
      if (amount > 0) {
        counts[ship.id] += amount;
      }
    }

    return counts;
  }

  function readAmountForShipName(text, pattern) {
    const match = text.match(pattern);
    if (!match) {
      return 0;
    }

    const afterName = text.slice(match.index + match[0].length);
    const beforeName = text.slice(0, match.index);
    const nextShipIndex = findNextShipNameIndex(afterName);
    const valueSegment = nextShipIndex === -1 ? afterName : afterName.slice(0, nextShipIndex);
    const valuesAfterName = readIntegerValues(valueSegment);

    if (valuesAfterName.length > 0) {
      return valuesAfterName.reduce((sum, value) => sum + value, 0);
    }

    const valuesBeforeName = readIntegerValues(beforeName);
    return valuesBeforeName.at(-1) || 0;
  }

  function findNextShipNameIndex(text) {
    const indexes = SHIP_NAME_PATTERNS
      .map((ship) => text.search(ship.pattern))
      .filter((index) => index >= 0);

    return indexes.length ? Math.min(...indexes) : -1;
  }

  function readIntegerValues(text) {
    return [...String(text || '').matchAll(/\d[\d.,]*/g)]
      .map((match) => parseLocalizedInteger(match[0]))
      .filter((value) => value !== null);
  }

  function readShipId(element) {
    const attributeShipId = readShipIdFromAttributes(element);
    if (attributeShipId) {
      return attributeShipId;
    }

    const searchableText = normalizeText(getSearchableText(element));
    return findShipIdByName(searchableText);
  }

  function readShipIdFromAttributes(element) {
    const values = [
      element.getAttribute('data-technology'),
      element.getAttribute('data-technology-id'),
      element.getAttribute('data-tech-id'),
      element.getAttribute('data-ship-id'),
      element.getAttribute('name'),
      element.getAttribute('rel'),
      element.getAttribute('ref'),
      element.id,
      element.className,
    ];

    for (const value of values) {
      const match = String(value || '').match(/\b(20[2-9]|21[01345]|218|219)\b/);
      if (match && SHIP_IDS.has(match[1])) {
        return match[1];
      }
    }

    return null;
  }

  function findShipIdByName(value) {
    const searchableText = normalizeText(value);
    const localizedShipId = findLocalizedShipIdByName(searchableText);
    if (localizedShipId) {
      return localizedShipId;
    }

    for (const ship of SHIP_NAME_PATTERNS) {
      if (ship.pattern.test(searchableText)) {
        return ship.id;
      }
    }
    return null;
  }

  function learnLocalizedShipNames(root) {
    let changed = false;
    const candidates = new Set([
      ...root.querySelectorAll('#technologies .technology[data-technology]'),
      ...root.querySelectorAll('[data-technology], [data-technology-id], [data-tech-id], [data-ship-id], [name^="ships["], [rel], [ref]'),
    ]);

    for (const element of candidates) {
      const shipId = readShipIdFromAttributes(element);
      if (!shipId) {
        continue;
      }

      const names = [
        element.getAttribute('aria-label'),
        element.getAttribute('title'),
        element.getAttribute('data-title'),
        element.getAttribute('data-tooltip-title'),
        element.querySelector('[aria-label]')?.getAttribute('aria-label'),
        element.querySelector('[title]')?.getAttribute('title'),
        element.querySelector('[data-title]')?.getAttribute('data-title'),
        element.querySelector('[data-tooltip-title]')?.getAttribute('data-tooltip-title'),
      ];

      for (const name of names) {
        for (const normalized of normalizeShipNameCandidates(name)) {
          if (state.localizedShipNames[normalized] === shipId) {
            continue;
          }
          state.localizedShipNames[normalized] = shipId;
          changed = true;
        }
      }
    }

    if (changed) {
      writeLocalizedShipNamesCache();
    }
  }

  function normalizeShipNameCandidates(value) {
    const normalized = normalizeText(stripHtml(value));
    const withoutAmount = normalized.replace(/\s*\(\s*[\d.,]+\s*\)\s*$/, '');

    return [...new Set([normalized, withoutAmount])]
      .filter(isUsefulShipName);
  }

  function findLocalizedShipIdByName(normalizedText) {
    const entries = Object.entries(state.localizedShipNames);
    entries.sort((a, b) => b[0].length - a[0].length);

    for (const [name, shipId] of entries) {
      if (name && normalizedText.includes(name)) {
        return shipId;
      }
    }

    return null;
  }

  function isUsefulShipName(value) {
    return value
      && value.length >= 3
      && !/^\d+$/.test(value)
      && !value.includes('data-')
      && !value.includes('<')
      && !value.includes('>');
  }

  function sumNumericCells(row) {
    const cells = row.querySelectorAll('td, .cell, .value, .amount, .level, .stockAmount, [class*="amount"], [class*="level"], [data-value], [data-amount], [data-count]');
    let total = 0;

    for (const cell of cells) {
      if (cell.querySelector('td, .cell, .value, .amount, .level, .stockAmount, [class*="amount"], [class*="level"], [data-value], [data-amount], [data-count]')) {
        continue;
      }

      const attributeValue = readNumericAttribute(cell, ['data-value', 'data-amount', 'data-count', 'data-level']);
      if (attributeValue !== null) {
        total += attributeValue;
        continue;
      }

      const cellText = cleanText(cell.textContent);
      if (looksLikePlainInteger(cellText)) {
        total += parseLocalizedInteger(cellText) || 0;
      }
    }

    return total;
  }

  function looksLikePlainInteger(value) {
    return /^[\d\s.,]+$/.test(String(value || '').trim());
  }

  function readShipAmountNearElement(element) {
    const directValue = readNumericAttribute(element, ['data-value', 'data-amount', 'data-count', 'data-level']);
    if (directValue !== null) {
      return directValue;
    }

    const valueElement = element.querySelector('.amount, .level, .count, .stockAmount, .available, [class*="amount"], [class*="level"]');
    const elementValue = parseLocalizedInteger(cleanText(valueElement?.textContent));
    if (elementValue !== null) {
      return elementValue;
    }

    const titleValue = parseLocalizedInteger(element.getAttribute('title') || element.getAttribute('data-tooltip-title'));
    if (titleValue !== null) {
      return titleValue;
    }

    return 0;
  }

  function readNumericAttribute(element, names) {
    const candidates = [element, ...element.querySelectorAll(names.map((name) => `[${name}]`).join(','))];

    for (const candidate of candidates) {
      for (const name of names) {
        const value = parseLocalizedInteger(candidate.getAttribute(name));
        if (value !== null) {
          return value;
        }
      }
    }

    return null;
  }

  function createShipCounts() {
    const counts = { unknown: 0 };
    for (const shipId of SHIP_IDS) {
      counts[shipId] = 0;
    }
    return counts;
  }

  function addShipCounts(target, source) {
    for (const key of Object.keys(source)) {
      target[key] = (target[key] || 0) + source[key];
    }
  }

  function sumShipCounts(counts) {
    return Object.values(counts).reduce((sum, value) => sum + value, 0);
  }

  function isActiveExpedition(event) {
    return event && event.isExpedition && event.returning && event.arrivalAt > getNow();
  }

  function mergeExpeditions(events) {
    const byKey = new Map();

    for (const event of events) {
      const key = [
        event.id,
        Math.floor(event.arrivalAt / 1000),
        event.origin || '',
        event.destination || '',
      ].join('|');
      byKey.set(key, event);
    }

    return [...byKey.values()].sort((a, b) => a.arrivalAt - b.arrivalAt);
  }

  function readFleetEvent(row) {
    const agoReturnEvent = readAgoReturnExpedition(row);
    if (agoReturnEvent) {
      return agoReturnEvent;
    }

    const text = cleanText(row.textContent);
    const searchableText = getSearchableText(row);
    const missionTitle = readMissionTitle(row);
    const missionType = row.getAttribute('data-mission-type')
      || row.dataset?.missionType
      || row.querySelector('[data-mission-type]')?.getAttribute('data-mission-type');
    const normalizedText = normalizeText(`${searchableText} ${missionTitle}`);
    const normalizedMission = normalizeText(missionTitle || readMissionCellText(row));
    const coordinates = readAllCoordinates(searchableText);
    const isExpedition = isExpeditionMissionType(missionType)
      || isExpeditionText(normalizedMission);

    if (!isExpedition) {
      return null;
    }

    const arrivalAt = readArrivalTimestamp(row);
    if (!arrivalAt) {
      return null;
    }

    const origin = readCoordinates(row, ['coordsOrigin', 'origin', 'start', 'from']) || coordinates[0] || null;
    const destination = readCoordinates(row, ['destCoords', 'destination', 'target', 'dest', 'to']) || coordinates[1] || null;

    const returning = isReturnFlight(row, normalizedText);

    return {
      id: row.id || row.getAttribute('data-fleet-id') || text.slice(0, 80),
      isExpedition,
      arrivalAt,
      origin,
      destination,
      returning,
      label: text,
    };
  }

  function readAgoReturnExpedition(row) {
    const missionType = row.getAttribute('data-mission-type') || row.dataset?.missionType || '';
    const normalizedMission = normalizeText(readMissionTitle(row) || readMissionCellText(row));
    const isReturn = row.getAttribute('data-return-flight') === 'true'
      || row.getAttribute('data-return-flight') === '1'
      || row.classList.contains('ago_events_reverse');
    const isExpedition = isExpeditionMissionType(missionType)
      || isExpeditionText(normalizedMission);

    if (!isReturn || !isExpedition) {
      return null;
    }

    const arrivalAt = readAgoArrivalTimestamp(row) || readArrivalTimestamp(row);
    if (!arrivalAt) {
      return null;
    }

    const origin = readCoordinates(row, ['coordsOrigin', 'originFleet']) || null;
    const destination = readCoordinates(row, ['destCoords', 'destFleet']) || null;

    return {
      id: row.id || `ago-return-${arrivalAt}`,
      isExpedition: true,
      arrivalAt,
      origin,
      destination,
      returning: true,
      label: cleanText(row.textContent),
    };
  }

  function readAgoArrivalTimestamp(row) {
    const rawTimestamp = row.getAttribute('data-arrival-time');
    const timestamp = normalizeTimestamp(rawTimestamp);
    if (timestamp) {
      return timestamp;
    }

    const counter = row.querySelector('[id^="counter-eventlist-"], .countDown');
    const seconds = parseDurationToSeconds(cleanText(counter?.textContent));
    return seconds === null ? null : getNow() + seconds * 1000;
  }

  function readArrivalTimestamp(row) {
    const arrivalClockTimestamp = readArrivalClockTimestamp(row);
    if (arrivalClockTimestamp) {
      return arrivalClockTimestamp;
    }

    const timestamp = readTimestampAttribute(row, [
      'data-arrival-time',
      'data-return-time',
      'data-time',
      'data-end-time',
    ]);

    if (timestamp) {
      return timestamp;
    }

    const timerElement = row.querySelector('[data-arrival-time], [data-return-time], [data-time], [data-end-time], .countDown, .countdown, .timer');
    const seconds = parseDurationToSeconds(cleanText(timerElement?.textContent) || cleanText(row.textContent));
    return seconds === null ? null : getNow() + seconds * 1000;
  }

  function readArrivalClockTimestamp(row) {
    const arrivalTime = row.querySelector('.arrivalTime, td[class*="arrival"]');
    const value = arrivalTime?.getAttribute('original') || cleanText(arrivalTime?.textContent);
    const match = String(value || '').match(/^(\d{1,2}):(\d{2}):(\d{2})$/);

    if (!match) {
      return null;
    }

    const serverDate = new Date(getNow());
    serverDate.setHours(Number(match[1]), Number(match[2]), Number(match[3]), 0);

    if (serverDate.getTime() < getNow() - 1000) {
      serverDate.setDate(serverDate.getDate() + 1);
    }

    return serverDate.getTime();
  }

  function readTimestampAttribute(row, names) {
    const candidates = [row, ...row.querySelectorAll(names.map((name) => `[${name}]`).join(','))];

    for (const element of candidates) {
      for (const name of names) {
        const value = element.getAttribute(name);
        const timestamp = normalizeTimestamp(value);

        if (timestamp) {
          return timestamp;
        }
      }
    }

    return null;
  }

  function normalizeTimestamp(value) {
    if (!value) {
      return null;
    }

    const numeric = Number.parseInt(String(value), 10);
    if (!Number.isFinite(numeric) || numeric <= 0) {
      return null;
    }

    return numeric > 100000000000 ? numeric : numeric * 1000;
  }

  function parseDurationToSeconds(value) {
    const text = cleanText(value);
    if (!text) {
      return null;
    }

    const clock = text.match(/(\d{1,2}):(\d{2}):(\d{2})/);
    if (clock) {
      return Number(clock[1]) * 3600 + Number(clock[2]) * 60 + Number(clock[3]);
    }

    const units = {
      t: 7 * 24 * 3600,
      d: 24 * 3600,
      g: 3600,
      h: 3600,
      min: 60,
      sek: 1,
      s: 1,
    };
    let total = 0;
    const regex = /(\d+)\s*(t|d|g|h|min\.?|sek\.?|s)(?=\s|$|[.,;:])/gi;
    let match;

    while ((match = regex.exec(text)) !== null) {
      const unit = match[2].replace('.', '').toLowerCase();
      total += Number(match[1]) * (units[unit] || 0);
    }

    return total > 0 ? total : null;
  }

  function readCoordinates(row, hints) {
    const selector = hints.map((hint) => `.${hint}, [class*="${hint}"], [data-${hint}]`).join(',');
    const element = row.querySelector(selector);
    return readCoordinatesFromText(cleanText(element?.textContent), 0);
  }

  function readCoordinatesFromText(text, index) {
    return readAllCoordinates(text)[index] || null;
  }

  function readAllCoordinates(text) {
    return [...String(text || '').matchAll(/\[(\d+:\d+:\d+)\]/g)].map((match) => match[1]);
  }

  function isReturnFlight(row, normalizedText) {
    const value = row.getAttribute('data-return-flight') || row.dataset?.returnFlight;
    if (value === 'true' || value === '1') {
      return true;
    }
    if (value === 'false' || value === '0') {
      return false;
    }

    return normalizedText.includes('powrot')
      || normalizedText.includes('ekspedycja (r)')
      || normalizedText.includes('wraca')
      || normalizedText.includes('return')
      || normalizedText.includes('retorno')
      || normalizedText.includes('regreso')
      || normalizedText.includes('regresa')
      || normalizedText.includes('vuelve')
      || normalizedText.includes('retour')
      || normalizedText.includes('ruckkehr')
      || row.classList.contains('return')
      || row.classList.contains('returning')
      || Boolean(row.querySelector('.return, .returning, .mission_return, .icon_movement_return'));
  }

  function isExpeditionMissionType(missionType) {
    return EXPEDITION_MISSION_TYPES.has(String(missionType || ''));
  }

  function isExpeditionText(normalizedText) {
    return normalizedText.includes('ekspedycj')
      || normalizedText.includes('expedition')
      || normalizedText.includes('expedicion')
      || normalizedText.includes('expedicao');
  }

  function refreshPanel() {
    ensurePanel();

    const panel = document.getElementById(APP_ID);
    if (!panel) {
      return;
    }

    const next = state.expeditions[0] || null;
    const timer = panel.querySelector('[data-role="next-expedition-time"]');
    const fleetPercent = panel.querySelector('[data-role="fleet-flight-percent"]');
    let fleetDataQuality = panel.querySelector('[data-role="fleet-data-quality"]');
    const largestFleetPercent = panel.querySelector('[data-role="largest-fleet-percent"]');
    const expeditionSlots = panel.querySelector('[data-role="expedition-slots"]');
    const moonActivityTimer = panel.querySelector('[data-role="moon-activity-timer"]');

    if (!timer || !fleetPercent || !expeditionSlots || !largestFleetPercent || !moonActivityTimer) {
      return;
    }
    fleetDataQuality = fleetDataQuality || ensureFleetDataQualityElement(fleetPercent);

    if (!next) {
      timer.textContent = '-';
    } else {
      timer.textContent = formatCountdown(next.arrivalAt - getNow());
    }
    setValueTone(timer, getNextExpeditionTone(next));

    const displayFleetInventory = getDisplayFleetInventory();
    fleetPercent.textContent = formatFleetFlightPercent(displayFleetInventory);
    fleetPercent.removeAttribute('title');
    setValueTone(fleetPercent, getFleetPercentTone(displayFleetInventory));
    setDataQualityIndicator(fleetDataQuality, getFleetDataQuality(displayFleetInventory));
    largestFleetPercent.textContent = formatLargestInFlightTime(displayFleetInventory);
    setPanelTooltipIfChanged(largestFleetPercent, buildFleetSaveTimerTitle(displayFleetInventory));
    setValueTone(largestFleetPercent, getLargestInFlightTone(displayFleetInventory));
    expeditionSlots.textContent = formatExpeditionSlots(state.expeditionSlots);
    removePanelTooltip(expeditionSlots);
    setValueTone(expeditionSlots, getExpeditionSlotsTone(state.expeditionSlots));
    moonActivityTimer.textContent = formatMoonActivityTimer(state.moonActivity);
    setPanelTooltipIfChanged(moonActivityTimer, buildMoonActivityTitleV2(state.moonActivity));
    setValueTone(moonActivityTimer, getMoonActivityTone(state.moonActivity));
    writePanelCache();
  }

  function setPanelTooltipIfChanged(element, title) {
    if (element.dataset.oghTooltip !== title) {
      element.dataset.oghTooltip = title;
    }
    if (element.hasAttribute('title')) {
      element.removeAttribute('title');
    }
  }

  function removePanelTooltip(element) {
    delete element.dataset.oghTooltip;
    if (element.hasAttribute('title')) {
      element.removeAttribute('title');
    }
  }

  function ensureFleetDataQualityElement(fleetPercent) {
    const indicator = document.createElement('span');
    indicator.className = 'ogh-data-quality';
    indicator.dataset.role = 'fleet-data-quality';
    indicator.textContent = '●';
    fleetPercent.insertAdjacentElement('afterend', indicator);
    return indicator;
  }

  function setValueTone(element, tone) {
    element.classList.remove('ogh-value-good', 'ogh-value-caution', 'ogh-value-warn', 'ogh-value-bad');
    if (tone) {
      element.classList.add(`ogh-value-${tone}`);
    }
  }

  function setDataQualityIndicator(element, quality) {
    element.classList.remove('ogh-quality-good', 'ogh-quality-warn', 'ogh-quality-bad', 'ogh-quality-unknown');
    element.classList.add(`ogh-quality-${quality.level}`);
    setPanelTooltipIfChanged(element, quality.title);
  }

  function getFleetPercentTone(inventory) {
    if (!inventory || inventory.coverage !== 'empire-full' || inventory.flightPercent === null) {
      return '';
    }

    if (inventory.flightPercent >= 90) {
      return 'good';
    }
    return 'warn';
  }

  function getFleetDataQuality(inventory) {
    if (!inventory || inventory.coverage !== 'empire-full' || inventory.flightPercent === null) {
      return {
        level: 'unknown',
        title: t('dataQualityMissing'),
      };
    }

    if (inventory.warning) {
      return {
        level: 'bad',
        title: t('dataQualityNeedsAttention'),
      };
    }

    const lastUpdatedAt = normalizeDate(inventory.lastUpdatedAt);
    const stale = !lastUpdatedAt || Date.now() - lastUpdatedAt.getTime() > 15 * 60 * 1000;
    if (stale) {
      return {
        level: 'warn',
        title: t('dataQualityStale'),
      };
    }

    return {
      level: 'good',
      title: t('dataQualityGood'),
    };
  }

  function getLargestInFlightTone(inventory) {
    return getTimerTone(inventory?.largestInFlight?.arrivalAt, TIMER_TONES.fleetSave);
  }

  function getNextExpeditionTone(event) {
    return getTimerTone(event?.arrivalAt, TIMER_TONES.nextExpedition);
  }

  function getTimerTone(arrivalAt, thresholds) {
    const timestamp = Number(arrivalAt);
    if (!Number.isFinite(timestamp) || timestamp <= 0) {
      return '';
    }

    const remainingMs = timestamp - getNow();
    for (const threshold of thresholds) {
      if (remainingMs <= threshold.maxMs) {
        return threshold.tone;
      }
    }

    return '';
  }

  function getExpeditionSlotsTone(slots) {
    if (!slots || !Number.isFinite(slots.used) || !Number.isFinite(slots.total) || slots.total <= 0) {
      return '';
    }

    if (slots.used >= slots.total) {
      return 'good';
    }
    if (slots.used > 0) {
      return 'warn';
    }
    return 'bad';
  }

  function getMoonActivityTone(activity) {
    if (!activity?.lastCompletedAt) {
      return '';
    }

    const elapsed = getNow() - Number(activity.lastCompletedAt);
    if (elapsed <= 15 * 60 * 1000) {
      return 'good';
    }
    if (elapsed <= 60 * 60 * 1000) {
      return 'caution';
    }
    if (elapsed <= 3 * 60 * 60 * 1000) {
      return 'warn';
    }
    return 'bad';
  }

  function getDisplayFleetInventory() {
    if (state.fleetInventory.complete) {
      return state.fleetInventory;
    }

    return readFleetInventoryCache() || state.fleetInventory;
  }

  function formatFleetFlightPercent(inventory) {
    if (!inventory || inventory.coverage !== 'empire-full' || inventory.flightPercent === null) {
      return '-';
    }

    return `${inventory.flightPercent.toFixed(0)}%`;
  }

  function formatMoonActivityTimer(activity) {
    if (!activity?.lastCompletedAt) {
      return '-';
    }

    return formatElapsedDuration(getNow() - Number(activity.lastCompletedAt));
  }

  function buildMoonActivityTitle(activity) {
    const moons = Object.values(activity?.moons || {});
    const startedAt = Number(activity?.currentRunStartedAt) || null;
    const currentRunId = Number(activity?.currentRunId) || 0;
    const hasCurrentRun = currentRunId > 0 && startedAt;
    const activeMoons = hasCurrentRun
      ? moons.filter((moon) => Number(moon.lastRunId) === currentRunId && Number(moon.lastActivityAt) >= startedAt)
      : [];
    const lines = [
      `${t('activeMoons')}: ${activeMoons.length}/${moons.length}`,
      `${t('runStart')}: ${formatDateTimeOrDash(startedAt)}`,
      `${t('fullRun')}: ${formatDateTimeOrDash(activity?.lastCompletedAt)}`,
    ];

    const oldest = activeMoons
      .sort((a, b) => Number(a.lastActivityAt) - Number(b.lastActivityAt))[0];
    if (oldest) {
      lines.push(`${t('oldestActivity')}: ${formatMoonLabel(oldest)} - ${formatElapsedDuration(getNow() - Number(oldest.lastActivityAt))}`);
    }

    return lines.join('\n');
  }

  function formatMoonLabel(moon) {
    return [moon.name, moon.coordinates ? `[${moon.coordinates}]` : ''].filter(Boolean).join(' ') || moon.id || '-';
  }

  function buildMoonActivityTitleV2(activity) {
    const moons = Object.values(activity?.moons || {});
    const startedAt = Number(activity?.currentRunStartedAt) || null;
    const currentRunId = Number(activity?.currentRunId) || 0;
    const hasCurrentRun = currentRunId > 0 && startedAt;
    const activeMoons = hasCurrentRun
      ? moons.filter((moon) => Number(moon.lastRunId) === currentRunId && Number(moon.lastActivityAt) >= startedAt)
      : [];
    const activeMoonIds = new Set(activeMoons.map((moon) => moon.id));
    const missingMoons = moons.filter((moon) => !activeMoonIds.has(moon.id));
    const lines = [
      `${t('activeMoons')}: ${activeMoons.length}/${moons.length}`,
    ];

    if (missingMoons.length) {
      lines.push('');
      lines.push(t('missingMoons'));
      for (const moon of missingMoons.sort(compareMoonCoordinates)) {
        lines.push(`- ${formatMoonLabel(moon)}`);
      }
    }

    return lines.join('\n');
  }

  function compareMoonCoordinates(a, b) {
    const first = parseCoordinates(a?.coordinates);
    const second = parseCoordinates(b?.coordinates);
    if (first && second) {
      return first.galaxy - second.galaxy
        || first.system - second.system
        || first.position - second.position;
    }
    if (first) {
      return -1;
    }
    if (second) {
      return 1;
    }
    return formatMoonLabel(a).localeCompare(formatMoonLabel(b), undefined, { numeric: true, sensitivity: 'base' });
  }

  function parseCoordinates(value) {
    const match = String(value || '').match(/(\d+):(\d+):(\d+)/);
    if (!match) {
      return null;
    }
    return {
      galaxy: Number(match[1]),
      system: Number(match[2]),
      position: Number(match[3]),
    };
  }

  function formatLargestInFlightPercent(inventory) {
    const percent = inventory?.largestInFlight?.percentOfTotal;
    if (!inventory || inventory.coverage !== 'empire-full' || !Number.isFinite(percent)) {
      return '-';
    }

    const precision = percent > 0 && percent < 10 ? 1 : 0;
    return `${percent.toFixed(precision)}%`;
  }

  function formatLargestInFlightTime(inventory) {
    const largest = inventory?.largestInFlight;
    if (!largest?.arrivalAt) {
      return '-';
    }

    return formatCountdown(largest.arrivalAt - getNow());
  }

  function buildLargestInFlightTitle(inventory) {
    const largest = inventory?.largestInFlight;
    if (!largest) {
      return t('noFleetRowsData');
    }

    return [
      `${t('largestFleet')}: ${formatInteger(largest.total)}`,
      `${t('time')}: ${largest.arrivalAt ? formatCountdown(largest.arrivalAt - getNow()) : '-'}`,
      `${t('arrival')}: ${formatDateTimeOrDash(largest.arrivalAt)}`,
      `${t('direction')}: ${largest.returning ? t('returnDirection') : t('outboundDirection')}`,
      `${t('mission')}: ${largest.mission || largest.missionType || '--'}`,
      `${t('route')}: ${[largest.origin, largest.destination].filter(Boolean).join(' -> ') || '--'}`,
      `${t('shareTotal')}: ${formatLargestInFlightPercent(inventory)}`,
      `${t('shareInFlight')}: ${Number.isFinite(largest.percentOfInFlight) ? `${largest.percentOfInFlight.toFixed(0)}%` : '--'}`,
      `${t('description')}: ${largest.label || '--'}`,
    ].join('\n');
  }

  function buildFleetSaveTimerTitle(inventory) {
    const largest = inventory?.largestInFlight;
    if (!largest) {
      return t('noFleetRowsData');
    }

    const eventLabel = largest.returning ? t('fleetSaveReturn') : t('fleetSaveLanding');
    const location = (largest.returning ? largest.origin : largest.destination)
      || largest.destination
      || largest.origin
      || '--';

    return [
      `${eventLabel}: ${formatFleetSaveDateTimeOrDash(largest.arrivalAt)}`,
      `${t('location')}: ${location}`,
    ].join('\n');
  }

  function buildFleetInventoryTitle(inventory) {
    if (!inventory) {
      return t('noFullShipData');
    }

    const sources = inventory.sourceLabels.length ? inventory.sourceLabels.join(', ') : t('none');
    const lines = [
      `${t('inFlight')}: ${formatInteger(inventory.inFlight)}`,
      `${t('coverage')}: ${inventory.coverage}`,
      `${t('totalKnown')}: ${formatInteger(inventory.totalKnown)}`,
      `${t('sources')}: ${sources}`,
    ];

    if (inventory.complete) {
      lines.splice(2, 0, `${t('stationary')}: ${formatInteger(inventory.stationary)}`);
      if (inventory.largestInFlight) {
        lines.splice(3, 0, `${t('largestFleet')}: ${formatInteger(inventory.largestInFlight.total)} (${formatLargestInFlightTime(inventory)}, ${formatLargestInFlightPercent(inventory)})`);
      }
    } else if (inventory.warning) {
      lines.splice(2, 0, inventory.warning);
    }

    if (inventory.sourceAudit?.length) {
      lines.push(`${t('audit')}:`);
      for (const source of inventory.sourceAudit) {
        lines.push(`${source.label}: ${t('flight')} ${formatInteger(source.inFlight)}, ${t('rows')} ${formatInteger(source.fleetRows || 0)}, ${t('ships')} ${formatInteger(source.stationary)}, ${source.coverage}`);
      }
    }

    return lines.join('\n');
  }

  function formatExpeditionSlots(slots) {
    if (!slots) {
      return '-';
    }

    const used = Number.isFinite(slots.used) ? slots.used : 0;
    const total = Number.isFinite(slots.total) ? slots.total : '-';
    return `${used}/${total}`;
  }

  function buildExpeditionSlotsTitle(slots) {
    return [
      `${t('occupiedExpeditions')}: ${formatExpeditionSlots(slots)}`,
      `${t('source')}: ${slots?.source || '--'}`,
      `${t('lastUpdate')}: ${formatDateTimeOrDash(slots?.lastUpdatedAt)}`,
      `${t('empireRead')}: ${formatDateTimeOrDash(state.lastEmpireReadAt)}`,
      `${t('empireParsed')}: ${formatDateTimeOrDash(state.lastEmpireParsedAt)}`,
      `${t('missionsInFlight')}: ${formatDateTimeOrDash(state.lastMissionsReadAt)}`,
      `${t('empireCache')}: ${state.lastCacheStatus || '--'}`,
      `${t('empireFleetStatus')}: ${state.lastShipFetchStatus || '--'}`,
      `${t('eventlistStatus')}: ${state.lastFetchStatus || '--'}`,
    ].join('\n');
  }

  function renderExpeditionRow(event) {
    const route = [event.origin, event.destination].filter(Boolean).join(' -> ');
    const routeText = route ? `<span>${escapeHtml(route)}</span>` : `<span>${escapeHtml(t('unknownRoute'))}</span>`;

    return `
      <div class="ogh-expedition">
        <strong>${escapeHtml(formatCountdown(event.arrivalAt - getNow()))}</strong>
        ${routeText}
      </div>
    `;
  }

  function buildEventDetails(event) {
    const route = [event.origin, event.destination].filter(Boolean).join(' -> ');
    const prefix = event.returning ? t('returnPrefix') : 'Event';
    return route ? `${prefix}: ${route}` : prefix;
  }

  function formatCountdown(ms) {
    const totalSeconds = Math.max(0, Math.floor(ms / 1000));
    const days = Math.floor(totalSeconds / 86400);
    const hours = Math.floor((totalSeconds % 86400) / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    const clock = [hours, minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':');

    return days > 0 ? `${days}d ${clock}` : clock;
  }

  function formatElapsedDuration(ms) {
    const totalSeconds = Math.max(0, Math.floor(ms / 1000));
    const days = Math.floor(totalSeconds / 86400);
    const hours = Math.floor((totalSeconds % 86400) / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    const clock = [hours, minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':');

    return days > 0 ? `${days}d ${clock}` : clock;
  }

  function formatInteger(value) {
    return String(Math.max(0, Math.round(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
  }

  function formatClockOrDash(value) {
    const date = normalizeDate(value);
    if (!date) {
      return '-';
    }

    return [
      date.getHours(),
      date.getMinutes(),
      date.getSeconds(),
    ].map((part) => String(part).padStart(2, '0')).join(':');
  }

  function formatDateTimeOrDash(value) {
    const date = normalizeDate(value);
    if (!date) {
      return '-';
    }

    return `${date.toLocaleDateString()} ${formatClockOrDash(date)}`;
  }

  function formatFleetSaveDateTimeOrDash(value) {
    const date = normalizeDate(value);
    if (!date) {
      return '-';
    }

    const day = String(date.getDate()).padStart(2, '0');
    const month = String(date.getMonth() + 1).padStart(2, '0');
    return `${day}.${month}.${date.getFullYear()} ${formatClockOrDash(date)}`;
  }

  function normalizeDate(value) {
    if (!value) {
      return null;
    }

    const date = value instanceof Date ? value : new Date(value);
    return Number.isNaN(date.getTime()) ? null : date;
  }

  function readServerTimeOffset() {
    const timestamp = document.querySelector('meta[name="ogame-timestamp"]')?.content;
    const serverSeconds = Number.parseInt(timestamp, 10);

    if (!Number.isFinite(serverSeconds) || serverSeconds <= 0) {
      return 0;
    }

    return serverSeconds * 1000 - Date.now();
  }

  function getNow() {
    return Date.now() + state.serverTimeOffsetMs;
  }

  function cleanText(value) {
    return value ? String(value).replace(/\s+/g, ' ').trim() : '';
  }

  function stripHtml(value) {
    return cleanText(String(value || '').replace(/<[^>]*>/g, ' '));
  }

  function getSearchableText(element) {
    const parts = [element.textContent || ''];
    const attributes = ['title', 'alt', 'aria-label', 'data-title', 'data-tooltip-title', 'data-mission-name'];

    for (const node of [element, ...element.querySelectorAll('*')]) {
      for (const attribute of attributes) {
        const value = node.getAttribute?.(attribute);
        if (value) {
          parts.push(value);
        }
      }
      if (node.className && typeof node.className === 'string') {
        parts.push(node.className);
      }
    }

    return cleanText(parts.join(' '));
  }

  function readMissionTitle(row) {
    const mission = row.querySelector('.missionFleet [data-tooltip-title], .missionFleet [title], .missionFleet img');
    return mission?.getAttribute('data-tooltip-title')
      || mission?.getAttribute('title')
      || mission?.getAttribute('alt')
      || '';
  }

  function readMissionCellText(row) {
    const mission = row.querySelector('.missionFleet');
    return mission ? getSearchableText(mission) : '';
  }

  function normalizeText(value) {
    return cleanText(value)
      .toLowerCase()
      .replace(/ł/g, 'l')
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '');
  }

  function parseLocalizedInteger(value) {
    const digits = String(value || '').replace(/[^\d]/g, '');
    if (!digits) {
      return null;
    }

    const parsed = Number.parseInt(digits, 10);
    return Number.isFinite(parsed) ? parsed : null;
  }

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }

  function cssEscape(value) {
    if (window.CSS?.escape) {
      return window.CSS.escape(String(value));
    }

    return String(value).replace(/["\\]/g, '\\$&');
  }

  GM_addStyle(`
    #${APP_ID} {
      box-sizing: border-box;
      clear: both;
      display: grid !important;
      grid-template-columns: repeat(3, 220px) !important;
      gap: 3px;
      justify-content: start;
      width: 670px !important;
      min-width: 670px !important;
      max-width: none !important;
      margin: var(--ogh-overview-offset, 0px) auto 8px;
      padding: 0;
      color: lightgrey;
      font-family: Verdana, Arial, Helvetica, sans-serif;
      line-height: 1;
      text-shadow: none;
    }

    #overviewcomponent:has(+ #${APP_ID}) {
      margin-bottom: 7px !important;
    }

    #${APP_ID} .ogh-status-column {
      box-sizing: border-box;
      min-width: 0;
      width: 220px;
      margin: 0;
      float: none;
    }

    #${APP_ID} .ogh-status-component {
      width: 220px;
    }

    #${APP_ID} .content-box-s {
      width: 220px !important;
      margin: 0 0 5px 0 !important;
      float: none !important;
      overflow: hidden;
    }

    #${APP_ID} .content {
      overflow: hidden;
    }

    #${APP_ID} .ogh-status-table {
      display: table;
      table-layout: fixed;
      width: 100% !important;
      min-width: 0 !important;
      max-width: 100% !important;
    }

    #${APP_ID} .ogh-status-table tbody,
    #${APP_ID} .ogh-status-table tr {
      width: 100% !important;
      max-width: 100% !important;
    }

    #${APP_ID} .ogh-status-table td.idle {
      box-sizing: border-box;
      width: 100% !important;
      min-width: 0 !important;
      max-width: 100% !important;
    }

    #${APP_ID} .ogh-panel-main {
      box-sizing: border-box;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      gap: 4px;
      min-height: 0;
      padding: 0 8px;
      width: 100%;
      max-width: 100%;
      font: 700 11px/1.35 Verdana, Arial, Helvetica, sans-serif;
      white-space: nowrap;
    }

    #${APP_ID} .ogh-metrics-table {
      border-collapse: collapse !important;
      border-spacing: 0 !important;
      margin: 0 auto !important;
      table-layout: fixed !important;
      width: 160px !important;
      min-width: 160px !important;
      max-width: 160px !important;
    }

    #${APP_ID} .ogh-metrics-table td {
      background: none !important;
      border: 0 !important;
      box-sizing: border-box;
      height: auto !important;
      line-height: 15px !important;
      padding-bottom: 0 !important;
      padding-top: 0 !important;
      color: #ffffff;
      font-weight: 700;
    }

    #${APP_ID} .ogh-label-cell {
      min-width: 96px !important;
      padding-left: 0 !important;
      padding-right: 5px !important;
      text-align: right !important;
      white-space: nowrap !important;
      width: 96px !important;
    }

    #${APP_ID} .ogh-value-cell {
      padding-left: 0 !important;
      padding-right: 0 !important;
      text-align: left !important;
      white-space: nowrap !important;
      width: 64px !important;
    }

    #${APP_ID} .ogh-label {
      color: #ffffff;
      font-weight: 400;
    }

    #${APP_ID} .ogh-time {
      color: #ffffff;
      font-weight: 700;
      font-variant-numeric: tabular-nums;
    }

    #${APP_ID} .ogh-timer-blue {
      color: ${STATUS_COLORS.timerBlue};
    }

    #${APP_ID} .ogh-data-quality {
      display: inline-block;
      font-size: 9px;
      line-height: 1;
      margin-left: 3px;
      vertical-align: 1px;
    }

    #${APP_ID} .ogh-value-good {
      color: ${STATUS_COLORS.good};
    }

    #${APP_ID} .ogh-value-warn {
      color: ${STATUS_COLORS.warn};
    }

    #${APP_ID} .ogh-value-caution {
      color: ${STATUS_COLORS.caution};
    }

    #${APP_ID} .ogh-value-bad {
      color: ${STATUS_COLORS.bad};
    }

    #${APP_ID} .ogh-quality-good {
      color: ${STATUS_COLORS.good};
    }

    #${APP_ID} .ogh-quality-warn {
      color: ${STATUS_COLORS.warn};
    }

    #${APP_ID} .ogh-quality-bad {
      color: ${STATUS_COLORS.bad};
    }

    #${APP_ID} .ogh-quality-unknown {
      color: ${STATUS_COLORS.unknown};
    }

    .ogh-tooltip {
      position: fixed;
      display: none;
      z-index: 100000;
      box-sizing: border-box;
      max-width: 360px;
      padding: 7px 9px;
      border: 1px solid rgba(255, 255, 255, 0.16);
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.60);
      color: #ffffff;
      font: 11px/1.45 Verdana, Arial, Helvetica, sans-serif;
      white-space: pre-line;
      pointer-events: none;
      box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
      text-shadow: none;
    }

    #planetList .moonlink {
      position: relative;
    }

    #planetList .moonlink > .ogh-moon-activity-dot {
      position: absolute;
      left: var(--ogh-moon-activity-dot-left, -9px);
      top: var(--ogh-moon-activity-dot-top, 50%);
      z-index: 3;
      box-sizing: border-box;
      width: 6px;
      height: 6px;
      border: 1px solid rgba(0, 0, 0, 0.75);
      border-radius: 50%;
      pointer-events: none;
      transform: translateY(-50%);
    }

    #planetList .moonlink > .ogh-moon-activity-dot-fresh {
      background: ${STATUS_COLORS.good};
      box-shadow: 0 0 3px rgba(134, 201, 119, 0.8);
    }

    #planetList .moonlink > .ogh-moon-activity-dot-stale {
      background: ${STATUS_COLORS.unknown};
      opacity: 0.45;
    }

    #planetList .moonlink > .ogh-moon-activity-dot-unknown {
      background: ${STATUS_COLORS.unknown};
      opacity: 0;
    }

    @media (max-width: 700px) {
      #${APP_ID} {
        margin-left: 0;
        margin-right: 0;
      }

      #${APP_ID} .ogh-panel-main {
        white-space: normal;
      }
    }
  `);

  try {
    init();
  } catch (error) {
    console.error('[OGame Status Panel] failed', error);
  }
})();