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,105 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {
|
||||
SubtitleChatMonitor,
|
||||
containsFuzzy,
|
||||
boundedLevenshtein
|
||||
} = require('../src/core/subtitleChatMonitor');
|
||||
|
||||
/** 收集回调结果的假监听器 */
|
||||
function makeListener() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
onProgress: (percent) => events.push({ type: 'progress', percent }),
|
||||
onSuccess: (chunk, blockX, blockZ) => events.push({ type: 'success', chunk, blockX, blockZ }),
|
||||
onGenericSuccess: () => events.push({ type: 'generic' })
|
||||
};
|
||||
}
|
||||
|
||||
test('字幕解析开垦进度百分比', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onSubtitle('开垦中 50%');
|
||||
assert.deepStrictEqual(listener.events, [{ type: 'progress', percent: 50 }]);
|
||||
});
|
||||
|
||||
test('字幕进度支持小数并钳制到 0-100', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onSubtitle('开垦中 99.6%');
|
||||
monitor.onSubtitle('开垦中 150%');
|
||||
// 负号不属于百分比模式(与原 Java 版一致:模式为数字 + %,负号被当作普通文本忽略)
|
||||
monitor.onSubtitle('开垦中 -20%');
|
||||
assert.deepStrictEqual(listener.events.map((e) => e.percent), [100, 100, 20]);
|
||||
});
|
||||
|
||||
test('不含开垦关键词的字幕被忽略', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onSubtitle('服务器公告:今晚 20:00 攻城 50%');
|
||||
assert.strictEqual(listener.events.length, 0);
|
||||
});
|
||||
|
||||
test('聊天解析带坐标的开垦成功消息', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onChat('疆土(123,-45) [1968,-720] 已为你的邦国开拓');
|
||||
assert.deepStrictEqual(listener.events, [{
|
||||
type: 'success',
|
||||
chunk: { x: 123, z: -45 },
|
||||
blockX: 1968,
|
||||
blockZ: -720
|
||||
}]);
|
||||
});
|
||||
|
||||
test('聊天成功消息兼容全角括号与全角逗号', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onChat('疆土(7,8) [ 112 , 128 ] 已为你的邦国开拓');
|
||||
assert.strictEqual(listener.events.length, 1);
|
||||
assert.deepStrictEqual(listener.events[0].chunk, { x: 7, z: 8 });
|
||||
assert.strictEqual(listener.events[0].blockX, 112);
|
||||
assert.strictEqual(listener.events[0].blockZ, 128);
|
||||
});
|
||||
|
||||
test('无坐标的通用成功消息走 onGenericSuccess', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onChat('你的疆土已开拓,继续努力!');
|
||||
assert.deepStrictEqual(listener.events, [{ type: 'generic' }]);
|
||||
});
|
||||
|
||||
test('完全无关的聊天不触发任何回调', () => {
|
||||
const listener = makeListener();
|
||||
const monitor = new SubtitleChatMonitor(listener);
|
||||
monitor.onChat('<Steve> 大家好');
|
||||
monitor.onChat('');
|
||||
monitor.onChat(null);
|
||||
assert.strictEqual(listener.events.length, 0);
|
||||
});
|
||||
|
||||
test('containsFuzzy 容忍细微文案差异', () => {
|
||||
assert.strictEqual(containsFuzzy('开垦中 50%', '开垦中', 0), true);
|
||||
// 差一个字("开垦" vs "开恳")在 maxEdits=1 下应命中
|
||||
assert.strictEqual(containsFuzzy('开恳中 50%', '开垦中', 1), true);
|
||||
assert.strictEqual(containsFuzzy('开恳中 50%', '开垦中', 0), false);
|
||||
assert.strictEqual(containsFuzzy('完全无关的文本', '开垦中', 1), false);
|
||||
});
|
||||
|
||||
test('containsFuzzy 空关键词视为命中,空文本视为不命中', () => {
|
||||
assert.strictEqual(containsFuzzy('任意文本', '', 0), true);
|
||||
assert.strictEqual(containsFuzzy('', '开垦中', 1), false);
|
||||
assert.strictEqual(containsFuzzy(null, '开垦中', 1), false);
|
||||
});
|
||||
|
||||
test('boundedLevenshtein 计算有界编辑距离', () => {
|
||||
assert.strictEqual(boundedLevenshtein('abc', 0, 'abc', 3), 0);
|
||||
assert.strictEqual(boundedLevenshtein('abd', 0, 'abc', 3), 1);
|
||||
assert.strictEqual(boundedLevenshtein('xyz', 0, 'abc', 3), 3);
|
||||
// 超过 limit 时提前返回 limit+1
|
||||
assert.strictEqual(boundedLevenshtein('xyzxyz', 0, 'abc', 2), 3);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户