/* 90轮回启动器 · 单窗口页面与工具整合 */ (function () { const $ = (id) => document.getElementById(id); const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog']; let shellGmInit = false; let loConfig = null, loPage = 0, loScheme = -1; let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null; let stFeatures = {}, stActivity = {}, stTab = 'global'; let stGameplay = {}; let stBatchPickup = true; let hotkeyCapture = false; let stRaidTab = 'anton'; let stFeatureTab = 'instance.json'; let stRawText = ''; const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'}; function shellGo(page) { if (!pages.includes(page)) page = 'start'; document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page)); document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page)); $('shellCrumb').textContent = crumb[page] || page; if (page === 'start') loadStart(); if (page === 'loadout') loadLoadout(); if (page === 'companion') loadCompanion(); if (page === 'servertool') loadServerTool(); if (page === 'settings') loadSettings(); if (page === 'gm') { // 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区 document.body.classList.add('gm-fullscreen'); const w = win(); if (w && w.enter_gm) w.enter_gm(); if (!shellGmInit && typeof initGm === 'function') { shellGmInit = true; initGm(); if (typeof renderGmPage === 'function') renderGmPage('dashboard'); } else if (typeof renderGmPage === 'function') { renderGmPage(gmCurrentPage || 'dashboard'); } } else { // 离开 GM:移除全屏状态,并恢复窗口几何(若正处于全屏) if (document.body.classList.contains('gm-fullscreen')) { document.body.classList.remove('gm-fullscreen'); const w2 = win(); if (w2 && w2.restore_gm) w2.restore_gm(); } } } window.shellGo = shellGo; // 从 GM 返回启动器:恢复窗口几何 + 回到开始页 function shellExitGm() { document.body.classList.remove('gm-fullscreen'); const w = win(); if (w && w.restore_gm) w.restore_gm(); shellGo('start'); } window.shellExitGm = shellExitGm; document.querySelectorAll('.shell-nav-item').forEach(el => { el.addEventListener('click', () => shellGo(el.dataset.shellPage)); }); // 顶部窗口按钮 const win = () => (window.pywebview && window.pywebview.api) || null; const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); }); const bClose = $('shellWinClose'); if (bClose) bClose.addEventListener('click', () => { const w = win(); if (w && w.close) w.close(); }); // GM 全屏模式下的窗口按钮(最小化 / 关闭) const gmWinMin = $('gmWinMin'); const gmWinClose = $('gmWinClose'); if (gmWinMin) gmWinMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); }); if (gmWinClose) gmWinClose.addEventListener('click', () => { const w = win(); if (w && w.close) w.close(); }); // 账号登录:打开原账号管理弹窗,可选择默认账号 $('shellAccount').addEventListener('click', () => { if (typeof openAccounts === 'function') openAccounts(); else alert('账号管理未加载'); }); async function shellAddAccount() { const user = $('newAccUser').value.trim(); const pass = $('newAccPass').value; const toast = (msg, type) => { if (typeof gmToast === 'function') gmToast(msg, type || 'info'); else alert(msg); }; if (!user || !pass) { toast('请填写用户名和密码', 'err'); return; } try { const r = await post('/api/accounts-config', {op:'add', username:user, password:pass}); if (!r.ok) { toast('添加失败:' + (r.error || '未知错误'), 'err'); return; } $('newAccUser').value = ''; $('newAccPass').value = ''; $('accountsModal').classList.add('hidden'); await loadAccountLabel(); toast('账号添加成功:' + user, 'ok'); } catch (e) { toast('添加失败:' + e.message, 'err'); } } // app.js 的 init 如果没有完成绑定,这里也保证添加按钮可用 const addBtn = $('btnAddAccount'); if (addBtn) { const fresh = addBtn.cloneNode(true); addBtn.replaceWith(fresh); fresh.addEventListener('click', shellAddAccount); } // 开始页 let startState = 'idle'; // idle | starting | started function setStartState(state) { startState = state; const btn = $('shellStart'); if (!btn) return; btn.disabled = state === 'starting'; if (state === 'starting') btn.innerHTML = '⏳ 游戏启动中…'; else if (state === 'started') btn.innerHTML = '✔ 游戏已启动'; else btn.innerHTML = '▶ 开始游戏'; } $('shellStart').addEventListener('click', () => { if (startState === 'starting') return; setStartState('starting'); doAction('start'); }); $('shellStartMenu').addEventListener('click', () => $('shellStartPop').classList.toggle('hidden')); document.querySelectorAll('#shellStartPop button').forEach(b => b.addEventListener('click', () => { $('shellStartPop').classList.add('hidden'); if (b.dataset.shellAction === 'stop') setStartState('idle'); doAction(b.dataset.shellAction); })); $('shellStop').addEventListener('click', () => { setStartState('idle'); doAction('stop'); }); async function doAction(action) { const map = {start:'start', launch:'start', stop:'stop'}; const payload = {action: map[action] || action}; if (payload.action === 'start') { payload.account = $('shellAccountSelect').value || ''; } try { const r = await post('/api/action', payload); if (!r.ok) { if (startState === 'starting') setStartState('idle'); alert(r.error || '操作失败'); } setTimeout(loadStart, 900); } catch (e) { if (startState === 'starting') setStartState('idle'); alert('操作失败:' + e.message); } } async function loadStart() { try { const s = await api('/api/status'); const running = !!s.server_running; if (running && startState !== 'started') setStartState('started'); else if (!running && startState === 'started') setStartState('idle'); $('shellStatusDot').style.background = running ? '#3dd68b' : '#5a7568'; $('shellStatusText').textContent = running ? '运行中' : '未运行'; $('shellPid').textContent = running ? '服务端已启动' : 'PID —'; $('shellBottomStatus').textContent = running ? '运行中' : '未运行'; $('statClient').textContent = running ? '运行中' : '未运行'; $('statPorts').textContent = running ? '18111 / 7001' : '—'; $('statPvf').textContent = s.db_exists ? '已校验通过' : '未检测'; $('statClientDir').textContent = s.client_dir || '—'; $('statDb').textContent = s.db_exists ? 'SQLite 就绪' : '未创建'; $('statGmPort').textContent = '18111'; $('statApiPort').textContent = location.port || '19527'; $('shellBottomPort').textContent = running ? '18111 / 7001' : '—'; $('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整'; $('statVersion').textContent = 'v2026.11.03'; $('statRemote').textContent = '当前版本'; $('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失'; $('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false}); $('statResource').textContent = s.admin ? '管理员' : '普通权限'; loadAccountLabel(); } catch (e) { /* ignore */ } } async function loadAccountLabel() { try { const r = await api('/api/accounts-config'); const list = (r && r.data) || []; const selected = list.find(a => a.is_default) || list[0]; $('shellAccountName').textContent = selected ? selected.username : '未配置账号'; const sel = $('shellAccountSelect'); if (sel && list.length) { const current = sel.value; sel.innerHTML = list.map(a => ``).join(''); sel.value = selected ? selected.username : (current || list[0].username); } } catch (e) { /* ignore */ } } $('shellAccountSelect').addEventListener('change', async () => { const username = $('shellAccountSelect').value; try { const r = await post('/api/accounts-config', {op:'select', username}); if (r.ok) { $('shellAccountName').textContent = username; } } catch (e) { /* ignore */ } }); // ---------------- 一键换装 ---------------- function normalizeConfig(cfg) { cfg = cfg || {}; if (!Array.isArray(cfg.Pages) || !cfg.Pages.length) cfg.Pages = [{Name:'默认页', Schemes:[]}]; cfg.Pages.forEach(p => { if (!Array.isArray(p.Schemes)) p.Schemes = []; }); cfg.ActivePage = Math.max(0, Math.min(Number(cfg.ActivePage) || 0, cfg.Pages.length - 1)); return cfg; } async function loadLoadout() { try { const r = await api('/api/loadout/config'); loConfig = normalizeConfig(r.data); loPage = loConfig.ActivePage || 0; renderLoadout(); } catch (e) { $('loStatus').textContent = '读取失败:' + e.message; } } function currentPage() { return loConfig.Pages[loPage]; } function currentScheme() { return currentPage().Schemes[loScheme] || null; } function loadoutCooldownRemaining() { return Math.max(0, Math.ceil((loCooldownUntil - Date.now()) / 1000)); } function updateLoadoutCooldown() { const s = currentScheme(); const active = s && s.CharacterName === loCooldownCharacter; const remain = active ? loadoutCooldownRemaining() : 0; const el = $('loCooldown'); if (remain > 0) { el.textContent = `换装冷却:${remain} 秒`; el.classList.add('active'); $('loApply').disabled = true; } else { el.textContent = ''; el.classList.remove('active'); $('loApply').disabled = false; if (loCooldownTimer) { clearInterval(loCooldownTimer); loCooldownTimer = null; } } } function startLoadoutCooldown(character, seconds) { loCooldownCharacter = character || ''; loCooldownUntil = Date.now() + Math.max(0, Number(seconds) || 0) * 1000; if (loCooldownTimer) clearInterval(loCooldownTimer); updateLoadoutCooldown(); loCooldownTimer = setInterval(updateLoadoutCooldown, 250); } function renderLoadout() { const pages = $('loPages'); pages.innerHTML = ''; loConfig.Pages.forEach((p, i) => { const b = document.createElement('button'); b.className = 'lo-page' + (i === loPage ? ' active' : ''); b.textContent = p.Name || ('页 ' + (i + 1)); b.onclick = async () => { loPage = i; loConfig.ActivePage = i; loScheme = -1; renderLoadout(); await saveLoadout(false); }; pages.appendChild(b); }); const list = $('loList'); list.innerHTML = ''; currentPage().Schemes.forEach((s, i) => { const d = document.createElement('div'); d.className = 'lo-item' + (i === loScheme ? ' active' : ''); d.textContent = `${s.Name || '方案'} [${s.CharacterName || '未绑定'}] ${hotkeyText(s.Modifiers, s.KeyCode)} (${(s.Slots||[]).length} 件)`; d.onclick = () => { loScheme = i; renderLoadout(); }; list.appendChild(d); }); const s = currentScheme(); $('loName').value = s ? (s.Name || '') : ''; $('loCharacter').value = s ? (s.CharacterName || '') : ''; $('loHotkey').value = s ? hotkeyText(s.Modifiers, s.KeyCode) : '未设置'; const slots = $('loSlots'); slots.innerHTML = ''; if (!s || !(s.Slots || []).length) { slots.innerHTML = '
选择方案后显示装备快照;点击「保存当前装备」从服务端读取。
'; } else { s.Slots.forEach(it => { const d = document.createElement('div'); d.className = 'lo-slot'; d.innerHTML = `槽位 ${it.slot}${it.item_id}`; slots.appendChild(d); }); } updateLoadoutCooldown(); } function hotkeyText(mods, key) { if (!key) return '未设置'; const p = []; if (mods & 2) p.push('Ctrl'); if (mods & 1) p.push('Alt'); if (mods & 4) p.push('Shift'); p.push(keyName(key)); return p.join('+'); } function keyName(code) { if (code >= 112 && code <= 123) return 'F' + (code - 111); if (code >= 65 && code <= 90) return String.fromCharCode(code); if (code >= 48 && code <= 57) return String.fromCharCode(code); return String(code); } $('loAddPage').addEventListener('click', async () => { const name = prompt('换装页名称', '换装页' + (loConfig.Pages.length + 1)); if (!name) return; loConfig.Pages.push({Name:name, Schemes:[]}); loPage = loConfig.Pages.length - 1; loConfig.ActivePage = loPage; loScheme = -1; renderLoadout(); await saveLoadout(); }); $('loAddScheme').addEventListener('click', async () => { const name = prompt('方案名称', '方案' + (currentPage().Schemes.length + 1)); if (!name) return; const character = prompt('绑定角色游戏内名称(可稍后修改)', '') || ''; currentPage().Schemes.push({Name:name, CharacterName:character, Modifiers:0, KeyCode:0, Slots:[]}); loScheme = currentPage().Schemes.length - 1; renderLoadout(); await saveLoadout(); }); $('loDeleteScheme').addEventListener('click', async () => { if (loScheme < 0) return; if (!confirm('删除当前方案?')) return; currentPage().Schemes.splice(loScheme, 1); loScheme = -1; renderLoadout(); await saveLoadout(); }); $('loSaveConfig').addEventListener('click', () => saveLoadout()); $('loName').addEventListener('change', () => { const s=currentScheme(); if(s){s.Name=$('loName').value; renderLoadout();} }); $('loCharacter').addEventListener('change', () => { const s=currentScheme(); if(s){s.CharacterName=$('loCharacter').value.trim(); renderLoadout();} }); async function saveLoadout(show = true) { try { if (loConfig) { loConfig.ActivePage = loPage; const s=currentScheme(); if(s){s.Name=$('loName').value; s.CharacterName=$('loCharacter').value.trim();} } const r = await post('/api/loadout/config', {config: loConfig}); if (r.ok) { if (show) $('loStatus').textContent = '配置已保存 ' + new Date().toLocaleTimeString(); } else $('loStatus').textContent = r.error || '保存失败'; } catch (e) { $('loStatus').textContent = '保存失败:' + e.message; } } $('loRefreshChars').addEventListener('click', async () => { try { const r = await api('/api/loadout/characters'); if (!r.ok) { $('loStatus').textContent = r.error || '读取失败'; return; } const list = r.data || []; $('loStatus').textContent = list.map(c => `${c.name}(${c.online?'在线':'离线'})`).join(' ') || '没有角色'; } catch (e) { $('loStatus').textContent = e.message; } }); $('loSaveCurrent').addEventListener('click', async () => { const s = currentScheme(); if (!s) { $('loStatus').textContent = '请先选择方案'; return; } if (!s.CharacterName) { $('loStatus').textContent = '请先填写绑定角色'; return; } try { const r = await api('/api/loadout/equipment?character=' + encodeURIComponent(s.CharacterName)); if (!r.ok) { $('loStatus').textContent = r.error || '读取装备失败'; return; } s.Slots = r.data.slots || []; renderLoadout(); await saveLoadout(); $('loStatus').textContent = `已保存 ${s.Slots.length} 个部位`; } catch (e) { $('loStatus').textContent = e.message; } }); $('loApply').addEventListener('click', async () => { const s = currentScheme(); if (!s) { $('loStatus').textContent = '请先选择方案'; return; } if (s.CharacterName === loCooldownCharacter && loadoutCooldownRemaining() > 0) { $('loStatus').textContent = `换装冷却中,还需 ${loadoutCooldownRemaining()} 秒`; return; } try { const r = await post('/api/loadout/apply', {character:s.CharacterName, slots:s.Slots || []}); if (r.ok) { const d = r.data || {}; const applied = (d.applied || []).length; const skipped = (d.skipped || []).length; if (Number(d.cooldown_seconds) > 0) { startLoadoutCooldown(s.CharacterName, Number(d.cooldown_seconds)); $('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped};换装冷却 ${d.cooldown_seconds} 秒`; } else { $('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped}`; } } else { const msg = r.error || '应用失败'; const m = msg.match(/cooldown[^0-9]*(\d+)\s*seconds?/i); if (m) { startLoadoutCooldown(s.CharacterName, Number(m[1])); $('loStatus').textContent = `换装冷却中,还需 ${m[1]} 秒才能再次换装`; } else { $('loStatus').textContent = msg; } } } catch (e) { $('loStatus').textContent = e.message; } }); $('loCaptureHotkey').addEventListener('click', () => { if (!currentScheme()) { $('loStatus').textContent = '请先选择方案'; return; } hotkeyCapture = true; $('loHotkey').value = '请按下组合键…'; $('loHotkey').focus(); }); $('loHotkey').addEventListener('keydown', (e) => { if (!hotkeyCapture) return; e.preventDefault(); let mods = 0; if (e.ctrlKey) mods |= 2; if (e.altKey) mods |= 1; if (e.shiftKey) mods |= 4; const key = e.keyCode; if (!key || key === 17 || key === 18 || key === 16) return; const s = currentScheme(); if (!s) return; s.Modifiers = mods; s.KeyCode = key; hotkeyCapture = false; $('loHotkey').value = hotkeyText(mods, key); saveLoadout(); }); // ---------------- 支援兵 ---------------- let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set(); let cpAIProfiles = {}, cpAISource = ''; let cpLoading = false; const CP_AI_DEFAULTS = { behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto', combo_enabled:true, aoe_min_targets:3, protect_leader:true, tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90, chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85, heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700, detour_ms:900,give_up_ms:3500,follow_distance:75} }; async function loadCompanion() { if (cpLoading) return; cpLoading = true; $('cpStatus').textContent = '读取账号和角色…'; try { const a = await api('/api/accounts'); const ba = await api('/api/basic-attack'); $('cpBasicAttack').checked = !ba || ba.enabled !== false; cpAccounts = (a && a.data) || []; const sel = $('cpAccount'); sel.innerHTML = cpAccounts.map(x => ``).join(''); if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; } await loadCompanionCharacters(); $('cpStatus').textContent = ''; } catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; } finally { cpLoading = false; } } async function loadCompanionCharacters() { const account = $('cpAccount').value; const r = await api('/api/characters?account=' + encodeURIComponent(account)); cpCharacters = (r && r.data) || []; const owner = $('cpOwner'); owner.innerHTML = cpCharacters.map(c => ``).join(''); if (cpCharacters.length) await loadCompanionState(); else { $('cpGrid').innerHTML = '
当前账号没有角色
'; } } async function loadCompanionState() { const owner = $('cpOwner').value; if (!owner) return; $('cpStatus').textContent = '读取支援兵配置…'; try { const r = await api('/api/companion?character=' + encodeURIComponent(owner)); if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; } cpState = r.data || {}; const cfg = (cpState.state && cpState.state.config) || {}; $('cpEnabled').checked = !!cfg.enabled; $('cpBalance').checked = !!cfg.balance_enabled; $('cpProgression').checked = !!cfg.progression_enabled; $('cpRandom').checked = !!cfg.random_equipment_enabled; cpSelected = new Set((cfg.source_character_ids || []).map(String)); cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {})); cpAISource = [...cpSelected][0] || ''; renderCompanionGrid(); $('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取'; } catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; } } function renderCompanionGrid() { const owner = String($('cpOwner').value || ''); const list = cpCharacters.filter(c => String(c.character_id) !== owner); const grid = $('cpGrid'); if (!list.length) { grid.innerHTML = '
当前账号没有其他角色可作为支援兵
'; renderCpAI(); return; } grid.innerHTML = list.map(c => { const id = String(c.character_id), selected = cpSelected.has(id); return ``; }).join(''); grid.querySelectorAll('input[data-cp-source]').forEach(inp => { inp.onchange = () => { const id = inp.dataset.cpSource; if (inp.checked) cpSelected.add(id); else cpSelected.delete(id); inp.closest('.cp-card').classList.toggle('selected', inp.checked); renderCpAI(); }; }); renderCpAI(); } function cpProfile(id) { const raw = cpAIProfiles[String(id)] || {}; return { ...CP_AI_DEFAULTS, ...raw, tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})} }; } function renderCpAI() { const select = $('cpAISource'); const ids = [...cpSelected]; if (!ids.includes(cpAISource)) cpAISource = ids[0] || ''; select.innerHTML = ids.map(id => { const c = cpCharacters.find(x => String(x.character_id) === id); return ``; }).join(''); if (cpAISource) select.value = cpAISource; if (!cpAISource) { $('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first'; $('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3; $('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true; document.querySelectorAll('[data-cp-tuning]').forEach(inp => { inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0; }); return; } const p = cpProfile(cpAISource); $('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive'; $('cpAIBuff').value = p.buff_policy || 'leader_first'; $('cpAIComboPreset').value = p.combo_preset || 'auto'; $('cpAIAoe').value = Number(p.aoe_min_targets) || 3; $('cpAIComboEnabled').checked = !!p.combo_enabled; $('cpAIProtect').checked = !!p.protect_leader; document.querySelectorAll('[data-cp-tuning]').forEach(inp => { inp.value = p.tuning[inp.dataset.cpTuning] ?? 0; }); } function readCpAI() { if (!cpAISource) return; const p = cpProfile(cpAISource); p.behavior = $('cpAIBehavior').value; p.buff_policy = $('cpAIBuff').value; p.combo_preset = $('cpAIComboPreset').value; p.combo_enabled = $('cpAIComboEnabled').checked; p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3)); p.protect_leader = $('cpAIProtect').checked; p.tuning = {...p.tuning}; document.querySelectorAll('[data-cp-tuning]').forEach(inp => { const n = Number(inp.value); if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n; }); cpAIProfiles[String(cpAISource)] = p; } $('cpAccount').addEventListener('change', async () => { try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; } }); $('cpOwner').addEventListener('change', loadCompanionState); $('cpReload').addEventListener('click', loadCompanion); $('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); }); ['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => { $(id).addEventListener('change', readCpAI); }); document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI)); $('cpBasicAttack').addEventListener('change', async () => { try { const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked}); if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败'; else $('cpStatus').textContent = $('cpBasicAttack').checked ? '已允许支援兵普攻;重启客户端后生效。' : '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。'; } catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; } }); $('cpSave').addEventListener('click', async () => { const owner = $('cpOwner').value; if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; } if ($('cpEnabled').checked && cpSelected.size === 0) { $('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return; } const state = (cpState && cpState.state) || {}; const cfg = state.config || {}; const sources = [...cpSelected]; readCpAI(); const payload = { characterId: String(owner), expectedRevision: Number(cfg.revision || 0), enabled: $('cpEnabled').checked, balanceEnabled: $('cpBalance').checked, progressionEnabled: $('cpProgression').checked, randomEquipmentEnabled: $('cpRandom').checked, sourceCharacterId: sources[0] || '', sourceCharacterIds: sources, ai: cfg.ai || {}, aiProfiles: cpAIProfiles }; $('cpStatus').textContent = '保存中…'; try { const r = await post('/api/companion', payload); if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; } cpState = r.data || cpState; $('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。'; } catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; } }); // ---------------- 服务端工具 ---------------- async function loadServerTool() { try { $('stBody').innerHTML = '
正在读取服务端配置…
'; const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]); stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {}; stBatchPickup = !!(b.data || {}).enabled; window.__stStatusInfo = s || {}; $('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置'; renderServerTool(); } catch (e) { $('stBody').innerHTML = '
读取失败:' + (e && e.message ? e.message : e) + '
'; $('stStatus').textContent = '读取失败'; } } document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => { stTab = b.dataset.st; document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b)); renderServerTool(); })); const LABEL = { raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活', hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率', epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数', roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%', rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒', soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数', auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长', auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%', antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒', antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒', antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒', antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒', lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒', lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%', fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限', antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数', antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔', antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量', antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数', antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数', lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量', fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限', fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待', fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试', reviveByDungeon:'按副本复活配置' }; const ST_DESC = { soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。', soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。', auctionStandardListingLimit:'普通拍卖行同时可上架数量。', auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。', auctionPremiumListingLimit:'高级拍卖行同时可上架数量。', auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。', auctionStandardFeePercent:'普通拍卖成交手续费百分比。', auctionPremiumFeePercent:'高级拍卖成交手续费百分比。', raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。', allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。', hellOrbChance:'深渊宝珠掉落判定值。', hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。', equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。', epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。', allowFreeRevive:'允许不使用复活币直接复活。', roomFatigueCost:'进入每个房间消耗的疲劳值。', dailyFatigueResetHour:'每日疲劳重置小时,0-23。', adventureGroupBonusPercent:'冒险团经验加成百分比。', rentalMaxReinforce:'租赁装备允许的最大强化等级。', rentalMaxForge:'租赁装备允许的最大锻造等级。', antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。', antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。', antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。', antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。', antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。', antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。', antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。', lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。', lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。', lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。', lukePhaseTimeoutPercent:'卢克各阶段时限百分比。', lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。', fiendWarSquadCount:'超时空之战小队编制数量。', fiendWarAreaLimitPercent:'超时空区域进入限制百分比。', fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。' }; const ST_FIELDS = { global: [ 'soloTeleportCooldownSeconds','soloTeleportCooldownIndices', 'auctionStandardListingLimit','auctionStandardDurationHours', 'auctionPremiumListingLimit','auctionPremiumDurationHours', 'auctionStandardFeePercent','auctionPremiumFeePercent' ], gameplay: [ 'allDeadReturnSeconds','hellOrbChance', 'hellDropChanceDenominator','equipmentRollMultiplier', 'epicPieceRollMultiplier','allowFreeRevive' ], growth: [ 'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent', 'rentalMaxReinforce','rentalMaxForge' ], raid: { anton: [ 'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds', 'antonQuakeRecoverySeconds','antonQuakeActiveSeconds', 'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds' ], luke: [ 'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds', 'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent', 'lukeEnergyRecoveryPercent' ], fiend: [ 'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent' ] } }; function allAssignedKeys() { return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth, ...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]); } function renderServerTool() { const body = $('stBody'); if (stTab === 'activity') { renderActivity(body); return; } if (stTab === 'launch') { body.innerHTML = `

启动控制

配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。

`; $('stStart').onclick = () => doAction('start'); $('stStop').onclick = () => doAction('stop'); api('/api/status').then(s => { $('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`; }).catch(() => {}); return; } let sub = ''; let keys; if (stTab === 'features') { keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k)); } else if (stTab === 'raid') { const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'}; $('stSubTabs').innerHTML = Object.keys(names).map(k => ``).join(''); $('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => { stRaidTab = b.dataset.raid; renderServerTool(); }); keys = ST_FIELDS.raid[stRaidTab] || []; } else { $('stSubTabs').innerHTML = ''; keys = ST_FIELDS[stTab] || []; } let html = `

${tabTitle(stTab)}

`; if (stTab === 'features') { const info = window.__stStatusInfo || {}; const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json']; $('stSubTabs').innerHTML = rawNames.map(n => ``).join(''); $('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => { stFeatureTab = b.dataset.raw; renderServerTool(); }); html += `
`; html += `
当前配置文件:${stFeatureTab}
`; html += ``; html += `
服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。
`; } html += `
${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}
`; if (stTab === 'gameplay') { html += `
`; html += `
`; } html += `
`; html += `
高级:完整 JSON 配置
可直接编辑全部服务端字段
`; body.innerHTML = html; if (stTab === 'features') loadRawConfig(stFeatureTab); const jsonBox = $('stJson'); if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2); $('stJsonApply').onclick = () => { try { const obj = JSON.parse($('stJson').value); if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象'); stFeatures = obj; renderServerTool(); $('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入'; } catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; } }; bindFeatureInputs(body); } async function loadRawConfig(name) { try { const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name)); stRawText = r.text || ''; const box = $('stRaw'); if (box) box.value = stRawText; } catch (e) { /* ignore */ } } function bindFeatureInputs(body) { body.querySelectorAll('input[data-st-key]').forEach(inp => { inp.addEventListener('change', () => { const key = inp.dataset.stKey; let v = inp.value; if (inp.type === 'checkbox') v = inp.checked; else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; } stFeatures[key] = v; }); }); body.querySelectorAll('input[data-game-key]').forEach(inp => { inp.addEventListener('change', () => { stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0; }); }); body.querySelectorAll('input[data-batch-pickup]').forEach(inp => { inp.addEventListener('change', () => { stBatchPickup = inp.checked; }); }); } function groupFields(obj, keys) { return keys.map(k => { const val = obj[k]; const label = LABEL[k] || k; if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) { return ``; } const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k); const type = numeric ? 'number' : 'text'; const shown = val === undefined || val === null ? '' : String(val); const hint = ST_DESC[k] ? `${ST_DESC[k]}` : ''; return ``; }).join(''); } function tabTitle(tab) { return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab; } function tabKeys(tab) { const all = Object.keys(stFeatures); const match = { features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'], global: [], gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'], growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'], raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'], }; if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k)); return (match[tab] || []).filter(k => all.includes(k)); } function renderActivity(body) { const keys = Object.keys(stActivity); body.innerHTML = `

在线活动开关

${keys.map(k => { const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k; return ``; }).join('')}
`; body.querySelectorAll('input[data-act-key]').forEach(inp => { inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; }); }); } $('stReload').addEventListener('click', loadServerTool); $('stSave').addEventListener('click', async () => { try { let r; if (stTab === 'activity') { r = await post('/api/servertool/activity', stActivity); } else if (stTab === 'features' && $('stRaw')) { r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value}); } else if (stTab === 'gameplay') { r = await post('/api/servertool/features', stFeatures); const g = await post('/api/servertool/gameplay', stGameplay); const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup}); if (!g.ok) r = g; if (!bp.ok) r = bp; } else { r = await post('/api/servertool/features', stFeatures); } $('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败'); } catch (e) { $('stStatus').textContent = '保存失败:' + e.message; } }); async function loadSettings() { try { const s = await api('/api/status'); $('setServerRoot').textContent = s.server_root || '未绑定'; $('setControl').textContent = s.control_exists ? '已找到 DNF90Control.exe' : '缺失'; $('setDb').textContent = s.db || '未找到'; $('setClient').textContent = s.client_dir || '未找到'; $('setRun').textContent = s.server_running ? '运行中' : '未运行'; $('setNative').textContent = s.native_available ? '可用' : '不可用'; } catch (e) { /* ignore */ } } $('setRefresh').addEventListener('click', loadSettings); // 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium) // 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar) ['shell-topnav', 'shell-topbar'].forEach((sel) => { const topbar = document.querySelector('.' + sel); if (!topbar) return; let dragState = null; const onMove = (e) => { if (!dragState) return; const w = win(); if (w && w.move) w.move(dragState.x + e.screenX - dragState.sx, dragState.y + e.screenY - dragState.sy); }; const onUp = () => { dragState = null; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('mouseup', onUp, true); }; topbar.addEventListener('mousedown', (e) => { if (e.target.closest('button,select,input,a')) return; const w = win(); if (!w || !w.getPosition) return; e.preventDefault(); const sx = e.screenX, sy = e.screenY; w.getPosition().then((pos) => { dragState = {x: Number(pos[0]) || 0, y: Number(pos[1]) || 0, sx, sy}; document.addEventListener('mousemove', onMove, true); document.addEventListener('mouseup', onUp, true); }).catch(() => {}); }); }); document.querySelectorAll('.win-resize').forEach(el => { el.addEventListener('mousedown', (e) => { e.preventDefault(); const edge = el.dataset.edge; const w = win(); if (!w) return; // 优先系统边缘缩放;右侧/底部再用 JS 尺寸兜底 if (['left','top','topleft','topright','bottomleft'].includes(edge) && w.start_resize) w.start_resize(edge); if (['right','bottom','bottomright'].includes(edge) && w.resize_window) { const sx = e.screenX, sy = e.screenY; const sw = window.innerWidth, sh = window.innerHeight; let rz = {edge, sx, sy, sw, sh}; const move = (ev) => { let nw = sw, nh = sh; if (edge.includes('right') || edge === 'right') nw = sw + ev.screenX - sx; if (edge.includes('bottom') || edge === 'bottom') nh = sh + ev.screenY - sy; w.resize_window(nw, nh); }; const up = () => { rz = null; document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); }; document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true); } }); }); // 兼容旧 GM 页面里的返回 window.shellGo = shellGo; loadStart(); setInterval(loadStart, 4000); })();