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 行删除
+68
查看文件
@@ -0,0 +1,68 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { defaultConfig, normalizeConfig } = require('./expansionConfig');
/**
* 配置文件读写器:把扩地配置持久化为 JSON。
*
* 默认路径为项目根目录下的 `data/autostore.json`(已在 .gitignore 中忽略),
* 也可通过构造参数覆盖,便于测试使用临时目录。
*/
class ConfigStore {
/**
* @param {string} [filePath] 配置文件绝对路径
*/
constructor(filePath) {
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'autostore.json');
}
/**
* 从磁盘加载配置;文件不存在或解析失败时返回默认配置。
* @returns {object}
*/
load() {
let raw;
try {
raw = fs.readFileSync(this.filePath, 'utf8');
} catch {
return defaultConfig();
}
try {
const parsed = JSON.parse(raw);
const config = normalizeConfig(parsed);
// 固定值字段:防止旧配置文件里的异常值影响运行
config.autoEat = true;
config.eatThreshold = 14;
// API Key 兜底:配置文件未填写时从环境变量 SIMP_API_KEY 读取
//(密钥不进代码仓库,开发环境由 .env 提供,运行环境可设置系统环境变量)
if (!config.apiKey) {
const envKey = process.env.SIMP_API_KEY;
if (envKey && envKey.trim()) config.apiKey = envKey.trim();
}
return config;
} catch {
return defaultConfig();
}
}
/**
* 把配置写入磁盘(自动创建父目录)。
* @param {object} config
* @returns {boolean} 是否写入成功
*/
save(config) {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf8');
return true;
} catch {
// 保存失败时静默忽略(不阻塞运行)
return false;
}
}
}
module.exports = { ConfigStore };
+328
查看文件
@@ -0,0 +1,328 @@
'use strict';
/**
* 自动扩地配置。
*
* 字段命名与原 Java 版 `ExpansionConfig` 保持一一对应(便于对照移植正确性),
* 新增能力统一收拢在三个分组对象里:
* - `pathing`:mineflayer-pathfinder 专属的寻路开关
* - `grim`:Grim 反作弊合规层的阈值
* - `server`:与服务器交互的指令与节流
*/
/** 扩地策略枚举 */
const STRATEGY = {
/** 方形外扩(优先填洞) */
SQUARE: 1,
/** 区域扩地(S 形蛇形扫描) */
REGION: 2,
/** 玩家周围扩地 */
AROUND_PLAYER: 3,
/** 绕国一圈 */
PERIMETER: 4
};
/** 水域处理策略 */
const WATER_MODE = {
/** 绕开水域(默认,最安全) */
AVOID: 'avoid',
/** 游泳通过 */
SWIM: 'swim',
/** 放船渡水 */
BOAT: 'boat'
};
/**
* 生成一份默认配置。
* @returns {object}
*/
function defaultConfig() {
return {
// ==================== 扩地策略(与 Java 版同名) ====================
/** 扩地策略:1=方形外扩,2=区域扩地,3=玩家周围,4=绕国一圈 */
strategy: STRATEGY.SQUARE,
/** 目标邦国名称;为空时表示自动识别玩家当前所在邦国 */
stateName: '',
/** 目标扩地数量;-1 表示持续扩地直到无可用区块 */
targetCount: -1,
/** 扩圈上限(0 表示不限制) */
maxRings: 0,
// ==================== 区域扩地范围(与 Java 版同名) ====================
regionCenterX: 0,
regionCenterZ: 0,
regionRadius: 5,
/** 矩形边界(区块坐标);maxX < minX 表示未圈选,回退到中心+半径 */
regionMinX: 0,
regionMaxX: -1,
regionMinZ: 0,
regionMaxZ: -1,
// ==================== 超时与重试(与 Java 版同名) ====================
/** 等待 / 寻路超时时间(秒) */
waitTimeoutSeconds: 60,
/** 超时后的最大重试次数 */
retries: 2,
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
returnThresholdSeconds: 60,
/** 发送回城指令后等待传送完成的时间(秒) */
returnWaitSeconds: 12,
/** 单个目标最多触发「往边上走两步」自救的轮数 */
sideStepMaxRounds: 3,
// ==================== 开垦时段限制(新增,原版硬编码 11:00-22:00) ====================
/** 是否启用开垦时段限制(服务器只在特定时段接受开拓) */
enforceClaimTime: true,
/** 允许开拓的现实时间起始小时(含,本地时区) */
claimStartHour: 11,
/** 允许开拓的现实时间结束小时(不含,本地时区) */
claimEndHour: 22,
// ==================== 寻路基础开关(与 Java 版同名) ====================
allowSprint: true,
allowSwim: true,
allowJump: true,
allowBreak: false,
allowPlace: false,
/** 寻路高度偏好:优先往高处走 */
altitudePreference: true,
/** 高度偏好权重:每上升 1 格折算的启发成本减少量 */
altitudeWeight: 0.1,
// ==================== 生存辅助(与 Java 版同名) ====================
avoidMobs: true,
autoEat: true,
/** 触发自动进食的饥饿值阈值(0-20) */
eatThreshold: 14,
autoAttack: true,
durabilityGuard: true,
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
durabilityThreshold: 15,
/** 是否启用自动用船过水 */
autoBoat: false,
/** 触发自动用船的水面最小宽度(格) */
autoBoatMinWidth: 4,
/** 自动用船最短水域长度(格) */
autoBoatMinLength: 6,
/** 水域处理策略:avoid / swim / boat */
waterMode: WATER_MODE.AVOID,
// ==================== 数据源(与 Java 版同名) ====================
/** 地图自动刷新间隔(秒),最小 10 秒 */
refreshIntervalSeconds: 30,
/** 是否启用 SimpCore 开放 API 作为占领判定补充来源 */
apiEnabled: false,
/** SimpCore 开放 API Key(留空时回退读环境变量 SIMP_API_KEY) */
apiKey: '',
/** API 领土数据轮询间隔(秒),最小 60 */
apiRefreshIntervalSeconds: 180,
// ==================== 寻路器专属(新增) ====================
pathing: {
/** 是否允许跑酷跳跃(mineflayer 起跳时机与原版有偏差,默认关闭更安全) */
allowParkour: false,
/** 是否允许 1×1 搭塔(Grim 的 place 类检查极易命中,默认关闭) */
allow1by1towers: false,
/** 允许的最大下落高度(格),超过则绕行 */
maxDropDown: 3,
/** 进入液体格的额外代价(越大越倾向绕开水域) */
liquidCost: 40,
/** 是否允许打开门/栅栏门 */
canOpenDoors: false
},
// ==================== 战斗辅助(新增) ====================
combat: {
/** 自动攻击索敌半径(格):只打进入该范围的敌对生物,不追击 */
scanRange: 4.5,
/** 是否攻击末影人(mineflayer 读不到「是否被激怒」,默认不打) */
attackEnderman: false,
/** 是否攻击蜘蛛(mineflayer 读不到环境亮度,默认不打) */
attackSpiders: false,
/** 是否攻击猪灵(仅在未穿金盔甲时才会攻击) */
attackPiglins: true,
/** 是否跳过高危生物(监守者 / 末影龙 / 凋灵),默认跳过 */
avoidDangerousMobs: true
},
// ==================== Grim 反作弊合规层(新增) ====================
grim: {
/** 总开关:关闭后不做任何限速/校验(仅用于本地测试服) */
enabled: true,
/** 每 tick 允许的最大旋转角度(度);mineflayer 默认 yawSpeed=3.0 rad/s ≈ 8.6°/tick */
maxRotationPerTickDeg: 8.6,
/** 攻击判定距离(格),生存模式实体交互距离为 3.0 */
attackReach: 3.0,
/** 攻击前是否要求视线可达(防 Reach / LineOfSight 检查) */
requireLineOfSight: true,
/** 是否遵守攻击冷却(1.9+ 武器攻速) */
respectAttackCooldown: true,
/** 进食期间是否停止移动(原版进食会取消疾跑并减速,防 NoSlow / MultiActions) */
stopMovementWhileEating: true,
/**
* 是否禁止疾跑跳跃。
* 疾跑跳本身是原版合法动作,Grim 的预测支持;默认放行。
* 若实测被标记,可开启此项退化为普通跳跃(代价是移动变慢)。
*/
blockSprintJump: false,
/** 是否禁止疾跑与潜行/使用物品同时进行(防 MultiActions) */
blockConflictingActions: true,
/** 是否在启动时校验物理参数未被篡改 */
checkPhysicsIntegrity: true,
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
blockActionsWhileRiding: true
},
// ==================== 服务器交互(新增) ====================
server: {
/** 启用开垦模式的指令(不带斜杠),留空表示服务器自动开启 */
claimCommand: 'u claimmode',
/** 登录指令模板,`{password}` 会被替换为 .env 中的 MC_PASSWORD;留空则不发送 */
loginCommand: 'login {password}',
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
registerCommand: '',
/** 聊天指令最小间隔(毫秒),防刷屏 */
commandCooldownMs: 1200,
/** 断线后是否自动重连 */
autoReconnect: true,
/** 重连间隔(毫秒) */
reconnectDelayMs: 10000
}
};
}
/**
* 深拷贝配置。
* @param {object} config
* @returns {object}
*/
function copyConfig(config) {
return JSON.parse(JSON.stringify(config));
}
/**
* 把外部传入(配置文件 / Web 控制台)的部分配置合并到默认配置上,并做范围钳制。
* 只接受已知字段,未知字段一律丢弃,避免脏配置污染运行状态。
*
* @param {object} partial 用户提供的部分配置
* @param {object} [base] 基线配置(默认取 defaultConfig())
* @returns {object} 规范化后的完整配置
*/
function normalizeConfig(partial, base) {
const defaults = base || defaultConfig();
const merged = copyConfig(defaults);
if (!partial || typeof partial !== 'object') return validateConfig(merged);
for (const key of Object.keys(merged)) {
const value = partial[key];
if (value === undefined) continue;
if (value && typeof value === 'object' && !Array.isArray(value) && typeof merged[key] === 'object') {
for (const subKey of Object.keys(merged[key])) {
if (value[subKey] !== undefined) merged[key][subKey] = value[subKey];
}
} else {
merged[key] = value;
}
}
return validateConfig(merged);
}
/**
* 钳制配置中的越界值,防止异常配置导致「永不回城」或「刚出门就回城」。
*
* 会先把缺失的字段(含嵌套分组)补齐为默认值,因此可以直接传入部分配置。
*
* @param {object} config
* @returns {object} 同一个对象(原地修正)
*/
function validateConfig(config) {
const base = defaultConfig();
for (const key of Object.keys(base)) {
const defaultValue = base[key];
if (defaultValue !== null && typeof defaultValue === 'object') {
config[key] = Object.assign({}, defaultValue, config[key] || {});
} else if (config[key] === undefined) {
config[key] = defaultValue;
}
}
config.strategy = clampEnum(config.strategy, [1, 2, 3, 4], STRATEGY.SQUARE);
config.targetCount = clampInt(config.targetCount, -1, 1e9, -1);
config.maxRings = clampInt(config.maxRings, 0, 1000, 0);
config.regionRadius = clampInt(config.regionRadius, 1, 512, 5);
config.regionMinX = clampInt(config.regionMinX, -1e6, 1e6, 0);
config.regionMaxX = clampInt(config.regionMaxX, -1e6, 1e6, -1);
config.regionMinZ = clampInt(config.regionMinZ, -1e6, 1e6, 0);
config.regionMaxZ = clampInt(config.regionMaxZ, -1e6, 1e6, -1);
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
config.retries = clampInt(config.retries, 1, 20, 2);
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
config.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
config.apiRefreshIntervalSeconds = clampInt(config.apiRefreshIntervalSeconds, 60, 3600, 180);
config.altitudeWeight = clampNumber(config.altitudeWeight, 0, 10, 0.1);
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
config.waterMode = WATER_MODE.AVOID;
}
if (typeof config.stateName !== 'string') config.stateName = '';
if (typeof config.apiKey !== 'string') config.apiKey = '';
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
return config;
}
function clampInt(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/**
* 枚举字段校验:不在允许集合内时回退到默认值。
*
* 之所以不用「钳制到最近边界」,是因为策略这类枚举没有大小含义,
* 把非法的 99 静默变成「策略 4 绕国一圈」比回退到默认策略更危险。
*
* @param {*} value
* @param {Array<*>} allowed
* @param {*} fallback
* @returns {*}
*/
function clampEnum(value, allowed, fallback) {
return allowed.includes(value) ? value : fallback;
}
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
module.exports = {
STRATEGY,
WATER_MODE,
defaultConfig,
copyConfig,
normalizeConfig,
validateConfig
};