feat: 支持多机器人挂机、开拓日志、公屏完整日志与大地图

多机器人(src/bot/botPool.js、src/config/botsConfig.js)
- .env 用 MC_BOTS 一次配置多个账号(JSON 数组或 用户名:密码 逗号分隔),未配置时回退单机器人
- 每个机器人独立连接 / 登录 / 扩地状态机,共享一份领土数据服务与运行日志
- 密码只从 .env 读取;data/bots.json 只存启用状态、备注名、自动开始与各自的配置覆盖
- 每个机器人可覆盖全局配置(策略 / 目标邦国 / 目标数量等),未覆盖字段回退全局默认

开拓日志(src/core/expansionLog.js)
- 逐条记录开拓玩家、邦国、区块、方块坐标、时间与判定来源,追加写入 data/expansion-log.jsonl
- 支持按机器人 / 邦国 / 时间筛选、统计、导出带 BOM 的 CSV,超过 16MB 自动轮转

游戏公屏完整日志与聊天框(src/core/chatLog.js)
- 每个机器人的全部公屏 / 系统 / 动作栏消息按机器人分文件留档,自身发出的聊天与指令同样留档
- 控制台新增公屏聊天页签与消息发送框

登录验证门禁
- 游戏侧:未完成登录验证时拒绝发送指令 / 聊天,isReady() 也不放行扩地任务;
  新增 server.loginConfirmTimeoutMs 兜底,避免服务器不回提示导致永远卡在未登录
- 控制台侧:新增 WEB_PASSWORD 与 /api/login、/api/session,页面登录框验证通过才允许操作

大地图(src/core/bigMapService.js)
- 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,一张图上看全部机器人坐标
- 坐标系对源站实测标定并用 27 个王城交叉验证;瓦片磁盘缓存,层级 0 启动预爬取、更高层级按需穿透
- 领土按 markers.json 原始多边形绘制(预编译 Path2D,even-odd 保留孔洞),叠加王城与机器人标签

配套改造
- TerritoryModel 新增 flyingChunksOf / ownChunksOf,邦国归属改为按名称显式传入,
  不再依赖全局 selfStateName(多机器人会互相覆盖)
- Web 接口以 /api/bots/:id/... 为主,原单机器人接口保留为默认机器人的等价别名
- 新增 scripts/check-web-ids.js 校验前端元素引用一致性

