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 语法自检通过。
这个提交包含在:
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户