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 行删除
+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 };
+202
查看文件
@@ -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 };
+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 };
+44
查看文件
@@ -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 };
+582
查看文件
@@ -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
};
+247
查看文件
@@ -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 };