Files

524 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 咸鱼喵喵 · 截图辅助工具 —— 本地服务
//
// 职责:
// 1. 给浏览器提供一个控制面板页面
// 2. 通过 CDP 把面板上的操作转成游戏里的 JavaScript
// 3. 管存档备份 / 恢复、沙盒副本
//
// 安全:
// - 只监听 127.0.0.1,同一个 WiFi 下的别人连不上
// - 不改程序和素材;明确选择后可保存/还原存档,复制沙盒。
const http = require('http');
const fs = require('fs');
const path = require('path');
const { CdpClient } = require('./lib/cdp.js');
const { OPS } = require('./lib/ops.js');
const { storyPosition } = require('./lib/story-position');
const { loadAll } = require('./lib/data.js');
const { StoryCache } = require('./lib/story-cache');
const SAFETY = require('./lib/safety');
const FILES = require('./lib/files');
const { assertGamesStopped } = require('./lib/processes');
// ---------- 路径与配置 ----------
const TOOL_DIR = __dirname;
const AITEMP_DIR = path.resolve(TOOL_DIR, '..'); // aitemp/
const LEGACY_GAME_DIR = path.resolve(AITEMP_DIR, '..'); // 旧布局的游戏根目录
const WEB_DIR = path.join(TOOL_DIR, 'web');
const BACKUP_DIR = path.join(AITEMP_DIR, 'backups');
const CONFIG_FILE = path.join(TOOL_DIR, 'config.json');
// 沙盒默认位置:和游戏同一个盘的根目录下。
// ⚠️ 不要写死具体盘符 —— 别人的游戏不一定装在 L 盘。
// 放在盘根是为了**避开 Steam 库**(放库里 Steam 会扫到,云同步也可能够得着)。
const DEFAULT_SANDBOX = path.join(path.parse(LEGACY_GAME_DIR).root, 'Nyaruru_shots');
const DEFAULTS = {
cdpPort: 9223, // 沙盒实例的调试端口
sandboxDir: DEFAULT_SANDBOX,
webPort: 8787,
};
function loadConfig() { return SAFETY.readConfig(CONFIG_FILE, DEFAULTS, LEGACY_GAME_DIR); }
const CONFIG = loadConfig();
const GAME_DIR = CONFIG.gameDir || LEGACY_GAME_DIR;
function classifyGameDir(dir) {
if (!dir) return 'unknown';
try {
if (SAFETY.samePath(dir, GAME_DIR)) return 'original';
if (SAFETY.samePath(dir, CONFIG.sandboxDir)) return 'sandbox';
} catch { return 'unknown'; }
return 'other';
}
/** 校验并写回 config.json。只接受已知字段,避免前端传进来的杂项污染配置 */
function saveConfig(patch) {
if (sandboxJob.running) throw new Error('沙盒复制期间不能修改配置');
const allowed = Object.fromEntries(Object.entries(patch).filter(([k]) => ['cdpPort', 'webPort', 'sandboxDir'].includes(k)));
const next = SAFETY.validateConfig({ ...CONFIG, ...allowed }, GAME_DIR);
// 面板端口修改要重启,因此也不能让 CDP 占用当前正在监听的旧端口。
if (server?.listening && next.cdpPort === server.address().port) throw new Error('调试端口与当前正在使用的面板端口冲突,请先重启面板');
const temp = CONFIG_FILE + '.tmp';
fs.writeFileSync(temp, JSON.stringify(next, null, 2), 'utf8');
fs.renameSync(temp, CONFIG_FILE);
const portChanged = next.cdpPort !== CONFIG.cdpPort;
const webPortChanged = next.webPort !== CONFIG.webPort;
Object.assign(CONFIG, next);
if (portChanged) { cdp.close(); cdp.port = CONFIG.cdpPort; erasedCache.clear(); storyCache.reset(); }
return { config: CONFIG, cdpReconnected: portChanged, webPortNeedsRestart: webPortChanged };
}
// ---------- 静态数据(启动时载入一次)----------
let DATA;
try {
DATA = loadAll(AITEMP_DIR);
console.log(`[数据] 开关 ${DATA.stats.switches} · 变量 ${DATA.stats.variables} · 地图 ${DATA.stats.maps}`);
console.log(`[数据] 触发点 ${DATA.stats.triggersUnique} 条(可跳转 ${DATA.stats.triggersActionable} 条)· 常用开关 ${DATA.stats.commonSwitches} 个`);
} catch (e) {
console.error('[数据] 载入失败:', e.message);
console.error(' 请确认 aitemp/out/triggers.csv 与 aitemp/unpacked/data/ 存在。');
process.exit(1);
}
const cdp = new CdpClient(CONFIG.cdpPort);
const storyCache = new StoryCache(cdp, OPS, DATA.triggers);
// "mapId:eventId" → 该事件是否被游戏自己抹除(_erased)。
// 每次只保留当前地图扫描结果,避免切换存档后沿用旧标记。
const erasedCache = new Map();
// ---------- 存档备份 ----------
const backupSaves = (dir, label) => FILES.backupSaves(BACKUP_DIR, dir, label);
const listBackups = () => FILES.listBackups(BACKUP_DIR);
let sandboxJob = { running: false, done: false, error: null, message: '', startedAt: 0 };
function sandboxExists() { return FILES.sandboxStatus(GAME_DIR, CONFIG.sandboxDir, AITEMP_DIR).ready; }
function createSandbox() {
if (sandboxJob.running) throw new Error('已有拷贝任务在进行中');
const dest = CONFIG.sandboxDir;
const promise = FILES.copySandbox(GAME_DIR, dest, AITEMP_DIR);
sandboxJob = { running: true, done: false, error: null, message: '正在拷贝并验证…', startedAt: Date.now(), dest };
promise.then(() => { sandboxJob = { ...sandboxJob, running: false, done: true, message: '拷贝及验证完成' }; }, e => {
sandboxJob = { ...sandboxJob, running: false, done: false, error: e.message, message: '拷贝失败,不能启动该副本' };
});
return { started: true, dest };
}
function targetDir(body) {
if (!['original', 'sandbox'].includes(body.target)) throw new Error('必须明确选择原版或沙盒目标');
return body.target === 'sandbox' ? CONFIG.sandboxDir : GAME_DIR;
}
let operationTail = Promise.resolve();
const offlineActions = new Set(['/api/config', '/api/backup', '/api/restore-backup', '/api/sandbox', '/api/shutdown', '/api/story-text']);
const getRoutes = new Set(['/api/status', '/api/snapshot', '/api/trainer', '/api/item-icons', '/api/encyclopedia', '/api/world-map', '/api/combat', '/api/maps', '/api/switches', '/api/config', '/api/ground', '/api/events/lost', '/api/events', '/api/triggers', '/api/backups', '/api/sandbox/status', '/api/health', '/api/story-text']);
async function identity() {
const r = await cdp.evalJson(OPS.selfPath());
if (!r?.ok) throw new Error(r?.error || '无法识别游戏实例');
return { ...r, dir: r.gameDir, kind: classifyGameDir(r.gameDir), context: `${cdp.generation}:${r.mapToken}` };
}
function guarded(script, token) {
return `(async()=>{ const i=JSON.parse(await ${OPS.selfPath()}); if(i.mapToken!==${JSON.stringify(token)}) return JSON.stringify({ok:false,error:'地图或存档已经变化,请刷新后重试'}); return await ${script}; })()`;
}
// ---------- 工具函数 ----------
function readBody(req) {
return new Promise((resolve, reject) => {
let buf = '';
req.on('data', c => { buf += c; if (buf.length > 1e6) req.destroy(); });
req.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { reject(new Error('请求 JSON 格式错误')); } });
req.on('error', reject);
req.on('aborted', () => reject(new Error('请求已取消')));
});
}
function sendJson(res, obj, code = 200) {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(body);
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.ico': 'image/x-icon' };
function serveStatic(res, urlPath) {
const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
const file = path.join(WEB_DIR, rel);
// 防止 ../ 穿越到 web 目录之外
if (!SAFETY.contains(WEB_DIR, file)) { res.writeHead(403); res.end('forbidden'); return; }
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); res.end('not found'); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
fs.createReadStream(file).pipe(res);
}
// ---------- 路由 ----------
async function handleApi(req, res, urlPath, query) {
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, { ok: false, error: '不支持的请求方法' }, 405);
if (req.method === 'GET' && !getRoutes.has(urlPath)) return sendJson(res, { ok: false, error: '修改操作必须使用 POST' }, 405);
if (req.method === 'POST' && req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return sendJson(res, { ok: false, error: '拒绝跨站修改请求' }, 403);
const body = req.method === 'POST' ? await readBody(req) : {};
if (req.method === 'POST') {
const task = operationTail.then(() => executeApi(req, res, urlPath, body));
operationTail = task.catch(() => {});
return task;
}
return executeApi(req, res, urlPath, body);
}
async function executeApi(req, res, urlPath, body) {
let current = null;
if (req.method === 'POST' && !offlineActions.has(urlPath)) {
current = await identity();
if (!['sandbox', 'original'].includes(current.kind)) throw new Error('无法确认游戏身份,停止修改操作');
if (!body.context || body.context !== current.context) throw new Error('连接、地图或存档已变化,请刷新后重试');
}
const game = async script => {
if (current && current.context.split(':')[0] !== String(cdp.generation)) throw new Error('连接已变化,操作已取消');
return cdp.evalJson(current ? guarded(script, current.mapToken) : script);
};
switch (urlPath) {
case '/api/encyclopedia/travel':return sendJson(res,await game(OPS.catalogTravel(body.mapId,body.eventId)));
case '/api/world-map/travel':return sendJson(res,await game(OPS.worldMapTravel(body.mapId,body.cellX,body.cellY,body.page)));
case '/api/combat': {
if(req.method==='GET')current=await identity();
const r=await game(OPS.combat(req.method==='POST'?body:null));return sendJson(res,{...r,context:current.context});
}
case '/api/shutdown': {
if (sandboxJob.running) throw new Error('沙盒仍在复制,请等待任务结束再停止面板');
sendJson(res, { ok: true });
setTimeout(() => { cdp.close(); server.close(() => { if (require.main === module) process.exit(0); }); }, 100);
return;
}
case '/api/health': return sendJson(res, { ok: true, toolDir: TOOL_DIR, version: 2, config: CONFIG });
// --- 连接状态 ---
case '/api/status': {
const alive = await cdp.isAlive();
// 检测到调试端口就顺手建立会话(首次之后会复用,开销很小),
// 这样面板一打开就能显示"已连接",而不是等到第一次操作
let connected = false;
if (alive) {
try { await cdp.connect(); connected = !!cdp.sessionId; } catch { connected = false; }
}
// 顺手问一句:游戏窗口自己是从哪个目录加载的?
// 面板据此显示"当前连接:沙盒 / 原版",把"以为在沙盒、其实改了原版"这种
// 将实际实例身份显示给用户;约定见 tool/开发文档.md。
let gameSelf = null;
if (connected) { try { gameSelf = await identity(); } catch { connected = false; } }
if (!connected) {
erasedCache.clear(); cdp.close(); storyCache.reset();
} else { void storyCache.ensure().catch(() => {}); }
const storyText = storyCache.status();
return sendJson(res, {
ok: true,
gameRunning: alive,
connected,
cdpPort: CONFIG.cdpPort,
gameDir: GAME_DIR,
sandboxDir: CONFIG.sandboxDir,
sandboxExists: sandboxExists(),
sandboxJob,
gameSelf,
stats: { ...DATA.stats, storyLoaded: storyText.count, triggersWithLine: storyText.triggersWithLine },
dataMode: 'runtime', storyText,
context: gameSelf?.context || null,
});
}
// --- 游戏状态快照 ---
case '/api/world-map': {
if (req.method !== 'GET') throw new Error('世界地图为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.worldMap(), before.mapToken));
return sendJson(res, { ...r, context: before.context });
}
case '/api/encyclopedia': {
if (req.method !== 'GET') throw new Error('百科为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.encyclopedia(), before.mapToken));
return sendJson(res, { ...r, context: before.context });
}
case '/api/item-icons': {
if (req.method !== 'GET') throw new Error('图标为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.itemIcons(), before.mapToken));
return sendJson(res, { ...r, context:before.context });
}
case '/api/trainer': {
if (req.method === 'GET') current = await identity();
const r = await game(OPS.trainer(req.method === 'POST' ? body : null));
return sendJson(res, { ...r, context: current.context });
}
case '/api/snapshot': {
const snap = await game(OPS.snapshot());
return sendJson(res, snap);
}
// --- 地图列表 ---
case '/api/maps':
return sendJson(res, { ok: true, maps: DATA.maps });
// --- 开关 ---
case '/api/switches': {
let values = {};
try { values = (await game(OPS.readSwitches())).values || {}; } catch { /* 没连上就只给名字 */ }
const common = new Set(DATA.commonSwitchIds);
return sendJson(res, {
ok: true,
switches: DATA.switches.map(s => ({ ...s, common: common.has(s.id) })),
values,
connected: Object.keys(values).length > 0,
});
}
case '/api/switch': {
const r = await game(OPS.setSwitch(body.id, body.value));
return sendJson(res, r);
}
// 批量设置开关("一键前往"用:一次把该对话需要的条件全部打开)
case '/api/switches/set': {
const pairs = Array.isArray(body.pairs) ? body.pairs : [];
if (!pairs.length) return sendJson(res, { ok: true, applied: 0 });
return sendJson(res, await game(OPS.setSwitches(pairs)));
}
// --- 传送 ---
// body.float 默认 true:传送后进入"悬浮安全模式"(暂停重力 + 穿墙),
// 避免落进坑里触发游戏的坠落复位。详见 ops.js 里 teleport 的注释。
case '/api/teleport': {
const r = await game(OPS.teleport(body.x, body.y, {
float: body.float !== false,
// mapId 不传或与当前地图相同 → 地图内定位;
// 不同 → 走 reserveTransfer 跨地图(见 ops.js 的 teleport 注释)
mapId: body.mapId ?? null,
}));
return sendJson(res, r);
}
case '/api/nudge': return sendJson(res, await game(OPS.nudge(body.dx, body.dy, body.pixels)));
// --- 直接播放一段剧情序列 ---
// 绕开地图事件与开关条件,用于通关存档上复现"错过就没了"的对话
case '/api/story/start': {
const position = storyPosition(body);
const tp = await game(OPS.teleport(position.x, position.y, { mapId: body.mapId, story: body }));
if (!tp.ok) return sendJson(res, tp);
const next = await identity();
if (next.context.split(':')[0] !== current.context.split(':')[0] || !SAFETY.samePath(next.dir, current.dir)) throw new Error('传送期间连接已变化');
current = next;
const r = await game(body.mode === 'scene' ? OPS.runStoryScene(body.storyId, body.eventId, body.pageIndex) : OPS.playStory(body.storyId));
r.positionNote = tp.positionNote;
// 传送及补角色会临时开启悬浮;过场准备完成后恢复安全落地。
// 放在 runStoryScene 之后,避免其避让角色逻辑再次开启悬浮。
if (r.ok && body.mode === 'scene') {
r.landing = await game(OPS.land());
if (!r.landing.ok) r.landingWarning = '当前落点未能自动落地:' + r.landing.error;
else if (r.landing.warning) r.landingWarning = r.landing.warning;
}
return sendJson(res, r);
}
case '/api/story/goto': {
// 验证所有参数后才修改开关,并把开关及传送放在同一排队任务中。
const position = storyPosition(body);
const teleport = OPS.teleport(position.x, position.y, { mapId: body.mapId, story: body });
const pairs = Array.isArray(body.pairs) ? body.pairs : [];
if (pairs.some(p => !Array.isArray(p) || !DATA.switches.some(s => s.id === p[0]) || typeof p[1] !== 'boolean')) throw new Error('开关参数无效');
if (pairs.length) {
const r = await game(OPS.setSwitches(pairs));
if (!r.ok) return sendJson(res, r);
}
return sendJson(res, await game(teleport));
}
case '/api/story/play':
return sendJson(res, await game(OPS.playStory(body.storyId)));
// --- 运行整段过场(含 NPC 站位与演出),而不是只念台词 ---
case '/api/story/scene':
return sendJson(res, await game(OPS.runStoryScene(body.storyId)));
// --- 关掉当前的消息窗口(截完图收尾用)---
case '/api/story/close': return sendJson(res, await game(OPS.closeMessage()));
// --- 莉莉娅显示 / 隐藏 ---
case '/api/lily': return sendJson(res, await game(OPS.setLily(body.show !== false)));
// --- 拍照辅助:隐藏 / 显示 对话框、立绘、HUD ---
// 只藏不关,随时能显示回来(要"真的关掉对话"请用 /api/story/close)
case '/api/ui-hidden':
return sendJson(res, await game(OPS.setUiHidden({
message: typeof body.message === 'boolean' ? body.message : null,
artwork: typeof body.artwork === 'boolean' ? body.artwork : null,
hud: typeof body.hud === 'boolean' ? body.hud : null,
})));
// --- 落地:从悬浮状态站到实地上(会先探测脚下是否安全)---
case '/api/land': return sendJson(res, await game(OPS.land()));
// --- 探测脚下地形("落地"按钮的预检,也可单独用)---
case '/api/ground': return sendJson(res, await game(OPS.probeGround()));
// --- 配置 ---
case '/api/config': {
if (req.method === 'POST') {
try {
const r = saveConfig(body);
return sendJson(res, { ok: true, ...r, defaults: DEFAULTS });
} catch (e) {
return sendJson(res, { ok: false, error: String(e.message) }, 400);
}
}
return sendJson(res, { ok: true, config: CONFIG, defaults: DEFAULTS, configFile: CONFIG_FILE });
}
// --- 作弊开关 ---
case '/api/gravity': return sendJson(res, await game(OPS.setGravity(!!body.on)));
case '/api/invincible': return sendJson(res, await game(OPS.setInvincible(!!body.on)));
case '/api/noclip': return sendJson(res, await game(OPS.setNoclip(!!body.on)));
case '/api/restore': return sendJson(res, await game(OPS.restoreAll()));
// --- 场景还原(摆拍):扫描本图丢失的角色 → 强行切页让它们出现 ---
// 静态摆拍与撤销由 photo-state 管理,自动和手动存档照常,仅提示风险。
case '/api/events/lost': {
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.scanLostEvents(), before.mapToken));
return sendJson(res, { ...r, context: before.context });
}
case '/api/events/force-page':
return sendJson(res, await game(OPS.forcePage(body.eventId, body.page)));
// 取消本图所有"钉住"的页号,恢复游戏自己的选页
case '/api/events/unpin':
return sendJson(res, await game(OPS.unpinPages()));
case '/api/events/restore-cast':
// includeHidden=false(默认)只恢复"被抹除"的,那些是明确的该在不在;
// true 才连"游戏有意隐藏"的一起拉出来 —— 会凭空多出人物,界面上要警告
return sendJson(res, await game(OPS.restoreLostCast({
includeHidden: body.includeHidden === true,
})));
// --- 存"拍照专用档" ---
// ⚠️ 会覆盖该槽位原有进度,所以这里先自动备份整个存档目录再存
case '/api/save-photo': {
// 先在服务端做个粗校验,避免参数写错时白备份一次存档目录
// (真正的合法性由游戏侧按 DataManager.maxSavefiles() 判断)
const slot = Number(body.slot);
if (!Number.isInteger(slot) || slot < 1 || slot > 23) {
return sendJson(res, { ok: false, error: `槽位必须是 1~23 之间的整数(0 是自动存档槽,不能存)` }, 400);
}
const target = targetDir(body);
if (current.kind !== body.target || !SAFETY.samePath(current.dir, target) || !current.saveDir || !SAFETY.samePath(current.saveDir, path.join(target, 'save'))) throw new Error('实际连接的游戏或存档目录与所选目标不一致,未保存');
const backup = backupSaves(target, 'presave');
const r = await game(OPS.savePhotoSlot(slot));
return sendJson(res, { ...r, target: current.saveDir, safetyBackup: backup.name });
}
// --- 事件列表(当前地图)---
// 把事件页里 call_story 的故事序号翻译成台词,这样列表上显示的是
// 「台词」— 说话人 而不是 EV058 这种内部编号(见开发文档第 6 节)
case '/api/events': {
await storyCache.ensure();
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.listEvents(), before.mapToken));
r.context = before.context;
if (r && r.ok) {
for (const ev of r.events) {
const lines = (ev.storyIds || [])
.map(id => ({ id, ...(storyCache.get(id) || {}) }))
.filter(s => s.first);
ev.lines = lines;
// 有剧情的事件优先展示,UI 也据此排序
ev.hasStory = (ev.storyIds || []).length > 0;
}
}
return sendJson(res, r);
}
// --- 剧情触发点 ---
// 顺便抓一次**当前地图**的"抹除状态"并缓存(开发文档第 6 节):
// 被游戏自己抹除的事件页号钉死在 -1,走过去也触发不了,
// 只标记当前地图的本次扫描,不跨地图或存档保留旧标记。
case '/api/story-text': {
await storyCache.ensure({ force: body.refresh === true });
return sendJson(res, { ok: true, ...storyCache.status() });
}
case '/api/triggers': {
await storyCache.ensure();
erasedCache.clear();
let scannedMap = null;
if (await cdp.isAlive()) {
try {
const ev = await game(OPS.listEvents());
if (ev && ev.ok && ev.mapId) {
scannedMap = ev.mapId;
for (const e of ev.events) {
erasedCache.set(`${ev.mapId}:${e.id}`, !!e.erased);
}
}
} catch { /* 没连上就只给静态数据 */ }
}
const triggers = DATA.triggers.map(t => {
const key = (t.mapId !== null && t.eventId !== '') ? `${t.mapId}:${t.eventId}` : null;
const known = key !== null && erasedCache.has(key);
const line = storyCache.get(t.storyId);
return { ...t, triggerX: t.x, triggerY: t.y, ...storyPosition(t), line: line?.first || '', speaker: line?.speaker || '',
lineCount: line?.count || 0, lines: line?.lines || [], searchText: line?.searchText || '',
erased: known ? erasedCache.get(key) : null };
});
return sendJson(res, { ok: true, triggers, scannedMap, knownErased: erasedCache.size, storyText: storyCache.status() });
}
// --- 存档备份 ---
case '/api/backups':
return sendJson(res, { ok: true, backups: listBackups(), backupDir: BACKUP_DIR });
case '/api/backup': {
const target = targetDir(body);
const label = body.target;
return sendJson(res, { ok: true, ...backupSaves(target, label) });
}
case '/api/restore-backup': {
if (!body.confirm) return sendJson(res, { ok: false, error: '需要确认(会覆盖当前存档)' }, 400);
const target = targetDir(body);
await assertGamesStopped();
return sendJson(res, { ok: true, ...FILES.restoreBackup(BACKUP_DIR, body.name, target) });
}
// --- 沙盒 ---
case '/api/sandbox': return sendJson(res, { ok: true, ...createSandbox() });
case '/api/sandbox/status': return sendJson(res, { ok: true, ...sandboxJob, exists: sandboxExists() });
default:
return sendJson(res, { ok: false, error: '未知接口: ' + urlPath }, 404);
}
}
const server = http.createServer(async (req, res) => {
try {
const u = new URL(req.url, `http://127.0.0.1:${CONFIG.webPort}`);
if (u.pathname.startsWith('/api/')) await handleApi(req, res, u.pathname, u.searchParams);
else serveStatic(res, u.pathname);
} catch (e) {
sendJson(res, { ok: false, error: String(e && e.message || e) }, 500);
}
});
// 只监听回环地址 —— 局域网内的其他人访问不到
if (require.main === module) server.listen(CONFIG.webPort, '127.0.0.1', async () => {
fs.writeFileSync(path.join(TOOL_DIR, '.server-runtime.json'), JSON.stringify({ port: server.address().port, pid: process.pid }));
console.log('');
console.log(' 咸鱼喵喵 · 截图辅助工具');
console.log(' ────────────────────────────────────────');
console.log(` 控制面板: http://127.0.0.1:${CONFIG.webPort}`);
console.log(` 游戏目录: ${GAME_DIR}`);
console.log(` 沙盒目录: ${CONFIG.sandboxDir} ${sandboxExists() ? '(已存在)' : '(未创建)'}`);
console.log(` 调试端口: ${CONFIG.cdpPort}`);
const alive = await cdp.isAlive();
console.log(` 游戏连接: ${alive ? '已检测到调试端口' : '未检测到(请先启动游戏)'}`);
console.log(' ────────────────────────────────────────');
console.log(' 按 Ctrl+C 退出');
console.log('');
});
// 退出时主动断开 CDP,不给游戏留悬挂连接
if (require.main === module) process.on('SIGINT', () => {
console.log('\n正在退出…');
cdp.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1000);
});
module.exports = { server, cdp, CONFIG, saveConfig, handleApi, executeApi, identity, storyCache };