(() => { // ----------------------------------------------------------------------- // DOM refs // ----------------------------------------------------------------------- const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); const vmChip = $('#vm-chip'); const bgChip = $('#bg-chip'); const vmBadge = $('#vm-badge'); const bgBadge = $('#bg-badge'); const vmState = $('#vm-state'); const vmIp = $('#vm-ip'); const vmMemory = $('#vm-memory'); const vmUptime = $('#vm-uptime'); const bgStatusText = $('#bg-status-text'); const consoleOut = $('#console-output'); const consoleToggle = $('#console-toggle'); const consoleWrap = $('#console-wrapper'); const consoleBadge = $('#console-badge'); const overlay = $('#overlay'); const overlayText = $('#overlay-text'); const linkFB = $('#link-filebrowser'); const linkDir = $('#link-director'); const monFB = $('#mon-filebrowser'); const monDir = $('#mon-director'); // ----------------------------------------------------------------------- // State // ----------------------------------------------------------------------- let status = null; let busy = false; // ----------------------------------------------------------------------- // Tabs // ----------------------------------------------------------------------- $$('.tab').forEach((tab) => { tab.addEventListener('click', () => { $$('.tab').forEach((t) => t.classList.remove('active')); $$('.panel').forEach((p) => p.classList.remove('active')); tab.classList.add('active'); $(`#tab-${tab.dataset.tab}`).classList.add('active'); }); }); // ----------------------------------------------------------------------- // Console // ----------------------------------------------------------------------- consoleWrap.classList.add('collapsed'); consoleToggle.addEventListener('click', () => { consoleWrap.classList.toggle('collapsed'); consoleBadge.hidden = true; }); function appendConsole(text) { consoleOut.textContent += text; consoleOut.parentElement.scrollTop = consoleOut.parentElement.scrollHeight; if (consoleWrap.classList.contains('collapsed')) { consoleBadge.hidden = false; } } function expandConsole() { consoleWrap.classList.remove('collapsed'); consoleBadge.hidden = true; } // ----------------------------------------------------------------------- // Setup log pipe (defined early so WS handler can call it) // ----------------------------------------------------------------------- let wizStep = 1; function pipeToSetupLog(text) { if (wizStep === 3) { const el = $('#setup-log'); if (el) { el.textContent += text; el.scrollTop = el.scrollHeight; } } else if (wizStep === 6) { const el = $('#bootstrap-log'); if (el) { el.textContent += text; el.scrollTop = el.scrollHeight; } } } // ----------------------------------------------------------------------- // WebSocket // ----------------------------------------------------------------------- let ws; function connectWs() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}`); ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'output') { appendConsole(msg.data); pipeToSetupLog(msg.data); } } catch { /* ignore */ } }; ws.onclose = () => setTimeout(connectWs, 3000); } connectWs(); // ----------------------------------------------------------------------- // API helpers // ----------------------------------------------------------------------- async function api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`/api/${path}`, opts); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch { throw new Error( res.ok ? 'Invalid JSON from server' : `Server error (${res.status}): ${text.startsWith(' ${label}\n`); try { await api('POST', path, body); } catch (e) { appendConsole(`Error: ${e.message}\n`); expandConsole(); alert(`${label} failed: ${e.message}`); } hideOverlay(); refreshStatus(); } async function startVmAction(memoryGB) { if (busy) return; const vmRetryPanel = $('#vm-start-retry'); if (vmRetryPanel) vmRetryPanel.hidden = true; showOverlay('Starting VM...'); appendConsole('\n> Starting VM\n'); expandConsole(); try { const body = memoryGB ? { memoryGB } : undefined; const result = await api('POST', 'vm/start', body); if (result.memoryGB) { appendConsole(`VM started with ${result.memoryGB} GB RAM.\n`); } } catch (e) { appendConsole(`Error: ${e.message}\n`); expandConsole(); alert('Failed to start VM: ' + e.message); if (e.startFailed || e.status === 507) { if (vmRetryPanel) vmRetryPanel.hidden = false; } } hideOverlay(); refreshStatus(); } // ----------------------------------------------------------------------- // Status refresh // ----------------------------------------------------------------------- function applyStatus(s) { status = s; const vm = s.vm || {}; const bg = s.battlegroup; const links = s.links || {}; // VM card const running = vm.exists && vm.state === 'Running'; const stopped = vm.exists && vm.state !== 'Running'; const missing = !vm.exists; vmState.textContent = vm.exists ? vm.state : 'Not Found'; vmIp.textContent = vm.ip || '—'; if (running && vm.memoryMB) { vmMemory.textContent = `${Math.round(vm.memoryMB)} MB`; } else if (vm.startupMemoryMB) { vmMemory.textContent = `${Math.round(vm.startupMemoryMB / 1024)} GB configured`; } else { vmMemory.textContent = '—'; } vmUptime.textContent = vm.uptime || '—'; vmBadge.textContent = running ? 'Running' : stopped ? vm.state : 'Not Found'; vmBadge.className = `badge ${running ? 'running' : stopped ? 'stopped' : ''}`; vmChip.className = `status-chip ${running ? 'running' : stopped ? 'stopped' : 'unknown'}`; $('#vm-chip-state').textContent = running ? vm.ip || 'Running' : vm.exists ? vm.state : '—'; $('#btn-vm-start').disabled = busy || running || missing; $('#btn-vm-stop').disabled = busy || !running; // Battlegroup card const bgUp = bg && bg.running; bgBadge.textContent = !running ? 'VM Off' : bgUp ? 'Active' : 'Inactive'; bgBadge.className = `badge ${!running ? '' : bgUp ? 'running' : 'stopped'}`; bgChip.className = `status-chip ${!running ? 'unknown' : bgUp ? 'running' : 'stopped'}`; $('#bg-chip-state').textContent = !running ? '—' : bgUp ? 'Active' : 'Inactive'; bgStatusText.textContent = bg ? bg.output || 'No details' : 'VM not running'; const bgBtns = ['btn-bg-start', 'btn-bg-restart', 'btn-bg-stop', 'btn-bg-update']; bgBtns.forEach((id) => { $(`#${id}`).disabled = busy || !running; }); // All data-action buttons $$('[data-action]').forEach((btn) => { btn.disabled = busy || !running; }); // Links function setLink(el, url) { if (url) { el.href = url; el.classList.remove('disabled'); el.removeAttribute('data-nolink'); } else { el.removeAttribute('href'); el.classList.add('disabled'); el.setAttribute('data-nolink', '1'); } } setLink(linkFB, links.fileBrowser); setLink(linkDir, links.director); setLink(monFB, links.fileBrowser); setLink(monDir, links.director); // Settings buttons $('#btn-rotate-key').disabled = busy || !running; // Config warning banner const cw = $('#config-warning'); if (cw) { if (bgUp) { cw.classList.remove('ok'); cw.innerHTML = 'Battlegroup is running. Stop it before editing. Changes apply on next start.'; } else { cw.classList.add('ok'); cw.innerHTML = 'Battlegroup is offline. Safe to edit. Changes will apply on next start.'; } } const repairPanel = $('#repair-bootstrap-panel'); if (repairPanel) { const needsRepair = running && bg && bg.needsBootstrap; repairPanel.hidden = !needsRepair; $('#btn-repair-bootstrap').disabled = busy || !needsRepair; } const sshPanel = $('#ssh-key-warning'); if (sshPanel) { const sshMissing = running && s.ssh && !s.ssh.keyPresent; const sshAuthFailed = running && bg && bg.output && /Identity file .* not accessible|Permission denied \(publickey/i.test(bg.output); sshPanel.hidden = !(sshMissing || sshAuthFailed); if (sshMissing || sshAuthFailed) { const hint = $('#ssh-key-hint'); if (hint) { hint.textContent = sshMissing ? `Expected key at ${s.ssh && s.ssh.keyPath ? s.ssh.keyPath : 'LOCALAPPDATA\\DuneAwakeningServer\\sshKey'}` : 'SSH cannot read the key (common when running from WSL after reboot). Restart the manager or use Settings → Rotate SSH Key.'; } } } const vmRetryPanel = $('#vm-start-retry'); if (vmRetryPanel && running) vmRetryPanel.hidden = true; } async function refreshStatus() { try { const s = await api('GET', 'status'); applyStatus(s); } catch { /* silent */ } } refreshStatus(); setInterval(refreshStatus, 3000); // ----------------------------------------------------------------------- // Setup Wizard // ----------------------------------------------------------------------- let wizData = {}; const wizNext = $('#wiz-next'); const wizBack = $('#wiz-back'); const wizBadge = $('#setup-step-badge'); const wizFill = $('#wizard-progress-fill'); function showWizStep(n) { wizStep = n; $$('.wizard-step').forEach((s) => s.classList.remove('active')); const el = $(`#wiz-step-${n}`); if (el) el.classList.add('active'); wizBadge.textContent = n <= 6 ? `Step ${n} of 6` : 'Complete'; wizFill.style.width = `${Math.min((n / 6) * 100, 100)}%`; wizBack.hidden = n <= 1 || n >= 7; wizNext.hidden = n >= 7; wizNext.textContent = n === 6 ? 'Finish Setup' : 'Next'; if (n === 7) { wizNext.hidden = true; wizBack.hidden = true; } if (n === 5) updateSetupPortForwardPanel(); } // Retry start with different memory $('#btn-retry-start').addEventListener('click', async () => { const memGB = parseInt($('#retry-memory').value, 10); const logEl = $('#setup-log'); const spinner = $('#setup-spinner'); const retryPanel = $('#retry-start'); spinner.hidden = false; retryPanel.hidden = true; $('#btn-retry-start').disabled = true; try { const result = await api('POST', 'setup/retry-start', { memoryGB: memGB }); if (result.success) { wizData.ip = result.ip; wizData.memoryGB = memGB; logEl.textContent += `\nVM ready at ${result.ip}\n`; spinner.hidden = true; wizNext.disabled = false; if (memGB < 20) $('#setup-swap').checked = true; } else { throw new Error(result.error || 'Start failed'); } } catch (e) { logEl.textContent += `\nError: ${e.message}\n`; spinner.hidden = true; retryPanel.hidden = false; $('#btn-retry-start').disabled = false; } }); // Preflight let preflightPassed = false; let preflightData = null; function applyPreflightResults(data) { preflightData = data; function mark(id, ok) { const el = $(id); el.classList.toggle('pass', ok); el.classList.toggle('fail', !ok); el.querySelector('.pf-icon').textContent = ok ? '\u2705' : '\u274C'; } mark('#pf-hyperv', data.hyperv); mark('#pf-vmcx', data.vmcxFound); mark('#pf-drives', data.drives && data.drives.length > 0); preflightPassed = data.hyperv && data.vmcxFound && data.drives && data.drives.length > 0; const resetPanel = $('#setup-reset-panel'); const resetDesc = $('#setup-reset-desc'); if (resetPanel) { if (data.vmExists) { resetPanel.hidden = false; if (resetDesc) { resetDesc.textContent = data.vmState ? `VM "dune-awakening" exists (${data.vmState}). Delete everything below to run setup again from scratch.` : 'VM "dune-awakening" exists. Delete everything below to run setup again from scratch.'; } } else { resetPanel.hidden = true; } } if (preflightPassed) { const sel = $('#setup-drive'); sel.innerHTML = ''; data.drives.forEach((d) => { const opt = document.createElement('option'); opt.value = d.name; opt.textContent = `${d.name}: — ${d.freeGB} GB free`; sel.appendChild(opt); }); if (data.nics && data.nics.length > 0) { const nicSel = $('#setup-nic'); nicSel.innerHTML = ''; data.nics.forEach((n) => { const opt = document.createElement('option'); opt.value = n.name; opt.textContent = `${n.name} (${n.desc})`; nicSel.appendChild(opt); }); } } } async function runPreflight() { $('#btn-preflight').disabled = true; $('#btn-preflight').textContent = 'Checking...'; try { applyPreflightResults(await api('GET', 'setup/preflight')); } catch (e) { appendConsole(`Preflight error: ${e.message}\n`); } $('#btn-preflight').disabled = false; $('#btn-preflight').textContent = 'Run Checks'; } $('#btn-preflight').addEventListener('click', runPreflight); document.querySelector('.tab[data-tab="setup"]')?.addEventListener('click', () => { if (!preflightData) runPreflight(); }); $('#btn-setup-reset').addEventListener('click', async () => { if (busy) return; const msg = 'This permanently deletes your Dune VM, all battlegroup/world data inside it, ' + 'install folders, and SSH keys.\n\n' + 'Export a database backup first if you need to keep your world.\n\n' + 'Type DELETE to confirm.'; const typed = prompt(msg); if (typed !== 'DELETE') { if (typed !== null) alert('Reset cancelled — you must type DELETE exactly.'); return; } busy = true; showOverlay('Deleting existing installation...'); try { const res = await api('POST', 'setup/reset'); if (!res.success) throw new Error(res.error || 'Reset failed'); wizData = {}; preflightPassed = false; showWizStep(1); $('#setup-log').textContent = ''; $('#bootstrap-log').textContent = ''; appendConsole('Installation reset complete. Run pre-flight checks to begin a fresh setup.\n'); cachedVmStatus = null; await runPreflight(); refreshStatus(); } catch (e) { alert('Reset failed: ' + e.message); appendConsole(`Reset error: ${e.message}\n`); } hideOverlay(); busy = false; }); // Show/hide NIC field based on network mode document.addEventListener('change', (e) => { if (e.target.id === 'setup-network') { $('#nic-field').style.display = e.target.value === 'external' ? '' : 'none'; } if (e.target.id === 'setup-ip-mode') { $('#static-fields').style.display = e.target.value === 'static' ? '' : 'none'; } if (e.target.name === 'playerIpChoice') { $('#setup-player-ip-manual').style.display = e.target.value === 'manual' ? '' : 'none'; updateSetupPortForwardPanel(); } }); // Next / Back wizBack.addEventListener('click', () => { if (wizStep > 1) showWizStep(wizStep - 1); }); wizNext.addEventListener('click', async () => { if (busy) return; // Validate and execute per step if (wizStep === 1) { if (!preflightPassed) { alert('Pre-flight checks must pass before continuing. Click "Run Checks".'); return; } if (preflightData && preflightData.vmExists) { if (!confirm( 'A VM already exists. Continuing will replace it during import, but SSH keys may be stale.\n\n' + 'For a clean reinstall, use "Delete & Start Fresh" on step 1 instead.\n\nContinue anyway?' )) return; } showWizStep(2); } else if (wizStep === 2) { const token = $('#setup-token').value.trim(); if (!token) { alert('Server token is required. Get one from account.duneawakening.com'); return; } wizData.token = token; wizData.drive = $('#setup-drive').value; wizData.memoryGB = parseInt($('#setup-memory').value, 10); wizData.networkMode = $('#setup-network').value; wizData.nicName = wizData.networkMode === 'external' ? $('#setup-nic').value : null; if (wizData.memoryGB < 20) { $('#setup-swap').checked = true; } showWizStep(3); // Auto-run import const logEl = $('#setup-log'); const spinner = $('#setup-spinner'); const retryPanel = $('#retry-start'); logEl.textContent = ''; spinner.hidden = false; retryPanel.hidden = true; wizNext.disabled = true; try { const result = await api('POST', 'setup/import', { drive: wizData.drive, memoryGB: wizData.memoryGB, networkMode: wizData.networkMode, nicName: wizData.nicName, }); if (result.success) { wizData.ip = result.ip; logEl.textContent += `\nVM ready at ${result.ip}\n`; spinner.hidden = true; wizNext.disabled = false; } else if (result.imported && result.startFailed) { spinner.hidden = true; retryPanel.hidden = false; } else { throw new Error(result.error || 'Import failed'); } } catch (e) { logEl.textContent += `\nError: ${e.message}\n`; spinner.hidden = true; wizNext.disabled = false; } } else if (wizStep === 3) { if (!wizData.ip) { alert('VM must be running before continuing. If the start failed, use Retry Start below.'); return; } showWizStep(4); } else if (wizStep === 4) { const curPw = $('#setup-curpw').value || 'dune'; const pw = $('#setup-pw').value; const pw2 = $('#setup-pw2').value; if (!pw) { alert('New password required.'); return; } if (pw !== pw2) { alert('Passwords do not match.'); return; } showOverlay('Installing SSH key and changing password...'); try { const res = await api('POST', 'setup/security', { ip: wizData.ip, currentPassword: curPw, newPassword: pw, }); if (!res.success) throw new Error(res.error || 'Security setup failed'); appendConsole('SSH key installed and password changed.\n'); } catch (e) { appendConsole(`Security setup error: ${e.message}\n`); hideOverlay(); alert('Failed: ' + e.message + '. Check the console for details.'); return; } hideOverlay(); showWizStep(5); // Detect IPs try { const ips = await api('POST', 'setup/detect-ip', { ip: wizData.ip }); wizData.publicIp = ips.publicIp; wizData.privateIp = ips.privateIp; $('#opt-public').textContent = ips.publicIp ? `Public IP: ${ips.publicIp} (requires port forwarding)` : 'Public IP: not detected'; $('#opt-private').textContent = `Private IP: ${ips.privateIp} (LAN only)`; if (!ips.publicIp) { document.querySelector('input[name="playerIpChoice"][value="private"]').checked = true; } updateSetupPortForwardPanel(); } catch { /* ignore */ } } else if (wizStep === 5) { const ipMode = $('#setup-ip-mode').value; const choice = document.querySelector('input[name="playerIpChoice"]:checked').value; let playerIp; if (choice === 'public') playerIp = wizData.publicIp; else if (choice === 'private') playerIp = wizData.privateIp || wizData.ip; else playerIp = $('#setup-player-ip-manual').value; if (!playerIp) { alert('Please enter a player-facing IP.'); return; } showOverlay('Configuring network...'); try { const body = { ip: wizData.ip, mode: ipMode, playerIp, token: wizData.token }; if (ipMode === 'static') { body.staticIp = $('#setup-static-ip').value; body.staticGw = $('#setup-static-gw').value; } const res = await api('POST', 'setup/network', body); if (res.vmIp) wizData.ip = res.vmIp; } catch (e) { appendConsole(`Network config error: ${e.message}\n`); } hideOverlay(); showWizStep(6); } else if (wizStep === 6) { const logEl = $('#bootstrap-log'); const spinner = $('#bootstrap-spinner'); logEl.textContent = ''; spinner.hidden = false; wizNext.disabled = true; try { const worldName = $('#setup-world-name').value.trim(); if (!worldName) { alert('World name is required.'); spinner.hidden = true; wizNext.disabled = false; return; } const res = await api('POST', 'setup/bootstrap', { ip: wizData.ip, enableSwap: $('#setup-swap').checked, token: wizData.token, worldName, region: $('#setup-region').value, }); if (res.success) { logEl.textContent += '\nSetup complete!\n'; } else { logEl.textContent += `\nError: ${res.error}\n`; } } catch (e) { logEl.textContent += `\nError: ${e.message}\n`; } spinner.hidden = true; showWizStep(7); // Replace nav with a "Go to Dashboard" button wizNext.hidden = false; wizNext.disabled = false; wizNext.textContent = 'Go to Dashboard'; wizNext.onclick = () => { document.querySelector('.tab[data-tab="dashboard"]').click(); }; wizBack.hidden = true; cachedVmStatus = null; refreshStatus(); } }); // Block clicks on disabled links document.addEventListener('click', (e) => { const a = e.target.closest('a[data-nolink]'); if (a) e.preventDefault(); }); // ----------------------------------------------------------------------- // Button bindings — Dashboard // ----------------------------------------------------------------------- $('#btn-vm-start').addEventListener('click', () => startVmAction()); $('#btn-dashboard-retry-start').addEventListener('click', () => { const memGB = parseInt($('#dashboard-retry-memory').value, 10); startVmAction(memGB); }); $('#btn-vm-stop').addEventListener('click', () => { if (!confirm('Stop the VM? All running servers will go down.')) return; runAction('vm/stop', 'Stopping VM'); }); $('#btn-bg-start').addEventListener('click', () => runAction('bg/start', 'Starting battlegroup')); $('#btn-bg-restart').addEventListener('click', () => runAction('bg/restart', 'Restarting battlegroup')); $('#btn-bg-stop').addEventListener('click', () => { if (!confirm('Stop the battlegroup?')) return; runAction('bg/stop', 'Stopping battlegroup'); }); $('#btn-bg-update').addEventListener('click', () => runAction('bg/update', 'Checking for updates')); $('#btn-repair-bootstrap').addEventListener('click', async () => { const token = $('#repair-token').value.trim(); const worldName = $('#repair-world-name').value.trim(); if (!token) { alert('Server token is required.'); return; } if (!worldName) { alert('World name is required.'); return; } if (!confirm('Repair will delete the empty battlegroup namespace and re-run setup. Continue?')) return; const logEl = $('#repair-log'); const spinner = $('#repair-spinner'); logEl.textContent = ''; spinner.hidden = false; $('#btn-repair-bootstrap').disabled = true; showOverlay('Repairing battlegroup setup...'); try { const res = await api('POST', 'setup/repair', { token, worldName, region: $('#repair-region').value, enableSwap: $('#repair-swap').checked, }); if (res.success) { logEl.textContent += '\nRepair complete. Refreshing status...\n'; await refreshStatus(); } else { logEl.textContent += `\nError: ${res.error}\n`; } } catch (e) { logEl.textContent += `\nError: ${e.message}\n`; } spinner.hidden = true; $('#btn-repair-bootstrap').disabled = false; hideOverlay(); }); // data-action buttons (battlegroup tab, monitoring, database) $$('[data-action]').forEach((btn) => { btn.addEventListener('click', () => { const action = btn.dataset.action; const label = btn.textContent.trim(); runAction(action, label); }); }); // ----------------------------------------------------------------------- // Settings // ----------------------------------------------------------------------- $('#form-password').addEventListener('submit', async (e) => { e.preventDefault(); const pw = $('#pw-new').value; const pw2 = $('#pw-confirm').value; if (pw !== pw2) { alert('Passwords do not match.'); return; } if (!pw) { alert('Password cannot be empty.'); return; } showOverlay('Changing password...'); appendConsole('\n> Changing VM password\n'); try { const res = await api('POST', 'vm/password', { password: pw }); if (res.success) { appendConsole('Password changed successfully.\n'); $('#pw-new').value = ''; $('#pw-confirm').value = ''; } else { appendConsole(`Failed: ${res.error}\n`); } } catch (e) { appendConsole(`Error: ${e.message}\n`); } hideOverlay(); }); $('#btn-rotate-key').addEventListener('click', () => { if (!confirm('Generate a new SSH key? The old key will be replaced.')) return; runAction('vm/rotate-key', 'Rotating SSH key'); }); // ----------------------------------------------------------------------- // Game Config // ----------------------------------------------------------------------- let configOriginal = {}; async function loadConfig() { const loading = $('#config-loading'); const panels = $('#config-panels'); loading.style.display = ''; panels.style.display = 'none'; try { const data = await api('GET', 'config'); if (data.error) throw new Error(data.error); configOriginal = { game: { ...data.game }, engine: { ...data.engine } }; $$('.cfg').forEach((el) => { const file = el.dataset.file; const key = el.dataset.key; const values = file === 'game' ? data.game : data.engine; if (key in values) { let val = values[key]; // Strip surrounding quotes for string values if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); el.value = val; } el.classList.remove('cfg-dirty'); }); loading.style.display = 'none'; panels.style.display = ''; } catch (e) { loading.querySelector('.card-body').textContent = 'Failed to load config: ' + e.message; } } // Mark dirty on change document.addEventListener('input', (e) => { if (e.target.classList.contains('cfg')) e.target.classList.add('cfg-dirty'); }); document.addEventListener('change', (e) => { if (e.target.classList.contains('cfg')) e.target.classList.add('cfg-dirty'); }); // Load when tab opens const configTabBtn = document.querySelector('.tab[data-tab="gameconfig"]'); configTabBtn.addEventListener('click', () => { if ($('#config-panels').style.display === 'none') loadConfig(); }); // Reload $('#btn-config-reload').addEventListener('click', loadConfig); // Save $('#btn-config-save').addEventListener('click', async () => { const changes = { game: {}, engine: {} }; let count = 0; $$('.cfg.cfg-dirty').forEach((el) => { const file = el.dataset.file; const key = el.dataset.key; changes[file][key] = el.value; count++; }); if (count === 0) { alert('No changes to save.'); return; } if (status && status.battlegroup && status.battlegroup.running) { alert('Stop the battlegroup before saving config changes.'); return; } showOverlay(`Saving ${count} setting(s)...`); try { const res = await api('POST', 'config', changes); if (res.success) { appendConsole(`Saved ${count} config change(s). Settings deployed — stop & start the battlegroup to apply.\n`); $$('.cfg.cfg-dirty').forEach((el) => el.classList.remove('cfg-dirty')); } else { throw new Error(res.error); } } catch (e) { appendConsole(`Config save error: ${e.message}\n`); alert('Save failed: ' + e.message); } hideOverlay(); }); // ----------------------------------------------------------------------- // Server Visibility // ----------------------------------------------------------------------- let visibilityData = null; function buildPortForwardHtml(vmIp, directorPort, opts = {}) { const director = directorPort || 'see Dashboard'; const target = vmIp || 'your VM IP'; return ( 'Router port forwarding required (WAN)' + '
Forward these ports to your VM at ' + target + ' — not your Windows PC.
| Port | Protocol | Purpose |
|---|---|---|
| 31982 | TCP | Queue / matchmaking (required for server finder) |
| ' + director + ' | TCP | Director (matchmaking) |
| 7777–7810 | UDP | Game server traffic |
Finish setup, then configure these forwards on your router before sharing the server publicly.
' : 'After applying a public IP, stop the battlegroup completely, then start it again so Funcom registers the correct join address.
') ); } function isWanVisibilityChoice(selectedValue, vmIp, publicIp) { if (!selectedValue || selectedValue === 'custom') return true; if (publicIp && selectedValue === publicIp) return true; return vmIp && selectedValue !== vmIp; } function updateVisibilityPortForwardPanel() { const panel = $('#visibility-port-forward'); if (!panel || !visibilityData) return; const selected = document.querySelector('input[name="visibility"]:checked'); let selectedValue = selected ? selected.value : ''; if (selectedValue === 'custom') { selectedValue = $('#visibility-custom-ip').value.trim() || 'custom'; } const show = isWanVisibilityChoice(selectedValue, visibilityData.vmIp, visibilityData.publicIp); panel.innerHTML = buildPortForwardHtml( visibilityData.vmIp, visibilityData.directorPort, ); panel.hidden = !show; panel.style.display = show ? '' : 'none'; } function updateSetupPortForwardPanel() { const panel = $('#setup-port-forward'); if (!panel) return; const choice = document.querySelector('input[name="playerIpChoice"]:checked'); const isPublic = choice && (choice.value === 'public' || choice.value === 'manual'); if (!isPublic) { panel.hidden = true; panel.style.display = 'none'; return; } panel.innerHTML = buildPortForwardHtml(wizData.ip || 'VM IP', null, { setupHint: true }); panel.hidden = false; panel.style.display = ''; } async function loadVisibility() { const loading = $('#visibility-loading'); const controls = $('#visibility-controls'); loading.style.display = ''; controls.style.display = 'none'; try { visibilityData = await api('GET', 'server-visibility'); if (visibilityData.error) throw new Error(visibilityData.error); const radios = $('#visibility-radios'); radios.innerHTML = ''; const options = []; if (visibilityData.publicIp) { options.push({ value: visibilityData.publicIp, label: `Public (WAN) — ${visibilityData.publicIp}`, hint: 'Internet players — port forwarding required', }); } options.push({ value: visibilityData.vmIp, label: `LAN — ${visibilityData.vmIp}`, hint: 'Only players on your local network', }); options.push({ value: 'custom', label: 'Custom IP', hint: 'Enter manually' }); const current = visibilityData.advertisedIp || visibilityData.vmIp; let matchedCustom = true; options.forEach((opt) => { const id = 'vis-' + opt.value.replace(/\./g, '-'); const isSelected = opt.value !== 'custom' && current === opt.value; if (isSelected) matchedCustom = false; const div = document.createElement('label'); div.className = 'radio-option' + (isSelected ? ' selected' : ''); div.innerHTML = `` + `${opt.label}` + `${opt.hint}`; radios.appendChild(div); }); if (matchedCustom && current) { const customRadio = radios.querySelector('input[value="custom"]'); if (customRadio) { customRadio.checked = true; customRadio.closest('.radio-option').classList.add('selected'); $('#visibility-custom-ip').value = current; $('#visibility-custom-row').style.display = ''; } } radios.querySelectorAll('input[type="radio"]').forEach((r) => { r.addEventListener('change', () => { radios.querySelectorAll('.radio-option').forEach((o) => o.classList.remove('selected')); r.closest('.radio-option').classList.add('selected'); $('#visibility-custom-row').style.display = r.value === 'custom' ? '' : 'none'; updateVisibilityPortForwardPanel(); }); }); const customIpInput = $('#visibility-custom-ip'); if (customIpInput) { customIpInput.addEventListener('input', updateVisibilityPortForwardPanel); } $('#visibility-current').textContent = `Currently advertising: ${current}`; updateVisibilityPortForwardPanel(); loading.style.display = 'none'; controls.style.display = ''; } catch (e) { loading.textContent = 'Failed to load visibility: ' + e.message; } } $('#btn-visibility-save').addEventListener('click', async () => { const selected = document.querySelector('input[name="visibility"]:checked'); if (!selected) { alert('Select an option.'); return; } let ip = selected.value; if (ip === 'custom') { ip = $('#visibility-custom-ip').value.trim(); if (!ip) { alert('Enter a custom IP address.'); return; } } showOverlay('Setting server visibility...'); try { const res = await api('POST', 'server-visibility', { advertisedIp: ip }); appendConsole(`Server visibility set to ${ip}.\n`); if (res.message) appendConsole(res.message + '\n'); appendConsole('Self-hosted servers appear in-game under Servers → Experimental.\n'); await loadVisibility(); } catch (e) { alert('Failed: ' + e.message); } hideOverlay(); }); // Load visibility when Game Config tab opens const origConfigLoad = loadConfig; loadConfig = async function() { await origConfigLoad(); loadVisibility(); }; // ----------------------------------------------------------------------- // Character Editor // ----------------------------------------------------------------------- const INVENTORY_LABELS = { 0: 'Backpack', 1: 'Recipes', 12: 'Emotes', 14: 'Social', 15: 'Hotbar', 20: 'Quick-use', 25: 'Slot-25', 27: 'Equipped', 29: 'Slot-29', 30: 'Storage', 31: 'Slot-31', 32: 'Slot-32', 33: 'Slot-33', }; const WRITABLE_INV_TYPES = [0, 15, 20, 27]; const STACK_LIMITS = { 'Resources': 100, 'Ammo': 100, 'Consumables': 20, 'Fuel': 5, 'Weapons - Melee': 1, 'Weapons - Ranged': 1, 'Garments': 1, 'Garments - Head': 1, 'Garments - Chest': 1, 'Garments - Hands': 1, 'Garments - Legs': 1, 'Garments - Feet': 1, 'Tools': 1, 'Vehicle Modules': 1, 'Building': 1, 'Contract Items': 1, 'Misc': 1, }; let itemCatalog = null; let catalogArr = []; let charData = null; async function loadItemCatalog() { if (itemCatalog) return; try { const resp = await fetch('/data/item-catalog.json'); const data = await resp.json(); itemCatalog = data.items; catalogArr = Object.entries(itemCatalog).map(([tid, info]) => ({ tid, name: info.name, category: info.category, })); catalogArr.sort((a, b) => a.name.localeCompare(b.name)); } catch (e) { appendConsole('Failed to load item catalog: ' + e.message + '\n'); } } let cosmeticCatalog = null; let cosmeticArr = []; let unlockedCosmetics = new Set(); async function loadCosmeticCatalog() { if (cosmeticCatalog) return; try { const resp = await fetch('/data/cosmetic-catalog.json'); const data = await resp.json(); cosmeticCatalog = data.cosmetics; cosmeticArr = Object.entries(cosmeticCatalog).map(([id, info]) => ({ id, name: info.name, category: info.category, unlock: info.unlock || 'customization', })); cosmeticArr.sort((a, b) => a.name.localeCompare(b.name)); const hint = $('#cosmetic-results-hint'); if (hint && data._meta?.unlockable) { hint.textContent = `Type at least 2 characters to search across ${data._meta.unlockable} unlockable cosmetics (${data._meta.total} total incl. inventory swatch tokens), or pick a category filter.`; } } catch (e) { appendConsole('Failed to load cosmetic catalog: ' + e.message + '\n'); } } function catalogName(tid) { if (!itemCatalog) return tid; const info = itemCatalog[tid]; return info ? info.name : tid; } function catalogCategory(tid) { if (!itemCatalog) return 'Misc'; const info = itemCatalog[tid]; return info ? info.category : 'Misc'; } function isEquipmentCategory(cat) { return /Weapon|Garment|Tool/i.test(cat); } async function loadCharacterList() { const sel = $('#char-select'); sel.innerHTML = ''; try { const data = await api('GET', 'characters'); sel.innerHTML = ''; (data.characters || []).forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = `${c.name} (ID: ${c.id})`; sel.appendChild(opt); }); } catch (e) { sel.innerHTML = ''; } } function readStat(data, field, pathStr) { const parts = pathStr.split('.'); let obj = field === 'properties' ? data.properties : data.gasAttributes; for (const p of parts) { if (!obj || typeof obj !== 'object') return ''; obj = obj[p]; } if (obj && typeof obj === 'object' && 'BaseValue' in obj) return obj.BaseValue; return obj != null ? obj : ''; } function renderInventory() { if (!charData) return; const tbody = $('#inv-tbody'); const items = charData.items || []; tbody.innerHTML = ''; const nonEmoteItems = items.filter(i => !i.template_id.startsWith('Emote_') && !i.template_id.startsWith('Social_') ); nonEmoteItems.forEach(item => { const tr = document.createElement('tr'); const name = catalogName(item.template_id); const loc = INVENTORY_LABELS[item.inventory_type] || `Type ${item.inventory_type}`; tr.innerHTML = `