首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后暴露的问题,单元测试全绿但机器人干不了活: 致命 - 寻路插件从未加载:项目从没调用 bot.loadPlugin(pathfinder),bot.pathfinder 恒为 undefined、isReady() 恒为 false,扩地任务连启动都启动不了,界面只显示含糊的 「机器人尚未就绪」。现在 createBot 后立刻加载,失败时明确报错。 登录门禁相关(三处死锁) - preLoginCommands 白名单:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先被门禁一并拦下,形成「要先登录才能发登录指令」 - 验证码常被要求直接当聊天内容发出(不是指令),原先被 sendChat 的门禁挡住; 现在服务器明确要求验证时自动放行,另有 server.allowChatBeforeLogin 兜底 - 配置了登录指令却没有密码时 loggedIn 永久为 false,控制台连手动指令都发不出; 现在走与其他「服务器不回提示」一致的超时兜底并给出说明 可用性 - 离线时指令被静默丢弃却回报「已发送」;现在要求已连接并进入世界,失败信息区分 「未连接」「登录被拒」「未通过验证」 - 「被服务器传送」日志洪水:实测 3 分钟 748 行,会冲光日志缓冲并打满 WebSocket; 现在按 30 秒窗口聚合,断开时刷出未汇总计数 - 识别外站账号绑定流程并给出可操作告警,新增 server.authCommand 模板 - 重连指数退避(server.reconnectMaxDelayMs) - 控制台瓦片 URL 与后端路由不一致 新增 - 控制台添加 / 移除玩家:不必改 .env 重启。密码只写入 data/bots.credentials.json (强制 0600),不进仓库、不出现在任何响应里;.env 与凭据文件同名时以 .env 为准, .env 里的账号不允许从控制台移除 - 手动移动测试接口与按钮:绕开寻路器直接按方向键,返回真实位移, 用于区分「算不出路径」与「根本走不动」 - 位置同步诊断:位置包收发计数、onGround、游戏模式、维度、脚下方块与六向实心 - grim.reportOnGroundFromWorld:按世界数据修正出站 onGround(默认开启) - scripts/check-protocol.js:核对包名与字段名同目标版本协议是否一致 文档 - README 新增「部署到服务器」与「机器人走不动的排查方法」两章 已知限制:实测本服上 mineflayer 客户端无法移动(按方向键 2 秒位移 0.00~0.02 格, 服务端每 tick 回弹一次),已排除区块数据、onGround、Grim 层、游戏模式、物理引擎、 寻路插件、teleport_confirm 协议字段等因素,彻底定位需与原生客户端逐字节对比移动包。 连接 / 登录 / 指令 / 聊天 / 公屏日志 / 大地图均正常。 测试从 263 增至 290,全部通过。
980 行
34 KiB
JavaScript
980 行
34 KiB
JavaScript
'use strict';
|
||
|
||
const http = require('http');
|
||
const path = require('path');
|
||
|
||
const express = require('express');
|
||
const { WebSocketServer } = require('ws');
|
||
|
||
const chunkPos = require('../core/chunkPos');
|
||
const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
||
|
||
/** 状态推送间隔(毫秒) */
|
||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||
/** 登录接口路径(免令牌,否则无法登录) */
|
||
const LOGIN_PATH = '/api/login';
|
||
/** 会话探测接口路径(免令牌) */
|
||
const SESSION_PATH = '/api/session';
|
||
|
||
/**
|
||
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
|
||
*
|
||
* 安全约定:
|
||
* - 默认只监听 127.0.0.1;
|
||
* - 配置 `WEB_TOKEN` / `WEB_PASSWORD` 后所有 `/api` 请求与 WebSocket 握手都必须带正确凭证;
|
||
* - 控制台页面提供登录框:验证通过后把凭证存在浏览器本地,用于操作各机器人的控制台 / 聊天框;
|
||
* - 令牌比较使用定时安全比较,避免时序侧信道。
|
||
*
|
||
* 多机器人:依赖注入的 `pool`({@link module:bot/botPool.BotPool})负责管理多个机器人,
|
||
* 每个机器人的控制台 / 聊天框 / 任务接口都以 `/api/bots/:id/...` 暴露。
|
||
* 为兼容既有单机器人用法,未传 `pool` 时会用 `botManager` + `controller` 包装出一个单机器人池。
|
||
*/
|
||
class WebConsole {
|
||
/**
|
||
* @param {object} deps
|
||
* @param {import('../bot/botPool').BotPool} [deps.pool] 机器人池
|
||
* @param {import('../bot/botManager').BotManager} [deps.botManager] 单机器人模式
|
||
* @param {import('../controller/expansionController').ExpansionController} [deps.controller] 单机器人模式
|
||
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||
* @param {import('../util/logger').Logger} deps.logger
|
||
* @param {() => object} deps.getConfig
|
||
* @param {(config: object) => object} deps.applyConfig
|
||
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
|
||
* @param {import('../core/chatLog').ChatLog} [deps.chatLog]
|
||
* @param {import('../core/bigMapService').BigMapService} [deps.bigMap]
|
||
* @param {{host?: string, port?: number, token?: string, password?: string, exportDir?: string}} [options]
|
||
*/
|
||
constructor(deps, options) {
|
||
const opts = options || {};
|
||
this.pool = deps.pool || new SingleBotPool(deps.botManager, deps.controller);
|
||
this.dataService = deps.dataService;
|
||
this.logger = deps.logger;
|
||
this.expansionLog = deps.expansionLog || null;
|
||
this.chatLog = deps.chatLog || null;
|
||
this.bigMap = deps.bigMap || null;
|
||
this.getConfig = deps.getConfig;
|
||
this.applyConfig = deps.applyConfig;
|
||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||
this.host = opts.host || '127.0.0.1';
|
||
this.port = opts.port === undefined ? 3081 : opts.port;
|
||
/** 可写进 URL 的令牌(`?token=`) */
|
||
this.token = opts.token || '';
|
||
/** 控制台登录密码(`WEB_PASSWORD`);与 token 二选一或同时配置 */
|
||
this.password = opts.password || '';
|
||
|
||
this.app = express();
|
||
this.server = null;
|
||
this.wss = null;
|
||
this._pushTimer = null;
|
||
/** @type {Set<import('ws').WebSocket>} */
|
||
this.clients = new Set();
|
||
|
||
this._setupRoutes();
|
||
}
|
||
|
||
/** 默认机器人条目(`pool` 里的第一个) */
|
||
get defaultEntry() {
|
||
return this.pool.get(this.pool.defaultId);
|
||
}
|
||
|
||
/** 兼容既有调用:默认机器人的管理器 */
|
||
get botManager() {
|
||
const entry = this.defaultEntry;
|
||
return entry ? entry.manager : null;
|
||
}
|
||
|
||
/** 兼容既有调用:默认机器人的控制器 */
|
||
get controller() {
|
||
const entry = this.defaultEntry;
|
||
return entry ? entry.controller : null;
|
||
}
|
||
|
||
/** 启动 HTTP + WebSocket 服务 */
|
||
start() {
|
||
this.server = http.createServer(this.app);
|
||
this.wss = new WebSocketServer({ noServer: true });
|
||
this.server.on('upgrade', (request, socket, head) => {
|
||
if (!this._authorized(request)) {
|
||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||
socket.destroy();
|
||
return;
|
||
}
|
||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||
this.wss.emit('connection', ws, request);
|
||
});
|
||
});
|
||
this.wss.on('connection', (ws) => {
|
||
this.clients.add(ws);
|
||
ws.on('close', () => this.clients.delete(ws));
|
||
ws.on('error', () => this.clients.delete(ws));
|
||
this._send(ws, { type: 'status', data: this.statusPayload() });
|
||
this._send(ws, { type: 'logs', data: this.logger.list() });
|
||
});
|
||
|
||
this._pushTimer = setInterval(() => {
|
||
if (this.clients.size === 0) return;
|
||
const payload = JSON.stringify({ type: 'status', data: this.statusPayload() });
|
||
for (const ws of this.clients) {
|
||
if (ws.readyState === 1) ws.send(payload);
|
||
}
|
||
}, STATUS_PUSH_INTERVAL_MS);
|
||
if (this._pushTimer.unref) this._pushTimer.unref();
|
||
|
||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||
if (this.expansionLog) this.expansionLog.onEntry = (entry) => this._broadcast('expansion', entry);
|
||
|
||
return new Promise((resolve) => {
|
||
this.server.listen(this.port, this.host, () => {
|
||
const address = this.server.address();
|
||
this.actualPort = address && typeof address === 'object' ? address.port : this.port;
|
||
resolve(this.url());
|
||
});
|
||
});
|
||
}
|
||
|
||
/** @returns {string} 控制台访问地址(带令牌,便于直接点击打开) */
|
||
url() {
|
||
const tokenSuffix = this.token ? `/?token=${encodeURIComponent(this.token)}` : '/';
|
||
return this.baseUrl() + tokenSuffix;
|
||
}
|
||
|
||
/** @returns {string} 控制台根地址(不含令牌),供 API 调用方拼接路径 */
|
||
baseUrl() {
|
||
return `http://${this.host}:${this.actualPort || this.port}`;
|
||
}
|
||
|
||
/** 停止服务 */
|
||
async stop() {
|
||
if (this._pushTimer) clearInterval(this._pushTimer);
|
||
this.logger.onEntry = null;
|
||
if (this.chatLog) this.chatLog.onEntry = null;
|
||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||
for (const ws of this.clients) {
|
||
try {
|
||
ws.close();
|
||
} catch {
|
||
// 忽略
|
||
}
|
||
}
|
||
this.clients.clear();
|
||
if (this.wss) await new Promise((resolve) => this.wss.close(resolve));
|
||
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
||
}
|
||
|
||
// ==================== 路由 ====================
|
||
|
||
_setupRoutes() {
|
||
const app = this.app;
|
||
app.disable('x-powered-by');
|
||
app.use(express.json({ limit: '8mb' }));
|
||
// 静态资源(控制台页面本身)不需要令牌:浏览器加载 index.html 时带的 ?token= 不会
|
||
// 附加到后续 style.css / app.js 的子请求上,若一并拦截会导致页面样式与脚本 404/401。
|
||
// 页面本身不含任何敏感信息,真正需要保护的是下面的 /api 接口。
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// ===== 免鉴权:登录与会话探测(否则用户无法登录)=====
|
||
app.post(LOGIN_PATH, (req, res) => {
|
||
const provided = String((req.body && req.body.password) || req.body && req.body.token || '');
|
||
if (!this.authRequired()) {
|
||
res.json({ ok: true, data: { authenticated: true, required: false, token: '' } });
|
||
return;
|
||
}
|
||
if (!this._matchesSecret(provided)) {
|
||
res.status(401).json({ ok: false, message: '密码错误' });
|
||
return;
|
||
}
|
||
res.json({ ok: true, data: { authenticated: true, required: true, token: provided } });
|
||
});
|
||
|
||
app.get(SESSION_PATH, (req, res) => {
|
||
res.json({
|
||
ok: true,
|
||
data: {
|
||
required: this.authRequired(),
|
||
authenticated: !this.authRequired() || this._authorized(req)
|
||
}
|
||
});
|
||
});
|
||
|
||
// ===== 其余 /api 一律要求凭证 =====
|
||
app.use((req, res, next) => {
|
||
if (!this._authorized(req)) {
|
||
res.status(401).json({ ok: false, message: '未授权:请先登录控制台或提供 X-Auth-Token 头' });
|
||
return;
|
||
}
|
||
next();
|
||
});
|
||
|
||
// -------- 全局状态 --------
|
||
app.get('/api/status', (req, res) => {
|
||
res.json({ ok: true, data: this.statusPayload() });
|
||
});
|
||
|
||
app.get('/api/config', (req, res) => {
|
||
res.json({ ok: true, data: this.getConfig() });
|
||
});
|
||
|
||
app.post('/api/config', (req, res) => {
|
||
try {
|
||
const merged = normalizeConfig(req.body, this.getConfig());
|
||
const applied = this.applyConfig(merged);
|
||
res.json({ ok: true, data: applied });
|
||
} catch (err) {
|
||
res.status(400).json({ ok: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/config/defaults', (req, res) => {
|
||
res.json({ ok: true, data: defaultConfig() });
|
||
});
|
||
|
||
app.get('/api/logs', (req, res) => {
|
||
res.json({ ok: true, data: this.logger.list() });
|
||
});
|
||
|
||
app.post('/api/logs/export', (req, res) => {
|
||
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
|
||
const controller = this.controller;
|
||
const result = controller ? controller.exportLogs(file) : null;
|
||
if (result) res.json({ ok: true, data: { file: result } });
|
||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||
});
|
||
|
||
// -------- 机器人池 --------
|
||
app.get('/api/bots', (req, res) => {
|
||
res.json({ ok: true, data: this.botsPayload() });
|
||
});
|
||
|
||
// 控制台新增玩家:密码只落到服务器本地凭据文件(0600),不会出现在任何响应里
|
||
app.post('/api/bots', (req, res) => {
|
||
if (typeof this.pool.addBot !== 'function') {
|
||
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台新增玩家' });
|
||
return;
|
||
}
|
||
try {
|
||
const body = req.body || {};
|
||
const entry = this.pool.addBot({
|
||
username: body.username,
|
||
password: body.password,
|
||
label: body.label,
|
||
auth: body.auth,
|
||
host: body.host,
|
||
port: body.port,
|
||
version: body.version
|
||
});
|
||
res.json({
|
||
ok: true,
|
||
message: `已添加玩家 ${entry.connection.username}`,
|
||
data: { id: entry.id, username: entry.connection.username, bots: this.botsPayload() }
|
||
});
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
app.delete('/api/bots/:id', (req, res) => {
|
||
if (typeof this.pool.removeBot !== 'function') {
|
||
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台移除玩家' });
|
||
return;
|
||
}
|
||
try {
|
||
const entry = this.pool.removeBot(req.params.id);
|
||
res.json({
|
||
ok: true,
|
||
message: `已移除玩家 ${entry.connection.username}`,
|
||
data: { bots: this.botsPayload() }
|
||
});
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
app.get('/api/bots/:id/status', (req, res) => {
|
||
this._withEntry(req, res, (entry) => ({
|
||
ok: true,
|
||
data: Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })
|
||
}));
|
||
});
|
||
|
||
app.get('/api/bots/:id/settings', (req, res) => {
|
||
this._withEntry(req, res, (entry) => ({
|
||
ok: true,
|
||
data: this._settingSnapshot(entry)
|
||
}));
|
||
});
|
||
|
||
app.post('/api/bots/:id/settings', (req, res) => {
|
||
this._withEntry(req, res, (entry) => {
|
||
const body = req.body || {};
|
||
const patch = {};
|
||
if (body.label !== undefined) patch.label = String(body.label);
|
||
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
||
if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true;
|
||
if (body.config !== undefined) patch.config = body.config;
|
||
const saved = this._setBotSetting(entry, patch);
|
||
res.json({ ok: true, data: saved });
|
||
return null;
|
||
});
|
||
});
|
||
|
||
app.post('/api/bots/:id/task/start', async (req, res) => {
|
||
try {
|
||
const entry = this.pool.require(req.params.id);
|
||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||
const result = await this._startTask(entry, body);
|
||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
app.post('/api/bots/:id/task/stop', (req, res) => {
|
||
this._withEntry(req, res, (entry) => {
|
||
this.pool.stopTask(entry.id);
|
||
res.json({ ok: true, data: this.statusPayload(), message: entry.connection.username + ' 已停止' });
|
||
return null;
|
||
});
|
||
});
|
||
|
||
app.post('/api/bots/:id/reconnect', (req, res) => {
|
||
this._withEntry(req, res, (entry) => {
|
||
this.pool.reconnect(entry.id);
|
||
res.json({ ok: true, message: entry.connection.username + ' 正在重连' });
|
||
return null;
|
||
});
|
||
});
|
||
|
||
// 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」
|
||
app.post('/api/bots/:id/move', async (req, res) => {
|
||
const body = req.body || {};
|
||
if (typeof this.pool.walkTest !== 'function') {
|
||
res.status(400).json({ ok: false, message: '当前运行模式不支持移动测试' });
|
||
return;
|
||
}
|
||
try {
|
||
const entry = this.pool.require(req.params.id);
|
||
const result = await this.pool.walkTest(entry.id, body.direction, body.seconds);
|
||
res.status(result.ok ? 200 : 409).json(result);
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||
app.post('/api/bots/:id/command', (req, res) => {
|
||
const command = req.body && req.body.command;
|
||
if (!command || typeof command !== 'string') {
|
||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||
return;
|
||
}
|
||
this._withEntry(req, res, (entry) => {
|
||
const result = this.pool.sendCommand(entry.id, command);
|
||
res.status(result.ok ? 200 : 409).json(result);
|
||
return null;
|
||
});
|
||
});
|
||
|
||
app.post('/api/bots/:id/chat', (req, res) => {
|
||
const message = req.body && req.body.message;
|
||
if (!message || typeof message !== 'string') {
|
||
res.status(400).json({ ok: false, message: '缺少 message 字段' });
|
||
return;
|
||
}
|
||
this._withEntry(req, res, (entry) => {
|
||
const result = this.pool.sendChat(entry.id, message);
|
||
res.status(result.ok ? 200 : 409).json(result);
|
||
return null;
|
||
});
|
||
});
|
||
|
||
app.get('/api/bots/:id/chat', (req, res) => {
|
||
if (!this.chatLog) {
|
||
res.json({ ok: true, data: [] });
|
||
return;
|
||
}
|
||
const query = req.query || {};
|
||
res.json({
|
||
ok: true,
|
||
data: this.chatLog.list(req.params.id, {
|
||
limit: toPositiveInt(query.limit, 300),
|
||
since: toOptionalInt(query.since),
|
||
kind: typeof query.kind === 'string' && query.kind ? query.kind : undefined,
|
||
keyword: typeof query.keyword === 'string' && query.keyword ? query.keyword : undefined
|
||
})
|
||
});
|
||
});
|
||
|
||
app.post('/api/bots/:id/chat/export', (req, res) => {
|
||
if (!this.chatLog) {
|
||
res.status(500).json({ ok: false, message: '未启用公屏日志' });
|
||
return;
|
||
}
|
||
const file = path.join(this.exportDir, `chat-${sanitizeFile(req.params.id)}-${Date.now()}.log`);
|
||
const result = this.chatLog.exportTo(req.params.id, file);
|
||
if (result) res.json({ ok: true, data: { file: result } });
|
||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||
});
|
||
|
||
// -------- 开拓日志 --------
|
||
app.get('/api/expansion-log', (req, res) => {
|
||
if (!this.expansionLog) {
|
||
res.json({ ok: true, data: { entries: [], stats: null } });
|
||
return;
|
||
}
|
||
const query = req.query || {};
|
||
res.json({
|
||
ok: true,
|
||
data: {
|
||
entries: this.expansionLog.list({
|
||
limit: toPositiveInt(query.limit, 200),
|
||
botId: typeof query.botId === 'string' && query.botId ? query.botId : undefined,
|
||
state: typeof query.state === 'string' && query.state ? query.state : undefined,
|
||
since: toOptionalInt(query.since)
|
||
}),
|
||
stats: this.expansionLog.stats()
|
||
}
|
||
});
|
||
});
|
||
|
||
app.post('/api/expansion-log/export', (req, res) => {
|
||
if (!this.expansionLog) {
|
||
res.status(500).json({ ok: false, message: '未启用开拓日志' });
|
||
return;
|
||
}
|
||
const body = req.body || {};
|
||
const file = path.join(this.exportDir, `expansion-log-${Date.now()}.csv`);
|
||
const result = this.expansionLog.exportCsv(file, {
|
||
botId: body.botId || undefined,
|
||
since: toOptionalInt(body.since)
|
||
});
|
||
if (result) res.json({ ok: true, data: { file: result } });
|
||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||
});
|
||
|
||
app.post('/api/expansion-log/clear', (req, res) => {
|
||
if (this.expansionLog) this.expansionLog.clearBuffer();
|
||
res.json({ ok: true, message: '已清空内存中的开拓日志(磁盘文件保留)' });
|
||
});
|
||
|
||
// -------- 大地图 --------
|
||
app.get('/api/bigmap', (req, res) => {
|
||
if (!this.bigMap) {
|
||
res.json({ ok: true, data: { enabled: false } });
|
||
return;
|
||
}
|
||
res.json({ ok: true, data: this.bigMapPayload() });
|
||
});
|
||
|
||
app.get('/api/bigmap/markers', (req, res) => {
|
||
res.json({ ok: true, data: this.bigMapMarkersPayload() });
|
||
});
|
||
|
||
app.get('/api/bigmap/tile/:z/:x/:y', async (req, res) => {
|
||
if (!this.bigMap) {
|
||
res.status(404).end();
|
||
return;
|
||
}
|
||
const zoom = Number.parseInt(req.params.z, 10);
|
||
const tileX = Number.parseInt(req.params.x, 10);
|
||
const tileZ = Number.parseInt(String(req.params.y).replace(/\.png$/i, ''), 10);
|
||
if (![zoom, tileX, tileZ].every(Number.isFinite)) {
|
||
res.status(400).json({ ok: false, message: '瓦片坐标非法' });
|
||
return;
|
||
}
|
||
try {
|
||
const data = await this.bigMap.getTile(zoom, tileX, tileZ);
|
||
if (!data) {
|
||
res.status(404).end();
|
||
return;
|
||
}
|
||
res.setHeader('Content-Type', 'image/png');
|
||
// 瓦片内容随服务器地图更新,但变更频率很低;缓存 10 分钟足以兼顾新鲜度与带宽
|
||
res.setHeader('Cache-Control', 'public, max-age=600');
|
||
res.end(data);
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
app.post('/api/bigmap/crawl', async (req, res) => {
|
||
if (!this.bigMap) {
|
||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||
return;
|
||
}
|
||
const body = req.body || {};
|
||
const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined;
|
||
// 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询
|
||
this.bigMap.crawlAll(zoom, { force: body.force === true }).catch((err) => {
|
||
this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error');
|
||
});
|
||
res.json({ ok: true, message: '已开始爬取地图瓦片', data: this.bigMap.snapshot() });
|
||
});
|
||
|
||
app.post('/api/bigmap/cache/clear', (req, res) => {
|
||
if (!this.bigMap) {
|
||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||
return;
|
||
}
|
||
const removed = this.bigMap.clearCache();
|
||
res.json({ ok: true, message: `已清空瓦片缓存(${removed} 个文件)` });
|
||
});
|
||
|
||
// -------- 地图数据(领土模型) --------
|
||
app.get('/api/map', (req, res) => {
|
||
try {
|
||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1', req.query.bot) });
|
||
} catch (err) {
|
||
res.status(500).json({ ok: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/map/refresh', (req, res) => {
|
||
this.dataService.refreshAsync();
|
||
res.json({ ok: true, message: '已触发地图刷新' });
|
||
});
|
||
|
||
app.post('/api/api/fetch-all', async (req, res) => {
|
||
const result = await this.dataService.fetchAllTerritoryOnce();
|
||
res.json({ ok: result.ok, message: result.message });
|
||
});
|
||
|
||
// -------- 单机器人兼容接口(等价于默认机器人的 /api/bots/:id/...)--------
|
||
app.post('/api/task/start', async (req, res) => {
|
||
try {
|
||
const entry = this.defaultEntry;
|
||
if (!entry) {
|
||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||
return;
|
||
}
|
||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||
const result = await this._startTask(entry, body);
|
||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
});
|
||
|
||
app.post('/api/task/stop', (req, res) => {
|
||
const entry = this.defaultEntry;
|
||
if (entry) this.pool.stopTask(entry.id);
|
||
res.json({ ok: true, data: this.statusPayload() });
|
||
});
|
||
|
||
app.post('/api/command', (req, res) => {
|
||
const command = req.body && req.body.command;
|
||
if (!command || typeof command !== 'string') {
|
||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||
return;
|
||
}
|
||
const entry = this.defaultEntry;
|
||
if (!entry) {
|
||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||
return;
|
||
}
|
||
const result = this.pool.sendCommand(entry.id, command);
|
||
res.status(result.ok ? 200 : 409).json(result);
|
||
});
|
||
|
||
app.post('/api/bot/reconnect', (req, res) => {
|
||
const entry = this.defaultEntry;
|
||
if (entry) this.pool.reconnect(entry.id);
|
||
res.json({ ok: true, message: '正在重连' });
|
||
});
|
||
}
|
||
|
||
// ==================== 路由辅助 ====================
|
||
|
||
/**
|
||
* 解析机器人条目并把 handler 的返回值作为 JSON 响应;抛错统一转成 HTTP 状态码。
|
||
* @param {import('express').Request} req
|
||
* @param {import('express').Response} res
|
||
* @param {(entry: object) => object|null} handler 返回 null 表示已自行响应
|
||
*/
|
||
_withEntry(req, res, handler) {
|
||
try {
|
||
const entry = this.pool.require(req.params.id);
|
||
const payload = handler(entry);
|
||
if (payload !== null && payload !== undefined) res.json(payload);
|
||
} catch (err) {
|
||
this._error(res, err);
|
||
}
|
||
}
|
||
|
||
_error(res, err) {
|
||
const status = err && err.statusCode ? err.statusCode : 500;
|
||
res.status(status).json({ ok: false, message: err && err.message ? err.message : String(err) });
|
||
}
|
||
|
||
/** 兼容不同形态的机器人池:取设置快照 */
|
||
_settingSnapshot(entry) {
|
||
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
||
return { id: entry.id, label: entry.manager.label, enabled: true, autoStart: false, config: entry.config };
|
||
}
|
||
|
||
/** 兼容不同形态的机器人池:保存设置 */
|
||
_setBotSetting(entry, patch) {
|
||
if (typeof this.pool.setBotSetting === 'function') return this.pool.setBotSetting(entry.id, patch);
|
||
return this._settingSnapshot(entry);
|
||
}
|
||
|
||
/** 兼容不同形态的机器人池:启动任务 */
|
||
async _startTask(entry, body) {
|
||
if (typeof this.pool.startTask === 'function') return this.pool.startTask(entry.id, body);
|
||
const config = body ? normalizeConfig(body, this.getConfig()) : this.getConfig();
|
||
entry.controller.config = config;
|
||
const ok = await entry.controller.start(config);
|
||
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
|
||
}
|
||
|
||
// ==================== 鉴权 ====================
|
||
|
||
/** @returns {boolean} 是否配置了访问凭证 */
|
||
authRequired() {
|
||
return !!(this.token || this.password);
|
||
}
|
||
|
||
/**
|
||
* 校验请求授权:支持 `X-Auth-Token` 头、`?token=` 查询参数与 Cookie。
|
||
* @param {import('http').IncomingMessage} req
|
||
* @returns {boolean}
|
||
*/
|
||
_authorized(req) {
|
||
if (!this.authRequired()) return true;
|
||
return this._matchesSecret(this._providedSecret(req));
|
||
}
|
||
|
||
/** @returns {boolean} 给定凭证是否匹配 token 或密码 */
|
||
_matchesSecret(provided) {
|
||
if (!provided) return false;
|
||
if (this.token && timingSafeEqual(provided, this.token)) return true;
|
||
if (this.password && timingSafeEqual(provided, this.password)) return true;
|
||
return false;
|
||
}
|
||
|
||
/** 从请求里提取凭证(头 → 查询参数 → Cookie) */
|
||
_providedSecret(req) {
|
||
const header = req.headers['x-auth-token'];
|
||
if (typeof header === 'string' && header) return header;
|
||
if (req.url) {
|
||
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
|
||
const params = new URLSearchParams(query);
|
||
const fromQuery = params.get('token');
|
||
if (fromQuery) return fromQuery;
|
||
}
|
||
const cookie = req.headers.cookie;
|
||
if (typeof cookie === 'string') {
|
||
const match = /(?:^|;\s*)autostore_token=([^;]*)/.exec(cookie);
|
||
if (match) return decodeURIComponent(match[1]);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ==================== 数据组装 ====================
|
||
|
||
/** @returns {object} 完整状态负载 */
|
||
statusPayload() {
|
||
const entry = this.defaultEntry;
|
||
const bots = this.pool.snapshot();
|
||
const primary = bots.length > 0 ? bots[0] : null;
|
||
return {
|
||
// 单机器人字段保留,避免既有前端 / 调用方失效
|
||
bot: primary || (entry ? entry.manager.snapshot() : null),
|
||
task: entry ? entry.controller.snapshot() : null,
|
||
data: this.dataService.snapshot(),
|
||
bots,
|
||
defaultBotId: this.pool.defaultId || '',
|
||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||
bigMap: this.bigMap ? this.bigMap.snapshot() : null,
|
||
serverTime: Date.now(),
|
||
claimTime: {
|
||
start: this.getConfig().claimStartHour,
|
||
end: this.getConfig().claimEndHour,
|
||
enforce: this.getConfig().enforceClaimTime
|
||
}
|
||
};
|
||
}
|
||
|
||
/** @returns {Array<object>} 机器人列表快照 */
|
||
botsPayload() {
|
||
return this.pool.snapshot();
|
||
}
|
||
|
||
/**
|
||
* 组装地图数据。
|
||
*
|
||
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
|
||
*
|
||
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
|
||
* @param {string} [botId] 以哪个机器人为视角(缺省取默认机器人)
|
||
* @returns {object}
|
||
*/
|
||
mapPayload(full, botId) {
|
||
const model = this.dataService.getModel();
|
||
const entry = this.pool.get(botId) || this.defaultEntry;
|
||
const controller = entry ? entry.controller : null;
|
||
const selfName = (controller && controller.stateName) || model.selfStateNameValue();
|
||
const self = model.state(selfName);
|
||
const result = {
|
||
selfState: selfName,
|
||
self: self ? flattenChunks(self.chunks) : [],
|
||
session: flattenChunks(model.sessionClaimsSet()),
|
||
flying: self ? flattenChunks(model.flyingChunksOf(selfName)) : [],
|
||
target: controller ? controller.target : null,
|
||
playerChunk: controller ? controller.playerChunk : null,
|
||
states: [],
|
||
full
|
||
};
|
||
for (const state of model.statesIter()) {
|
||
const bounds = boundsOf(state.chunks);
|
||
result.states.push({
|
||
name: state.name,
|
||
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
|
||
capital: state.capital,
|
||
count: state.chunks.size,
|
||
bounds
|
||
});
|
||
}
|
||
result.states.sort((a, b) => b.count - a.count);
|
||
if (full) {
|
||
result.otherChunks = [];
|
||
for (const state of model.statesIter()) {
|
||
if (state.name === selfName) continue;
|
||
const flat = flattenChunks(state.chunks);
|
||
result.otherChunks.push({ name: state.name, color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'), chunks: flat });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */
|
||
bigMapPayload() {
|
||
const snapshot = this.bigMap.snapshot();
|
||
return Object.assign(snapshot, {
|
||
bots: this.pool.positions(),
|
||
markers: this.bigMapMarkersPayload()
|
||
});
|
||
}
|
||
|
||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||
bigMapMarkersPayload() {
|
||
const model = this.dataService.getModel();
|
||
const states = [];
|
||
for (const state of model.statesIter()) {
|
||
states.push({
|
||
name: state.name,
|
||
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
|
||
capital: state.capital,
|
||
chunkCount: state.chunks.size,
|
||
polygons: state.polygons || []
|
||
});
|
||
}
|
||
states.sort((a, b) => b.chunkCount - a.chunkCount);
|
||
return {
|
||
world: this.bigMap ? this.bigMap.world : '',
|
||
updatedAt: model.updatedAt || 0,
|
||
states
|
||
};
|
||
}
|
||
|
||
_broadcast(type, data) {
|
||
if (this.clients.size === 0) return;
|
||
const payload = JSON.stringify({ type, data });
|
||
for (const ws of this.clients) {
|
||
if (ws.readyState === 1) ws.send(payload);
|
||
}
|
||
}
|
||
|
||
_send(ws, payload) {
|
||
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单机器人池适配器:把 `{botManager, controller}` 包装成与
|
||
* {@link module:bot/botPool.BotPool} 相同的接口,使 WebConsole 只有一条代码路径。
|
||
*
|
||
* 这是为了向后兼容既有的单机器人用法与测试,不承担多机器人职责。
|
||
*/
|
||
class SingleBotPool {
|
||
/**
|
||
* @param {import('../bot/botManager').BotManager} botManager
|
||
* @param {import('../controller/expansionController').ExpansionController} controller
|
||
*/
|
||
constructor(botManager, controller) {
|
||
const id = (botManager && botManager.id)
|
||
|| (botManager && botManager.connection && botManager.connection.username)
|
||
|| 'bot';
|
||
this.defaultId = id;
|
||
this.entries = new Map([[id, { id, manager: botManager, controller, config: null, connection: { username: id } }]]);
|
||
}
|
||
|
||
/** @param {string} [id] */
|
||
get(id) {
|
||
if (!id) return this.entries.get(this.defaultId) || null;
|
||
return this.entries.get(id) || null;
|
||
}
|
||
|
||
/** @param {string} id */
|
||
require(id) {
|
||
const entry = this.get(id);
|
||
if (!entry) {
|
||
const err = new Error('机器人不存在:' + id);
|
||
err.statusCode = 404;
|
||
throw err;
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/** @returns {Array<object>} */
|
||
snapshot() {
|
||
const entry = this.get();
|
||
if (!entry) return [];
|
||
return [Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })];
|
||
}
|
||
|
||
/** @returns {Array<object>} */
|
||
positions() {
|
||
const entry = this.get();
|
||
if (!entry) return [];
|
||
const snap = entry.manager.snapshot();
|
||
return [{
|
||
id: entry.id,
|
||
label: snap.label || entry.id,
|
||
username: snap.username,
|
||
state: snap.state,
|
||
loggedIn: snap.loggedIn,
|
||
position: snap.position,
|
||
chunk: snap.chunk,
|
||
stateName: entry.controller.stateName,
|
||
taskState: entry.controller.state,
|
||
completedClaims: entry.controller.completedClaims
|
||
}];
|
||
}
|
||
|
||
/** @param {string} id */
|
||
stopTask(id) {
|
||
const entry = this.require(id);
|
||
entry.controller.stop();
|
||
return entry;
|
||
}
|
||
|
||
/** @param {string} id */
|
||
reconnect(id) {
|
||
const entry = this.require(id);
|
||
entry.manager.disconnect();
|
||
setTimeout(() => entry.manager.connect(), 500).unref?.();
|
||
return entry;
|
||
}
|
||
|
||
/** @param {string} id @param {string} command */
|
||
sendCommand(id, command) {
|
||
const entry = this.require(id);
|
||
const text = String(command || '').replace(/^\//, '').trim();
|
||
if (!text) return { ok: false, message: '指令为空' };
|
||
if (!entry.manager.canSendCommand(text)) {
|
||
const manager = entry.manager;
|
||
if (manager.state !== 'online' || !manager.bot) {
|
||
return { ok: false, message: `当前未连接(状态:${manager.state}),指令未发送` };
|
||
}
|
||
return { ok: false, message: `未完成登录验证,已拒绝发送 /${text}` };
|
||
}
|
||
const ok = entry.manager.sendCommand(text);
|
||
return { ok, message: ok ? '已发送:' + command : '发送失败' };
|
||
}
|
||
|
||
/** @param {string} id @param {string} message */
|
||
sendChat(id, message) {
|
||
const entry = this.require(id);
|
||
const text = String(message || '').trim();
|
||
if (!text) return { ok: false, message: '消息为空' };
|
||
if (!entry.manager.canSendChat()) {
|
||
return { ok: false, message: '未完成登录验证,已拒绝发送公屏消息' };
|
||
}
|
||
const ok = entry.manager.sendChat(text);
|
||
return { ok, message: ok ? '已发送' : '发送失败' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
|
||
* @param {Set<number>} keys
|
||
* @returns {number[]}
|
||
*/
|
||
function flattenChunks(keys) {
|
||
const out = new Array(keys.size * 2);
|
||
let i = 0;
|
||
for (const k of keys) {
|
||
const pos = chunkPos.decode(k);
|
||
out[i++] = pos.x;
|
||
out[i++] = pos.z;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 计算区块集合的外接矩形。
|
||
* @param {Set<number>} keys
|
||
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number}|null}
|
||
*/
|
||
function boundsOf(keys) {
|
||
if (keys.size === 0) return null;
|
||
let minX = Infinity;
|
||
let maxX = -Infinity;
|
||
let minZ = Infinity;
|
||
let maxZ = -Infinity;
|
||
for (const k of keys) {
|
||
const pos = chunkPos.decode(k);
|
||
if (pos.x < minX) minX = pos.x;
|
||
if (pos.x > maxX) maxX = pos.x;
|
||
if (pos.z < minZ) minZ = pos.z;
|
||
if (pos.z > maxZ) maxZ = pos.z;
|
||
}
|
||
return { minX, maxX, minZ, maxZ };
|
||
}
|
||
|
||
/**
|
||
* 定时安全字符串比较,避免令牌比较的时序侧信道。
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {boolean}
|
||
*/
|
||
function timingSafeEqual(a, b) {
|
||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||
if (a.length !== b.length) return false;
|
||
let diff = 0;
|
||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||
return diff === 0;
|
||
}
|
||
|
||
/** 查询参数转正整数 */
|
||
function toPositiveInt(value, fallback) {
|
||
const n = Number.parseInt(value, 10);
|
||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||
}
|
||
|
||
/** 查询参数转可选整数(缺省返回 undefined,便于过滤函数跳过) */
|
||
function toOptionalInt(value) {
|
||
if (value === undefined || value === null || value === '') return undefined;
|
||
const n = Number.parseInt(value, 10);
|
||
return Number.isFinite(n) ? n : undefined;
|
||
}
|
||
|
||
/** 文件名安全化 */
|
||
function sanitizeFile(name) {
|
||
return String(name).replace(/[\\/:*?"<>|]/g, '_');
|
||
}
|
||
|
||
module.exports = {
|
||
WebConsole,
|
||
SingleBotPool,
|
||
flattenChunks,
|
||
boundsOf,
|
||
timingSafeEqual,
|
||
STATUS_PUSH_INTERVAL_MS,
|
||
LOGIN_PATH,
|
||
SESSION_PATH
|
||
};
|