ArmA Reforger Workshop - Preset Maker

Create mod presets to use with Reforger server configs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ArmA Reforger Workshop - Preset Maker
// @namespace    reforger_preset_maker
// @version      1.2
// @description  Create mod presets to use with Reforger server configs.
// @author       JJayRex
// @match        https://reforger.armaplatform.com/workshop*
// @grant        none
// @run-at       document-idle
// @license      GPL-3.0-or-later
// ==/UserScript==
(function () {
  'use strict';

  const STORAGE_KEY = 'reforgerPreset';

  // Storage helpers
  function loadMods() {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
    catch { return []; }
  }
  function saveMods(mods) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(mods));
    refreshPanel();
  }
  function upsertMod(mod) {
    const mods = loadMods();
    const idx = mods.findIndex(m => m.modId === mod.modId);
    if (idx >= 0) mods[idx] = mod; else mods.push(mod);
    saveMods(mods);
  }
  function removeMod(modId) {
    const mods = loadMods().filter(m => m.modId !== modId);
    saveMods(mods);
  }

  // UI
  function ensureUI() {
    if (document.getElementById('presetToggle')) return;

    const btn = document.createElement('button');
    btn.id = 'presetToggle';
    btn.textContent = 'Preset';
    Object.assign(btn.style, {
      position: 'fixed', bottom: '10px', right: '20px', zIndex: '999999',
      background: '#0d6efd', color: '#fff', border: 'none',
      padding: '6px 10px', borderRadius: '6px', cursor: 'pointer',
      fontSize: '14px', boxShadow: '0 2px 8px rgba(0,0,0,.3)'
    });
    document.body.appendChild(btn);

    const panel = document.createElement('div');
    panel.id = 'presetPanel';
    Object.assign(panel.style, {
      display: 'none', position: 'fixed', bottom: '60px', right: '20px',
      zIndex: '999999', background: 'rgba(20,20,20,.95)', color: '#eee',
      padding: '12px', border: '1px solid #444', borderRadius: '8px',
      maxHeight: '75vh', overflowY: 'auto', width: '460px',
      boxShadow: '0 8px 24px rgba(0,0,0,.4)', backdropFilter: 'blur(2px)'
    });

    const header = document.createElement('div');
    header.style.display = 'flex';
    header.style.gap = '8px';
    header.style.alignItems = 'center';
    header.style.marginBottom = '8px';

    const title = document.createElement('strong');
    title.textContent = 'Mod Preset';
    title.style.flex = '1';

    const clearBtn = document.createElement('button');
    clearBtn.textContent = 'Clear all';
    Object.assign(clearBtn.style, smallBtnStyle('#6c757d'));

    const closeBtn = document.createElement('button');
    closeBtn.textContent = 'Close';
    Object.assign(closeBtn.style, smallBtnStyle('#dc3545'));

    header.appendChild(title);
    header.appendChild(clearBtn);
    header.appendChild(closeBtn);

    const listWrap = document.createElement('div');
    listWrap.id = 'modListWrap';
    listWrap.style.marginBottom = '10px';
    listWrap.style.border = '1px solid #333';
    listWrap.style.borderRadius = '6px';
    listWrap.style.padding = '8px';
    listWrap.style.background = '#1d1f20';

    const jsonTitle = h3('JSON');
    const jsonArea = ta('modJsonView', 140);

    const strTitle = h3('STRING');
    const strArea = ta('modStringView', 60);
    strArea.setAttribute('wrap', 'off');
    strArea.style.overflowX = 'auto';

    panel.append(header, listWrap, jsonTitle, jsonArea, strTitle, strArea);
    document.body.appendChild(panel);

    btn.addEventListener('click', () => {
      panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
      if (panel.style.display === 'block') refreshPanel();
    });
    closeBtn.addEventListener('click', () => panel.style.display = 'none');
    clearBtn.addEventListener('click', () => {
      if (confirm('Clear entire mod preset?')) saveMods([]);
    });
  }

  function smallBtnStyle(bg) {
    return {
      background: bg, color: '#fff', border: 'none', padding: '4px 8px',
      borderRadius: '6px', cursor: 'pointer', fontSize: '12px'
    };
  }
  function h3(text) {
    const el = document.createElement('h3');
    el.textContent = text;
    el.style.margin = '8px 0 4px 0';
    el.style.fontSize = '14px';
    return el;
  }
  function ta(id, heightPx) {
    const el = document.createElement('textarea');
    el.id = id; el.readOnly = true;
    Object.assign(el.style, {
      width: '100%', height: `${heightPx}px`, fontFamily: 'monospace',
      fontSize: '12px', background: '#111', color: '#eee',
      border: '1px solid #333', borderRadius: '6px', padding: '6px'
    });
    return el;
  }

  function refreshPanel() {
    const panel = document.getElementById('presetPanel');
    if (!panel) return;

    const mods = loadMods();

    // List
    const listWrap = document.getElementById('modListWrap');
    listWrap.innerHTML = '';
    if (mods.length === 0) {
      const empty = document.createElement('div');
      empty.textContent = 'No mods added yet.';
      empty.style.opacity = '0.8';
      listWrap.appendChild(empty);
    } else {
      for (const m of mods) {
        const row = document.createElement('div');
        row.style.display = 'grid';
        row.style.gridTemplateColumns = '1fr auto';
        row.style.alignItems = 'center';
        row.style.gap = '6px';
        row.style.padding = '4px 0';
        const info = document.createElement('div');
        info.innerHTML = `<div><b>${escapeHtml(m.name || '')}</b></div>
                              <div style="opacity:.8">ID: ${escapeHtml(m.modId)}</div>`;
        const rm = document.createElement('button');
        rm.textContent = 'Remove';
        Object.assign(rm.style, smallBtnStyle('#dc3545'));
        rm.addEventListener('click', () => removeMod(m.modId));
        row.append(info, rm);
        listWrap.appendChild(row);
        const hr = document.createElement('hr');
        hr.style.border = 'none'; hr.style.borderTop = '1px solid #2b2b2b';
        listWrap.appendChild(hr);
      }
      if (listWrap.lastChild && listWrap.lastChild.tagName === 'HR') listWrap.removeChild(listWrap.lastChild);
    }

    // JSON view
    const jsonArea = document.getElementById('modJsonView');
    jsonArea.value = JSON.stringify(mods, null, 2);

    // STRING view
    const ids = mods.map(m => m.modId).filter(Boolean);
    document.getElementById('modStringView').value = `-addon ${ids.join(', ')}`;
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, m => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[m]));
  }

  // Add button
  function isModPage() {
    return /\/workshop\/[0-9A-Fa-f]{16}(?:-|$)/.test(location.pathname);
  }

  function getIdFromUrl() {
    const m = location.pathname.match(/\/workshop\/([0-9A-Fa-f]{16})/);
    return m ? m[1].toUpperCase() : '';
  }

  async function ensureAddButton() {
    if (!isModPage()) return;

    // Avoid duplicates
    if (document.getElementById('addToPresetBtn')) return;

    // Wait for H1
    const h1 = await waitForElm('main h1, main section h1', 8000).catch(() => null);

    // Create button
    const addBtn = document.createElement('button');
    addBtn.id = 'addToPresetBtn';
    addBtn.textContent = 'Add to Preset';
    Object.assign(addBtn.style, smallBtnStyle('#28a745'));
    addBtn.style.fontSize = '13px';
    addBtn.style.margin = '6px 0';

    // Place near top
    const anchor = h1 || document.querySelector('main section, main');
    if (h1 && h1.parentNode) {
      h1.parentNode.insertBefore(addBtn, h1.nextSibling);
    } else if (anchor) {
      anchor.insertBefore(addBtn, anchor.firstChild);
    } else {
      document.body.insertBefore(addBtn, document.body.firstChild);
    }

    addBtn.addEventListener('click', async () => {
      addBtn.disabled = true;
      const orig = addBtn.textContent;
      try {
        const data = await scrapeModData();
        if (!data.modId) throw new Error('Mod ID not found');
        upsertMod(data);

        const deps = collectDependencies();
        let msg = 'Added!';
        if (deps.length > 0) {
          deps.forEach(d => upsertMod(d));
          msg = `Added (+${deps.length} deps)`;
        }

        addBtn.textContent = 'Added!';
        setTimeout(() => addBtn.textContent = orig, 1500);
      } catch (e) {
        console.warn(e);
        addBtn.textContent = 'Failed (see console)';
        setTimeout(() => addBtn.textContent = orig, 2000);
      } finally {
        addBtn.disabled = false;
      }
    });
  }

  async function scrapeModData() {
    const h1 = document.querySelector('main h1, main section h1');
    const name = (h1?.textContent || '').trim();

    let modId = '';
    // Try semantic pass
    document.querySelectorAll('dl, div').forEach(dl => {
      // Only inspect blocks that look like info rows
      const rows = dl.querySelectorAll(':scope > div');
      rows.forEach(div => {
        const dt = div.querySelector('dt');
        const dd = div.querySelector('dd');
        if (!dt || !dd) return;
        const label = dt.textContent.trim().toLowerCase();
        if (label === 'id') {
          const span = dd.querySelector('span');
          modId = (span ? span.textContent : dd.textContent).trim();
        }
      });
    });

    // Fallbacks
    if (!modId) modId = getIdFromUrl();
    // Normalize
    modId = (modId || '').toUpperCase();

    return { modId, name };
  }

  // Dependencies
  function xpevalFirst(xpath) {
    try {
      return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    } catch { return null; }
  }

  // Try the user-given container first
  function findDependenciesContainer() {
    const exact = xpevalFirst('/html/body/div[1]/div/main/div/section/div[2]/div[2]/section/div');
    return exact;
  }

  function collectDependencies() {
    const container = findDependenciesContainer();
    if (!container) return [];

    const anchors = Array.from(container.querySelectorAll('a[href^="/workshop/"][href*="-"]'));
    const currentId = getIdFromUrl();
    const results = [];

    for (const a of anchors) {
      const href = a.getAttribute('href') || '';
      const m = href.match(/\/workshop\/([0-9A-Fa-f]{16})-([^/?#]+)/);
      if (!m) continue;
      const modId = m[1].toUpperCase();
      if (modId === currentId) continue; // skip self
      const slug = decodeURIComponent(m[2]);
      const name = deriveNameFromAnchor(a, slug);
      results.push({ modId, name });
    }
    return uniqBy(results, x => x.modId);
  }

  function deriveNameFromAnchor(a, slug) {
    // Prefer visible text on the dependency card/link
    let txt = (a.innerText || a.textContent || '').trim();
    if (txt) {
      // Take first non-empty line (cards often have multiple lines)
      const firstLine = txt.split('\n').map(s => s.trim()).filter(Boolean)[0];
      if (firstLine) return firstLine;
    }
    // Fallback: guess from slug: underscores -> spaces, keep hyphens (e.g., UH-60)
    let s = (slug || '').replace(/_/g, ' ');
    // Insert spaces for CamelCase boundaries: ProjectRedline -> Project Redline
    s = s.replace(/([a-z])([A-Z])/g, '$1 $2');
    return s.trim();
  }

  function uniqBy(arr, keyFn) {
    const seen = new Set();
    const out = [];
    for (const it of arr) {
      const k = keyFn(it);
      if (!seen.has(k)) { seen.add(k); out.push(it); }
    }
    return out;
  }

  // Utils
  function waitForElm(selector, timeoutMs = 5000) {
    return new Promise((resolve, reject) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);

      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) {
          obs.disconnect();
          resolve(el);
        }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });

      if (timeoutMs > 0) {
        setTimeout(() => {
          obs.disconnect();
          reject(new Error(`Timeout waiting for ${selector}`));
        }, timeoutMs);
      }
    });
  }

  // Hook SPA navigation
  function hookRouting() {
    const fire = () => window.dispatchEvent(new Event('locationchange'));
    const _ps = history.pushState;
    history.pushState = function () {
      const r = _ps.apply(this, arguments);
      fire(); return r;
    };
    const _rs = history.replaceState;
    history.replaceState = function () {
      const r = _rs.apply(this, arguments);
      fire(); return r;
    };
    window.addEventListener('popstate', fire);
    window.addEventListener('locationchange', () => {
      // Attempt to add button on route change
      setTimeout(ensureAddButton, 0);
    });
  }

  // Boot
  function boot() {
    ensureUI();
    hookRouting();
    ensureAddButton();

    // Just in case
    const mo = new MutationObserver(() => {
      if (isModPage() && !document.getElementById('addToPresetBtn')) {
        clearTimeout(mo._t);
        mo._t = setTimeout(ensureAddButton, 150);
      }
    });
    mo.observe(document.body, { childList: true, subtree: true });
  }

  // Delay boot
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();