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 语法自检通过。
这个提交包含在:
lyizai
2026-09-27 17:17:41 +08:00
当前提交 a12a24c716
共修改 44 个文件,包含 12225 行新增和 0 行删除
+384
查看文件
@@ -0,0 +1,384 @@
'use strict';
const { EventEmitter } = require('events');
const mineflayer = require('mineflayer');
const { GrimCompat } = require('./grimCompat');
const { PathingService } = require('./pathingService');
const { AutoEater } = require('./autoEat');
const { AutoBoatController } = require('./autoBoat');
const { DurabilityGuard } = require('./durabilityGuard');
/** 服务器要求登录 / 注册时的提示关键词 */
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
/**
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
*
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
* 使它们与 bot 实例的生命周期保持一致。
*/
class BotManager extends EventEmitter {
/**
* @param {object} connection 连接参数
* @param {string} connection.host
* @param {number} connection.port
* @param {string} connection.username
* @param {string} connection.version
* @param {string} [connection.auth]
* @param {string} [connection.password]
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
*/
constructor(connection, config, logger) {
super();
this.connection = connection;
this.config = config;
this.logger = logger || null;
/** @type {import('mineflayer').Bot|null} */
this.bot = null;
/** @type {GrimCompat|null} */
this.grim = null;
/** @type {PathingService|null} */
this.pathing = null;
/** @type {AutoEater|null} */
this.eater = null;
/** @type {AutoBoatController|null} */
this.boat = null;
/** @type {DurabilityGuard|null} */
this.guard = null;
/** 连接状态:idle | connecting | online | offline */
this.state = 'idle';
/** 是否已完成登录(离线服密码登录) */
this.loggedIn = false;
/** 断线重连计数 */
this.reconnectAttempts = 0;
/** 是否由用户主动断开(主动断开不重连) */
this._manualDisconnect = false;
this._reconnectTimer = null;
this._lastCommandAt = 0;
/** 待发送的指令队列(保证最小间隔) */
this._commandQueue = [];
this._commandTimer = null;
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
this.lastMessages = [];
}
// ==================== 连接生命周期 ====================
/** 建立连接(会自动重连) */
connect() {
if (this.bot) return;
this._manualDisconnect = false;
this.state = 'connecting';
const options = {
host: this.connection.host,
port: this.connection.port,
username: this.connection.username,
version: this.connection.version,
auth: this.connection.auth || 'offline',
// 关掉 mineflayer 自带的聊天模式匹配,避免无谓的解析开销
chatLengthLimit: 256
};
this._log(`正在连接 ${options.host}:${options.port}(${options.username} / ${options.version} / ${options.auth})`);
let bot;
try {
bot = mineflayer.createBot(options);
} catch (err) {
this.state = 'offline';
this._log('创建 bot 失败:' + (err && err.message ? err.message : String(err)));
this._scheduleReconnect();
return;
}
this.bot = bot;
this._wireEvents(bot);
}
/** 主动断开(不触发重连) */
disconnect() {
this._manualDisconnect = true;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this._commandQueue.length = 0;
if (this.bot) {
try {
this.bot.quit('AutoStore 主动断开');
} catch {
// 忽略
}
}
this.state = 'idle';
}
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
isReady() {
return this.state === 'online' && !!this.bot && !!this.bot.entity && !!this.bot.pathfinder;
}
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
isSpawned() {
return !!this.bot && !!this.bot.entity && this.state === 'online';
}
_scheduleReconnect() {
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
if (this._reconnectTimer) return;
this.reconnectAttempts++;
const delay = this.config.server.reconnectDelayMs;
this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this.bot = null;
this.connect();
}, delay);
if (this._reconnectTimer.unref) this._reconnectTimer.unref();
}
// ==================== 事件装配 ====================
_wireEvents(bot) {
bot.on('login', () => {
this.state = 'online';
this._log('已连接到服务器,正在进入世界');
this._handleLogin();
});
bot.on('spawn', () => {
this._log('已进入世界');
this.reconnectAttempts = 0;
this._installModules();
this.emit('spawn');
});
bot.on('end', (reason) => {
this.state = 'offline';
this.loggedIn = false;
this._log('连接已断开:' + (reason || '未知原因'));
this.emit('end', reason);
this._scheduleReconnect();
});
bot.on('kicked', (reason) => {
const text = typeof reason === 'string' ? reason : JSON.stringify(reason);
this._log('被服务器踢出:' + text, 'warn');
this.emit('kicked', text);
});
bot.on('error', (err) => {
this._log('连接错误:' + (err && err.message ? err.message : String(err)), 'error');
this.emit('botError', err);
});
bot.on('death', () => {
this._log('机器人死亡', 'warn');
this.emit('death');
});
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
bot.on('messagestr', (message) => {
if (!message) return;
this._pushMessage(message);
this._handleAuthPrompt(message);
this.emit('chat', message);
});
bot.on('title', (text, type) => {
if (!text) return;
if (type === 'subtitle') this.emit('subtitle', text);
else this.emit('title', text);
});
bot.on('actionBar', (message) => {
const text = message && message.toString ? message.toString() : String(message || '');
if (text) this.emit('subtitle', text);
});
bot.on('bossBarUpdated', (bar) => {
const text = bar && bar.title ? String(bar.title) : '';
if (text) this.emit('subtitle', text);
});
bot.on('bossBarCreated', (bar) => {
const text = bar && bar.title ? String(bar.title) : '';
if (text) this.emit('subtitle', text);
});
bot.on('forcedMove', () => {
this._log('被服务器传送(位置强制同步)');
this.emit('forcedMove');
});
}
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
_installModules() {
const bot = this.bot;
this.grim = new GrimCompat(bot, this.config, this.logger);
this.grim.install();
const audit = this.grim.audit();
if (!audit.ok) {
for (const issue of audit.issues) this._log('合规自检:' + issue, 'warn');
}
this.pathing = new PathingService(bot, this.config, this.logger);
this.pathing.configure();
this.eater = new AutoEater(bot, this.config, this.logger, this.grim);
this.boat = new AutoBoatController(bot, this.config, this.logger, this.grim);
this.guard = new DurabilityGuard(bot, this.config, this.logger);
this.emit('modulesReady');
}
/**
* 配置变更后重新应用到子模块(寻路器、合规层等)。
* @param {object} config
*/
applyConfig(config) {
this.config = config;
if (this.pathing) this.pathing.applyConfig(config);
if (this.grim) this.grim.config = config;
if (this.eater) this.eater.config = config;
if (this.boat) this.boat.config = config;
if (this.guard) this.guard.config = config;
}
// ==================== 登录 ====================
/** 进入世界后按配置尝试登录 / 注册 */
_handleLogin() {
const server = this.config.server;
const password = this.connection.password;
this.loggedIn = !server.loginCommand;
if (!server.loginCommand || !password) return;
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
setTimeout(() => {
if (!this.bot || this.state !== 'online') return;
this.sendCommand(fillTemplate(server.loginCommand, password));
}, 1500).unref?.();
}
/** 从聊天消息里识别服务器的登录 / 注册提示 */
_handleAuthPrompt(message) {
const server = this.config.server;
const password = this.connection.password;
if (!password) return;
if (LOGIN_OK_PATTERN.test(message)) {
if (!this.loggedIn) this._log('登录成功');
this.loggedIn = true;
return;
}
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
this.loggedIn = true;
return;
}
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
this._log('服务器要求注册,正在注册');
this.sendCommand(fillTemplate(server.registerCommand, password));
return;
}
if (server.loginCommand && LOGIN_PROMPT_PATTERN.test(message)) {
this.sendCommand(fillTemplate(server.loginCommand, password));
}
}
// ==================== 指令发送 ====================
/**
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
*
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
* @param {{immediate?: boolean}} [options]
*/
sendCommand(command, options) {
if (!command) return;
const opts = options || {};
if (opts.immediate) {
this._writeCommand(command);
return;
}
this._commandQueue.push(command);
this._drainCommandQueue();
}
_drainCommandQueue() {
if (this._commandTimer) return;
const now = Date.now();
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
this._commandTimer = setTimeout(() => {
this._commandTimer = null;
const next = this._commandQueue.shift();
if (next === undefined) return;
this._writeCommand(next);
if (this._commandQueue.length > 0) this._drainCommandQueue();
}, wait);
if (this._commandTimer.unref) this._commandTimer.unref();
}
_writeCommand(command) {
if (!this.bot) return;
this._lastCommandAt = Date.now();
const text = command.startsWith('/') ? command : '/' + command;
this._log('发送指令:' + text);
try {
this.bot.chat(text);
} catch (err) {
this._log('发送指令失败:' + (err && err.message ? err.message : String(err)));
}
}
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
snapshot() {
const bot = this.bot;
const entity = bot ? bot.entity : null;
return {
state: this.state,
loggedIn: this.loggedIn,
username: this.connection.username,
host: `${this.connection.host}:${this.connection.port}`,
version: this.connection.version,
reconnectAttempts: this.reconnectAttempts,
health: bot ? bot.health : null,
food: bot ? bot.food : null,
position: entity ? {
x: Number(entity.position.x.toFixed(2)),
y: Number(entity.position.y.toFixed(2)),
z: Number(entity.position.z.toFixed(2))
} : null,
chunk: entity ? {
x: Math.floor(entity.position.x / 16),
z: Math.floor(entity.position.z / 16)
} : null,
inWater: entity ? entity.isInWater === true : false,
riding: !!(bot && bot.vehicle),
pathing: this.pathing ? this.pathing.isPathing() : false,
eating: this.eater ? this.eater.eating : false,
grim: this.grim ? this.grim.snapshot() : null
};
}
_pushMessage(message) {
this.lastMessages.push({ time: Date.now(), message });
if (this.lastMessages.length > 200) this.lastMessages.shift();
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
}
}
/**
* 把指令模板里的 `{password}` 占位符替换为真实密码。
* @param {string} template
* @param {string} password
* @returns {string}
*/
function fillTemplate(template, password) {
return String(template).replace(/\{password\}/g, password);
}
module.exports = { BotManager, fillTemplate };