文件
BotStore/src/web/server.js
T
lyizai abecdd2648 fix: 修复实机运行才暴露的 8 个缺陷,新增控制台添加玩家与移动测试
首次真正连上线上服务器(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,全部通过。
2026-09-27 23:06:39 +08:00

980 行
34 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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
};