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,269 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { ExpansionPlanner } = require('../src/core/expansionPlanner');
|
||||
const { TerritoryModel } = require('../src/core/territoryModel');
|
||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||
const chunkPos = require('../src/core/chunkPos');
|
||||
|
||||
/**
|
||||
* 构造领土模型。
|
||||
* @param {Record<string, Array<[number, number]>>} states 邦国名 → 区块坐标列表
|
||||
* @param {string} [selfName] 己方邦国名
|
||||
*/
|
||||
function makeModel(states, selfName) {
|
||||
const map = new Map();
|
||||
for (const [name, coords] of Object.entries(states)) {
|
||||
const chunks = new Set(coords.map(([x, z]) => chunkPos.key(x, z)));
|
||||
map.set(name, {
|
||||
name,
|
||||
capital: { x: coords[0][0], z: coords[0][1] },
|
||||
chunks,
|
||||
color: 0x75bd20
|
||||
});
|
||||
}
|
||||
const model = new TerritoryModel(map);
|
||||
if (selfName) model.setSelfStateName(selfName);
|
||||
return model;
|
||||
}
|
||||
|
||||
const planner = new ExpansionPlanner();
|
||||
|
||||
test('策略 1:只返回与己方领土相邻的未占领区块', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 1 });
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
// 接壤只算四个正方向,因此单个己方区块只有 4 个合法邻居(对角线不算)
|
||||
assert.strictEqual(result.length, 4, '单个己方区块应产生 4 个正方向邻居候选');
|
||||
for (const chunk of result) {
|
||||
assert.strictEqual(
|
||||
Math.abs(chunk.x) + Math.abs(chunk.z),
|
||||
1,
|
||||
`候选 ${chunk.x},${chunk.z} 不是 (0,0) 的正方向邻居`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('策略 1:绝不选中「只与其他邦国相邻」的区块(历史 bug 回归)', () => {
|
||||
// 己方只有 (0,0);其他邦国占据 (1,0) 与 (1,1)
|
||||
const model = makeModel({ A: [[0, 0]], B: [[1, 0], [1, 1]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 1 });
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
const keys = result.map((c) => chunkPos.key(c.x, c.z));
|
||||
assert.ok(!keys.includes(chunkPos.key(1, 0)), '不能选中已被 B 占领的区块');
|
||||
assert.ok(!keys.includes(chunkPos.key(1, 1)), '不能选中已被 B 占领的区块');
|
||||
// (2,0) 只与 B 的 (1,0) 相邻,与 A 不相邻,必须被排除
|
||||
assert.ok(!keys.includes(chunkPos.key(2, 0)), '不能选中只与他国相邻的区块');
|
||||
for (const chunk of result) {
|
||||
const adjacentToOwn = chunkPos.cardinalNeighbors(chunk.x, chunk.z)
|
||||
.some((n) => n.x === 0 && n.z === 0);
|
||||
assert.ok(adjacentToOwn, `候选 ${chunk.x},${chunk.z} 必须与己方领土接壤`);
|
||||
}
|
||||
});
|
||||
|
||||
test('策略 1:矩形内部的空洞优先于外部边界', () => {
|
||||
// 己方占 3×3 外圈,中心 (1,1) 是空洞
|
||||
const ring = [];
|
||||
for (let x = 0; x <= 2; x++) {
|
||||
for (let z = 0; z <= 2; z++) {
|
||||
if (x === 1 && z === 1) continue;
|
||||
ring.push([x, z]);
|
||||
}
|
||||
}
|
||||
const model = makeModel({ A: ring }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 1 });
|
||||
const result = planner.generate(model, config, { x: 1, z: 1 });
|
||||
assert.deepStrictEqual(result[0], { x: 1, z: 1 }, '内部空洞应排在最前');
|
||||
});
|
||||
|
||||
test('策略 1:maxRings 影响搜索圈数(扩张轮数为 0 时只看第一圈)', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 1, maxRings: 1 });
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
// maxRings=1 → maxExpansions=2,第一圈就能找到候选,结果仍是 4 个正方向邻居
|
||||
assert.strictEqual(result.length, 4);
|
||||
});
|
||||
|
||||
test('策略 2:矩形扩地严格按 S 形蛇形扫描', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), {
|
||||
strategy: 2,
|
||||
regionMinX: 0,
|
||||
regionMaxX: 3,
|
||||
regionMinZ: 0,
|
||||
regionMaxZ: 3
|
||||
});
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
assert.strictEqual(result.length, 15, '4×4 区域去掉 1 个己方区块应为 15 个');
|
||||
// 王城在 (0,0):起始边为 z=0(北),行内从 x 小到大
|
||||
assert.deepStrictEqual(result[0], { x: 1, z: 0 });
|
||||
assert.deepStrictEqual(result[1], { x: 2, z: 0 });
|
||||
assert.deepStrictEqual(result[2], { x: 3, z: 0 });
|
||||
// 第二行反向(蛇形)
|
||||
assert.deepStrictEqual(result[3], { x: 3, z: 1 });
|
||||
assert.deepStrictEqual(result[4], { x: 2, z: 1 });
|
||||
assert.deepStrictEqual(result[5], { x: 1, z: 1 });
|
||||
assert.deepStrictEqual(result[6], { x: 0, z: 1 });
|
||||
// 第三行再次反向
|
||||
assert.deepStrictEqual(result[7], { x: 0, z: 2 });
|
||||
});
|
||||
|
||||
test('策略 2:每个入选区块都与己方领土(含本次已选)接壤', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), {
|
||||
strategy: 2,
|
||||
regionMinX: -2,
|
||||
regionMaxX: 4,
|
||||
regionMinZ: -2,
|
||||
regionMaxZ: 4
|
||||
});
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
const claimed = new Set([chunkPos.key(0, 0)]);
|
||||
for (const chunk of result) {
|
||||
const adjacent = chunkPos.cardinalNeighbors(chunk.x, chunk.z)
|
||||
.some((n) => claimed.has(chunkPos.key(n.x, n.z)));
|
||||
assert.ok(adjacent, `区块 ${chunk.x},${chunk.z} 必须与己方领土接壤`);
|
||||
claimed.add(chunkPos.key(chunk.x, chunk.z));
|
||||
}
|
||||
});
|
||||
|
||||
test('策略 2:跳过他国区块但不打乱 S 形顺序', () => {
|
||||
// 己方 (0,0)(1,0),他国占 (1,1),区域 0..3 × 0..1
|
||||
const model = makeModel({ A: [[0, 0], [1, 0]], B: [[1, 1]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), {
|
||||
strategy: 2,
|
||||
regionMinX: 0,
|
||||
regionMaxX: 3,
|
||||
regionMinZ: 0,
|
||||
regionMaxZ: 1
|
||||
});
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
const keys = result.map((c) => `${c.x},${c.z}`);
|
||||
assert.ok(!keys.includes('1,1'), '他国区块必须跳过');
|
||||
// S 形顺序保持不变:第一行向右扫,第二行向左扫,(1,1) 被跳过但顺序不被打乱;
|
||||
// 末尾的 (0,1) 因为与己方 (0,0) 正方向接壤而被选中
|
||||
assert.deepStrictEqual(keys, ['2,0', '3,0', '3,1', '2,1', '0,1']);
|
||||
});
|
||||
|
||||
test('策略 2:他国区块会切断连通,其后不可达区块不入选', () => {
|
||||
// 己方只有 (0,0),他国占 (2,0):x=3 一列与他国相邻但离己方太远,必须排除
|
||||
const model = makeModel({ A: [[0, 0]], B: [[2, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), {
|
||||
strategy: 2,
|
||||
regionMinX: 0,
|
||||
regionMaxX: 3,
|
||||
regionMinZ: 0,
|
||||
regionMaxZ: 0
|
||||
});
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
assert.deepStrictEqual(result.map((c) => `${c.x},${c.z}`), ['1,0']);
|
||||
});
|
||||
|
||||
test('策略 3:按与玩家的距离由近到远排序', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 3 });
|
||||
const playerChunk = { x: 5, z: 5 };
|
||||
const result = planner.generate(model, config, playerChunk);
|
||||
assert.strictEqual(result.length, 4);
|
||||
const distances = result.map((c) => chunkPos.manhattan(c, playerChunk));
|
||||
for (let i = 1; i < distances.length; i++) {
|
||||
assert.ok(distances[i] >= distances[i - 1], '距离必须非递减');
|
||||
}
|
||||
assert.strictEqual(distances[0], 9, '最近的邻居距离应为 9');
|
||||
});
|
||||
|
||||
test('策略 4:绕国一圈按方位角均匀排序', () => {
|
||||
const model = makeModel({ A: [[0, 0], [1, 0], [0, 1], [1, 1]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 4, maxRings: 1 });
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
assert.strictEqual(result.length, 8, '2×2 领土外圈应有 8 个候选');
|
||||
// 质心 (0,0):方位角从 -π/2 起
|
||||
assert.deepStrictEqual(result[0], { x: 0, z: -1 });
|
||||
assert.deepStrictEqual(result[1], { x: 1, z: -1 });
|
||||
});
|
||||
|
||||
test('策略 4:多圈扩张时逐圈向外推进', () => {
|
||||
const model = makeModel({ A: [[0, 0]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 4, maxRings: 2 });
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
assert.ok(result.length > 4, '两圈应超过第一圈的 4 个候选');
|
||||
const firstRing = result.slice(0, 4).every((c) => Math.abs(c.x) + Math.abs(c.z) === 1);
|
||||
assert.ok(firstRing, '前 4 个应是第一圈的四个邻居');
|
||||
});
|
||||
|
||||
test('prefixFlyingChunks:飞地优先且去重、受目标数量限制', () => {
|
||||
const ring = [];
|
||||
for (let x = 0; x <= 2; x++) {
|
||||
for (let z = 0; z <= 2; z++) {
|
||||
if (x === 1 && z === 1) continue;
|
||||
ring.push([x, z]);
|
||||
}
|
||||
}
|
||||
const model = makeModel({ A: ring }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 3, targetCount: 3 });
|
||||
const planned = [{ x: 3, z: 0 }, { x: 3, z: 1 }, { x: 3, z: 2 }];
|
||||
const result = planner.prefixFlyingChunks(model, config, { x: 0, z: 0 }, planned);
|
||||
assert.deepStrictEqual(result[0], { x: 1, z: 1 }, '飞地必须排在最前');
|
||||
assert.strictEqual(result.length, 3, '不得超过 targetCount');
|
||||
const keys = result.map((c) => `${c.x},${c.z}`);
|
||||
assert.strictEqual(new Set(keys).size, keys.length, '结果不得重复');
|
||||
});
|
||||
|
||||
test('prefixFlyingChunks:区域外的飞地不被优先填入', () => {
|
||||
const ring = [];
|
||||
for (let x = 0; x <= 2; x++) {
|
||||
for (let z = 0; z <= 2; z++) {
|
||||
if (x === 1 && z === 1) continue;
|
||||
ring.push([x, z]);
|
||||
}
|
||||
}
|
||||
const model = makeModel({ A: ring }, 'A');
|
||||
const config = Object.assign(defaultConfig(), {
|
||||
strategy: 2,
|
||||
regionMinX: 10,
|
||||
regionMaxX: 12,
|
||||
regionMinZ: 10,
|
||||
regionMaxZ: 12
|
||||
});
|
||||
const planned = [{ x: 10, z: 10 }];
|
||||
const result = planner.prefixFlyingChunks(model, config, { x: 0, z: 0 }, planned);
|
||||
assert.deepStrictEqual(result, [{ x: 10, z: 10 }], '区域外的飞地应被过滤掉');
|
||||
});
|
||||
|
||||
test('isInRegion:非策略 2 恒为 true,策略 2 支持矩形与中心+半径', () => {
|
||||
const base = defaultConfig();
|
||||
assert.strictEqual(planner.isInRegion({ x: 999, z: -999 }, Object.assign(base, { strategy: 1 })), true);
|
||||
|
||||
const rect = Object.assign(defaultConfig(), {
|
||||
strategy: 2, regionMinX: 0, regionMaxX: 2, regionMinZ: 0, regionMaxZ: 2
|
||||
});
|
||||
assert.strictEqual(planner.isInRegion({ x: 2, z: 2 }, rect), true);
|
||||
assert.strictEqual(planner.isInRegion({ x: 3, z: 2 }, rect), false);
|
||||
|
||||
const circle = Object.assign(defaultConfig(), {
|
||||
strategy: 2, regionMinX: 0, regionMaxX: -1, regionCenterX: 0, regionCenterZ: 0, regionRadius: 2
|
||||
});
|
||||
assert.strictEqual(planner.isInRegion({ x: 2, z: -2 }, circle), true);
|
||||
assert.strictEqual(planner.isInRegion({ x: 3, z: 0 }, circle), false);
|
||||
});
|
||||
|
||||
test('己方领土为空时不做任何规划', () => {
|
||||
const model = makeModel({ B: [[5, 5]] }, 'A');
|
||||
const config = defaultConfig();
|
||||
for (const strategy of [1, 2, 3, 4]) {
|
||||
config.strategy = strategy;
|
||||
const result = planner.generate(model, config, { x: 0, z: 0 });
|
||||
assert.deepStrictEqual(result, [], `策略 ${strategy} 在己方无领土时应返回空`);
|
||||
}
|
||||
});
|
||||
|
||||
test('规划结果始终在世界边界内', () => {
|
||||
const model = makeModel({ A: [[1279, 1279]] }, 'A');
|
||||
const config = Object.assign(defaultConfig(), { strategy: 1 });
|
||||
const result = planner.generate(model, config, { x: 1279, z: 1279 });
|
||||
for (const chunk of result) {
|
||||
assert.ok(chunkPos.withinWorldBorder(chunk.x, chunk.z), `${chunk.x},${chunk.z} 越界`);
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户