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 语法自检通过。
这个提交包含在:
+471
@@ -0,0 +1,471 @@
|
||||
'use strict';
|
||||
|
||||
const { Vec3 } = require('vec3');
|
||||
|
||||
/**
|
||||
* 自动用船过水(mineflayer 版)。
|
||||
*
|
||||
* 流程:扫描前方开阔水域 → 装备船 → 放船 → 上船 → 朝目标航行 → 靠岸下船 → 打掉船回收。
|
||||
*
|
||||
* ⚠️ 与原 Java 版的重要差异(务必阅读):
|
||||
* mineflayer 使用的 prismarine-physics **没有实现载具物理**。上船后
|
||||
* mineflayer 会关闭本地物理预测(`bot.on('mount') → shouldUsePhysics = false`),
|
||||
* 机器人位置完全由服务端下发的船实体位置决定,只能靠 `bot.moveVehicle()`
|
||||
* (1.21+ 走 `player_input` 包)驱动船前进。
|
||||
*
|
||||
* 这意味着:
|
||||
* 1. 无法在本地预测船的轨迹,遇到激流 / 冰面 / 碰撞时容易「贴墙空转」;
|
||||
* 2. Grim 的 Vehicle 类检查会逐 tick 复算载具运动,一旦本地与服务器出现分歧就会被标记;
|
||||
* 3. 因此本模块**默认关闭**(`config.autoBoat=false`、`config.waterMode='avoid'`),
|
||||
* 只有在你实测确认该服务器对船宽容时才建议开启。
|
||||
*
|
||||
* 用船期间所有移动由本模块接管,寻路器必须处于停止状态。
|
||||
*/
|
||||
|
||||
/** 用船流程状态机 */
|
||||
const BoatState = {
|
||||
IDLE: 'IDLE',
|
||||
EQUIP: 'EQUIP',
|
||||
PLACE: 'PLACE',
|
||||
MOUNT: 'MOUNT',
|
||||
SAIL: 'SAIL',
|
||||
DISMOUNT: 'DISMOUNT',
|
||||
COLLECT: 'COLLECT'
|
||||
};
|
||||
|
||||
/** 前方水域扫描的最大距离(格) */
|
||||
const MAX_SCAN_DISTANCE = 48;
|
||||
/** 单个状态的最长持续时间(毫秒),超时后放弃流程并复位 */
|
||||
const STATE_TIMEOUT_MS = 15000;
|
||||
|
||||
class AutoBoatController {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {import('./grimCompat').GrimCompat} [grim]
|
||||
*/
|
||||
constructor(bot, config, logger, grim) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this.grim = grim || null;
|
||||
this.state = BoatState.IDLE;
|
||||
this._stateStartedAt = 0;
|
||||
/** 保存的主手物品名(流程结束后换回) */
|
||||
this._previousHeldName = null;
|
||||
/** 本次流程放置的船实体 */
|
||||
this.boat = null;
|
||||
/** 当前目标区块 */
|
||||
this._target = null;
|
||||
/** 最近一次扫描到的水域信息(供 Web 控制台展示) */
|
||||
this.lastScan = null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否处于用船流程中 */
|
||||
isActive() {
|
||||
return this.state !== BoatState.IDLE;
|
||||
}
|
||||
|
||||
/** @returns {string|null} 状态描述(供状态栏展示) */
|
||||
describe() {
|
||||
switch (this.state) {
|
||||
case BoatState.EQUIP: return '自动用船:装备船只';
|
||||
case BoatState.PLACE: return '自动用船:放下船只';
|
||||
case BoatState.MOUNT: return '自动用船:登船';
|
||||
case BoatState.SAIL: return '自动用船:航行中';
|
||||
case BoatState.DISMOUNT: return '自动用船:靠岸下船';
|
||||
case BoatState.COLLECT: return '自动用船:回收船只';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每帧驱动。
|
||||
*
|
||||
* @param {{running: boolean, canStart: boolean, target: {x: number, z: number}|null}} context
|
||||
*/
|
||||
tick(context) {
|
||||
const ctx = context || {};
|
||||
this._target = ctx.target || null;
|
||||
if (!this.config.autoBoat || this.config.waterMode !== 'boat' || !ctx.running) {
|
||||
if (this.isActive()) this.reset(true);
|
||||
return;
|
||||
}
|
||||
if (this.state === BoatState.IDLE) {
|
||||
if (!ctx.canStart || !this._target) return;
|
||||
if (!this._shouldUseBoat()) return;
|
||||
this._transition(BoatState.EQUIP);
|
||||
return;
|
||||
}
|
||||
// 状态超时保护:任何一步卡住超过 15 秒都直接放弃并复位,避免卡死扩地流程
|
||||
if (Date.now() - this._stateStartedAt > STATE_TIMEOUT_MS) {
|
||||
this._log('自动用船超时,放弃本次流程');
|
||||
this.reset(true);
|
||||
return;
|
||||
}
|
||||
switch (this.state) {
|
||||
case BoatState.EQUIP: this._tickEquip(); break;
|
||||
case BoatState.PLACE: this._tickPlace(); break;
|
||||
case BoatState.MOUNT: this._tickMount(); break;
|
||||
case BoatState.SAIL: this._tickSail(); break;
|
||||
case BoatState.DISMOUNT: this._tickDismount(); break;
|
||||
case BoatState.COLLECT: this._tickCollect(); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复位用船流程:松开方向键、下船、换回原主手物品。
|
||||
* @param {boolean} [releaseItem] 是否换回流程前的主手物品
|
||||
*/
|
||||
reset(releaseItem) {
|
||||
if (this.bot.vehicle) {
|
||||
try {
|
||||
this.bot.dismount();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
if (this.grim) {
|
||||
this.grim.setControlState('forward', false);
|
||||
this.grim.setControlState('sprint', false);
|
||||
}
|
||||
if (releaseItem && this._previousHeldName) {
|
||||
const item = this._findItemByName(this._previousHeldName);
|
||||
if (item) {
|
||||
this.bot.equip(item, 'hand').catch(() => {});
|
||||
}
|
||||
}
|
||||
this.state = BoatState.IDLE;
|
||||
this._stateStartedAt = 0;
|
||||
this.boat = null;
|
||||
this._previousHeldName = null;
|
||||
}
|
||||
|
||||
// ==================== 各状态 ====================
|
||||
|
||||
_tickEquip() {
|
||||
const boatItem = this._findBoatItem();
|
||||
if (!boatItem) {
|
||||
this._log('背包里没有船,放弃自动用船');
|
||||
this.reset(false);
|
||||
return;
|
||||
}
|
||||
this._previousHeldName = this.bot.heldItem ? this.bot.heldItem.name : null;
|
||||
this.bot.equip(boatItem, 'hand')
|
||||
.then(() => this._transition(BoatState.PLACE))
|
||||
.catch((err) => {
|
||||
this._log('装备船只失败:' + (err && err.message ? err.message : String(err)));
|
||||
this.reset(false);
|
||||
});
|
||||
}
|
||||
|
||||
_tickPlace() {
|
||||
const bot = this.bot;
|
||||
const pos = bot.entity.position;
|
||||
// 找脚下前方的水面方块作为放置参考(placeEntity 需要一个参考方块 + 面偏移)
|
||||
const dir = this._directionToTarget();
|
||||
const refPos = new Vec3(
|
||||
Math.floor(pos.x + dir.x),
|
||||
Math.floor(pos.y - 1),
|
||||
Math.floor(pos.z + dir.z)
|
||||
);
|
||||
const refBlock = bot.blockAt(refPos);
|
||||
if (!refBlock || !isWaterName(refBlock.name)) {
|
||||
// 参考方块不是水面:退一步用玩家脚下的水
|
||||
const below = bot.blockAt(new Vec3(Math.floor(pos.x), Math.floor(pos.y - 1), Math.floor(pos.z)));
|
||||
if (!below || !isWaterName(below.name)) {
|
||||
this._log('前方没有可放船的水面,放弃自动用船');
|
||||
this.reset(true);
|
||||
return;
|
||||
}
|
||||
this._placeAgainst(below);
|
||||
return;
|
||||
}
|
||||
this._placeAgainst(refBlock);
|
||||
}
|
||||
|
||||
_placeAgainst(refBlock) {
|
||||
const bot = this.bot;
|
||||
bot.placeEntity(refBlock, new Vec3(0, 1, 0))
|
||||
.then((entity) => {
|
||||
this.boat = entity;
|
||||
this._log('已放下船只,准备登船');
|
||||
this._transition(BoatState.MOUNT);
|
||||
})
|
||||
.catch((err) => {
|
||||
this._log('放船失败:' + (err && err.message ? err.message : String(err)));
|
||||
this.reset(true);
|
||||
});
|
||||
}
|
||||
|
||||
_tickMount() {
|
||||
const bot = this.bot;
|
||||
if (bot.vehicle) {
|
||||
this._transition(BoatState.SAIL);
|
||||
return;
|
||||
}
|
||||
const boat = this.boat || this._findNearestBoatEntity();
|
||||
if (!boat) {
|
||||
this._log('找不到已放下的船,放弃自动用船');
|
||||
this.reset(true);
|
||||
return;
|
||||
}
|
||||
this.boat = boat;
|
||||
// 登船前先对准船(原版右键需要朝向目标),并等旋转同步
|
||||
const look = this.grim
|
||||
? this.grim.lookAtEntity(boat)
|
||||
: bot.lookAt(boat.position.offset(0, 0.5, 0));
|
||||
Promise.resolve(look).then(() => {
|
||||
try {
|
||||
bot.mount(boat);
|
||||
} catch (err) {
|
||||
this._log('登船失败:' + (err && err.message ? err.message : String(err)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_tickSail() {
|
||||
const bot = this.bot;
|
||||
const vehicle = bot.vehicle;
|
||||
if (!vehicle) {
|
||||
// 船没了(被打掉 / 沉了):结束流程
|
||||
this._transition(BoatState.DISMOUNT);
|
||||
return;
|
||||
}
|
||||
// 载具位置由服务端下发(mineflayer 无载具物理),因此用船实体的位置判断靠岸
|
||||
const boatPos = vehicle.position;
|
||||
const feetBlock = bot.blockAt(new Vec3(Math.floor(boatPos.x), Math.floor(boatPos.y - 0.5), Math.floor(boatPos.z)));
|
||||
const stillOnWater = feetBlock && isWaterName(feetBlock.name);
|
||||
const target = this._target;
|
||||
const arrived = target
|
||||
&& Math.floor(boatPos.x / 16) === target.x
|
||||
&& Math.floor(boatPos.z / 16) === target.z;
|
||||
if (!stillOnWater || arrived) {
|
||||
this._log(arrived ? '已航行到目标区块,准备下船' : '已靠岸,准备下船');
|
||||
this._transition(BoatState.DISMOUNT);
|
||||
return;
|
||||
}
|
||||
// 朝目标方向持续前进
|
||||
const dir = this._directionToTarget();
|
||||
const yaw = Math.atan2(-dir.x, -dir.z);
|
||||
if (this.grim) {
|
||||
this.grim.setLook(yaw, 0);
|
||||
} else {
|
||||
bot.look(yaw, 0, true);
|
||||
}
|
||||
try {
|
||||
// forward=1 前进;左右不修正,靠朝向对准目标
|
||||
bot.moveVehicle(0, 1);
|
||||
} catch {
|
||||
// 忽略:老版本协议没有 player_input 包时由 mineflayer 内部处理
|
||||
}
|
||||
}
|
||||
|
||||
_tickDismount() {
|
||||
const bot = this.bot;
|
||||
if (!bot.vehicle) {
|
||||
this._transition(BoatState.COLLECT);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bot.dismount();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
if (this.grim) {
|
||||
this.grim.setControlState('forward', false);
|
||||
this.grim.setControlState('sprint', false);
|
||||
}
|
||||
this._transition(BoatState.COLLECT);
|
||||
}
|
||||
|
||||
_tickCollect() {
|
||||
const bot = this.bot;
|
||||
const boat = this.boat && this.boat.isValid !== false ? this.boat : this._findNearestBoatEntity();
|
||||
if (!boat) {
|
||||
this._log('自动用船流程结束');
|
||||
this.reset(true);
|
||||
return;
|
||||
}
|
||||
const distance = bot.entity.position.distanceTo(boat.position);
|
||||
if (distance > 4) {
|
||||
// 船太远,直接放弃回收(避免为了捡船跑回去)
|
||||
this._log('船离得太远,放弃回收');
|
||||
this.reset(true);
|
||||
return;
|
||||
}
|
||||
const attack = this.grim
|
||||
? this.grim.attackEntity(boat, { skipRotation: false })
|
||||
: Promise.resolve(bot.attack(boat));
|
||||
Promise.resolve(attack).then((ok) => {
|
||||
if (ok !== false) {
|
||||
this._log('自动用船流程结束,已回收船只');
|
||||
this.reset(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 判定与工具 ====================
|
||||
|
||||
/**
|
||||
* 判断是否应该启用用船流程:目标方向前方存在足够开阔的水域。
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldUseBoat() {
|
||||
if (!this._findBoatItem()) return false;
|
||||
const dir = this._directionToTarget();
|
||||
const scan = scanWaterAhead(
|
||||
this.bot,
|
||||
dir.x,
|
||||
dir.z,
|
||||
this.config.autoBoatMinLength,
|
||||
this.config.autoBoatMinWidth
|
||||
);
|
||||
this.lastScan = scan;
|
||||
return scan.ok;
|
||||
}
|
||||
|
||||
/** @returns {{x: number, z: number}} 从当前位置指向目标区块中心的水平单位方向 */
|
||||
_directionToTarget() {
|
||||
const bot = this.bot;
|
||||
const pos = bot.entity.position;
|
||||
if (!this._target) return { x: 0, z: 1 };
|
||||
const dx = this._target.x * 16 + 8 - pos.x;
|
||||
const dz = this._target.z * 16 + 8 - pos.z;
|
||||
const length = Math.sqrt(dx * dx + dz * dz);
|
||||
if (length < 1e-6) return { x: 0, z: 1 };
|
||||
return { x: dx / length, z: dz / length };
|
||||
}
|
||||
|
||||
/** @returns {object|null} 背包中的第一个船类物品 */
|
||||
_findBoatItem() {
|
||||
return this._findItemByName(null, /_boat$/);
|
||||
}
|
||||
|
||||
/** @returns {object|null} 指定名字的物品 */
|
||||
_findItemByName(name, pattern) {
|
||||
const slots = this.bot.inventory ? this.bot.inventory.slots : [];
|
||||
for (const item of slots) {
|
||||
if (!item || !item.name) continue;
|
||||
if (name && item.name === name) return item;
|
||||
if (pattern && pattern.test(item.name)) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @returns {object|null} 最近的船实体 */
|
||||
_findNearestBoatEntity() {
|
||||
const bot = this.bot;
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const id of Object.keys(bot.entities || {})) {
|
||||
const entity = bot.entities[id];
|
||||
if (!entity || !entity.name || !entity.name.includes('boat')) continue;
|
||||
const distance = bot.entity.position.distanceTo(entity.position);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = entity;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
_transition(next) {
|
||||
this.state = next;
|
||||
this._stateStartedAt = Date.now();
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 沿指定方向扫描前方水域,判断是否值得用船。
|
||||
*
|
||||
* 判定条件(与原 Java 版语义一致):
|
||||
* - 前进方向上连续水方块长度 ≥ minLength(比宽度更稳健,防止横跨窄水道时在岸上提前登船)
|
||||
* - 水域中间处的垂直方向连续水方块宽度 ≥ minWidth(避免小水洼 / 小溪浪费船只)
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {number} dirX 水平方向单位向量 X
|
||||
* @param {number} dirZ 水平方向单位向量 Z
|
||||
* @param {number} minLength 最短水域长度(格)
|
||||
* @param {number} minWidth 最小水面宽度(格)
|
||||
* @returns {{ok: boolean, length: number, width: number, reason: string}}
|
||||
*/
|
||||
function scanWaterAhead(bot, dirX, dirZ, minLength, minWidth) {
|
||||
const pos = bot.entity.position;
|
||||
const startX = Math.floor(pos.x);
|
||||
const startY = Math.floor(pos.y);
|
||||
const startZ = Math.floor(pos.z);
|
||||
let length = 0;
|
||||
let started = false;
|
||||
let midX = startX;
|
||||
let midZ = startZ;
|
||||
for (let step = 1; step <= MAX_SCAN_DISTANCE; step++) {
|
||||
const x = Math.floor(pos.x + dirX * step);
|
||||
const z = Math.floor(pos.z + dirZ * step);
|
||||
if (isWaterAt(bot, x, startY, z)) {
|
||||
length++;
|
||||
started = true;
|
||||
midX = x;
|
||||
midZ = z;
|
||||
} else if (started) {
|
||||
break;
|
||||
}
|
||||
// 还没入水:继续往前找(可能隔着一小段岸)
|
||||
}
|
||||
if (length < minLength) {
|
||||
return { ok: false, length, width: 0, reason: `前方水域长度 ${length} < ${minLength}` };
|
||||
}
|
||||
// 在水域中段测垂直方向宽度
|
||||
const perpX = -dirZ;
|
||||
const perpZ = dirX;
|
||||
let width = 1;
|
||||
for (const sign of [1, -1]) {
|
||||
for (let step = 1; step <= 32; step++) {
|
||||
const x = Math.floor(midX + perpX * sign * step);
|
||||
const z = Math.floor(midZ + perpZ * sign * step);
|
||||
if (!isWaterAt(bot, x, startY, z)) break;
|
||||
width++;
|
||||
}
|
||||
}
|
||||
if (width < minWidth) {
|
||||
return { ok: false, length, width, reason: `水面宽度 ${width} < ${minWidth}` };
|
||||
}
|
||||
return { ok: true, length, width, reason: '水域足够开阔' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定列(脚部高度及其下一格)是否为水。
|
||||
*
|
||||
* 显式检查两格而不是用 `a || b` 短路:mineflayer 的 `bot.blockAt` 对未加载区块返回 null,
|
||||
* 但对已加载的空气返回方块对象,短路写法在假世界 / 测试环境里会拿到空气而漏判。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWaterAt(bot, x, y, z) {
|
||||
const atFeet = bot.blockAt(new Vec3(x, y, z));
|
||||
if (atFeet && isWaterName(atFeet.name)) return true;
|
||||
const below = bot.blockAt(new Vec3(x, y - 1, z));
|
||||
return !!(below && isWaterName(below.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断方块名是否为水(含水草等可游泳的「水方块」)。
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWaterName(name) {
|
||||
if (!name) return false;
|
||||
return name === 'water' || name === 'flowing_water' || name === 'bubble_column';
|
||||
}
|
||||
|
||||
module.exports = { AutoBoatController, BoatState, scanWaterAhead, isWaterName, isWaterAt, STATE_TIMEOUT_MS };
|
||||
@@ -0,0 +1,202 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 战斗辅助:自动攻击目标筛选、攻击距离判定与装备耐久保护。
|
||||
*
|
||||
* 目标筛选遵循一条核心原则:**只攻击会主动攻击玩家的生物**。
|
||||
* 铁傀儡、僵尸猪灵等中立生物永远不在目标内;末影人、蜘蛛、猪灵等
|
||||
* 「条件敌对」生物默认也不打(mineflayer 拿不到可靠的「是否被激怒 / 环境亮度」信息,
|
||||
* 宁可漏打也不误打),需要时可在配置里逐个放开。
|
||||
*/
|
||||
|
||||
/** 原版实体交互距离(格):视线到目标碰撞箱最近点超过该距离无法命中 */
|
||||
const ATTACK_RANGE = 3.0;
|
||||
|
||||
/** 永不攻击的实体名(含中立生物、宠物、召唤物) */
|
||||
const NEVER_ATTACK = new Set([
|
||||
'player',
|
||||
'iron_golem',
|
||||
'snow_golem',
|
||||
'zombified_piglin',
|
||||
'villager',
|
||||
'wandering_trader',
|
||||
'wolf',
|
||||
'cat',
|
||||
'ocelot',
|
||||
'parrot',
|
||||
'horse',
|
||||
'donkey',
|
||||
'mule',
|
||||
'skeleton_horse',
|
||||
'zombie_horse',
|
||||
'llama',
|
||||
'trader_llama',
|
||||
'fox',
|
||||
'bee',
|
||||
'allay',
|
||||
'camel',
|
||||
'sniffer',
|
||||
'armadillo',
|
||||
'turtle',
|
||||
'panda',
|
||||
'polar_bear',
|
||||
'goat',
|
||||
'axolotl',
|
||||
'frog',
|
||||
'creaking'
|
||||
]);
|
||||
|
||||
/** 默认跳过的高危生物:打不动或会被反杀 */
|
||||
const DANGEROUS_MOBS = new Set(['warden', 'ender_dragon', 'wither']);
|
||||
|
||||
/** 条件敌对:默认不打,可在配置里放开 */
|
||||
const CONDITIONAL_HOSTILE = new Set(['enderman', 'spider', 'cave_spider']);
|
||||
|
||||
/** 装备耐久保护检查的槽位(背包索引):头、胸、腿、脚、副手 */
|
||||
const GUARD_SLOTS = [
|
||||
{ slot: 5, name: '头盔' },
|
||||
{ slot: 6, name: '胸甲' },
|
||||
{ slot: 7, name: '护腿' },
|
||||
{ slot: 8, name: '靴子' },
|
||||
{ slot: 45, name: '副手' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断实体是否为合法攻击目标。
|
||||
*
|
||||
* @param {object} entity mineflayer 实体
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} [config] 完整配置(读取 config.combat)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAttackTarget(entity, bot, config) {
|
||||
const combat = (config && config.combat) || {};
|
||||
if (!entity || entity === bot.entity) return false;
|
||||
if (entity.isValid === false) return false;
|
||||
if (entity.type !== 'mob' && entity.type !== 'hostile') return false;
|
||||
const name = entity.name || '';
|
||||
if (!name || NEVER_ATTACK.has(name)) return false;
|
||||
if (combat.avoidDangerousMobs !== false && DANGEROUS_MOBS.has(name)) return false;
|
||||
// 猪灵:玩家穿戴金盔甲时保持中立
|
||||
if (name === 'piglin' || name === 'piglin_brute') {
|
||||
if (combat.attackPiglins === false) return false;
|
||||
return !wearsGoldArmor(bot);
|
||||
}
|
||||
// 条件敌对生物:默认不打,显式开启才打
|
||||
if (CONDITIONAL_HOSTILE.has(name)) {
|
||||
if (name === 'enderman') return combat.attackEnderman === true;
|
||||
return combat.attackSpiders === true;
|
||||
}
|
||||
// 其余仅认 minecraft-data 的敌对分类,避免误伤中立生物
|
||||
if (entity.kind === 'Hostile mobs') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在索敌范围内查找最近的合法攻击目标(不追击,仅就地战斗)。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {number} scanRange 索敌半径(格)
|
||||
* @param {object} [config]
|
||||
* @returns {object|null} 最近的攻击目标
|
||||
*/
|
||||
function findTarget(bot, scanRange, config) {
|
||||
if (!bot || !bot.entity || !bot.entities) return null;
|
||||
const range = scanRange === undefined ? 4.5 : scanRange;
|
||||
const origin = bot.entity.position;
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const id of Object.keys(bot.entities)) {
|
||||
const entity = bot.entities[id];
|
||||
if (!entity || !entity.position) continue;
|
||||
const dx = entity.position.x - origin.x;
|
||||
const dy = entity.position.y - origin.y;
|
||||
const dz = entity.position.z - origin.z;
|
||||
if (dx * dx + dy * dy + dz * dz > range * range) continue;
|
||||
if (!isAttackTarget(entity, bot, config)) continue;
|
||||
const distance = eyeToEntityDistance(bot, entity);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = entity;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视线到实体碰撞箱最近点的距离(格),与原版攻击距离判定一致。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} entity
|
||||
* @returns {number}
|
||||
*/
|
||||
function eyeToEntityDistance(bot, entity) {
|
||||
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
|
||||
const half = (entity.width || 0.6) / 2;
|
||||
const height = entity.height || 1.8;
|
||||
const dx = Math.max(Math.max(entity.position.x - half - eye.x, eye.x - (entity.position.x + half)), 0);
|
||||
const dy = Math.max(Math.max(entity.position.y - eye.y, eye.y - (entity.position.y + height)), 0);
|
||||
const dz = Math.max(Math.max(entity.position.z - half - eye.z, eye.z - (entity.position.z + half)), 0);
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查已装备物品(主手、副手、四件盔甲)中是否有耐久低于阈值的。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {number} thresholdPercent 耐久阈值(剩余百分比 ≤ 该值视为过低)
|
||||
* @returns {{slot: number, name: string, percent: number, itemName: string}|null}
|
||||
*/
|
||||
function findLowDurabilitySlot(bot, thresholdPercent) {
|
||||
if (!bot || !bot.inventory) return null;
|
||||
const threshold = Math.max(1, thresholdPercent);
|
||||
const candidates = [];
|
||||
if (bot.heldItem) candidates.push({ slot: bot.quickBarSlot === undefined ? -1 : bot.quickBarSlot, name: '主手', item: bot.heldItem });
|
||||
for (const guard of GUARD_SLOTS) {
|
||||
const item = bot.inventory.slots[guard.slot];
|
||||
if (item) candidates.push({ slot: guard.slot, name: guard.name, item });
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const item = candidate.item;
|
||||
const max = item.maxDurability;
|
||||
if (!max || max <= 0) continue;
|
||||
const used = item.durabilityUsed || 0;
|
||||
const remaining = max - used;
|
||||
const percent = (remaining * 100) / max;
|
||||
if (percent <= threshold) {
|
||||
return {
|
||||
slot: candidate.slot,
|
||||
name: candidate.name,
|
||||
percent: Math.round(percent),
|
||||
itemName: item.name
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断机器人四件盔甲中是否至少一件为金质(金盔甲可使猪灵保持中立)。
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function wearsGoldArmor(bot) {
|
||||
if (!bot || !bot.inventory) return false;
|
||||
for (const slot of [5, 6, 7, 8]) {
|
||||
const item = bot.inventory.slots[slot];
|
||||
if (item && item.name && item.name.startsWith('golden_')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ATTACK_RANGE,
|
||||
NEVER_ATTACK,
|
||||
DANGEROUS_MOBS,
|
||||
CONDITIONAL_HOSTILE,
|
||||
isAttackTarget,
|
||||
findTarget,
|
||||
eyeToEntityDistance,
|
||||
findLowDurabilitySlot,
|
||||
wearsGoldArmor
|
||||
};
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 自动进食:饥饿值低于阈值时自动吃背包里的食物。
|
||||
*
|
||||
* Grim 相关约束(见 grimCompat):
|
||||
* - 进食前松开所有移动键(原版进食会取消疾跑并减速,否则触发 NoSlow / MultiActions)
|
||||
* - 骑乘状态下不进食(防 Vehicle 类检查)
|
||||
* - 使用 mineflayer 的 `bot.consume()`,它内部走的是与原版一致的 use_item / block_dig 流程
|
||||
*/
|
||||
class AutoEater {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {import('./grimCompat').GrimCompat} [grim]
|
||||
*/
|
||||
constructor(bot, config, logger, grim) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this.grim = grim || null;
|
||||
/** 是否正在进食 */
|
||||
this.eating = false;
|
||||
/** 进食开始时间戳,用于超时保护 */
|
||||
this._startedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每帧调用。
|
||||
* @returns {boolean} true 表示本帧被进食占用(调用方应跳过其它逻辑)
|
||||
*/
|
||||
tick() {
|
||||
const bot = this.bot;
|
||||
if (!this.config.autoEat) {
|
||||
this.eating = false;
|
||||
return false;
|
||||
}
|
||||
const food = typeof bot.food === 'number' ? bot.food : 20;
|
||||
if (this.eating) {
|
||||
// 超时保护:进食超过 8 秒仍未结束,强制收尾,避免卡死状态机
|
||||
if (Date.now() - this._startedAt > 8000) {
|
||||
this._finish();
|
||||
return false;
|
||||
}
|
||||
if (food >= 20 || food > this.config.eatThreshold) {
|
||||
this._finish();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (food > this.config.eatThreshold) return false;
|
||||
this._begin(food);
|
||||
return this.eating;
|
||||
}
|
||||
|
||||
/** 开始进食:找到食物并装备到主手,随后异步 consume */
|
||||
_begin(foodLevel) {
|
||||
const bot = this.bot;
|
||||
const slot = findFoodSlot(bot);
|
||||
if (slot < 0) {
|
||||
if (!this._warnedNoFood) {
|
||||
this._warnedNoFood = true;
|
||||
this._log('饥饿但背包里没有食物');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._warnedNoFood = false;
|
||||
this.eating = true;
|
||||
this._startedAt = Date.now();
|
||||
this._log(`自动进食,饥饿值 ${foodLevel}`);
|
||||
this._consume(slot);
|
||||
}
|
||||
|
||||
/** 异步执行「装备食物 → 进食」,失败时复位状态 */
|
||||
async _consume(slot) {
|
||||
const bot = this.bot;
|
||||
try {
|
||||
const item = bot.inventory.slots[slot];
|
||||
if (!item) throw new Error('食物槽位已空');
|
||||
if (!bot.heldItem || bot.heldItem.name !== item.name) {
|
||||
await bot.equip(item, 'hand');
|
||||
}
|
||||
// 进食期间停止移动(原版行为,防 Grim 的 NoSlow / MultiActions)
|
||||
if (this.grim && this.config.grim.stopMovementWhileEating) {
|
||||
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
|
||||
this.grim.setControlState(control, false);
|
||||
}
|
||||
await bot.waitForTicks(1).catch(() => {});
|
||||
}
|
||||
await bot.consume();
|
||||
} catch (err) {
|
||||
this._log('自动进食失败:' + (err && err.message ? err.message : String(err)));
|
||||
} finally {
|
||||
if (this.eating) this._finish();
|
||||
}
|
||||
}
|
||||
|
||||
_finish() {
|
||||
this.eating = false;
|
||||
this._startedAt = 0;
|
||||
this._log('自动进食完成');
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在背包(含快捷栏)中查找第一个可食用的物品槽位。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @returns {number} 槽位索引,找不到返回 -1
|
||||
*/
|
||||
function findFoodSlot(bot) {
|
||||
if (!bot.inventory) return -1;
|
||||
const foodsByName = bot.registry && bot.registry.foodsByName ? bot.registry.foodsByName : {};
|
||||
const slots = bot.inventory.slots;
|
||||
for (let slot = 0; slot < slots.length; slot++) {
|
||||
const item = slots[slot];
|
||||
if (!item || !item.name) continue;
|
||||
if (foodsByName[item.name]) return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
module.exports = { AutoEater, findFoodSlot };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const { findLowDurabilitySlot } = require('./autoCombat');
|
||||
|
||||
/** 耐久保护检查间隔(毫秒),与原 Java 版的 40 tick ≈ 2 秒一致 */
|
||||
const CHECK_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* 耐久保护:主手 / 副手 / 四件盔甲任意一件剩余耐久低于阈值时触发回城,
|
||||
* 防止装备在继续战斗中被怪物磨没。
|
||||
*/
|
||||
class DurabilityGuard {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
*/
|
||||
constructor(bot, config, logger) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this._lastCheck = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按间隔节流地检查装备耐久。
|
||||
* @param {number} [now] 当前时间戳(便于测试注入)
|
||||
* @returns {{slot: number, name: string, percent: number, itemName: string}|null}
|
||||
*/
|
||||
check(now) {
|
||||
if (!this.config.durabilityGuard) return null;
|
||||
const current = now === undefined ? Date.now() : now;
|
||||
if (current - this._lastCheck < CHECK_INTERVAL_MS) return null;
|
||||
this._lastCheck = current;
|
||||
return findLowDurabilitySlot(this.bot, this.config.durabilityThreshold);
|
||||
}
|
||||
|
||||
/** 重置节流计时器(任务重新开始时调用) */
|
||||
reset() {
|
||||
this._lastCheck = 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DurabilityGuard, CHECK_INTERVAL_MS };
|
||||
@@ -0,0 +1,582 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Grim 反作弊合规层。
|
||||
*
|
||||
* 目标:让 mineflayer 机器人在装有 GrimAC 的服务器上「行为上像一个正常原版客户端」。
|
||||
*
|
||||
* 核心手段(每一项都对应一类 Grim 检查):
|
||||
*
|
||||
* 1. **旋转不超前**(防 Prediction / Aim 类检查)
|
||||
* mineflayer 的 `bot.look()` 会立刻把 `bot.entity.yaw` 改到目标值,
|
||||
* 而朝向数据包是按 `physics.yawSpeed` 逐 tick 慢慢补发的。
|
||||
* 这会造成「本地已经朝新方向走、服务端还以为朝旧方向」的预测分歧 —— Grim 的
|
||||
* Prediction 检查正是逐 tick 复算玩家位置并与上报位置比对,分歧必然标记。
|
||||
* 因此本层接管 `bot.look`:只把目标朝向记下来,在每个 physicsTick 里把
|
||||
* `bot.entity.yaw/pitch` 按 ≤ `grim.maxRotationPerTickDeg` 的步长推进,
|
||||
* 保证「本地朝向」永远等于「服务端已收到的朝向」,最多差 1 tick。
|
||||
*
|
||||
* 2. **攻击前三重校验**(防 Reach / LineOfSight / Post / MultiActions)
|
||||
* - 旋转已同步(服务端认为你正看着目标)
|
||||
* - 眼睛到目标碰撞箱的距离 ≤ `grim.attackReach`(生存模式 3.0)
|
||||
* - 视线可达(中间没有实心方块遮挡)
|
||||
* - 攻击冷却已好(1.9+ 武器攻速)
|
||||
* - 不处于骑乘 / 进食 / 使用物品状态
|
||||
*
|
||||
* 3. **动作互斥**(防 MultiActions)
|
||||
* 疾跑 + 潜行、疾跑 + 使用物品、疾跑 + 跳跃(可配)等原版客户端不会出现的组合一律拦下。
|
||||
*
|
||||
* 4. **物理参数只读校验**(防「改物理常数绕过预测」这类自杀式操作)
|
||||
* `audit()` 会核对 `bot.physics` 的关键常量是否仍为原版值。
|
||||
*/
|
||||
|
||||
/** 物理仿真步长(秒),与 mineflayer 的 PHYSICS_INTERVAL_MS=50 对应 */
|
||||
const PHYSICS_TIMESTEP = 0.05;
|
||||
|
||||
/** 原版物理常量基线,用于 audit() 校验未被篡改 */
|
||||
const VANILLA_PHYSICS_BASELINE = {
|
||||
gravity: 0.08,
|
||||
playerSpeed: 0.1,
|
||||
sprintSpeed: 0.3,
|
||||
sneakSpeed: 0.3,
|
||||
stepHeight: 0.6,
|
||||
airborneInertia: 0.91,
|
||||
waterInertia: 0.8
|
||||
};
|
||||
|
||||
/** 攻击速度(次/秒)→ 冷却毫秒;按物品名匹配,未命中回退 4.0(空手) */
|
||||
const ATTACK_SPEED_RULES = [
|
||||
[/^netherite_sword$|^diamond_sword$|^iron_sword$|^golden_sword$|^stone_sword$|^wooden_sword$/, 1.6],
|
||||
[/^netherite_axe$|^diamond_axe$/, 1.0],
|
||||
[/^iron_axe$/, 0.9],
|
||||
[/^golden_axe$|^stone_axe$|^wooden_axe$/, 0.8],
|
||||
[/^trident$/, 1.1],
|
||||
[/^.*_pickaxe$/, 1.2],
|
||||
[/^.*_shovel$/, 1.0],
|
||||
[/^.*_hoe$/, 1.0]
|
||||
];
|
||||
|
||||
/** 命中这些方块名视为「不遮挡视线」 */
|
||||
const NON_OCCLUDING = new Set([
|
||||
'air', 'cave_air', 'void_air', 'water', 'lava', 'seagrass', 'tall_seagrass',
|
||||
'kelp', 'kelp_plant', 'bubble_column', 'torch', 'wall_torch', 'soul_torch',
|
||||
'soul_wall_torch', 'redstone_torch', 'redstone_wall_torch', 'lever', 'tripwire',
|
||||
'tripwire_hook', 'vine', 'short_grass', 'grass', 'fern', 'large_fern', 'dead_bush',
|
||||
'sugar_cane', 'lily_pad', 'rail', 'powered_rail', 'detector_rail', 'activator_rail'
|
||||
]);
|
||||
|
||||
class GrimCompat {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象(读取 config.grim / config.allowSprint 等)
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
*/
|
||||
constructor(bot, config, logger) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
/** 目标朝向(弧度) */
|
||||
this.targetYaw = bot.entity ? bot.entity.yaw : 0;
|
||||
this.targetPitch = bot.entity ? bot.entity.pitch : 0;
|
||||
/** 上一 tick 的朝向,用于限速 */
|
||||
this.lastAppliedYaw = this.targetYaw;
|
||||
this.lastAppliedPitch = this.targetPitch;
|
||||
/** 等待旋转完成的 resolver 列表 */
|
||||
this._rotationWaiters = [];
|
||||
/** 最近一次攻击时间戳(毫秒) */
|
||||
this._lastAttackAt = 0;
|
||||
/** 拦截到的不合规动作计数,供 Web 控制台展示 */
|
||||
this.blockedActions = 0;
|
||||
this._installed = false;
|
||||
this._originalLook = null;
|
||||
this._originalLookAt = null;
|
||||
this._originalSetControlState = null;
|
||||
this._onPhysicsTick = this._onPhysicsTick.bind(this);
|
||||
}
|
||||
|
||||
/** 单 tick 允许的最大旋转弧度(取配置值与 mineflayer 物理限速的较小者) */
|
||||
get maxStepRad() {
|
||||
const configured = degToRad(this.config.grim.maxRotationPerTickDeg);
|
||||
const physicsLimit = PHYSICS_TIMESTEP * (this.bot.physics ? this.bot.physics.yawSpeed : 3.0);
|
||||
return Math.min(configured, physicsLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装合规层:接管 look / setControlState,并挂上 physicsTick 推进器。
|
||||
* 必须在 bot 完成 spawn 之后调用(此时 bot.look / bot.physics 才存在)。
|
||||
*/
|
||||
install() {
|
||||
if (this._installed) return;
|
||||
const bot = this.bot;
|
||||
if (!this.config.grim.enabled) {
|
||||
this._installed = true;
|
||||
return;
|
||||
}
|
||||
this.targetYaw = bot.entity.yaw;
|
||||
this.targetPitch = bot.entity.pitch;
|
||||
this.lastAppliedYaw = bot.entity.yaw;
|
||||
this.lastAppliedPitch = bot.entity.pitch;
|
||||
|
||||
this._originalLook = bot.look.bind(bot);
|
||||
this._originalLookAt = bot.lookAt.bind(bot);
|
||||
this._originalSetControlState = bot.setControlState.bind(bot);
|
||||
|
||||
bot.look = (yaw, pitch) => this.setLook(yaw, pitch === undefined ? this.targetPitch : pitch);
|
||||
bot.lookAt = (point) => this.lookAtPoint(point);
|
||||
bot.setControlState = (control, state) => this.setControlState(control, state);
|
||||
|
||||
bot.on('physicsTick', this._onPhysicsTick);
|
||||
bot.on('end', () => this._releaseRotationWaiters());
|
||||
this._installed = true;
|
||||
this._log('Grim 合规层已启用(旋转限速 ' + this.config.grim.maxRotationPerTickDeg + '°/tick,'
|
||||
+ '攻击距离 ' + this.config.grim.attackReach + ' 格)');
|
||||
}
|
||||
|
||||
/** 卸载合规层(恢复原始方法,主要供测试使用) */
|
||||
uninstall() {
|
||||
if (!this._installed) return;
|
||||
const bot = this.bot;
|
||||
bot.removeListener('physicsTick', this._onPhysicsTick);
|
||||
if (this._originalLook) bot.look = this._originalLook;
|
||||
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
|
||||
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
|
||||
this._installed = false;
|
||||
}
|
||||
|
||||
// ==================== 旋转 ====================
|
||||
|
||||
/**
|
||||
* 设置目标朝向(不立即生效,由 physicsTick 逐步推进)。
|
||||
* @param {number} yaw 目标 yaw(弧度)
|
||||
* @param {number} [pitch] 目标 pitch(弧度)
|
||||
* @returns {Promise<void>} 旋转真正同步到服务端后 resolve
|
||||
*/
|
||||
setLook(yaw, pitch) {
|
||||
if (typeof yaw !== 'number' || Number.isNaN(yaw)) return Promise.resolve();
|
||||
this.targetYaw = yaw;
|
||||
this.targetPitch = typeof pitch === 'number' && !Number.isNaN(pitch)
|
||||
? clamp(pitch, -Math.PI / 2, Math.PI / 2)
|
||||
: this.targetPitch;
|
||||
if (this.isRotationSettled()) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
this._rotationWaiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 朝指定世界坐标看过去。
|
||||
* @param {{x: number, y: number, z: number}} point
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
lookAtPoint(point) {
|
||||
const eye = this.bot.entity.position.offset(0, this.bot.entity.eyeHeight || 1.62, 0);
|
||||
const dx = point.x - eye.x;
|
||||
const dy = point.y - eye.y;
|
||||
const dz = point.z - eye.z;
|
||||
const yaw = Math.atan2(-dx, -dz);
|
||||
const groundDistance = Math.sqrt(dx * dx + dz * dz);
|
||||
const pitch = Math.atan2(dy, groundDistance);
|
||||
return this.setLook(yaw, pitch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 看向实体(取碰撞箱中心)。
|
||||
* @param {object} entity
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
lookAtEntity(entity) {
|
||||
const height = entity.height || 1.8;
|
||||
return this.lookAtPoint({
|
||||
x: entity.position.x,
|
||||
y: entity.position.y + height * 0.85,
|
||||
z: entity.position.z
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {boolean} 当前朝向是否已经与目标一致(即服务端已收到完整旋转) */
|
||||
isRotationSettled() {
|
||||
return Math.abs(shortestAngleDelta(this.bot.entity.yaw, this.targetYaw)) < 1e-4
|
||||
&& Math.abs(this.bot.entity.pitch - this.targetPitch) < 1e-4;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待旋转同步完成。
|
||||
* @param {number} [timeoutMs] 超时(默认 3000ms)
|
||||
* @returns {Promise<boolean>} 是否在超时前完成
|
||||
*/
|
||||
awaitRotationSettled(timeoutMs) {
|
||||
if (this.isRotationSettled()) return Promise.resolve(true);
|
||||
const limit = timeoutMs === undefined ? 3000 : timeoutMs;
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(false);
|
||||
}, limit);
|
||||
this._rotationWaiters.push(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** physicsTick:把 entity.yaw/pitch 朝目标推进一小步(mineflayer 会在同一 tick 把该朝向发包) */
|
||||
_onPhysicsTick() {
|
||||
const bot = this.bot;
|
||||
if (!bot.entity) return;
|
||||
const step = this.maxStepRad;
|
||||
const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw);
|
||||
if (Math.abs(yawDelta) > 1e-6) {
|
||||
bot.entity.yaw += clamp(yawDelta, -step, step);
|
||||
} else {
|
||||
bot.entity.yaw = this.targetYaw;
|
||||
}
|
||||
const pitchDelta = this.targetPitch - bot.entity.pitch;
|
||||
if (Math.abs(pitchDelta) > 1e-6) {
|
||||
bot.entity.pitch += clamp(pitchDelta, -step, step);
|
||||
} else {
|
||||
bot.entity.pitch = this.targetPitch;
|
||||
}
|
||||
if (this.isRotationSettled()) this._releaseRotationWaiters();
|
||||
}
|
||||
|
||||
_releaseRotationWaiters() {
|
||||
const waiters = this._rotationWaiters;
|
||||
this._rotationWaiters = [];
|
||||
for (const resolve of waiters) {
|
||||
try {
|
||||
resolve();
|
||||
} catch {
|
||||
// 忽略订阅者异常
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 控制键 ====================
|
||||
|
||||
/**
|
||||
* 带互斥规则的控制键设置(替换 bot.setControlState)。
|
||||
*
|
||||
* 拦下的组合(原版客户端不可能出现,Grim 的 MultiActions 会标记):
|
||||
* - 疾跑 + 潜行
|
||||
* - 疾跑 + 使用物品(进食 / 拉弓 / 举盾)
|
||||
* - 疾跑 + 跳跃(仅在 grim.blockSprintJump 开启时拦,默认放行,因为原版合法)
|
||||
*
|
||||
* @param {string} control 'forward'|'back'|'left'|'right'|'jump'|'sprint'|'sneak'
|
||||
* @param {boolean} state
|
||||
*/
|
||||
setControlState(control, state) {
|
||||
const bot = this.bot;
|
||||
if (!this.config.grim.enabled) {
|
||||
this._originalSetControlState(control, state);
|
||||
return;
|
||||
}
|
||||
if (state && this.config.grim.blockConflictingActions) {
|
||||
const conflicting = this._findConflict(control);
|
||||
if (conflicting) {
|
||||
this.blockedActions++;
|
||||
this._log(`已拦下不合规动作组合:${control} + ${conflicting}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state && control === 'sprint' && this.config.grim.blockSprintJump && bot.getControlState('jump')) {
|
||||
this.blockedActions++;
|
||||
return;
|
||||
}
|
||||
this._originalSetControlState(control, state);
|
||||
}
|
||||
|
||||
/** @returns {string|null} 与指定控制键冲突且当前已按下的控制键 */
|
||||
_findConflict(control) {
|
||||
const bot = this.bot;
|
||||
const sprinting = control === 'sprint' || bot.getControlState('sprint');
|
||||
if (control === 'sneak' && sprinting) return 'sprint';
|
||||
if (control === 'sprint' && bot.getControlState('sneak')) return 'sneak';
|
||||
if (this.config.grim.stopMovementWhileEating && this.isUsingItem()) {
|
||||
if (control === 'sprint' || control === 'forward' || control === 'jump') return 'use_item';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 松开所有移动控制键 */
|
||||
releaseAllControls() {
|
||||
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
|
||||
try {
|
||||
this._originalSetControlState
|
||||
? this._originalSetControlState(control, false)
|
||||
: this.bot.setControlState(control, false);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 攻击 ====================
|
||||
|
||||
/** @returns {boolean} 是否正在使用物品(进食 / 拉弓 / 举盾) */
|
||||
isUsingItem() {
|
||||
return this.bot.usingHeldItem === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前手持物品的两次攻击之间的冷却时间(毫秒)。
|
||||
* @returns {number}
|
||||
*/
|
||||
attackCooldownMs() {
|
||||
const item = this.bot.heldItem;
|
||||
const name = item && item.name ? item.name : '';
|
||||
for (const [pattern, speed] of ATTACK_SPEED_RULES) {
|
||||
if (pattern.test(name)) return Math.round(1000 / speed);
|
||||
}
|
||||
return Math.round(1000 / 4.0); // 空手 / 其它物品:4 次每秒
|
||||
}
|
||||
|
||||
/** @returns {number} 距离下次可攻击还剩多少毫秒(0 表示已冷却好) */
|
||||
attackCooldownRemainingMs() {
|
||||
const remaining = this.attackCooldownMs() - (Date.now() - this._lastAttackAt);
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以攻击目标(不实际攻击)。
|
||||
* @param {object} entity
|
||||
* @returns {{ok: boolean, reason?: string}}
|
||||
*/
|
||||
canAttack(entity) {
|
||||
const bot = this.bot;
|
||||
if (!entity || !entity.position || !entity.isValid) {
|
||||
return { ok: false, reason: 'invalid_entity' };
|
||||
}
|
||||
if (this.config.grim.blockActionsWhileRiding && bot.vehicle) {
|
||||
return { ok: false, reason: 'riding' };
|
||||
}
|
||||
if (this.isUsingItem()) {
|
||||
return { ok: false, reason: 'using_item' };
|
||||
}
|
||||
const distance = eyeDistanceToEntity(bot, entity);
|
||||
if (distance > this.config.grim.attackReach) {
|
||||
return { ok: false, reason: 'out_of_reach' };
|
||||
}
|
||||
if (this.config.grim.respectAttackCooldown && this.attackCooldownRemainingMs() > 0) {
|
||||
return { ok: false, reason: 'cooldown' };
|
||||
}
|
||||
if (this.config.grim.requireLineOfSight && !this.hasLineOfSight(entity)) {
|
||||
return { ok: false, reason: 'no_line_of_sight' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 视线可达判定:从眼睛朝目标碰撞箱中心射线,中途不得被实心方块遮挡。
|
||||
* @param {object} entity
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasLineOfSight(entity) {
|
||||
const bot = this.bot;
|
||||
if (!bot.world || typeof bot.world.raycast !== 'function') return true;
|
||||
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
|
||||
const target = {
|
||||
x: entity.position.x,
|
||||
y: entity.position.y + (entity.height || 1.8) * 0.5,
|
||||
z: entity.position.z
|
||||
};
|
||||
const dx = target.x - eye.x;
|
||||
const dy = target.y - eye.y;
|
||||
const dz = target.z - eye.z;
|
||||
const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (length < 1e-6) return true;
|
||||
const direction = { x: dx / length, y: dy / length, z: dz / length };
|
||||
try {
|
||||
const hit = bot.world.raycast(eye, direction, length, (block) => {
|
||||
return block && block.boundingBox !== 'empty' && !NON_OCCLUDING.has(block.name);
|
||||
});
|
||||
return !hit;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次合规攻击:先同步朝向,再校验距离 / 视线 / 冷却,最后出手。
|
||||
*
|
||||
* @param {object} entity 目标实体
|
||||
* @param {{skipRotation?: boolean}} [options]
|
||||
* @returns {Promise<boolean>} 是否成功出手
|
||||
*/
|
||||
async attackEntity(entity, options) {
|
||||
const opts = options || {};
|
||||
if (!this.config.grim.enabled) {
|
||||
bot_attack(this.bot, entity);
|
||||
this._lastAttackAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
if (!opts.skipRotation) {
|
||||
await this.lookAtEntity(entity);
|
||||
const settled = await this.awaitRotationSettled(600);
|
||||
if (!settled) return false;
|
||||
}
|
||||
const check = this.canAttack(entity);
|
||||
if (!check.ok) return false;
|
||||
bot_attack(this.bot, entity);
|
||||
this._lastAttackAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 使用物品 ====================
|
||||
|
||||
/**
|
||||
* 合规地使用一次手持物品(进食 / 放船等)。
|
||||
*
|
||||
* 原版客户端在使用物品时会自动取消疾跑并减速,这里显式松开移动键,
|
||||
* 避免 Grim 的 NoSlow / MultiActions 检查。
|
||||
*
|
||||
* @param {{holdMs?: number, stopMovement?: boolean}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async useItem(options) {
|
||||
const opts = options || {};
|
||||
const bot = this.bot;
|
||||
if (this.config.grim.blockActionsWhileRiding && bot.vehicle) return false;
|
||||
if (this.config.grim.stopMovementWhileEating && opts.stopMovement !== false) {
|
||||
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
|
||||
this.setControlState(control, false);
|
||||
}
|
||||
// 等待一个 tick,让服务端看到「已停止移动」后再开始使用物品
|
||||
await bot.waitForTicks(1).catch(() => {});
|
||||
}
|
||||
bot.activateItem();
|
||||
const holdMs = opts.holdMs === undefined ? 100 : opts.holdMs;
|
||||
await sleep(holdMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 结束使用物品(进食完成 / 放船完成后调用) */
|
||||
stopUsingItem() {
|
||||
try {
|
||||
this.bot.deactivateItem();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 自检 ====================
|
||||
|
||||
/**
|
||||
* 物理与游戏状态自检:确认没有被改成非原版状态。
|
||||
* @returns {{ok: boolean, issues: string[]}}
|
||||
*/
|
||||
audit() {
|
||||
const bot = this.bot;
|
||||
const issues = [];
|
||||
if (!bot.physics) {
|
||||
issues.push('bot.physics 不存在,物理引擎未启用');
|
||||
} else if (this.config.grim.checkPhysicsIntegrity) {
|
||||
for (const [key, expected] of Object.entries(VANILLA_PHYSICS_BASELINE)) {
|
||||
const actual = bot.physics[key];
|
||||
if (typeof actual === 'number' && Math.abs(actual - expected) > 1e-9) {
|
||||
issues.push(`物理常量 ${key} 被改为 ${actual}(原版应为 ${expected})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bot.physicsEnabled === false) {
|
||||
issues.push('bot.physicsEnabled 为 false,移动不会走本地预测,极易触发 Grim 的 Prediction 检查');
|
||||
}
|
||||
if (bot.game && bot.game.gameMode === 'creative') {
|
||||
issues.push('当前为创造模式,飞行/瞬间破坏会直接触发 Grim 检查');
|
||||
}
|
||||
if (bot.entity && bot.entity.elytraFlying) {
|
||||
issues.push('正在鞘翅飞行,mineflayer 的鞘翅物理与原版不一致');
|
||||
}
|
||||
if (bot.creative && bot.creative.flying) {
|
||||
issues.push('bot.creative.flying 为 true,必须关闭');
|
||||
}
|
||||
return { ok: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
/** @returns {object} 供 Web 控制台展示的合规状态快照 */
|
||||
snapshot() {
|
||||
const audit = this.audit();
|
||||
return {
|
||||
enabled: this.config.grim.enabled,
|
||||
maxRotationPerTickDeg: this.config.grim.maxRotationPerTickDeg,
|
||||
effectiveStepDeg: Number(radToDeg(this.maxStepRad).toFixed(2)),
|
||||
attackReach: this.config.grim.attackReach,
|
||||
rotationSettled: this.isRotationSettled(),
|
||||
attackCooldownMs: this.attackCooldownRemainingMs(),
|
||||
blockedActions: this.blockedActions,
|
||||
physicsOk: audit.ok,
|
||||
issues: audit.issues
|
||||
};
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算从 from 到 to 的最短角差(结果落在 (-π, π])。
|
||||
* @param {number} from
|
||||
* @param {number} to
|
||||
* @returns {number}
|
||||
*/
|
||||
function shortestAngleDelta(from, to) {
|
||||
const TWO_PI = Math.PI * 2;
|
||||
let delta = (to - from) % TWO_PI;
|
||||
if (delta > Math.PI) delta -= TWO_PI;
|
||||
else if (delta < -Math.PI) delta += TWO_PI;
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 眼睛到实体碰撞箱的最近距离。
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} entity
|
||||
* @returns {number}
|
||||
*/
|
||||
function eyeDistanceToEntity(bot, entity) {
|
||||
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
|
||||
const half = (entity.width || 0.6) / 2;
|
||||
const height = entity.height || 1.8;
|
||||
const cx = clamp(eye.x, entity.position.x - half, entity.position.x + half);
|
||||
const cy = clamp(eye.y, entity.position.y, entity.position.y + height);
|
||||
const cz = clamp(eye.z, entity.position.z - half, entity.position.z + half);
|
||||
const dx = cx - eye.x;
|
||||
const dy = cy - eye.y;
|
||||
const dz = cz - eye.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
function bot_attack(bot, entity) {
|
||||
bot.attack(entity, true);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function degToRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function radToDeg(rad) {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GrimCompat,
|
||||
PHYSICS_TIMESTEP,
|
||||
VANILLA_PHYSICS_BASELINE,
|
||||
ATTACK_SPEED_RULES,
|
||||
shortestAngleDelta,
|
||||
eyeDistanceToEntity,
|
||||
degToRad,
|
||||
radToDeg
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
'use strict';
|
||||
|
||||
const pathfinderPlugin = require('mineflayer-pathfinder');
|
||||
|
||||
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
||||
const { Movements } = pathfinderPlugin;
|
||||
|
||||
/**
|
||||
* 寻路服务:替代原 Java 版的 BaritoneBridge。
|
||||
*
|
||||
* 底层用 mineflayer-pathfinder(A* + 逐 tick 控制键执行),对外暴露与 Baritone 语义
|
||||
* 接近的接口:pathTo / cancel / 路径事件回调。
|
||||
*
|
||||
* 与 Grim 相关的关键约束(在 {@link configure} 里统一施加):
|
||||
* - 默认不允许挖方块、不允许搭方块(Grim 的 FastBreak / place 类检查极严)
|
||||
* - 默认关闭跑酷与 1×1 搭塔(mineflayer 的起跳时机与原版有偏差)
|
||||
* - 限制最大下落高度,避免长距离坠落被 NoFall 类检查盯上
|
||||
* - 大幅提高进入液体格的代价,优先绕开水域(mineflayer 液体物理与原版差异最大)
|
||||
*/
|
||||
|
||||
/** 路径事件枚举,语义对齐 Baritone 的 PathEvent */
|
||||
const PathEvent = {
|
||||
CALC_STARTED: 'CALC_STARTED',
|
||||
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
||||
CALC_FAILED: 'CALC_FAILED',
|
||||
AT_GOAL: 'AT_GOAL',
|
||||
CANCELED: 'CANCELED'
|
||||
};
|
||||
|
||||
class PathingService {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
*/
|
||||
constructor(bot, config, logger) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
/** @type {((event: string, detail?: object) => void)|null} */
|
||||
this.onPathEvent = null;
|
||||
/** 是否已就绪(pathfinder 插件已加载且 movements 已配置) */
|
||||
this.ready = false;
|
||||
/** 当前目标(区块坐标) */
|
||||
this.currentTarget = null;
|
||||
/** 最近一次路径计算是否失败 */
|
||||
this.calcFailed = false;
|
||||
this._pathing = false;
|
||||
this._lastStatus = null;
|
||||
this._configured = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:等待 pathfinder 插件就绪并配置 Movements。
|
||||
* @returns {boolean} 是否成功就绪
|
||||
*/
|
||||
configure() {
|
||||
const bot = this.bot;
|
||||
if (!bot.pathfinder) {
|
||||
this._log('mineflayer-pathfinder 尚未加载');
|
||||
return false;
|
||||
}
|
||||
const movements = new Movements(bot);
|
||||
// ===== Grim 合规约束 =====
|
||||
movements.canDig = !!this.config.allowBreak;
|
||||
movements.canOpenDoors = !!this.config.pathing.canOpenDoors;
|
||||
movements.allowParkour = !!this.config.pathing.allowParkour;
|
||||
movements.allow1by1towers = !!this.config.pathing.allow1by1towers;
|
||||
movements.allowSprinting = !!this.config.allowSprint;
|
||||
movements.allowFreeMotion = false;
|
||||
movements.maxDropDown = this.config.pathing.maxDropDown;
|
||||
movements.liquidCost = this.config.pathing.liquidCost;
|
||||
movements.infiniteLiquidDropdownDistance = false;
|
||||
movements.dontMineUnderFallingBlock = true;
|
||||
movements.dontCreateFlow = true;
|
||||
// 不允许搭方块时清空脚手架方块表(pathfinder 靠这张表决定能否放置)
|
||||
if (!this.config.allowPlace) {
|
||||
movements.scafoldingBlocks = [];
|
||||
}
|
||||
// 水域处理策略:avoid 时把液体代价推到极高,让 A* 宁可绕远路
|
||||
if (this.config.waterMode === 'avoid') {
|
||||
movements.liquidCost = Math.max(movements.liquidCost, 200);
|
||||
}
|
||||
bot.pathfinder.setMovements(movements);
|
||||
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath
|
||||
bot.pathfinder.thinkTimeout = 10000;
|
||||
bot.pathfinder.tickTimeout = 40;
|
||||
bot.pathfinder.searchRadius = 256;
|
||||
|
||||
bot.on('goal_reached', () => {
|
||||
this._pathing = false;
|
||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||
});
|
||||
bot.on('path_update', (r) => {
|
||||
this._lastStatus = r.status;
|
||||
if (r.status === 'success') {
|
||||
this.calcFailed = false;
|
||||
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
||||
} else if (r.status === 'noPath') {
|
||||
this.calcFailed = true;
|
||||
this._pathing = false;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
|
||||
} else if (r.status === 'timeout') {
|
||||
this.calcFailed = true;
|
||||
this._pathing = false;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
||||
}
|
||||
});
|
||||
bot.on('path_reset', (reason) => {
|
||||
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
||||
this._emit(PathEvent.CANCELED, { reason });
|
||||
}
|
||||
});
|
||||
this.ready = true;
|
||||
this._configured = true;
|
||||
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
||||
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
|
||||
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
|
||||
+ `最大下落 ${this.config.pathing.maxDropDown} 格,水域策略 ${this.config.waterMode})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置变更后重新应用(不重建事件监听)。
|
||||
* @param {object} config 新配置
|
||||
*/
|
||||
applyConfig(config) {
|
||||
this.config = config;
|
||||
if (this._configured) this.configure();
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否可用 */
|
||||
isAvailable() {
|
||||
return this.ready && !!this.bot.pathfinder;
|
||||
}
|
||||
|
||||
/** @returns {boolean} 当前是否正在寻路 */
|
||||
isPathing() {
|
||||
return this._pathing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 让机器人寻路到指定区块。
|
||||
*
|
||||
* @param {{x: number, z: number}} chunk 目标区块
|
||||
* @param {{y?: number}} [options] 可选的偏好高度
|
||||
* @returns {boolean} 是否成功下发寻路请求
|
||||
*/
|
||||
pathTo(chunk, options) {
|
||||
const bot = this.bot;
|
||||
if (!this.isAvailable()) return false;
|
||||
const opts = options || {};
|
||||
const blockX = chunk.x * 16 + 8;
|
||||
const blockZ = chunk.z * 16 + 8;
|
||||
const startY = Math.floor(bot.entity.position.y);
|
||||
let goal = new GoalNearXZ(blockX, blockZ, 2);
|
||||
if (opts.y !== undefined && opts.y !== null) {
|
||||
goal = new GoalNearXZ(blockX, blockZ, 2);
|
||||
}
|
||||
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||
}
|
||||
this.currentTarget = { x: chunk.x, z: chunk.z };
|
||||
this.calcFailed = false;
|
||||
this._pathing = true;
|
||||
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
|
||||
try {
|
||||
bot.pathfinder.setGoal(goal);
|
||||
} catch (err) {
|
||||
this._pathing = false;
|
||||
this.calcFailed = true;
|
||||
this._log('下发寻路请求失败:' + err.message);
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, error: err.message });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 取消当前寻路 */
|
||||
cancel() {
|
||||
if (!this.isAvailable()) return;
|
||||
this._pathing = false;
|
||||
this.currentTarget = null;
|
||||
try {
|
||||
this.bot.pathfinder.setGoal(null);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {string|null} 最近一次 path_update 的状态 */
|
||||
lastStatus() {
|
||||
return this._lastStatus;
|
||||
}
|
||||
|
||||
_emit(event, detail) {
|
||||
if (this.onPathEvent) {
|
||||
try {
|
||||
this.onPathEvent(event, detail);
|
||||
} catch {
|
||||
// 订阅者异常不影响寻路
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高度偏好目标:在原目标启发值上叠加「越高越优」的偏置,
|
||||
* 对应原 Java 版的 GoalXZAltitude。
|
||||
*
|
||||
* heuristic = max(0, 基础启发值 - 权重 × 节点高度)
|
||||
* 即 y 越大启发成本越低(A* 更倾向走高处),y 越小成本越高(对走低处施惩罚),
|
||||
* clamp 到非负以免破坏 A* 的排序。到达判定与内层目标完全一致(不约束高度)。
|
||||
*/
|
||||
class AltitudeGoal extends Goal {
|
||||
/**
|
||||
* @param {Goal} inner 被包装的真实目标
|
||||
* @param {number} weight 每格高度折算的启发成本
|
||||
* @param {number} startY 寻路起点高度(仅用于展示,不参与计算)
|
||||
*/
|
||||
constructor(inner, weight, startY) {
|
||||
super();
|
||||
this.inner = inner;
|
||||
this.weight = weight;
|
||||
this.startY = startY;
|
||||
}
|
||||
|
||||
/** @param {{x: number, y: number, z: number}} node */
|
||||
heuristic(node) {
|
||||
return Math.max(0, this.inner.heuristic(node) - this.weight * node.y);
|
||||
}
|
||||
|
||||
/** @param {{x: number, y: number, z: number}} node */
|
||||
isEnd(node) {
|
||||
return this.inner.isEnd(node);
|
||||
}
|
||||
|
||||
hasChanged() {
|
||||
return this.inner.hasChanged ? this.inner.hasChanged() : false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PathingService, AltitudeGoal, PathEvent, GoalXZ, GoalNearXZ };
|
||||
在新工单中引用
屏蔽一个用户