- 更新日志
从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。
+ 游戏更新
从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。
diff --git a/launcher/native.py b/launcher/native.py
index 2e24cbe..fe4bf20 100644
--- a/launcher/native.py
+++ b/launcher/native.py
@@ -622,16 +622,3 @@ class NativeClient(object):
def loadout_apply(self, character_id, slots):
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
"character_id": str(character_id), "slots": slots}))
-
- def get_server_features(self):
- return self._unwrap(self._get("/dnf/gm/features"))
-
- def save_server_features(self, features):
- return self._unwrap(self._post("/dnf/gm/features", features))
-
- def companion_state(self, character_id):
- qs = urlencode({"characterId": str(character_id)})
- return self._unwrap(self._get("/local/companion?" + qs))
-
- def companion_save(self, payload):
- return self._unwrap(self._post("/local/companion", payload))
diff --git a/launcher/server.py b/launcher/server.py
index d60e2af..bc1fab5 100644
--- a/launcher/server.py
+++ b/launcher/server.py
@@ -111,12 +111,8 @@ LOADOUT_EXE = os.path.join(LOADOUT_DIR, "换装工具.exe")
if not os.path.exists(LOADOUT_EXE):
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
-# 一键换装 / 服务端工具的持久化配置路径
+# 一键换装的持久化配置路径
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
-SERVER_CONFIG_DIR = os.path.join(SERVER_ROOT, "Server", "runtime", "config")
-SERVER_FEATURES_PATH = os.path.join(SERVER_CONFIG_DIR, "server-features.json")
-ACTIVITY_CONFIG_PATH = os.path.join(SERVER_CONFIG_DIR, "activity.json")
-RAW_CONFIG_NAMES = ("instance.json", "dnfbridge.toml", "logic.toml", "plan.json")
# 赞助二维码图片路径(放启动器目录下 sponsor.png)
SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png")
@@ -547,23 +543,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_loadout_characters()
elif path == "/api/loadout/equipment":
self._api_loadout_equipment(qs)
- elif path == "/api/servertool/features":
- self._api_servertool_features()
- elif path == "/api/servertool/activity":
- self._api_servertool_activity()
- elif path == "/api/servertool/info":
- self._api_servertool_info()
- elif path == "/api/servertool/raw":
- self._api_servertool_raw(qs)
- elif path == "/api/servertool/gameplay":
- self._api_servertool_gameplay()
- elif path == "/api/servertool/batch-pickup":
- self._api_servertool_batch_pickup()
- elif path == "/api/companion":
- self._api_companion(qs)
- elif path == "/api/basic-attack":
- self._json({"ok": True, "enabled": not os.path.exists(
- os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
elif path == "/api/logs":
self._api_logs()
elif path == "/api/gm/health":
@@ -629,32 +608,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_save_loadout_config(data)
elif path == "/api/loadout/apply":
self._api_loadout_apply(data)
- elif path == "/api/servertool/features":
- self._api_save_servertool_features(data)
- elif path == "/api/servertool/activity":
- self._api_save_servertool_activity(data)
- elif path == "/api/servertool/raw":
- self._api_save_servertool_raw(data)
- elif path == "/api/servertool/gameplay":
- self._api_save_servertool_gameplay(data)
- elif path == "/api/servertool/batch-pickup":
- self._api_save_servertool_batch_pickup(data)
- elif path == "/api/companion":
- self._api_save_companion(data)
- elif path == "/api/basic-attack":
- enabled = bool(data.get("enabled", True))
- flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
- try:
- if enabled:
- if os.path.exists(flag):
- os.remove(flag)
- else:
- os.makedirs(CLIENT_DIR, exist_ok=True)
- with open(flag, "w", encoding="utf-8") as f:
- f.write("disabled\n")
- self._json({"ok": True, "enabled": enabled})
- except Exception as e:
- self._json({"ok": False, "error": str(e)}, 500)
elif path == "/api/accounts-config":
self._api_accounts_config_post(data)
elif path == "/api/source":
@@ -868,161 +821,6 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
- # ==================== 服务端工具 ====================
- def _api_servertool_features(self):
- if NATIVE is not None and server_running():
- try:
- self._json({"ok": True, "source": "native",
- "data": NATIVE.get_server_features()})
- return
- except Exception:
- pass
- cfg = _read_json_file(SERVER_FEATURES_PATH, {})
- self._json({"ok": True, "source": "file", "data": cfg})
-
- def _api_save_servertool_features(self, data):
- if NATIVE is not None and server_running():
- try:
- self._json({"ok": True, "source": "native",
- "data": NATIVE.save_server_features(data)})
- return
- except Exception as e:
- fallback_error = str(e)
- else:
- fallback_error = "Native API 不可用"
- _write_json_file(SERVER_FEATURES_PATH, data)
- self._json({"ok": True, "source": "file",
- "warning": fallback_error, "data": data})
-
- def _api_servertool_activity(self):
- self._json({"ok": True,
- "data": _read_json_file(ACTIVITY_CONFIG_PATH, {})})
-
- def _api_save_servertool_activity(self, data):
- _write_json_file(ACTIVITY_CONFIG_PATH, data)
- self._json({"ok": True, "data": data})
-
- def _api_servertool_info(self):
- self._json({
- "ok": True,
- "features_path": SERVER_FEATURES_PATH,
- "activity_path": ACTIVITY_CONFIG_PATH,
- "config_dir": SERVER_CONFIG_DIR,
- "raw_names": list(RAW_CONFIG_NAMES),
- "server_running": server_running(),
- })
-
- def _api_servertool_raw(self, qs):
- name = (qs.get("name") or [""])[0]
- if name not in RAW_CONFIG_NAMES:
- self._json({"error": "invalid config name"}, 400)
- return
- path = os.path.join(SERVER_CONFIG_DIR, name)
- try:
- text = ""
- if os.path.exists(path):
- with open(path, "r", encoding="utf-8-sig") as f:
- text = f.read()
- self._json({"ok": True, "name": name, "path": path, "text": text})
- except Exception as e:
- self._json({"error": str(e)}, 500)
-
- def _api_save_servertool_raw(self, data):
- name = str(data.get("name", ""))
- text = str(data.get("text", ""))
- if name not in RAW_CONFIG_NAMES:
- self._json({"error": "invalid config name"}, 400)
- return
- path = os.path.join(SERVER_CONFIG_DIR, name)
- try:
- os.makedirs(SERVER_CONFIG_DIR, exist_ok=True)
- if os.path.exists(path):
- shutil.copy2(path, path + ".bak_tool")
- with open(path, "w", encoding="utf-8", newline="\n") as f:
- f.write(text)
- self._json({"ok": True, "name": name, "path": path})
- except Exception as e:
- self._json({"error": str(e)}, 500)
-
- def _api_servertool_gameplay(self):
- if NATIVE is not None and server_running():
- try:
- self._json({"ok": True, "source": "native",
- "data": NATIVE.get_gameplay_settings()})
- return
- except Exception:
- pass
- self._json({"ok": True, "source": "file", "data": {}})
-
- def _api_save_servertool_gameplay(self, data):
- if NATIVE is None or not server_running():
- self._json({"error": "服务端未运行,无法写入玩法参数"}, 400)
- return
- try:
- if data.get("hellEpicDropRatePercent") is not None:
- NATIVE.set_hell_drop_rate(int(data["hellEpicDropRatePercent"]))
- if data.get("epicPotionMultiplierPercent") is not None:
- NATIVE.set_epic_potion_multiplier(int(data["epicPotionMultiplierPercent"]))
- self._json({"ok": True, "data": data})
- except Exception as e:
- self._json({"error": str(e)}, 502)
-
- def _batch_pickup_path(self):
- return INSTANCE_PATH if INSTANCE_PATH else os.path.join(
- SERVER_CONFIG_DIR, "instance.json")
-
- def _api_servertool_batch_pickup(self):
- path = self._batch_pickup_path()
- cfg = _read_json_file(path, {})
- value = True
- try:
- value = bool(cfg.get("game", {}).get("dungeonBatchPickupEnabled", True))
- except Exception:
- value = True
- self._json({"ok": True, "data": {"enabled": value}, "path": path})
-
- def _api_save_servertool_batch_pickup(self, data):
- path = self._batch_pickup_path()
- cfg = _read_json_file(path, None)
- if not isinstance(cfg, dict):
- self._json({"error": "instance.json 不存在或格式错误"}, 400)
- return
- game = cfg.get("game")
- if not isinstance(game, dict):
- game = {}
- cfg["game"] = game
- game["dungeonBatchPickupEnabled"] = bool(data.get("enabled", True))
- try:
- if os.path.exists(path):
- shutil.copy2(path, path + ".bak_tool")
- _write_json_file(path, cfg)
- self._json({"ok": True, "data": {"enabled": game["dungeonBatchPickupEnabled"]}})
- except Exception as e:
- self._json({"error": str(e)}, 500)
-
- # ==================== 支援兵 ====================
- def _api_companion(self, qs):
- if NATIVE is None or not server_running():
- self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
- return
- character = (qs.get("character") or qs.get("id") or [""])[0].strip()
- if not character:
- self._json({"ok": False, "error": "缺少角色 ID"}, 400)
- return
- try:
- self._json({"ok": True, "data": NATIVE.companion_state(character)})
- except Exception as e:
- self._json({"ok": False, "error": str(e)}, 502)
-
- def _api_save_companion(self, data):
- if NATIVE is None or not server_running():
- self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
- return
- try:
- self._json({"ok": True, "data": NATIVE.companion_save(data)})
- except Exception as e:
- self._json({"ok": False, "error": str(e)}, 502)
-
# ==================== 自定义账号配置 ====================
def _api_accounts_config(self):
"""返回账号列表(含默认标记)。"""
diff --git a/launcher/shell.css b/launcher/shell.css
index 15c7e68..425c92a 100644
--- a/launcher/shell.css
+++ b/launcher/shell.css
@@ -165,32 +165,6 @@ button,input,select{font:inherit}
.lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace}
/* server tool */
-.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px}
-.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px}
-.st-tab:hover{color:#d8eee3}
-.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8}
-.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d}
-.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px}
-.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff}
-.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}
-.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px}
-.st-section{margin-bottom:16px}
-.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px}
-.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px}
-.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393}
-.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px}
-.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px}
-.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0}
-.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px}
-.st-check-row .st-check{color:#d5e8de;font-size:13px}
-.st-check input{width:15px;height:15px}
-.st-note{font-size:12.5px;color:#638373;margin:6px 0}
-.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px}
-.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
- font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0}
-.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px}
-.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
- font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px}
/* GM embedded */
#page-gm{padding:0}
@@ -210,7 +184,6 @@ button,input,select{font:inherit}
@media (max-width:1000px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
.loadout-layout{grid-template-columns:1fr}
- .st-form{grid-template-columns:repeat(2,1fr)}
}
/* ================= 沿用原启动器配色与图标 ================= */
@@ -277,7 +250,7 @@ button,input,select{font:inherit}
.shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)}
.tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)}
.empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)}
-.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
+.loadout-side,.loadout-main{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px}
.lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)}
.lo-list{border-top-color:rgba(255,255,255,.08)}
@@ -285,38 +258,11 @@ button,input,select{font:inherit}
.lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8}
.lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8}
.lo-slot b{color:#eef0fb}
-.st-tabs{border-bottom-color:rgba(255,255,255,.1)}
-.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff}
-.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0}
-.st-section h3{color:#c9beff;border-left-color:var(--primary)}
-.st-form label,.st-json{color:#9a9eb5}
-.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px}
-.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)}
-.st-check{color:#c4c8dc}
-.st-json summary{color:#c9beff}
-.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5}
.shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198}
.shell-statusbar b{color:#b8bcd0}
#page-gm #gmView{background:#0b0b14}
.settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.settings-label{color:#8d91a8}.settings-value{color:#e2e4f2}
-.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
-.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
-.cp-toolbar select{min-width:220px}
-.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px;
- padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)}
-.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
-.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
-.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
-.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
-.cp-card b{font-size:14px;display:block;color:#eef0fb}
-.cp-card small{font-size:12px;color:#9a9eb5}
-.cp-card input{margin-right:7px}
-.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
-.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
-.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
-.cp-ai-head select{width:240px}
-.cp-ai .st-form{margin-bottom:10px}
/* ================= 字号整体 +2,间距放宽 ================= */
body{font-size:16px}
@@ -381,26 +327,6 @@ body{font-size:16px}
.lo-cooldown.active{color:#ffcf7a}
.lo-slot{font-size:13px;padding:9px}
.lo-slot b{font-size:13px}
-.st-tabs{gap:4px;margin-bottom:14px}
-.st-tab{font-size:15px;padding:9px 14px}
-.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px}
-.st-subtab{font-size:13px;padding:5px 11px}
-.st-toolbar{gap:10px;margin-bottom:14px}
-.st-body{padding:18px;min-height:380px}
-.st-section{margin-bottom:20px}
-.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px}
-.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px}
-.st-form label{font-size:15px;gap:6px}
-.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px}
-.st-hint{font-size:13px;line-height:1.5}
-.st-note{font-size:14px;line-height:1.6}
-.st-check{font-size:15px;gap:9px;padding:8px 0}
-.st-check input{width:17px;height:17px}
-.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px}
-.st-check-row .st-check{font-size:15px}
-.st-json{font-size:14px;padding-top:14px;margin-top:18px}
-.st-json summary{font-size:14px;margin-bottom:10px}
-.st-json textarea,.st-raw{font-size:13px;height:260px}
.shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px}
.settings-grid{gap:14px;margin-top:18px}
.settings-card{padding:16px 18px}
@@ -408,7 +334,6 @@ body{font-size:16px}
.settings-value{font-size:13px;line-height:1.7}
@media (max-width:1100px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
- .st-form{grid-template-columns:1fr}
.hero-actions{flex-wrap:wrap;gap:10px}
}
.win-resize{position:fixed;z-index:10000;display:none}
diff --git a/launcher/shell.js b/launcher/shell.js
index 5e0a066..e519985 100644
--- a/launcher/shell.js
+++ b/launcher/shell.js
@@ -1,19 +1,13 @@
/* 90轮回启动器 · 单窗口页面与工具整合 */
(function () {
const $ = (id) => document.getElementById(id);
- const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog'];
+ const pages = ['start','gm','loadout','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:'更新日志'};
+ const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',settings:'设置',changelog:'更新日志'};
function shellGo(page) {
if (!pages.includes(page)) page = 'start';
@@ -22,8 +16,6 @@
$('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 === 'changelog') loadChangelog();
if (page === 'gm') {
@@ -483,492 +475,6 @@
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 = ``;
- 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');
diff --git a/launcher/templates/支援兵页面模板.md b/launcher/templates/支援兵页面模板.md
new file mode 100644
index 0000000..3021b85
--- /dev/null
+++ b/launcher/templates/支援兵页面模板.md
@@ -0,0 +1,404 @@
+# 支援兵页面模板(Companion Page Template)
+
+> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。
+> 导出日期:2026-11-04。
+> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。
+
+---
+
+## 一、HTML(index.html 中的 section)
+
+插入位置:`` 之前(或任意 shell-page 之间)。
+
+```html
+
+```
+
+---
+
+## 二、侧边栏导航入口(index.html 的 nav 内)
+
+```html
+
+```
+
+---
+
+## 三、JavaScript(shell.js 中的支援兵模块)
+
+插入位置:`// ---------------- 一键换装 ----------------` 区块之后、`// ---------------- 服务端工具 ----------------` 区块之前。
+
+```javascript
+ // ---------------- 支援兵 ----------------
+ 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; }
+ });
+```
+
+同时,需要在 `shellGo` 函数中加回页面切换调用:
+
+```javascript
+ if (page === 'companion') loadCompanion();
+```
+
+以及在 `pages` 数组加回 `'companion'`,在 `crumb` 加回 `companion:'工具 / 支援兵'`。
+
+---
+
+## 四、后端接口(server.py)
+
+### 4.1 GET 路由注册(do_GET 内)
+
+```python
+ elif path == "/api/companion":
+ self._api_companion(qs)
+ elif path == "/api/basic-attack":
+ self._json({"ok": True, "enabled": not os.path.exists(
+ os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
+```
+
+### 4.2 POST 路由注册(do_POST 内)
+
+```python
+ elif path == "/api/companion":
+ self._api_save_companion(data)
+ elif path == "/api/basic-attack":
+ enabled = bool(data.get("enabled", True))
+ flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
+ try:
+ if enabled:
+ if os.path.exists(flag):
+ os.remove(flag)
+ else:
+ os.makedirs(CLIENT_DIR, exist_ok=True)
+ with open(flag, "w", encoding="utf-8") as f:
+ f.write("disabled\n")
+ self._json({"ok": True, "enabled": enabled})
+ except Exception as e:
+ self._json({"ok": False, "error": str(e)}, 500)
+```
+
+### 4.3 处理函数
+
+```python
+ # ==================== 支援兵 ====================
+ def _api_companion(self, qs):
+ if NATIVE is None or not server_running():
+ self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
+ return
+ character = (qs.get("character") or qs.get("id") or [""])[0].strip()
+ if not character:
+ self._json({"ok": False, "error": "缺少角色 ID"}, 400)
+ return
+ try:
+ self._json({"ok": True, "data": NATIVE.companion_state(character)})
+ except Exception as e:
+ self._json({"ok": False, "error": str(e)}, 502)
+
+ def _api_save_companion(self, data):
+ if NATIVE is None or not server_running():
+ self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
+ return
+ try:
+ self._json({"ok": True, "data": NATIVE.companion_save(data)})
+ except Exception as e:
+ self._json({"ok": False, "error": str(e)}, 502)
+```
+
+---
+
+## 五、CSS 样式(shell.css 中的 cp- 相关)
+
+支援兵页面复用了 `.st-form`、`.st-check`、`.st-json`、`.st-note` 等「服务端工具」样式(这些在服务端工具删除后需要一并保留或从模板恢复),以及以下独立的 cp- 样式(位于 shell.css 明暗主题区域):
+
+```css
+.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
+.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
+.cp-toolbar select{min-width:220px}
+.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px}
+.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
+.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
+.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
+.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
+.cp-card b{font-size:14px;display:block;color:#eef0fb}
+.cp-card small{font-size:12px;color:#9a9eb5}
+.cp-card input{margin-right:7px}
+.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
+.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
+.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
+.cp-ai-head select{width:240px}
+.cp-ai .st-form{margin-bottom:10px}
+```
+
+> 注意:`cp-flags` 样式在原文件里是分成两行(续行),恢复时需合并为完整声明。
+
+---
+
+## 六、依赖说明
+
+支援兵页面依赖以下「服务端工具」的通用样式(这些如果被一并删除,恢复支援兵时需一并恢复):
+
+- `.st-form` / `.st-form label` / `.st-form input` / `.st-form select`
+- `.st-check` / `.st-check input`
+- `.st-json` / `.st-json summary` / `.st-json textarea`
+- `.st-note`
+
+依赖的后端接口:
+- `/api/accounts`(账号列表)
+- `/api/characters?account=`(角色列表)
+- `/api/companion`(支援兵状态读取/保存)
+- `/api/basic-attack`(普攻开关)
+
+---
+
+## 七、恢复步骤速查
+
+1. `index.html`:粘贴 section + 侧边栏按钮
+2. `shell.js`:粘贴 JS 模块,并在 `pages`/`crumb`/`shellGo` 三处加回 `companion`
+3. `server.py`:粘贴路由注册 + 两个处理函数
+4. `shell.css`:粘贴 cp- 样式(若 st- 样式已删,一并恢复 st- 通用样式)
+5. 重新打包
+
+---
+
+(完)