Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1881 lines
68 KiB
JavaScript
1881 lines
68 KiB
JavaScript
(() => {
|
||
// -----------------------------------------------------------------------
|
||
// 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('<!') ? 'endpoint missing or manager needs restart' : text.slice(0, 120)}`
|
||
);
|
||
}
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || data.message || `Request failed (${res.status})`);
|
||
err.status = res.status;
|
||
err.code = data.code;
|
||
err.startFailed = data.startFailed;
|
||
err.attemptedGB = data.attemptedGB;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function showOverlay(text) {
|
||
overlayText.textContent = text;
|
||
overlay.hidden = false;
|
||
busy = true;
|
||
}
|
||
|
||
function hideOverlay() {
|
||
overlay.hidden = true;
|
||
busy = false;
|
||
}
|
||
|
||
async function runAction(path, label, body) {
|
||
if (busy) return;
|
||
showOverlay(label + '...');
|
||
appendConsole(`\n> ${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 = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><span><strong>Battlegroup is running.</strong> Stop it before editing. Changes apply on next start.</span>';
|
||
} else {
|
||
cw.classList.add('ok');
|
||
cw.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12l2.5 2.5L16 9"/></svg><span><strong>Battlegroup is offline.</strong> Safe to edit. Changes will apply on next start.</span>';
|
||
}
|
||
}
|
||
|
||
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 (
|
||
'<strong>Router port forwarding required (WAN)</strong>' +
|
||
'<p class="pf-target">Forward these ports to your <strong>VM</strong> at <code>' + target + '</code> — not your Windows PC.</p>' +
|
||
'<table><thead><tr><th>Port</th><th>Protocol</th><th>Purpose</th></tr></thead><tbody>' +
|
||
'<tr><td><strong>31982</strong></td><td>TCP</td><td>Queue / matchmaking (required for server finder)</td></tr>' +
|
||
'<tr><td><strong>' + director + '</strong></td><td>TCP</td><td>Director (matchmaking)</td></tr>' +
|
||
'<tr><td><strong>7777–7810</strong></td><td>UDP</td><td>Game server traffic</td></tr>' +
|
||
'</tbody></table>' +
|
||
(opts.setupHint
|
||
? '<p>Finish setup, then configure these forwards on your router before sharing the server publicly.</p>'
|
||
: '<p>After applying a public IP, <strong>stop the battlegroup completely</strong>, then start it again so Funcom registers the correct join address.</p>')
|
||
);
|
||
}
|
||
|
||
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 =
|
||
`<input type="radio" name="visibility" value="${opt.value}" id="${id}" ${isSelected ? 'checked' : ''}>` +
|
||
`<span class="radio-label">${opt.label}</span>` +
|
||
`<span class="radio-hint">${opt.hint}</span>`;
|
||
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 = '<option value="">Loading...</option>';
|
||
try {
|
||
const data = await api('GET', 'characters');
|
||
sel.innerHTML = '<option value="">— Choose a character —</option>';
|
||
(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 = '<option value="">Error loading characters</option>';
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<td class="item-name">${name}</td>
|
||
<td class="item-tid">${item.template_id}</td>
|
||
<td>${item.stack_size}</td>
|
||
<td>${loc}</td>
|
||
<td><button class="btn-remove" data-item-id="${item.id}">Remove</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
$('#inv-count').textContent = `${nonEmoteItems.length} items`;
|
||
}
|
||
|
||
async function loadCharacter(actorId) {
|
||
showOverlay('Loading character...');
|
||
try {
|
||
charData = await api('GET', `characters/${actorId}`);
|
||
$('#char-editor').style.display = '';
|
||
|
||
$$('.char-stat').forEach(el => {
|
||
const field = el.dataset.field;
|
||
const pathStr = el.dataset.path;
|
||
el.value = readStat(charData, field, pathStr);
|
||
});
|
||
|
||
charData.writableInvs = charData.inventories
|
||
.filter(inv => WRITABLE_INV_TYPES.includes(inv.inventory_type))
|
||
.map(inv => ({
|
||
id: inv.id,
|
||
type: inv.inventory_type,
|
||
label: INVENTORY_LABELS[inv.inventory_type] || `Type ${inv.inventory_type}`,
|
||
}));
|
||
|
||
renderInventory();
|
||
} catch (e) {
|
||
alert('Failed to load character: ' + e.message);
|
||
}
|
||
hideOverlay();
|
||
}
|
||
|
||
// Character tab — load list on first open
|
||
let charTabLoaded = false;
|
||
document.querySelector('.tab[data-tab="characters"]').addEventListener('click', async () => {
|
||
await Promise.all([loadItemCatalog(), loadCosmeticCatalog()]);
|
||
if (!charTabLoaded) {
|
||
charTabLoaded = true;
|
||
loadCharacterList();
|
||
}
|
||
});
|
||
|
||
$('#btn-char-refresh').addEventListener('click', () => loadCharacterList());
|
||
$('#btn-char-load').addEventListener('click', () => {
|
||
const id = $('#char-select').value;
|
||
if (!id) { alert('Select a character first.'); return; }
|
||
loadCharacter(parseInt(id));
|
||
});
|
||
|
||
// Save stats
|
||
$('#btn-stats-save').addEventListener('click', async () => {
|
||
if (!charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup before editing characters.');
|
||
return;
|
||
}
|
||
|
||
const updates = [];
|
||
$$('.char-stat').forEach(el => {
|
||
const field = el.dataset.field;
|
||
const pathStr = el.dataset.path;
|
||
const parts = pathStr.split('.');
|
||
const val = parseFloat(el.value);
|
||
if (isNaN(val)) return;
|
||
|
||
if (field === 'gas_attributes' && parts.length === 2) {
|
||
updates.push({ field, path: [parts[0], parts[1], 'BaseValue'], value: val });
|
||
updates.push({ field, path: [parts[0], parts[1], 'CurrentValue'], value: val });
|
||
} else if (field === 'properties' && pathStr === 'DamageableActorComponent.m_TotalMaxHealth') {
|
||
updates.push({ field, path: parts, value: val });
|
||
updates.push({ field, path: ['DamageableActorComponent', 'm_CurrentMaxHealth'], value: val });
|
||
} else {
|
||
updates.push({ field, path: parts, value: val });
|
||
}
|
||
});
|
||
|
||
if (!updates.length) { alert('No changes to save.'); return; }
|
||
|
||
showOverlay('Saving stats...');
|
||
try {
|
||
const res = await api('POST', `characters/${charData.actorId}/stats`, { updates });
|
||
if (res.success) {
|
||
appendConsole(`Stats saved for character ${charData.actorId}.\n`);
|
||
alert('Stats saved. Restart the battlegroup for changes to take effect.');
|
||
} else {
|
||
throw new Error(res.error);
|
||
}
|
||
} catch (e) {
|
||
alert('Save failed: ' + e.message);
|
||
}
|
||
hideOverlay();
|
||
});
|
||
|
||
// Remove item
|
||
$('#inv-tbody').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.btn-remove');
|
||
if (!btn || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup before editing inventory.');
|
||
return;
|
||
}
|
||
|
||
const itemId = btn.dataset.itemId;
|
||
if (!confirm('Remove this item?')) return;
|
||
|
||
try {
|
||
await api('DELETE', `characters/${charData.actorId}/inventory/${itemId}`);
|
||
charData.items = charData.items.filter(i => i.id !== parseInt(itemId));
|
||
renderInventory();
|
||
} catch (e) {
|
||
alert('Remove failed: ' + e.message);
|
||
}
|
||
});
|
||
|
||
// Item search
|
||
let searchTimeout;
|
||
function runItemSearch() {
|
||
const query = ($('#item-search').value || '').trim().toLowerCase();
|
||
const catFilter = $('#item-cat-filter').value;
|
||
const resultsWrap = $('#item-results');
|
||
const tbody = $('#item-results-body');
|
||
const hint = $('#item-results-hint');
|
||
|
||
if (query.length < 2 && !catFilter) {
|
||
resultsWrap.style.display = 'none';
|
||
hint.style.display = '';
|
||
return;
|
||
}
|
||
|
||
let results = catalogArr;
|
||
if (query.length >= 2) {
|
||
results = results.filter(i =>
|
||
i.name.toLowerCase().includes(query) || i.tid.toLowerCase().includes(query));
|
||
}
|
||
if (catFilter) {
|
||
if (catFilter === 'Garments') {
|
||
results = results.filter(i => i.category.startsWith('Garments'));
|
||
} else {
|
||
results = results.filter(i => i.category === catFilter);
|
||
}
|
||
}
|
||
|
||
results = results.slice(0, 50);
|
||
|
||
tbody.innerHTML = '';
|
||
if (!results.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim);text-align:center;padding:1rem">No items found</td></tr>';
|
||
} else {
|
||
const invOptions = (charData && charData.writableInvs || [])
|
||
.map(inv => `<option value="${inv.id}">${inv.label}</option>`)
|
||
.join('');
|
||
const defaultInvOption = invOptions || '<option value="">No character loaded</option>';
|
||
|
||
results.forEach(item => {
|
||
const cat = item.category;
|
||
const maxStack = STACK_LIMITS[cat] || 100;
|
||
const isEq = isEquipmentCategory(cat);
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td class="item-name">${item.name}<br><span class="item-tid">${item.tid}</span></td>
|
||
<td style="font-size:.72rem;color:var(--text-dim)">${cat}</td>
|
||
<td><input type="number" value="${isEq ? 1 : 1}" min="1" max="${maxStack}" class="add-qty" data-max="${maxStack}"></td>
|
||
<td><select class="add-inv">${defaultInvOption}</select></td>
|
||
<td><button class="btn-add" data-tid="${item.tid}" data-eq="${isEq ? 1 : 0}">Add</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
resultsWrap.style.display = '';
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
$('#item-search').addEventListener('input', () => {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(runItemSearch, 200);
|
||
});
|
||
$('#item-cat-filter').addEventListener('change', runItemSearch);
|
||
|
||
// Add item
|
||
$('#item-results-body').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.btn-add');
|
||
if (!btn || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup before editing inventory.');
|
||
return;
|
||
}
|
||
|
||
const row = btn.closest('tr');
|
||
const qty = parseInt(row.querySelector('.add-qty').value);
|
||
const invId = parseInt(row.querySelector('.add-inv').value);
|
||
const tid = btn.dataset.tid;
|
||
const isEq = btn.dataset.eq === '1';
|
||
const maxStack = parseInt(row.querySelector('.add-qty').dataset.max);
|
||
|
||
if (!invId) { alert('Load a character first.'); return; }
|
||
if (isNaN(qty) || qty < 1) { alert('Invalid quantity.'); return; }
|
||
if (qty > maxStack) {
|
||
if (!confirm(`Warning: ${qty} exceeds the estimated max stack of ${maxStack} for this item type. This may cause issues. Continue?`)) return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
try {
|
||
const res = await api('POST', `characters/${charData.actorId}/inventory/add`, {
|
||
templateId: tid, stackSize: qty, inventoryId: invId, isEquipment: isEq,
|
||
});
|
||
if (res.success) {
|
||
appendConsole(`Added ${qty}x ${catalogName(tid)} to inventory.\n`);
|
||
await loadCharacter(charData.actorId);
|
||
runItemSearch();
|
||
} else {
|
||
throw new Error(res.error);
|
||
}
|
||
} catch (e) {
|
||
alert('Add failed: ' + e.message);
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = 'Add';
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Cosmetics
|
||
// -----------------------------------------------------------------------
|
||
function cosmeticLabel(id) {
|
||
if (cosmeticCatalog && cosmeticCatalog[id]) return cosmeticCatalog[id].name;
|
||
return id.replace(/^MTX_/, '').replace(/_MeshVariant$/, '').replace(/_/g, ' ');
|
||
}
|
||
|
||
function cosmeticCategory(id) {
|
||
if (cosmeticCatalog && cosmeticCatalog[id]) return cosmeticCatalog[id].category;
|
||
return 'Other';
|
||
}
|
||
|
||
async function addCosmetic(cosmeticId) {
|
||
if (!charData) { alert('Load a character first.'); return false; }
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return false;
|
||
}
|
||
if (unlockedCosmetics.has(cosmeticId)) return true;
|
||
|
||
showOverlay('Adding cosmetic...');
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/cosmetics/add`, { cosmeticId });
|
||
appendConsole(`Cosmetic "${cosmeticLabel(cosmeticId)}" added.\n`);
|
||
unlockedCosmetics.add(cosmeticId);
|
||
updateCosmeticCount();
|
||
runCosmeticSearch();
|
||
return true;
|
||
} catch (e) {
|
||
alert('Failed: ' + e.message);
|
||
return false;
|
||
} finally {
|
||
hideOverlay();
|
||
}
|
||
}
|
||
|
||
async function removeCosmetic(cosmeticId, { confirmRemove = true } = {}) {
|
||
if (!charData) { alert('Load a character first.'); return false; }
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return false;
|
||
}
|
||
if (!unlockedCosmetics.has(cosmeticId)) return true;
|
||
if (confirmRemove && !confirm(`Remove cosmetic "${cosmeticLabel(cosmeticId)}"?`)) return false;
|
||
|
||
showOverlay('Removing cosmetic...');
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/cosmetics/remove`, { cosmeticId });
|
||
appendConsole(`Cosmetic "${cosmeticLabel(cosmeticId)}" removed.\n`);
|
||
unlockedCosmetics.delete(cosmeticId);
|
||
updateCosmeticCount();
|
||
runCosmeticSearch();
|
||
return true;
|
||
} catch (e) {
|
||
alert('Failed: ' + e.message);
|
||
return false;
|
||
} finally {
|
||
hideOverlay();
|
||
}
|
||
}
|
||
|
||
function updateCosmeticCount() {
|
||
$('#cosmetic-count').textContent = `${unlockedCosmetics.size} unlocked`;
|
||
}
|
||
|
||
let cosmeticSearchTimeout;
|
||
function runCosmeticSearch() {
|
||
const query = ($('#cosmetic-search').value || '').trim().toLowerCase();
|
||
const catFilter = $('#cosmetic-cat-filter').value;
|
||
const resultsWrap = $('#cosmetic-results');
|
||
const tbody = $('#cosmetic-results-body');
|
||
const hint = $('#cosmetic-results-hint');
|
||
|
||
if (query.length < 2 && !catFilter) {
|
||
resultsWrap.style.display = 'none';
|
||
hint.style.display = '';
|
||
return;
|
||
}
|
||
|
||
let results = cosmeticArr;
|
||
if (query.length >= 2) {
|
||
results = results.filter(c =>
|
||
c.name.toLowerCase().includes(query) || c.id.toLowerCase().includes(query));
|
||
}
|
||
if (catFilter) {
|
||
results = results.filter(c => c.category === catFilter);
|
||
}
|
||
|
||
results = results.slice(0, 100);
|
||
|
||
tbody.innerHTML = '';
|
||
if (!results.length) {
|
||
tbody.innerHTML = '<tr><td colspan="3" style="color:var(--text-dim);text-align:center;padding:1rem">No cosmetics found</td></tr>';
|
||
} else {
|
||
results.forEach(c => {
|
||
const owned = unlockedCosmetics.has(c.id);
|
||
const invOnly = c.unlock === 'inventory';
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td class="item-name">${c.name}${invOnly ? ' <span style="color:var(--amber,#c9a227);font-size:.72rem">(inventory item)</span>' : ''}<br><span class="item-tid">${c.id}</span></td>
|
||
<td style="font-size:.72rem;color:var(--text-dim)">${c.category}</td>
|
||
<td><button class="btn btn-sm cosmetic-toggle ${owned ? 'btn-remove' : 'btn-green'}" data-cosmetic="${c.id}" data-owned="${owned ? '1' : '0'}" ${invOnly ? 'title="Swatch tokens usually need to be added via Inventory, not cosmetics"' : ''}>${owned ? 'Remove' : 'Add'}</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
resultsWrap.style.display = '';
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
$('#cosmetic-search').addEventListener('input', () => {
|
||
clearTimeout(cosmeticSearchTimeout);
|
||
cosmeticSearchTimeout = setTimeout(runCosmeticSearch, 200);
|
||
});
|
||
$('#cosmetic-cat-filter').addEventListener('change', runCosmeticSearch);
|
||
|
||
$('#cosmetic-results-body').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.cosmetic-toggle');
|
||
if (!btn) return;
|
||
|
||
const cosmeticId = btn.dataset.cosmetic;
|
||
const owned = btn.dataset.owned === '1';
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
|
||
const ok = owned
|
||
? await removeCosmetic(cosmeticId)
|
||
: await addCosmetic(cosmeticId);
|
||
|
||
if (!ok) {
|
||
btn.disabled = false;
|
||
btn.textContent = owned ? 'Remove' : 'Add';
|
||
}
|
||
});
|
||
|
||
async function loadCosmetics() {
|
||
if (!charData) return;
|
||
try {
|
||
const data = await api('GET', `characters/${charData.actorId}/cosmetics`);
|
||
unlockedCosmetics = new Set(data.cosmetics || []);
|
||
updateCosmeticCount();
|
||
} catch (e) {
|
||
appendConsole('Failed to load cosmetics: ' + e.message + '\n');
|
||
}
|
||
}
|
||
|
||
$('#btn-cosmetic-add').addEventListener('click', async () => {
|
||
const input = $('#cosmetic-add-input');
|
||
const cosmeticId = input.value.trim();
|
||
if (!cosmeticId) { alert('Enter a cosmetic ID.'); return; }
|
||
const ok = await addCosmetic(cosmeticId);
|
||
if (ok) input.value = '';
|
||
});
|
||
|
||
$('#btn-cosmetic-unlock-all').addEventListener('click', async () => {
|
||
if (!charData) { alert('Load a character first.'); return; }
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
if (!confirm('Unlock ALL cosmetics and swatches from the catalog on this character?')) return;
|
||
|
||
showOverlay('Unlocking all cosmetics...');
|
||
try {
|
||
const res = await api('POST', `characters/${charData.actorId}/cosmetics/unlock-all`);
|
||
appendConsole(`Unlocked ${res.total} cosmetics (${res.added} newly added).\n`);
|
||
await loadCosmetics();
|
||
runCosmeticSearch();
|
||
} catch (e) {
|
||
const needsFallback = /404|endpoint not found|manager needs restart/i.test(e.message);
|
||
if (needsFallback && cosmeticArr.length) {
|
||
if (!confirm(
|
||
'Bulk unlock API is unavailable (Server Manager needs a restart).\n\n' +
|
||
'Use slower one-by-one unlock instead? (~621 requests, may take a few minutes)'
|
||
)) {
|
||
hideOverlay();
|
||
return;
|
||
}
|
||
const missing = cosmeticArr.filter(c => !unlockedCosmetics.has(c.id));
|
||
let added = 0;
|
||
for (let i = 0; i < missing.length; i++) {
|
||
overlayText.textContent = `Unlocking ${i + 1} / ${missing.length}...`;
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/cosmetics/add`, { cosmeticId: missing[i].id });
|
||
unlockedCosmetics.add(missing[i].id);
|
||
added++;
|
||
} catch { /* skip failures / duplicates */ }
|
||
}
|
||
appendConsole(`Unlocked ${unlockedCosmetics.size} cosmetics (${added} newly added via fallback).\n`);
|
||
updateCosmeticCount();
|
||
runCosmeticSearch();
|
||
} else {
|
||
alert('Failed: ' + e.message + '\n\nStop and restart the Server Manager (start_as_admin.bat), then try again.');
|
||
}
|
||
}
|
||
hideOverlay();
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Tech Tree
|
||
// -----------------------------------------------------------------------
|
||
let techCatalogTotal = null;
|
||
|
||
async function loadTechCatalogTotal() {
|
||
if (techCatalogTotal != null) return techCatalogTotal;
|
||
try {
|
||
const res = await fetch('/data/tech-recipe-catalog.json');
|
||
const data = await res.json();
|
||
techCatalogTotal = data.total || Object.keys(data.recipes || {}).length;
|
||
} catch {
|
||
techCatalogTotal = null;
|
||
}
|
||
return techCatalogTotal;
|
||
}
|
||
|
||
async function refreshTechCount() {
|
||
if (!charData) return;
|
||
try {
|
||
const d = await api('GET', `characters/${charData.actorId}`);
|
||
const tree = d.properties?.TechKnowledgePlayerComponent?.m_TechKnowledge?.m_TechKnowledgeData || [];
|
||
const purchased = tree.filter(i => i.UnlockedState === 'Purchased').length;
|
||
const catalogTotal = await loadTechCatalogTotal();
|
||
if (catalogTotal != null) {
|
||
$('#tech-count').textContent = `${purchased} purchased / ${tree.length} in save / ${catalogTotal} in game`;
|
||
} else {
|
||
$('#tech-count').textContent = `${purchased} / ${tree.length} unlocked`;
|
||
}
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
$('#btn-tech-unlock-all').addEventListener('click', async () => {
|
||
if (!charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
if (!confirm('Unlock ALL tech tree recipes? This adds every game recipe node to your save.')) return;
|
||
showOverlay('Unlocking all recipes...');
|
||
try {
|
||
const res = await api('POST', `characters/${charData.actorId}/tech/unlock-all`);
|
||
appendConsole(`Tech tree: ${res.total} recipes unlocked (+${res.added} added to save, was ${res.previous}).\n`);
|
||
techCatalogTotal = res.catalogTotal ?? techCatalogTotal;
|
||
await refreshTechCount();
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
$('#btn-tech-lock-all').addEventListener('click', async () => {
|
||
if (!charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
if (!confirm('Lock ALL tech tree recipes? This resets your entire tech tree.')) return;
|
||
showOverlay('Locking all recipes...');
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/tech/lock-all`);
|
||
appendConsole('All tech tree recipes locked.\n');
|
||
await refreshTechCount();
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Specializations
|
||
// -----------------------------------------------------------------------
|
||
const SPEC_TRACKS = [
|
||
{ type: 'Combat', label: 'Combat' },
|
||
{ type: 'Crafting', label: 'Crafting' },
|
||
{ type: 'Exploration', label: 'Exploration' },
|
||
{ type: 'Gathering', label: 'Gathering' },
|
||
{ type: 'Sabotage', label: 'Sabotage' },
|
||
];
|
||
|
||
async function loadSpecializations() {
|
||
if (!charData) return;
|
||
try {
|
||
const data = await api('GET', `characters/${charData.actorId}/specializations`);
|
||
const grid = $('#spec-tracks-grid');
|
||
grid.innerHTML = '';
|
||
|
||
SPEC_TRACKS.forEach(spec => {
|
||
const track = (data.tracks || []).find(t => t.track_type === spec.type);
|
||
const xp = track ? track.xp_amount : 0;
|
||
const lvl = track ? track.level : 0;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'config-item';
|
||
div.innerHTML = `
|
||
<label>${spec.label} <span class="field-hint-inline">current: Lv${Math.floor(lvl)}, ${xp} XP</span></label>
|
||
<div class="input-with-unit">
|
||
<input type="number" class="select-input spec-level" data-track="${spec.type}" value="${Math.floor(lvl)}" min="0" max="100" step="1" placeholder="Level" style="width:70px" title="Level">
|
||
<input type="number" class="select-input spec-xp" data-track="${spec.type}" value="${xp}" min="0" step="1000" placeholder="XP" style="width:100px" title="XP">
|
||
<button class="btn btn-green btn-sm spec-save" data-track="${spec.type}">Set</button>
|
||
</div>
|
||
`;
|
||
grid.appendChild(div);
|
||
});
|
||
} catch (e) {
|
||
appendConsole('Failed to load specializations: ' + e.message + '\n');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', async (e) => {
|
||
const saveBtn = e.target.closest('.spec-save');
|
||
if (!saveBtn || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
|
||
const track = saveBtn.dataset.track;
|
||
const row = saveBtn.closest('.config-item');
|
||
const level = parseFloat(row.querySelector('.spec-level').value);
|
||
const xp = parseInt(row.querySelector('.spec-xp').value);
|
||
|
||
showOverlay(`Setting ${track}...`);
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/specializations/track`, {
|
||
trackType: track, xp, level,
|
||
});
|
||
appendConsole(`${track} set to level ${level}, ${xp} XP.\n`);
|
||
await loadSpecializations();
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
// Keystone unlock buttons
|
||
$('#spec-keystone-btns').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-prefix]');
|
||
if (!btn || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
|
||
const prefix = btn.dataset.prefix;
|
||
const trackName = prefix.replace('_', '');
|
||
if (!confirm(`Unlock ALL ${trackName} keystones (perks)?`)) return;
|
||
|
||
showOverlay(`Unlocking ${trackName} keystones...`);
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/specializations/unlock-keystones`, { trackPrefix: prefix });
|
||
appendConsole(`All ${trackName} keystones unlocked.\n`);
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Economy (Currency + Faction Rep)
|
||
// -----------------------------------------------------------------------
|
||
async function loadEconomy() {
|
||
if (!charData) return;
|
||
try {
|
||
const data = await api('GET', `characters/${charData.actorId}/economy`);
|
||
|
||
(data.currency || []).forEach(c => {
|
||
const el = $(`#econ-currency-${c.currency_id}`);
|
||
if (el) el.value = c.balance;
|
||
});
|
||
|
||
const grid = $('#faction-rep-grid');
|
||
grid.innerHTML = '';
|
||
(data.factions || []).forEach(f => {
|
||
if (f.name === 'None') return;
|
||
const rep = (data.factionRep || []).find(r => r.faction_id === f.id);
|
||
const amount = rep ? rep.reputation_amount : 0;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'config-item';
|
||
div.innerHTML = `
|
||
<label>${f.name} Reputation</label>
|
||
<div class="input-with-unit">
|
||
<input type="number" class="select-input faction-rep" data-faction="${f.id}" value="${amount}" min="0" step="500">
|
||
<button class="btn btn-green btn-sm faction-rep-save" data-faction="${f.id}">Set</button>
|
||
</div>
|
||
`;
|
||
grid.appendChild(div);
|
||
});
|
||
} catch (e) {
|
||
appendConsole('Failed to load economy: ' + e.message + '\n');
|
||
}
|
||
}
|
||
|
||
// Currency set buttons
|
||
document.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-currency]');
|
||
if (!btn || btn.tagName !== 'BUTTON' || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
|
||
const cid = parseInt(btn.dataset.currency);
|
||
const balance = parseInt($(`#econ-currency-${cid}`).value);
|
||
if (isNaN(balance)) { alert('Enter a valid amount.'); return; }
|
||
|
||
showOverlay('Setting currency...');
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/economy/currency`, { currencyId: cid, balance });
|
||
appendConsole(`Currency ${cid} set to ${balance}.\n`);
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
// Faction rep set buttons
|
||
document.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.faction-rep-save');
|
||
if (!btn || !charData) return;
|
||
if (status && status.battlegroup && status.battlegroup.running) {
|
||
alert('Stop the battlegroup first.'); return;
|
||
}
|
||
|
||
const fid = parseInt(btn.dataset.faction);
|
||
const row = btn.closest('.config-item');
|
||
const amount = parseInt(row.querySelector('.faction-rep').value);
|
||
if (isNaN(amount)) { alert('Enter a valid amount.'); return; }
|
||
|
||
showOverlay('Setting reputation...');
|
||
try {
|
||
await api('POST', `characters/${charData.actorId}/economy/reputation`, { factionId: fid, amount });
|
||
appendConsole(`Faction ${fid} reputation set to ${amount}.\n`);
|
||
} catch (e) { alert('Failed: ' + e.message); }
|
||
hideOverlay();
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Load all sections when a character is loaded
|
||
// -----------------------------------------------------------------------
|
||
const origLoadCharacter = loadCharacter;
|
||
loadCharacter = async function(actorId) {
|
||
await origLoadCharacter(actorId);
|
||
if (charData) {
|
||
refreshTechCount();
|
||
loadSpecializations();
|
||
loadEconomy();
|
||
await loadCosmetics();
|
||
runCosmeticSearch();
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Experimental: Multi-Sietch
|
||
// -----------------------------------------------------------------------
|
||
let sietchLoaded = false;
|
||
|
||
async function loadSietches() {
|
||
const statusEl = $('#sietch-status');
|
||
const controlsEl = $('#sietch-controls');
|
||
|
||
try {
|
||
const data = await api('GET', 'sietches');
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
const count = data.sietchCount;
|
||
const ramEst = (count * 12) + 6;
|
||
|
||
statusEl.innerHTML =
|
||
`<div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap">` +
|
||
`<div><strong style="font-size:1.8rem;color:var(--accent)">${count}</strong> <span style="color:var(--text-dim)">sietch${count !== 1 ? 'es' : ''} configured</span></div>` +
|
||
`<div style="font-size:.85rem;color:var(--text-dim)">Partitions: ${data.sietches.map(s => '#' + s.partitions[0]).join(', ')} · Est. RAM: ~${ramEst} GB</div>` +
|
||
`</div>`;
|
||
|
||
$('#btn-remove-sietch').disabled = count <= 1;
|
||
controlsEl.style.display = '';
|
||
sietchLoaded = true;
|
||
} catch (e) {
|
||
statusEl.innerHTML = `<span style="color:var(--danger)">Failed to load sietch info: ${e.message}</span>`;
|
||
controlsEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
$('#btn-add-sietch').addEventListener('click', async () => {
|
||
const msg = 'Add a new sietch to the battlegroup?\n\n' +
|
||
'This adds another Hagga Basin instance (~12 GB RAM).\n' +
|
||
'You must restart the battlegroup after for it to take effect.\n\n' +
|
||
'This feature is EXPERIMENTAL and has not been fully tested.';
|
||
if (!confirm(msg)) return;
|
||
|
||
showOverlay('Adding sietch...');
|
||
try {
|
||
const result = await api('POST', 'sietches/add');
|
||
if (result.error) throw new Error(result.error);
|
||
appendConsole(`Sietch ${result.sietchNumber} added (partition ${result.partitionId}). Restart the battlegroup to apply.\n`);
|
||
await loadSietches();
|
||
} catch (e) {
|
||
alert('Failed to add sietch: ' + e.message);
|
||
}
|
||
hideOverlay();
|
||
});
|
||
|
||
$('#btn-remove-sietch').addEventListener('click', async () => {
|
||
const msg = 'Remove the last added sietch?\n\n' +
|
||
'WARNING: Player bases and progress in this sietch may become inaccessible.\n' +
|
||
'Take a database backup first!\n\n' +
|
||
'You must restart the battlegroup after for it to take effect.';
|
||
if (!confirm(msg)) return;
|
||
|
||
showOverlay('Removing sietch...');
|
||
try {
|
||
const result = await api('POST', 'sietches/remove');
|
||
if (result.error) throw new Error(result.error);
|
||
appendConsole(`Sietch removed (partition ${result.removedPartition}). ${result.remainingSietches} sietch${result.remainingSietches !== 1 ? 'es' : ''} remaining. Restart the battlegroup to apply.\n`);
|
||
await loadSietches();
|
||
} catch (e) {
|
||
alert('Failed to remove sietch: ' + e.message);
|
||
}
|
||
hideOverlay();
|
||
});
|
||
|
||
document.querySelector('.tab[data-tab="experimental"]').addEventListener('click', () => {
|
||
if (!sietchLoaded) loadSietches();
|
||
});
|
||
})();
|