测试从 154 增至 242,全部通过。
这个提交包含在:
lyizai
2026-09-27 21:22:57 +08:00
父节点 223cf719b6
当前提交 39624da2d5
共修改 30 个文件,包含 5480 行新增和 362 行删除
+1039 -127
查看文件
文件差异内容过多而无法显示 加载差异
+114 -15
查看文件
@@ -12,12 +12,17 @@
<span class="logo">AS</span>
<div>
<h1>AutoStore Bot</h1>
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
</div>
</div>
<div class="topbar-right">
<label class="inline-field">
当前机器人
<select id="botSelect" class="input input-sm"></select>
</label>
<span id="connBadge" class="badge badge-offline">未连接</span>
<span id="taskBadge" class="badge badge-idle">空闲</span>
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
@@ -27,6 +32,17 @@
<main class="layout">
<!-- ==================== 左栏:状态面板 ==================== -->
<section class="column col-left">
<div class="card">
<div class="card-head">
<h2>机器人(<span id="botCount">0</span>)</h2>
<div class="row">
<button id="btnStartAll" class="btn btn-sm">全部开始</button>
<button id="btnStopAll" class="btn btn-sm">全部停止</button>
</div>
</div>
<div id="botList" class="bot-list"></div>
</div>
<div class="card">
<h2>连接</h2>
<dl class="kv">
@@ -37,8 +53,14 @@
<dt>所在区块</dt><dd id="kChunk">-</dd>
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
<dt>状态</dt><dd id="kBotState">-</dd>
<dt>登录验证</dt><dd id="kLogin">-</dd>
<dt>重连次数</dt><dd id="kReconnect">0</dd>
</dl>
<div class="row">
<label class="chk"><input type="checkbox" id="chkEnabled"> 启用该机器人</label>
<label class="chk"><input type="checkbox" id="chkAutoStart"> 启动后自动扩地</label>
<button id="btnSaveBotSetting" class="btn btn-sm">保存</button>
</div>
</div>
<div class="card">
@@ -46,6 +68,7 @@
<dl class="kv">
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
<dt>开拓邦国</dt><dd id="kTaskStateName">-</dd>
<dt>当前目标</dt><dd id="kTarget">-</dd>
<dt>队列</dt><dd id="kQueue">0</dd>
<dt>已完成</dt><dd id="kCompleted">0</dd>
@@ -90,12 +113,22 @@
</div>
</section>
<!-- ==================== 中栏:地图 ==================== -->
<!-- ==================== 中栏:地图与日志 ==================== -->
<section class="column col-center">
<div class="card card-map">
<div class="card-head">
<h2>领土地图</h2>
<div class="row">
<div class="tabs tabs-inline" id="mapTabs">
<button class="tab active" data-view="big">大地图</button>
<button class="tab" data-view="territory">领土地图</button>
</div>
<div class="row" id="bigMapControls">
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 机器人名字</label>
<button id="btnMapFitWorld" class="btn btn-sm">全世界</button>
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
<button id="btnCrawlMap" class="btn btn-sm">爬取地图</button>
</div>
<div class="row hidden" id="territoryMapControls">
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
@@ -105,30 +138,65 @@
</div>
<canvas id="mapCanvas"></canvas>
<div class="map-legend">
<span><i class="sw sw-self"></i>己方领土</span>
<span><i class="sw sw-session"></i>本次占领</span>
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
<span><i class="sw sw-target"></i>当前目标</span>
<span><i class="sw sw-player"></i>机器人</span>
<span><i class="sw sw-capital"></i>王城</span>
<span id="legendBig">
<i class="sw sw-self"></i>己方领土
<i class="sw sw-session"></i>本次占领
<i class="sw sw-capital"></i>王城
<i class="sw sw-bot"></i>机器人
</span>
<span id="legendTerritory" class="hidden">
<i class="sw sw-self"></i>己方领土
<i class="sw sw-session"></i>本次占领
<i class="sw sw-flying"></i>飞地(疏漏)
<i class="sw sw-target"></i>当前目标
<i class="sw sw-player"></i>机器人
<i class="sw sw-capital"></i>王城
</span>
<span id="mapInfo" class="mono small"></span>
</div>
</div>
<div class="card card-log">
<div class="card-head">
<h2>运行日志</h2>
<div class="row">
<div class="tabs tabs-inline" id="logTabs">
<button class="tab active" data-pane="log">运行日志</button>
<button class="tab" data-pane="chat">公屏聊天</button>
<button class="tab" data-pane="exp">开拓日志</button>
</div>
<div class="row" id="logControls">
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
<button id="btnClearLog" class="btn btn-sm">清屏</button>
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
</div>
<div class="row hidden" id="chatControls">
<label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label>
<label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label>
<button id="btnClearChat" class="btn btn-sm">清屏</button>
<button id="btnExportChat" class="btn btn-sm">导出公屏日志</button>
</div>
<div class="row hidden" id="expControls">
<label class="chk">机器人
<select id="expFilterBot" class="input input-sm"><option value="">全部</option></select>
</label>
<label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label>
<button id="btnExportExp" class="btn btn-sm">导出 CSV</button>
<button id="btnClearExp" class="btn btn-sm">清屏</button>
</div>
</div>
<div id="logView" class="log-view"></div>
<div id="chatView" class="log-view hidden"></div>
<div id="expView" class="log-view hidden">
<table class="exp-table">
<thead>
<tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr>
</thead>
<tbody id="expBody"></tbody>
</table>
</div>
</div>
</section>
<!-- ==================== 右栏:配置 ==================== -->
<!-- ==================== 右栏:配置与控制 ==================== -->
<section class="column col-right">
<div class="card card-config">
<div class="card-head">
@@ -138,12 +206,17 @@
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
</div>
</div>
<label class="chk config-scope">
<input type="checkbox" id="chkBotOverride">
仅对当前机器人生效(不勾选则写入全局默认配置)
</label>
<div class="tabs" id="configTabs">
<button class="tab active" data-tab="strategy">策略</button>
<button class="tab" data-tab="timing">超时</button>
<button class="tab" data-tab="pathing">寻路</button>
<button class="tab" data-tab="survival">生存</button>
<button class="tab" data-tab="data">数据</button>
<button class="tab" data-tab="bigmap">大地图</button>
<button class="tab" data-tab="grim">Grim</button>
<button class="tab" data-tab="server">服务器</button>
</div>
@@ -151,9 +224,17 @@
</div>
<div class="card">
<h2>手动指令</h2>
<div class="card-head">
<h2>控制台 / 聊天框</h2>
<span class="mono small" id="consoleTarget">-</span>
</div>
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
<div class="row">
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
<select id="sendMode" class="input input-sm">
<option value="chat">公屏聊天</option>
<option value="command">游戏指令</option>
</select>
<input id="cmdInput" class="input" type="text" placeholder="输入内容后回车发送">
<button id="btnSendCmd" class="btn btn-sm">发送</button>
</div>
<div class="row">
@@ -162,10 +243,28 @@
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
<div class="row">
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
</div>
</div>
</section>
</main>
<!-- 登录遮罩:控制台配置了 WEB_TOKEN / WEB_PASSWORD 时显示 -->
<div id="loginOverlay" class="overlay hidden">
<div class="overlay-box">
<h2>控制台登录</h2>
<p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p>
<input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password">
<div class="row">
<button id="btnLogin" class="btn btn-primary">登录</button>
<button id="btnLogout" class="btn btn-sm">退出登录</button>
</div>
<p id="loginError" class="hint error-text"></p>
</div>
</div>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
+76 -3
查看文件
@@ -85,7 +85,7 @@ html, body {
/* ==================== 布局 ==================== */
.layout {
display: grid;
grid-template-columns: 300px minmax(420px, 1fr) 360px;
grid-template-columns: 320px minmax(460px, 1fr) 380px;
gap: 12px;
padding: 12px;
align-items: start;
@@ -140,7 +140,7 @@ html, body {
.card-map { display: flex; flex-direction: column; }
#mapCanvas {
width: 100%;
height: 380px;
height: 440px;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
@@ -163,13 +163,86 @@ html, body {
.sw-target { background: #d29922; }
.sw-player { background: #ffffff; }
.sw-capital { background: #bc8cff; }
.sw-bot { background: #ffd33d; }
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
.hidden { display: none !important; }
.inline-field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.input-sm { width: auto; min-width: 110px; padding: 4px 8px; font-size: 12px; }
.tabs-inline { margin-bottom: 0; }
.error-text { color: var(--red); }
/* ==================== 机器人列表 ==================== */
.bot-list { display: flex; flex-direction: column; gap: 6px; }
.bot-item {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 8px; align-items: center;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
cursor: pointer;
}
.bot-item:hover { border-color: #48505e; }
.bot-item.active { border-color: var(--accent-2); box-shadow: 0 0 0 1px rgba(47,129,247,.35); }
.bot-item .dot {
width: 9px; height: 9px; border-radius: 50%; background: var(--muted);
}
.bot-item .dot.online { background: var(--green); }
.bot-item .dot.connecting { background: var(--yellow); }
.bot-item .dot.offline { background: var(--red); }
.bot-item .dot.disabled { background: #484f58; }
.bot-item .name { font-size: 13px; }
.bot-item .sub { font-size: 11px; color: var(--muted); }
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
/* ==================== 公屏聊天 ==================== */
.chat-line { display: block; }
.chat-time { color: #586069; margin-right: 6px; }
.chat-bot { color: var(--accent-2); margin-right: 4px; }
.chat-user { color: var(--purple); margin-right: 4px; }
.chat-out { color: var(--green); }
.chat-command { color: var(--yellow); }
.chat-system { color: var(--muted); }
.chat-action { color: #6e7681; }
/* ==================== 开拓日志 ==================== */
#expView { white-space: normal; padding: 0; }
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.exp-table th {
position: sticky; top: 0;
text-align: left; color: var(--muted); font-weight: 500;
background: #05070a; padding: 4px 6px;
border-bottom: 1px solid var(--border);
}
.exp-table td { padding: 3px 6px; border-bottom: 1px solid #12161d; white-space: nowrap; }
.exp-table tbody tr:hover { background: #0e131a; }
.exp-empty { color: var(--muted); }
.config-scope { margin-bottom: 8px; }
/* ==================== 登录遮罩 ==================== */
.overlay {
position: fixed; inset: 0; z-index: 200;
display: grid; place-items: center;
background: rgba(5, 7, 10, .78);
backdrop-filter: blur(2px);
}
.overlay-box {
width: min(360px, 92vw);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
}
.overlay-box h2 { font-size: 15px; margin: 0 0 6px; }
.overlay-box .input { margin: 10px 0; }
/* ==================== 日志 ==================== */
.card-log { display: flex; flex-direction: column; }
.log-view {
height: 260px;
height: 300px;
overflow-y: auto;
background: #05070a;
border: 1px solid var(--border);
+590 -55
查看文件
@@ -7,42 +7,60 @@ const express = require('express');
const { WebSocketServer } = require('ws');
const chunkPos = require('../core/chunkPos');
const { defaultConfig, normalizeConfig, copyConfig } = require('../config/expansionConfig');
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` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
* - 配置 `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/botManager').BotManager} deps.botManager
* @param {import('../controller/expansionController').ExpansionController} deps.controller
* @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 {{host?: string, port?: number, token?: string, exportDir?: string}} [options]
* @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.botManager = deps.botManager;
this.controller = deps.controller;
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;
@@ -54,6 +72,23 @@ class WebConsole {
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);
@@ -85,13 +120,10 @@ class WebConsole {
}, STATUS_PUSH_INTERVAL_MS);
if (this._pushTimer.unref) this._pushTimer.unref();
// 新日志实时推送
this.logger.onEntry = (entry) => {
const payload = JSON.stringify({ type: 'log', data: entry });
for (const ws of this.clients) {
if (ws.readyState === 1) ws.send(payload);
}
};
// 新日志 / 新公屏消息 / 新开拓记录实时推送
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, () => {
@@ -117,6 +149,8 @@ class WebConsole {
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();
@@ -134,19 +168,46 @@ class WebConsole {
_setupRoutes() {
const app = this.app;
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
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: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
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() });
});
@@ -175,36 +236,240 @@ class WebConsole {
app.post('/api/logs/export', (req, res) => {
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
const result = this.controller.exportLogs(file);
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() });
});
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/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') });
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/task/start', async (req, res) => {
try {
const config = req.body && Object.keys(req.body).length > 0
? normalizeConfig(req.body, this.getConfig())
: this.getConfig();
const ok = await this.controller.start(config);
res.json({ ok, data: this.statusPayload(), message: ok ? '任务已启动' : this.controller.status });
} catch (err) {
res.status(500).json({ ok: false, message: err.message });
}
});
app.post('/api/task/stop', (req, res) => {
this.controller.stop();
res.json({ ok: true, data: this.statusPayload() });
});
app.post('/api/map/refresh', (req, res) => {
this.dataService.refreshAsync();
res.json({ ok: true, message: '已触发地图刷新' });
@@ -215,49 +480,154 @@ class WebConsole {
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;
}
this.botManager.sendCommand(command.replace(/^\//, ''));
res.json({ ok: true, message: '已发送:' + command });
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) => {
this.botManager.disconnect();
setTimeout(() => this.botManager.connect(), 500);
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.token) return true;
let provided = '';
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') provided = header;
if (!provided && req.url) {
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);
provided = params.get('token') || '';
const fromQuery = params.get('token');
if (fromQuery) return fromQuery;
}
return timingSafeEqual(provided, this.token);
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: this.botManager.snapshot(),
task: this.controller.snapshot(),
// 单机器人字段保留,避免既有前端 / 调用方失效
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,
@@ -267,25 +637,33 @@ class WebConsole {
};
}
/** @returns {Array<object>} 机器人列表快照 */
botsPayload() {
return this.pool.snapshot();
}
/**
* 组装地图数据。
*
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
*
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
* @param {string} [botId] 以哪个机器人为视角(缺省取默认机器人)
* @returns {object}
*/
mapPayload(full) {
mapPayload(full, botId) {
const model = this.dataService.getModel();
const selfName = model.selfStateNameValue();
const self = model.selfState();
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: flattenChunks(model.selfFlyingChunks()),
target: this.controller.target,
playerChunk: this.controller.playerChunk,
flying: self ? flattenChunks(model.flyingChunksOf(selfName)) : [],
target: controller ? controller.target : null,
playerChunk: controller ? controller.playerChunk : null,
states: [],
full
};
@@ -311,11 +689,141 @@ class WebConsole {
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 ok = entry.manager.sendCommand(String(command).replace(/^\//, ''));
return { ok, message: ok ? '已发送:' + command : '发送失败(未完成登录验证)' };
}
/** @param {string} id @param {string} message */
sendChat(id, message) {
const entry = this.require(id);
const ok = entry.manager.sendChat(String(message || '').trim());
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' };
}
}
/**
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
* @param {Set<number>} keys
@@ -367,4 +875,31 @@ function timingSafeEqual(a, b) {
return diff === 0;
}
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };
/** 查询参数转正整数 */
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
};