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 行删除
+141
查看文件
@@ -0,0 +1,141 @@
'use strict';
const { EventEmitter } = require('events');
const { Vec3 } = require('vec3');
/** 真实的 1.21.4 注册表(mineflayer-pathfinder 的 Movements 依赖它) */
let cachedRegistry = null;
function defaultRegistry() {
if (!cachedRegistry) {
cachedRegistry = require('minecraft-data')('1.21.4');
}
return cachedRegistry;
}
/**
* 测试用的假 bot:只实现被测模块真正用到的接口,
* 避免测试依赖真实 Minecraft 服务器。
*/
class FakeBot extends EventEmitter {
constructor(options) {
super();
const opts = options || {};
this.username = opts.username || 'TestBot';
this.physicsEnabled = true;
this.usingHeldItem = false;
this.vehicle = null;
this.food = 20;
this.health = 20;
this.game = { gameMode: opts.gameMode || 'survival' };
this.physics = Object.assign({
gravity: 0.08,
playerSpeed: 0.1,
sprintSpeed: 0.3,
sneakSpeed: 0.3,
stepHeight: 0.6,
airborneInertia: 0.91,
waterInertia: 0.8,
yawSpeed: 3.0,
pitchSpeed: 3.0
}, opts.physics || {});
this.entity = {
position: new Vec3(opts.x || 0, opts.y || 64, opts.z || 0),
velocity: new Vec3(0, 0, 0),
yaw: 0,
pitch: 0,
eyeHeight: 1.62,
isInWater: false,
isInLava: false,
onGround: true
};
/** @type {Record<string, boolean>} */
this.controls = {};
this.heldItem = opts.heldItem || null;
this.inventory = { slots: opts.slots || [] };
this.entities = {};
this.world = opts.world || { raycast: () => null };
this.registry = opts.registry || defaultRegistry();
this.pathfinder = null;
this.blockMap = opts.blockMap || new Map();
this.creative = { flying: false };
}
getControlState(control) {
return this.controls[control] === true;
}
setControlState(control, state) {
this.controls[control] = state === true;
this.emit('controlState', control, state);
}
blockAt(pos) {
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
if (this.blockMap.has(key)) return this.blockMap.get(key);
return { name: 'air', boundingBox: 'empty' };
}
look(yaw, pitch) {
this.entity.yaw = yaw;
this.entity.pitch = pitch === undefined ? this.entity.pitch : pitch;
return Promise.resolve();
}
lookAt(point) {
return this.look(this.entity.yaw, this.entity.pitch);
}
attack(entity, swing) {
this.lastAttack = { entity, swing };
}
activateItem() {
this.usingHeldItem = true;
}
deactivateItem() {
this.usingHeldItem = false;
}
async waitForTicks(ticks) {
for (let i = 0; i < (ticks || 1); i++) this.emit('physicsTick');
}
chat(message) {
this.lastChat = message;
}
equip() {
return Promise.resolve();
}
consume() {
this.usingHeldItem = false;
this.food = 20;
return Promise.resolve();
}
mount() {
this.vehicle = { id: 99, position: this.entity.position.clone(), name: 'oak_boat' };
}
dismount() {
this.vehicle = null;
}
moveVehicle() {
this.lastVehicleMove = Array.from(arguments);
}
/** 推进一个物理 tick(触发合规层的旋转推进) */
tick() {
this.emit('physicsTick');
}
/** 推进 n 个物理 tick */
tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick');
}
}
module.exports = { FakeBot };