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 行删除
+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
};