feat: 用 Node.js + mineflayer 重构 AutoStore,UI 改为网页控制台并适配 Grim 反作弊
由 Fabric 客户端模组 AutoStore v1.3.1 重构为独立 Node.js 机器人,无需游戏客户端。 核心实现: - 领土数据:Dynmap markers.json 拉取解析(奇偶填充识别国中飞地)、SimpCore 开放 API 第四数据源、 带缓存击穿的时间戳请求与内容未变化跳过解析 - 扩地规划:四种策略(方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈)逐行对照移植, 接壤判定只算己方领土,飞地优先填补 - 扩地状态机:规划→寻路→等待开垦→成功/失败→下一目标→完成,含超时重试、 往边上走两步自救、连续超时回城、耐久保护回城 - 生存辅助:自动进食、自动攻击(豁免中立生物)、自动游泳防溺水、自动用船(默认关闭) - 寻路:Baritone → mineflayer-pathfinder,高度偏好用包装 Goal 等价移植 Grim 反作弊合规层(新增,核心差异点): - 旋转不超前:接管 bot.look 按 ≤8.6°/tick 推进朝向,消除 mineflayer 「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧 - 攻击三重校验:朝向已同步 + 距离 ≤3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食 - 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合 - 物理参数只读自检 + 寻路约束(禁挖/禁放/禁跑酷/禁搭塔/限下落/绕开水域) 网页控制台(新增): - 状态面板、Canvas 领土地图(缩放/平移/图层)、分组配置表单、实时日志流 - REST + WebSocket 接口,支持 WEB_TOKEN 令牌鉴权 修复: - containsFuzzy 只取固定长度前缀的末行编辑距离,导致完全相同的子串也匹配不上 - 占领去重依赖 Set.add 返回值(JS 中恒为真),导致同一区块被重复计数 - 非法枚举值不再钳制到最近边界,改为回退默认值 测试:154 个用例,npm test 全绿;npm run check 语法自检通过。
这个提交包含在:
+367
@@ -0,0 +1,367 @@
|
||||
'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, copyConfig } = require('../config/expansionConfig');
|
||||
|
||||
/** 状态推送间隔(毫秒) */
|
||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
|
||||
*
|
||||
* 安全约定:
|
||||
* - 默认只监听 127.0.0.1;
|
||||
* - 配置 `WEB_TOKEN` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
|
||||
* - 令牌比较使用定时安全比较,避免时序侧信道。
|
||||
*/
|
||||
class WebConsole {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @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]
|
||||
*/
|
||||
constructor(deps, options) {
|
||||
const opts = options || {};
|
||||
this.botManager = deps.botManager;
|
||||
this.controller = deps.controller;
|
||||
this.dataService = deps.dataService;
|
||||
this.logger = deps.logger;
|
||||
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;
|
||||
this.token = opts.token || '';
|
||||
|
||||
this.app = express();
|
||||
this.server = null;
|
||||
this.wss = null;
|
||||
this._pushTimer = null;
|
||||
/** @type {Set<import('ws').WebSocket>} */
|
||||
this.clients = new Set();
|
||||
|
||||
this._setupRoutes();
|
||||
}
|
||||
|
||||
/** 启动 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) => {
|
||||
const payload = JSON.stringify({ type: 'log', data: entry });
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === 1) ws.send(payload);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
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: '1mb' }));
|
||||
app.use((req, res, next) => {
|
||||
if (!this._authorized(req)) {
|
||||
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
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 result = this.controller.exportLogs(file);
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
app.get('/api/map', (req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1') });
|
||||
} 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: '已触发地图刷新' });
|
||||
});
|
||||
|
||||
app.post('/api/api/fetch-all', async (req, res) => {
|
||||
const result = await this.dataService.fetchAllTerritoryOnce();
|
||||
res.json({ ok: result.ok, message: result.message });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
app.post('/api/bot/reconnect', (req, res) => {
|
||||
this.botManager.disconnect();
|
||||
setTimeout(() => this.botManager.connect(), 500);
|
||||
res.json({ ok: true, message: '正在重连' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求授权。
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_authorized(req) {
|
||||
if (!this.token) return true;
|
||||
let provided = '';
|
||||
const header = req.headers['x-auth-token'];
|
||||
if (typeof header === 'string') provided = header;
|
||||
if (!provided && req.url) {
|
||||
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
provided = params.get('token') || '';
|
||||
}
|
||||
return timingSafeEqual(provided, this.token);
|
||||
}
|
||||
|
||||
// ==================== 数据组装 ====================
|
||||
|
||||
/** @returns {object} 完整状态负载 */
|
||||
statusPayload() {
|
||||
return {
|
||||
bot: this.botManager.snapshot(),
|
||||
task: this.controller.snapshot(),
|
||||
data: this.dataService.snapshot(),
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
end: this.getConfig().claimEndHour,
|
||||
enforce: this.getConfig().enforceClaimTime
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装地图数据。
|
||||
*
|
||||
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
|
||||
*
|
||||
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
|
||||
* @returns {object}
|
||||
*/
|
||||
mapPayload(full) {
|
||||
const model = this.dataService.getModel();
|
||||
const selfName = model.selfStateNameValue();
|
||||
const self = model.selfState();
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
_send(ws, payload) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把区块键集合压平成 `[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;
|
||||
}
|
||||
|
||||
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };
|
||||
在新工单中引用
屏蔽一个用户