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,125 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {
|
||||
parseMarkers,
|
||||
fillPolygonEvenOdd,
|
||||
pointInPolygon,
|
||||
extractPolygons,
|
||||
parseColor,
|
||||
buildMarkersUrl
|
||||
} = require('../src/core/territoryDataService');
|
||||
const chunkPos = require('../src/core/chunkPos');
|
||||
|
||||
/** 把 [x, z] 坐标对转换为 dynmap 的点对象形式 */
|
||||
function points(coords) {
|
||||
return coords.map(([x, z]) => ({ x, z }));
|
||||
}
|
||||
|
||||
/** 构造一个 markers.json 的单个多边形 marker */
|
||||
function claimMarker(name, polygons, color) {
|
||||
const converted = polygons.map(points);
|
||||
return {
|
||||
popup: `<div>疆土归属: ${name}</div>`,
|
||||
color: color || '#75bd20',
|
||||
points: converted.length === 1 ? converted[0] : converted
|
||||
};
|
||||
}
|
||||
|
||||
test('pointInPolygon 经典射线法', () => {
|
||||
const square = [[0, 0], [32, 0], [32, 32], [0, 32]];
|
||||
assert.strictEqual(pointInPolygon(16, 16, square), true);
|
||||
assert.strictEqual(pointInPolygon(-1, 16, square), false);
|
||||
assert.strictEqual(pointInPolygon(33, 16, square), false);
|
||||
});
|
||||
|
||||
test('extractPolygons 展平单个多边形与嵌套多边形数组', () => {
|
||||
const single = points([[0, 0], [16, 0], [16, 16]]);
|
||||
assert.strictEqual(extractPolygons(single).length, 1);
|
||||
|
||||
const nested = [points([[0, 0], [16, 0], [16, 16]]), points([[4, 4], [8, 4], [8, 8]])];
|
||||
assert.strictEqual(extractPolygons(nested).length, 2);
|
||||
|
||||
// 少于 3 个点的多边形被丢弃
|
||||
assert.strictEqual(extractPolygons(points([[0, 0], [1, 1]])).length, 0);
|
||||
});
|
||||
|
||||
test('fillPolygonEvenOdd 用内圈孔洞挖空外圈', () => {
|
||||
const outer = [[0, 0], [48, 0], [48, 48], [0, 48]];
|
||||
const hole = [[16, 16], [32, 16], [32, 32], [16, 32]];
|
||||
const out = new Set();
|
||||
fillPolygonEvenOdd(out, [outer, hole]);
|
||||
// 3×3 区块的外圈 = 9 格,挖掉中心 1 格 = 8 格
|
||||
assert.strictEqual(out.size, 8);
|
||||
assert.ok(!out.has(chunkPos.key(1, 1)), '孔洞中心区块不应被填充');
|
||||
assert.ok(out.has(chunkPos.key(0, 0)));
|
||||
assert.ok(out.has(chunkPos.key(2, 2)));
|
||||
});
|
||||
|
||||
test('parseMarkers 解析邦国领土与王城', () => {
|
||||
const json = JSON.stringify([
|
||||
{
|
||||
id: 'union_claims',
|
||||
markers: [
|
||||
claimMarker('测试国', [[[0, 0], [32, 0], [32, 32], [0, 32]]], '#ff0000'),
|
||||
claimMarker('另一国', [[[64, 64], [96, 64], [96, 96], [64, 96]]], '#00ff00')
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'union_capitals',
|
||||
markers: [
|
||||
{ popup: '<div>「测试国」的王城</div>', point: { x: 8, y: 70, z: 8 } }
|
||||
]
|
||||
}
|
||||
]);
|
||||
const model = parseMarkers(json);
|
||||
assert.strictEqual(model.stateCount, 2);
|
||||
|
||||
const state = model.state('测试国');
|
||||
assert.ok(state);
|
||||
assert.strictEqual(state.color, 0xff0000);
|
||||
assert.deepStrictEqual(state.capital, { x: 0, z: 0 }, '王城应由方块坐标 8,8 换算为区块 0,0');
|
||||
assert.strictEqual(state.chunks.size, 4, '2×2 区块应被填充');
|
||||
|
||||
// 没有王城 marker 的邦国回退到第一个区块
|
||||
const other = model.state('另一国');
|
||||
assert.ok(other.capital);
|
||||
});
|
||||
|
||||
test('parseMarkers 忽略弹窗中没有邦国名的 marker', () => {
|
||||
const json = JSON.stringify([
|
||||
{ id: 'union_claims', markers: [{ popup: '<div>无归属</div>', points: points([[0, 0], [16, 0], [16, 16]]) }] },
|
||||
{ id: 'other_layer', markers: [claimMarker('忽略我', [[[0, 0], [16, 0], [16, 16]]])] }
|
||||
]);
|
||||
const model = parseMarkers(json);
|
||||
assert.strictEqual(model.stateCount, 0);
|
||||
});
|
||||
|
||||
test('parseMarkers 顶层必须是数组', () => {
|
||||
assert.throws(() => parseMarkers('{}'), /数组/);
|
||||
});
|
||||
|
||||
test('parseColor 支持 #RRGGBB 与 #AARRGGBB', () => {
|
||||
assert.strictEqual(parseColor('#ff0000'), 0xff0000);
|
||||
assert.strictEqual(parseColor('#80ff0000'), 0xff0000);
|
||||
assert.strictEqual(parseColor('75bd20'), 0x75bd20);
|
||||
// 非法输入回退到默认邦国绿
|
||||
assert.strictEqual(parseColor('#zzzzzz'), 0x75bd20);
|
||||
assert.strictEqual(parseColor(''), 0x75bd20);
|
||||
});
|
||||
|
||||
test('buildMarkersUrl 拼接地图站点与世界目录', () => {
|
||||
assert.strictEqual(
|
||||
buildMarkersUrl('http://bgjq.simpfun.cn', 'minecraft_overworld'),
|
||||
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/markers.json'
|
||||
);
|
||||
// 末尾斜杠应被去掉
|
||||
assert.strictEqual(
|
||||
buildMarkersUrl('https://map.example.com/', 'world'),
|
||||
'https://map.example.com/tiles/world/markers.json'
|
||||
);
|
||||
// 缺省值
|
||||
assert.strictEqual(buildMarkersUrl(), 'http://bgjq.simpfun.cn/tiles/minecraft_overworld/markers.json');
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户