文件
BotStore/test/expansionPlanner.test.js
lyizai a12a24c716 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 语法自检通过。
2026-09-27 17:17:41 +08:00

270 行
11 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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} 越界`);
}
});