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 语法自检通过。
这个提交包含在:
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { AltitudeGoal, PathingService, PathEvent } = require('../src/bot/pathingService');
|
||||
const { GoalXZ, GoalNearXZ } = require('mineflayer-pathfinder').goals;
|
||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||
const { FakeBot } = require('./helpers/fakeBot');
|
||||
|
||||
test('AltitudeGoal 的到达判定与内层目标一致', () => {
|
||||
const inner = new GoalXZ(100, 200);
|
||||
const goal = new AltitudeGoal(inner, 0.1, 64);
|
||||
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 200 }), true, '不约束高度');
|
||||
assert.strictEqual(goal.isEnd({ x: 101, y: 70, z: 200 }), false);
|
||||
});
|
||||
|
||||
test('AltitudeGoal 对高处给更低启发值、对低处给更高启发值', () => {
|
||||
const goal = new AltitudeGoal(new GoalXZ(0, 0), 0.1, 64);
|
||||
const high = goal.heuristic({ x: 10, y: 100, z: 10 });
|
||||
const low = goal.heuristic({ x: 10, y: 20, z: 10 });
|
||||
assert.ok(high < low, `高处启发值 ${high} 应小于低处 ${low}`);
|
||||
// 与 GoalXZ 的差值应等于 weight × y
|
||||
const base = new GoalXZ(0, 0).heuristic({ x: 10, y: 100, z: 10 });
|
||||
assert.ok(Math.abs((base - high) - 0.1 * 100) < 1e-9);
|
||||
});
|
||||
|
||||
test('AltitudeGoal 启发值被钳制为非负', () => {
|
||||
const goal = new AltitudeGoal(new GoalXZ(0, 0), 10, 0);
|
||||
assert.ok(goal.heuristic({ x: 0, y: 300, z: 0 }) >= 0);
|
||||
});
|
||||
|
||||
test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
|
||||
const goal = new AltitudeGoal(new GoalNearXZ(100, 200, 2), 0.1, 64);
|
||||
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
||||
});
|
||||
|
||||
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => {
|
||||
const bot = new FakeBot();
|
||||
const movements = {};
|
||||
bot.pathfinder = {
|
||||
setMovements: (m) => { movements.value = m; },
|
||||
thinkTimeout: 0,
|
||||
tickTimeout: 0,
|
||||
searchRadius: 0
|
||||
};
|
||||
const config = defaultConfig();
|
||||
const service = new PathingService(bot, config, null);
|
||||
assert.strictEqual(service.configure(), true);
|
||||
|
||||
const m = movements.value;
|
||||
assert.strictEqual(m.canDig, false, '默认不允许挖方块');
|
||||
assert.deepStrictEqual(m.scafoldingBlocks, [], '默认不允许放方块');
|
||||
assert.strictEqual(m.allowParkour, false);
|
||||
assert.strictEqual(m.allow1by1towers, false);
|
||||
assert.strictEqual(m.allowFreeMotion, false);
|
||||
assert.strictEqual(m.maxDropDown, 3);
|
||||
assert.strictEqual(m.infiniteLiquidDropdownDistance, false);
|
||||
assert.strictEqual(m.dontMineUnderFallingBlock, true);
|
||||
// waterMode=avoid 时液体代价被推高
|
||||
assert.ok(m.liquidCost >= 200, 'avoid 模式下液体代价应被推高');
|
||||
});
|
||||
|
||||
test('configure 在 allowBreak / allowPlace 打开时放行对应能力', () => {
|
||||
const bot = new FakeBot();
|
||||
const movements = {};
|
||||
bot.pathfinder = { setMovements: (m) => { movements.value = m; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
|
||||
const config = defaultConfig();
|
||||
config.allowBreak = true;
|
||||
config.allowPlace = true;
|
||||
config.waterMode = 'swim';
|
||||
const service = new PathingService(bot, config, null);
|
||||
service.configure();
|
||||
assert.strictEqual(movements.value.canDig, true);
|
||||
assert.ok(movements.value.scafoldingBlocks.length > 0);
|
||||
assert.strictEqual(movements.value.liquidCost, config.pathing.liquidCost);
|
||||
});
|
||||
|
||||
test('pathTo 下发目标区块中心并触发 CALC_STARTED', () => {
|
||||
const bot = new FakeBot();
|
||||
let goal = null;
|
||||
bot.pathfinder = {
|
||||
setMovements: () => {},
|
||||
setGoal: (g) => { goal = g; },
|
||||
thinkTimeout: 0,
|
||||
tickTimeout: 0,
|
||||
searchRadius: 0
|
||||
};
|
||||
const events = [];
|
||||
const service = new PathingService(bot, defaultConfig(), null);
|
||||
service.configure();
|
||||
service.onPathEvent = (event, detail) => events.push({ event, detail });
|
||||
assert.strictEqual(service.pathTo({ x: 3, z: -2 }), true);
|
||||
assert.ok(goal, '应下发目标');
|
||||
assert.strictEqual(goal.isEnd({ x: 3 * 16 + 8, y: 64, z: -2 * 16 + 8 }), true, '目标应为区块中心附近');
|
||||
assert.strictEqual(events[0].event, PathEvent.CALC_STARTED);
|
||||
assert.strictEqual(service.isPathing(), true);
|
||||
});
|
||||
|
||||
test('关闭高度偏好时不包装目标', () => {
|
||||
const bot = new FakeBot();
|
||||
let goal = null;
|
||||
bot.pathfinder = { setMovements: () => {}, setGoal: (g) => { goal = g; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
|
||||
const config = defaultConfig();
|
||||
config.altitudePreference = false;
|
||||
const service = new PathingService(bot, config, null);
|
||||
service.configure();
|
||||
service.pathTo({ x: 0, z: 0 });
|
||||
assert.ok(!(goal instanceof AltitudeGoal), '未启用高度偏好时不应包装');
|
||||
});
|
||||
|
||||
test('cancel 清空目标并停止寻路状态', () => {
|
||||
const bot = new FakeBot();
|
||||
let cleared = false;
|
||||
bot.pathfinder = {
|
||||
setMovements: () => {},
|
||||
setGoal: (g) => { if (g === null) cleared = true; },
|
||||
thinkTimeout: 0,
|
||||
tickTimeout: 0,
|
||||
searchRadius: 0
|
||||
};
|
||||
const service = new PathingService(bot, defaultConfig(), null);
|
||||
service.configure();
|
||||
service.pathTo({ x: 1, z: 1 });
|
||||
service.cancel();
|
||||
assert.strictEqual(cleared, true);
|
||||
assert.strictEqual(service.isPathing(), false);
|
||||
assert.strictEqual(service.currentTarget, null);
|
||||
});
|
||||
|
||||
test('pathfinder 未加载时 configure 返回 false 且 pathTo 不抛异常', () => {
|
||||
const bot = new FakeBot();
|
||||
const service = new PathingService(bot, defaultConfig(), null);
|
||||
assert.strictEqual(service.configure(), false);
|
||||
assert.strictEqual(service.isAvailable(), false);
|
||||
assert.strictEqual(service.pathTo({ x: 0, z: 0 }), false);
|
||||
service.cancel(); // 不应抛异常
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户