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 语法自检通过。
这个提交包含在:
lyizai
2026-09-27 17:17:41 +08:00
当前提交 a12a24c716
共修改 44 个文件,包含 12225 行新增和 0 行删除
+118
查看文件
@@ -0,0 +1,118 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { scanWaterAhead, isWaterName, BoatState } = require('../src/bot/autoBoat');
const { FakeBot } = require('./helpers/fakeBot');
/**
* 构造一个「前方全是水」的假世界。
* @param {FakeBot} bot
* @param {(x: number, z: number) => boolean} isWater
*/
function fillWorld(bot, isWater, y) {
const level = y === undefined ? 63 : y;
for (let x = -40; x <= 40; x++) {
for (let z = -40; z <= 40; z++) {
for (const dy of [-1, 0]) {
bot.blockMap.set(`${x},${level + dy},${z}`, {
name: isWater(x, z) ? 'water' : 'stone',
boundingBox: 'block'
});
}
}
}
}
test('isWaterName 识别水与气泡柱', () => {
assert.strictEqual(isWaterName('water'), true);
assert.strictEqual(isWaterName('flowing_water'), true);
assert.strictEqual(isWaterName('bubble_column'), true);
assert.strictEqual(isWaterName('stone'), false);
assert.strictEqual(isWaterName(''), false);
assert.strictEqual(isWaterName(null), false);
});
test('前方开阔水域达到长度与宽度阈值时建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// x 从 1 到 30 全是水,其余为石头
fillWorld(bot, (x) => x >= 1 && x <= 30);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, true, scan.reason);
assert.ok(scan.length >= 6);
assert.ok(scan.width >= 4);
});
test('水域长度不足时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 只有 3 格宽的水沟
fillWorld(bot, (x) => x >= 1 && x <= 3);
const scan = scanWaterAhead(bot, 1, 0, 6, 1);
assert.strictEqual(scan.ok, false);
assert.ok(scan.reason.includes('长度'));
});
test('水面宽度不足时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 一条沿 x 方向的长水道,但 z 方向只有 1 格宽
fillWorld(bot, (x, z) => x >= 1 && x <= 30 && z === 0);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, false);
assert.ok(scan.reason.includes('宽度'));
});
test('前方完全没有水时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
fillWorld(bot, () => false);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, false);
assert.strictEqual(scan.length, 0);
});
test('用船状态机初始为空闲且描述为空', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const boat = new AutoBoatController(bot, defaultConfig(), null, null);
assert.strictEqual(boat.isActive(), false);
assert.strictEqual(boat.describe(), null);
assert.strictEqual(boat.state, BoatState.IDLE);
});
test('autoBoat 关闭时 tick 不会进入流程', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = false;
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 1, z: 1 } });
assert.strictEqual(boat.isActive(), false);
});
test('waterMode 非 boat 时 tick 不会进入流程(即使 autoBoat 打开)', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = true;
config.waterMode = 'avoid';
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 1, z: 1 } });
assert.strictEqual(boat.isActive(), false);
});
test('背包里没有船时不会启动用船流程', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0, slots: [] });
fillWorld(bot, (x) => x >= 1 && x <= 30);
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = true;
config.waterMode = 'boat';
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 2, z: 0 } });
assert.strictEqual(boat.isActive(), false);
});
+151
查看文件
@@ -0,0 +1,151 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { FakeBot } = require('./helpers/fakeBot');
const {
isAttackTarget,
findTarget,
eyeToEntityDistance,
findLowDurabilitySlot,
wearsGoldArmor
} = require('../src/bot/autoCombat');
const { defaultConfig } = require('../src/config/expansionConfig');
/** 构造一个假实体 */
function makeEntity(name, kind, position, extra) {
return Object.assign({
id: Math.floor(Math.random() * 100000),
name,
kind,
type: 'mob',
position: new Vec3(position[0], position[1], position[2]),
width: 0.6,
height: 1.8,
isValid: true
}, extra || {});
}
test('敌对生物可被攻击', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('skeleton', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('creeper', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('绝不攻击玩家与中立生物', () => {
const bot = new FakeBot();
const config = defaultConfig();
// 玩家
assert.strictEqual(isAttackTarget(makeEntity('Steve', 'Passive mobs', [0, 64, 0], { type: 'player' }), bot, config), false);
// 铁傀儡、僵尸猪灵(原版中立,绝不主动攻击)
assert.strictEqual(isAttackTarget(makeEntity('iron_golem', 'Passive mobs', [0, 64, 0]), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombified_piglin', 'Hostile mobs', [0, 64, 0]), bot, config), false);
// 宠物与常见中立生物
for (const name of ['wolf', 'cat', 'llama', 'bee', 'fox', 'polar_bear', 'villager']) {
assert.strictEqual(isAttackTarget(makeEntity(name, 'Passive mobs', [0, 64, 0]), bot, config), false, name + ' 不应被攻击');
}
});
test('条件敌对生物默认不打,可在配置里放开', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(makeEntity('enderman', 'Hostile mobs', [0, 64, 0]), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('spider', 'Hostile mobs', [0, 64, 0]), bot, config), false);
config.combat.attackEnderman = true;
config.combat.attackSpiders = true;
assert.strictEqual(isAttackTarget(makeEntity('enderman', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('spider', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('猪灵:未穿金甲才攻击', () => {
const config = defaultConfig();
const bareBot = new FakeBot();
assert.strictEqual(isAttackTarget(makeEntity('piglin', 'Hostile mobs', [0, 64, 0]), bareBot, config), true);
const armoredBot = new FakeBot({ slots: [] });
armoredBot.inventory.slots[5] = { name: 'golden_helmet' };
assert.strictEqual(wearsGoldArmor(armoredBot), true);
assert.strictEqual(isAttackTarget(makeEntity('piglin', 'Hostile mobs', [0, 64, 0]), armoredBot, config), false);
});
test('高危生物默认跳过', () => {
const bot = new FakeBot();
const config = defaultConfig();
for (const name of ['warden', 'ender_dragon', 'wither']) {
assert.strictEqual(isAttackTarget(makeEntity(name, 'Hostile mobs', [0, 64, 0]), bot, config), false, name + ' 应被跳过');
}
config.combat.avoidDangerousMobs = false;
assert.strictEqual(isAttackTarget(makeEntity('wither', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('无效 / 已消失 / 非生物实体不被攻击', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(null, bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0], { isValid: false }), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0], { type: 'object' }), bot, config), false);
assert.strictEqual(isAttackTarget(bot.entity, bot, config), false);
});
test('findTarget 在索敌半径内挑最近的合法目标', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const config = defaultConfig();
const near = makeEntity('zombie', 'Hostile mobs', [1, 64, 0]);
const far = makeEntity('skeleton', 'Hostile mobs', [4, 64, 0]);
const neutral = makeEntity('iron_golem', 'Passive mobs', [0.5, 64, 0]);
const outside = makeEntity('creeper', 'Hostile mobs', [20, 64, 0]);
bot.entities = { 1: near, 2: far, 3: neutral, 4: outside };
assert.strictEqual(findTarget(bot, 4.5, config), near);
});
test('findTarget 半径内没有敌对生物时返回 null', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const config = defaultConfig();
bot.entities = { 1: makeEntity('cow', 'Passive mobs', [1, 64, 0]) };
assert.strictEqual(findTarget(bot, 4.5, config), null);
});
test('eyeToEntityDistance 计算到碰撞箱最近点的距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const entity = makeEntity('zombie', 'Hostile mobs', [2, 64, 0]);
const distance = eyeToEntityDistance(bot, entity);
assert.ok(Math.abs(distance - 1.7) < 1e-6, `期望 1.7,实际 ${distance}`);
});
test('耐久保护:识别剩余耐久低于阈值的装备', () => {
const bot = new FakeBot({ slots: [] });
// 主手:钻石剑,最大 1561,已用 1400 → 剩余 10.3%
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1400 };
const low = findLowDurabilitySlot(bot, 15);
assert.ok(low);
assert.strictEqual(low.name, '主手');
assert.strictEqual(low.percent, 10);
assert.strictEqual(low.itemName, 'diamond_sword');
});
test('耐久保护:装备充足时返回 null', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 100 };
bot.inventory.slots[5] = { name: 'diamond_helmet', maxDurability: 363, durabilityUsed: 10 };
assert.strictEqual(findLowDurabilitySlot(bot, 15), null);
});
test('耐久保护:检查四件盔甲与副手', () => {
const bot = new FakeBot({ slots: [] });
bot.inventory.slots[7] = { name: 'diamond_leggings', maxDurability: 495, durabilityUsed: 480 };
const low = findLowDurabilitySlot(bot, 15);
assert.ok(low);
assert.strictEqual(low.name, '护腿');
});
test('耐久保护:不可损坏物品被忽略', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'stone' };
bot.inventory.slots[5] = { name: 'turtle_helmet', maxDurability: 0 };
assert.strictEqual(findLowDurabilitySlot(bot, 90), null);
});
+115
查看文件
@@ -0,0 +1,115 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { fillTemplate } = require('../src/bot/botManager');
const { AutoEater, findFoodSlot } = require('../src/bot/autoEat');
const { DurabilityGuard, CHECK_INTERVAL_MS } = require('../src/bot/durabilityGuard');
const { defaultConfig } = require('../src/config/expansionConfig');
const { FakeBot } = require('./helpers/fakeBot');
test('fillTemplate 替换密码占位符', () => {
assert.strictEqual(fillTemplate('login {password}', 'abc123'), 'login abc123');
assert.strictEqual(fillTemplate('register {password} {password}', 'p'), 'register p p');
assert.strictEqual(fillTemplate('login', 'abc'), 'login', '无占位符时原样返回');
});
test('findFoodSlot 找到背包中的食物', () => {
const bot = new FakeBot({
slots: [
{ name: 'diamond_sword' },
{ name: 'cooked_beef' },
{ name: 'apple' }
]
});
assert.strictEqual(findFoodSlot(bot), 1);
});
test('findFoodSlot 没有食物时返回 -1', () => {
const bot = new FakeBot({ slots: [{ name: 'diamond_sword' }, { name: 'stone' }] });
assert.strictEqual(findFoodSlot(bot), -1);
});
test('自动进食:饥饿低于阈值时开始进食', async () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 10;
const config = defaultConfig();
const eater = new AutoEater(bot, config, null, null);
const busy = eater.tick();
assert.strictEqual(busy, true);
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(bot.food, 20, '进食后饥饿值应回满');
assert.strictEqual(eater.eating, false, '进食完成后应复位');
});
test('自动进食:饥饿充足时不做任何事', () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 20;
const eater = new AutoEater(bot, defaultConfig(), null, null);
assert.strictEqual(eater.tick(), false);
assert.strictEqual(eater.eating, false);
});
test('自动进食:背包没有食物时保持非占用状态', () => {
const bot = new FakeBot({ slots: [{ name: 'stone' }] });
bot.food = 5;
const eater = new AutoEater(bot, defaultConfig(), null, null);
assert.strictEqual(eater.tick(), false);
assert.strictEqual(eater.eating, false);
});
test('自动进食:autoEat 关闭时完全跳过', () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 5;
const config = defaultConfig();
config.autoEat = false;
const eater = new AutoEater(bot, config, null, null);
assert.strictEqual(eater.tick(), false);
});
test('自动进食:进食前松开移动键(防 NoSlow)', async () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 10;
const controls = {};
const grim = {
setControlState(control, state) { controls[control] = state; },
setLook() {}
};
const config = defaultConfig();
const eater = new AutoEater(bot, config, null, grim);
eater.tick();
await new Promise((resolve) => setImmediate(resolve));
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
assert.strictEqual(controls[control], false, control + ' 应被松开');
}
});
test('耐久保护:按间隔节流检查', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1500 };
const guard = new DurabilityGuard(bot, defaultConfig(), null);
const first = guard.check(1000000);
assert.ok(first, '首次检查应立即生效');
assert.strictEqual(guard.check(1000000 + CHECK_INTERVAL_MS - 1), null, '间隔内不再检查');
assert.ok(guard.check(1000000 + CHECK_INTERVAL_MS), '超过间隔后再次检查');
});
test('耐久保护:关闭时永远返回 null', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1561 };
const config = defaultConfig();
config.durabilityGuard = false;
const guard = new DurabilityGuard(bot, config, null);
assert.strictEqual(guard.check(1000), null);
});
test('耐久保护:reset 后立即重新检查', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1500 };
const guard = new DurabilityGuard(bot, defaultConfig(), null);
guard.check(1000000);
guard.reset();
assert.ok(guard.check(1000000), 'reset 后应可立即再次检查');
});
+69
查看文件
@@ -0,0 +1,69 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const chunkPos = require('../src/core/chunkPos');
test('fromBlock 向下取整且兼容负数坐标', () => {
assert.deepStrictEqual(chunkPos.fromBlock(0, 0), { x: 0, z: 0 });
assert.deepStrictEqual(chunkPos.fromBlock(15, 15), { x: 0, z: 0 });
assert.deepStrictEqual(chunkPos.fromBlock(16, 31), { x: 1, z: 1 });
// 负数必须向下取整到更小的区块号,而不是向零取整
assert.deepStrictEqual(chunkPos.fromBlock(-1, -16), { x: -1, z: -1 });
assert.deepStrictEqual(chunkPos.fromBlock(-17, -33), { x: -2, z: -3 });
});
test('key/decode 往返一致(含负数与边界)', () => {
const samples = [
[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1], [-1, -1],
[1279, 1279], [-1280, -1280], [12345, -54321], [-2048, 2048]
];
for (const [x, z] of samples) {
const decoded = chunkPos.decode(chunkPos.key(x, z));
assert.deepStrictEqual(decoded, { x, z }, `坐标 ${x},${z} 编解码不一致`);
}
});
test('key 对不同坐标产生不同键', () => {
const seen = new Set();
for (let x = -50; x <= 50; x++) {
for (let z = -50; z <= 50; z++) {
const k = chunkPos.key(x, z);
assert.ok(!seen.has(k), `键冲突:${x},${z}`);
seen.add(k);
}
}
assert.strictEqual(seen.size, 101 * 101);
});
test('centerBlock 取区块中心(+8)', () => {
assert.strictEqual(chunkPos.centerBlockX(0), 8);
assert.strictEqual(chunkPos.centerBlockX(-1), -8);
assert.strictEqual(chunkPos.centerBlockZ(3), 56);
});
test('曼哈顿与切比雪夫距离', () => {
assert.strictEqual(chunkPos.manhattan({ x: 0, z: 0 }, { x: 3, z: -4 }), 7);
assert.strictEqual(chunkPos.chebyshev({ x: 0, z: 0 }, { x: 3, z: -4 }), 4);
assert.strictEqual(chunkPos.chebyshev({ x: 5, z: 5 }, { x: 5, z: 5 }), 0);
});
test('equals 处理 null 与同值', () => {
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, { x: 1, z: 2 }), true);
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, { x: 2, z: 1 }), false);
assert.strictEqual(chunkPos.equals(null, null), true);
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, null), false);
});
test('cardinalNeighbors 返回四个正方向邻居', () => {
const neighbors = chunkPos.cardinalNeighbors(0, 0).map((c) => `${c.x},${c.z}`);
assert.deepStrictEqual(neighbors.sort(), ['-1,0', '0,-1', '0,1', '1,0']);
});
test('withinWorldBorder 以 ±1280 为界', () => {
assert.strictEqual(chunkPos.withinWorldBorder(0, 0), true);
assert.strictEqual(chunkPos.withinWorldBorder(1279, -1280), true);
assert.strictEqual(chunkPos.withinWorldBorder(1280, 0), false);
assert.strictEqual(chunkPos.withinWorldBorder(0, -1281), false);
});
+157
查看文件
@@ -0,0 +1,157 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
defaultConfig,
normalizeConfig,
validateConfig,
copyConfig,
STRATEGY,
WATER_MODE
} = require('../src/config/expansionConfig');
const { ConfigStore } = require('../src/config/configStore');
test('默认配置的关键默认值与原 Java 版一致', () => {
const config = defaultConfig();
assert.strictEqual(config.strategy, STRATEGY.SQUARE);
assert.strictEqual(config.targetCount, -1);
assert.strictEqual(config.waitTimeoutSeconds, 60);
assert.strictEqual(config.retries, 2);
assert.strictEqual(config.returnThresholdSeconds, 60);
assert.strictEqual(config.returnWaitSeconds, 12);
assert.strictEqual(config.sideStepMaxRounds, 3);
assert.strictEqual(config.durabilityThreshold, 15);
assert.strictEqual(config.eatThreshold, 14);
assert.strictEqual(config.refreshIntervalSeconds, 30);
assert.strictEqual(config.apiRefreshIntervalSeconds, 180);
assert.strictEqual(config.allowBreak, false);
assert.strictEqual(config.allowPlace, false);
assert.strictEqual(config.altitudeWeight, 0.1);
});
test('Grim 合规默认值偏保守', () => {
const config = defaultConfig();
assert.strictEqual(config.grim.enabled, true);
assert.strictEqual(config.grim.attackReach, 3.0);
assert.strictEqual(config.grim.requireLineOfSight, true);
assert.strictEqual(config.grim.respectAttackCooldown, true);
assert.strictEqual(config.grim.stopMovementWhileEating, true);
assert.strictEqual(config.grim.blockConflictingActions, true);
// 疾跑跳是原版合法动作,默认放行
assert.strictEqual(config.grim.blockSprintJump, false);
// mineflayer 无载具物理,用船默认关闭
assert.strictEqual(config.autoBoat, false);
assert.strictEqual(config.waterMode, WATER_MODE.AVOID);
// 挖 / 放方块默认关闭(Grim 检查严格)
assert.strictEqual(config.allowBreak, false);
assert.strictEqual(config.allowPlace, false);
assert.strictEqual(config.pathing.allowParkour, false);
assert.strictEqual(config.pathing.allow1by1towers, false);
});
test('normalizeConfig 只接受已知字段', () => {
const merged = normalizeConfig({ strategy: 3, 未知字段: 'x', grim: { attackReach: 4 } });
assert.strictEqual(merged.strategy, 3);
assert.strictEqual(merged.grim.attackReach, 4);
assert.strictEqual(merged.未知字段, undefined);
// 未提供的字段保持默认
assert.strictEqual(merged.retries, 2);
assert.strictEqual(merged.grim.requireLineOfSight, true);
});
test('normalizeConfig 对部分配置做深合并而非整体替换', () => {
const merged = normalizeConfig({ pathing: { maxDropDown: 5 } });
assert.strictEqual(merged.pathing.maxDropDown, 5);
assert.strictEqual(merged.pathing.liquidCost, 40, '未提供的子字段应保留默认值');
});
test('validateConfig 钳制越界值', () => {
const config = validateConfig({
strategy: 99,
targetCount: -5,
retries: 0,
waitTimeoutSeconds: 1,
durabilityThreshold: 0,
refreshIntervalSeconds: 1,
apiRefreshIntervalSeconds: 5,
altitudeWeight: -3,
eatThreshold: 50,
pathing: { maxDropDown: 999, liquidCost: 0 },
grim: { attackReach: 99, maxRotationPerTickDeg: 0 },
server: { commandCooldownMs: -1, reconnectDelayMs: 10 },
waterMode: '不存在的模式'
});
assert.strictEqual(config.strategy, 1, '非法策略应回退到默认策略而不是钳制到 4');
assert.strictEqual(config.targetCount, -1);
assert.strictEqual(config.retries, 1);
assert.strictEqual(config.waitTimeoutSeconds, 5);
assert.strictEqual(config.durabilityThreshold, 1);
assert.strictEqual(config.refreshIntervalSeconds, 10);
assert.strictEqual(config.apiRefreshIntervalSeconds, 60);
assert.strictEqual(config.altitudeWeight, 0);
assert.strictEqual(config.eatThreshold, 20);
assert.strictEqual(config.pathing.maxDropDown, 64);
assert.strictEqual(config.pathing.liquidCost, 1);
assert.strictEqual(config.grim.attackReach, 6);
assert.strictEqual(config.grim.maxRotationPerTickDeg, 1);
assert.strictEqual(config.server.commandCooldownMs, 0);
assert.strictEqual(config.server.reconnectDelayMs, 1000);
assert.strictEqual(config.waterMode, WATER_MODE.AVOID);
});
test('copyConfig 是深拷贝', () => {
const original = defaultConfig();
const copy = copyConfig(original);
copy.grim.attackReach = 5;
copy.server.claimCommand = 'changed';
assert.strictEqual(original.grim.attackReach, 3.0);
assert.strictEqual(original.server.claimCommand, 'u claimmode');
});
test('ConfigStore 读写往返一致,且能容忍损坏文件', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-cfg-'));
const file = path.join(dir, 'nested', 'autostore.json');
const store = new ConfigStore(file);
// 文件不存在 → 返回默认配置
const defaults = store.load();
assert.strictEqual(defaults.strategy, STRATEGY.SQUARE);
// 保存后能读回
defaults.strategy = STRATEGY.PERIMETER;
defaults.stateName = '测试国';
assert.strictEqual(store.save(defaults), true);
const loaded = store.load();
assert.strictEqual(loaded.strategy, STRATEGY.PERIMETER);
assert.strictEqual(loaded.stateName, '测试国');
// 损坏文件 → 回退到默认配置
fs.writeFileSync(file, '{ 这不是合法 JSON', 'utf8');
assert.strictEqual(store.load().strategy, STRATEGY.SQUARE);
fs.rmSync(dir, { recursive: true, force: true });
});
test('ConfigStore 在配置文件缺失 API Key 时回退到环境变量', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-cfg-'));
const file = path.join(dir, 'autostore.json');
const store = new ConfigStore(file);
const config = defaultConfig();
config.apiKey = '';
store.save(config);
const previous = process.env.SIMP_API_KEY;
process.env.SIMP_API_KEY = 'env-key-for-test';
try {
assert.strictEqual(store.load().apiKey, 'env-key-for-test');
} finally {
if (previous === undefined) delete process.env.SIMP_API_KEY;
else process.env.SIMP_API_KEY = previous;
fs.rmSync(dir, { recursive: true, force: true });
}
});
+430
查看文件
@@ -0,0 +1,430 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ExpansionController, State } = require('../src/controller/expansionController');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { FakeBot } = require('./helpers/fakeBot');
const chunkPos = require('../src/core/chunkPos');
/** 假寻路服务 */
class FakePathing {
constructor() {
this.calcFailed = false;
this.currentTarget = null;
this._pathing = false;
this.pathCalls = 0;
}
pathTo(chunk) {
this.currentTarget = chunk;
this._pathing = true;
this.pathCalls++;
return true;
}
cancel() {
this._pathing = false;
}
isPathing() {
return this._pathing;
}
applyConfig() {}
}
/** 假数据服务 */
class FakeDataService {
constructor(model) {
this.model = model;
this.sessionClaims = [];
this.refreshCount = 0;
}
getModel() {
return this.model;
}
setSelectedStateName(name) {
this.model.setSelfStateName(name);
}
addSessionClaim(chunk) {
this.sessionClaims.push(chunk);
this.model.addSessionClaim(chunk);
}
refreshAsync() {
this.refreshCount++;
}
async detectStateByPlayer() {
return null;
}
snapshot() {
return { stateCount: this.model.stateCount };
}
}
/** 假机器人管理器 */
class FakeBotManager {
constructor(bot, model) {
this.bot = bot;
this.pathing = new FakePathing();
this.grim = null;
this.eater = null;
this.boat = null;
this.guard = null;
this.connection = { username: 'TestBot' };
this.commands = [];
this._model = model;
}
isReady() {
return true;
}
isSpawned() {
return true;
}
sendCommand(command) {
if (command) this.commands.push(command);
}
applyConfig() {}
}
/**
* 搭建被测环境。
* @param {{states?: Record<string, Array<[number, number]>>, selfName?: string, x?: number, z?: number, config?: object}} [options]
*/
function setup(options) {
const opts = options || {};
const states = opts.states || { A: [[0, 0]] };
const map = new Map();
for (const [name, coords] of Object.entries(states)) {
map.set(name, {
name,
capital: { x: coords[0][0], z: coords[0][1] },
chunks: new Set(coords.map(([x, z]) => chunkPos.key(x, z))),
color: 0x75bd20
});
}
const model = new TerritoryModel(map);
if (opts.selfName) model.setSelfStateName(opts.selfName);
const bot = new FakeBot({ x: (opts.x === undefined ? 0 : opts.x) * 16 + 8, y: 64, z: (opts.z === undefined ? 0 : opts.z) * 16 + 8 });
const botManager = new FakeBotManager(bot, model);
const dataService = new FakeDataService(model);
const logger = new Logger({ echo: false });
const controller = new ExpansionController({ botManager, dataService, logger });
const config = defaultConfig();
config.enforceClaimTime = false;
config.autoEat = false;
config.autoAttack = false;
config.durabilityGuard = false;
config.autoBoat = false;
Object.assign(config, opts.config || {});
return { controller, botManager, dataService, model, bot, logger, config };
}
test('start:自动识别所在邦国并下发开垦模式指令', async () => {
const { controller, botManager, config, model } = setup({ selfName: '' });
const ok = await controller.start(config);
assert.strictEqual(ok, true);
assert.strictEqual(model.selfStateNameValue(), 'A', '应自动识别为所在邦国 A');
assert.ok(botManager.commands.includes('u claimmode'), '应发送开垦模式指令');
assert.strictEqual(controller.state, State.PATHING);
assert.ok(controller.target, '应选出目标区块');
assert.strictEqual(botManager.pathing.pathCalls, 1, '应下发一次寻路');
});
test('start:指定不存在的邦国时拒绝启动', async () => {
const { controller, config } = setup();
config.stateName = '不存在的国';
const ok = await controller.start(config);
assert.strictEqual(ok, false);
assert.strictEqual(controller.state, State.IDLE);
assert.ok(controller.status.includes('不存在'));
});
test('start:不在开垦时段时登记等待自动开始', async () => {
const { controller, config } = setup();
const hour = new Date().getHours();
config.enforceClaimTime = true;
// 构造一个一定不含当前小时的时段
config.claimStartHour = (hour + 2) % 24;
config.claimEndHour = (hour + 3) % 24;
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
const ok = await controller.start(config);
assert.strictEqual(ok, true);
assert.strictEqual(controller.isWaitingForClaimTime(), true);
assert.strictEqual(controller.state, State.IDLE);
assert.ok(controller.status.includes('等待开垦时段'));
});
test('到达目标区块后进入等待开垦状态', async () => {
const { controller, botManager, config, bot } = setup();
await controller.start(config);
const target = controller.target;
// 把机器人移到目标区块
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
assert.strictEqual(controller.state, State.WAITING);
assert.strictEqual(botManager.pathing.isPathing(), false, '到达后应取消寻路');
});
test('开垦成功消息推进计数并进入 SUCCESS', async () => {
const { controller, config, bot, dataService } = setup();
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
assert.strictEqual(controller.state, State.WAITING);
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16},${target.z * 16}] 已为你的邦国开拓`);
assert.strictEqual(controller.completedClaims, 1);
assert.strictEqual(controller.state, State.SUCCESS);
assert.strictEqual(dataService.sessionClaims.length, 1);
assert.ok(dataService.refreshCount > 0, '占领成功后应触发一次地图刷新');
});
test('同一区块的重复成功消息只计一次', async () => {
const { controller, config, bot } = setup();
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
const message = `疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`;
controller.monitor.onChat(message);
controller.monitor.onChat(message);
assert.strictEqual(controller.completedClaims, 1);
});
test('通用成功消息只在机器人确实位于目标区块时生效', async () => {
const { controller, config, bot } = setup();
await controller.start(config);
const target = controller.target;
// 机器人不在目标区块上
bot.entity.position.x = (target.x + 5) * 16;
bot.entity.position.z = (target.z + 5) * 16;
controller.playerChunk = { x: target.x + 5, z: target.z + 5 };
controller.monitor.onChat('你的疆土已开拓');
assert.strictEqual(controller.completedClaims, 0, '不在目标区块时不得计为成功');
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.playerChunk = { x: target.x, z: target.z };
controller.monitor.onChat('你的疆土已开拓');
assert.strictEqual(controller.completedClaims, 1);
});
test('目标已被他人占领时跳过', async () => {
const { controller, config, model } = setup();
await controller.start(config);
const target = controller.target;
// 地图刷新后该区块变成他国领土
model.allClaimedChunks().add(chunkPos.key(target.x, target.z));
controller.tick();
assert.strictEqual(controller.state, State.NEXT);
assert.strictEqual(controller.status, '跳过已占领区块');
});
test('目标不再与己方领土接壤时跳过', async () => {
const { controller, config, model } = setup();
await controller.start(config);
const target = controller.target;
// 清空己方领土,使目标不再接壤
model.selfState().chunks.clear();
controller.tick();
assert.strictEqual(controller.state, State.NEXT);
assert.strictEqual(controller.status, '跳过不接壤区块');
});
test('寻路超时会重新寻路,重试耗尽后走两步', async () => {
const { controller, botManager, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 2, sideStepMaxRounds: 2 } });
await controller.start(config);
const callsAfterStart = botManager.pathing.pathCalls;
// 把阶段开始时间拨回 10 秒前,触发超时
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.retryCount, 1);
assert.strictEqual(controller.state, State.PATHING, '未耗尽重试时应继续寻路');
assert.strictEqual(botManager.pathing.pathCalls, callsAfterStart + 1);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.sideStepping, true, '重试耗尽后应进入走两步阶段');
assert.strictEqual(controller.sideStepCount, 1);
});
test('连续超时累计达到阈值时回城', async () => {
const { controller, botManager, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick(); // 第一次超时:重试次数达到上限(retries=1),走两步轮数为 0 → giveUp → 回城
assert.strictEqual(controller.state, State.RETURNING);
assert.ok(botManager.commands.includes('u return'), '应发送回城指令');
});
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
const { controller, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1, returnWaitSeconds: 1 }
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
assert.strictEqual(controller.state, State.PATHING, '回城完成后应重新规划并开始寻路');
assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '连续超时计数应清零');
});
test('耐久保护触发回城并在传送后停止任务', async () => {
const { controller, botManager, config } = setup({ config: { durabilityGuard: true, returnWaitSeconds: 1 } });
botManager.guard = {
check: () => ({ slot: 0, name: '主手', percent: 5, itemName: 'diamond_sword' }),
reset: () => {}
};
await controller.start(config);
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
assert.ok(botManager.commands.includes('u return'));
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
});
test('达成目标数量后进入 COMPLETE', async () => {
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick(); // → WAITING
controller.monitor.onChat(`疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`); // → SUCCESS
controller.tick(); // SUCCESS → NEXT
controller.tick(); // NEXT → 达成目标 → COMPLETE
assert.strictEqual(controller.state, State.COMPLETE);
assert.strictEqual(controller.completedClaims, 1);
assert.strictEqual(controller.isRunning(), false);
});
test('stop 后进入 STOPPED 且不再运行', async () => {
const { controller, botManager, config } = setup();
await controller.start(config);
controller.stop();
assert.strictEqual(controller.state, State.STOPPED);
assert.strictEqual(controller.isRunning(), false);
assert.strictEqual(botManager.pathing.isPathing(), false, '停止时应取消寻路');
});
test('stop 可取消「等待自动开始」', async () => {
const { controller, config } = setup();
const hour = new Date().getHours();
config.enforceClaimTime = true;
config.claimStartHour = (hour + 2) % 24;
config.claimEndHour = (hour + 3) % 24;
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
await controller.start(config);
assert.strictEqual(controller.isWaitingForClaimTime(), true);
controller.stop();
assert.strictEqual(controller.isWaitingForClaimTime(), false);
assert.strictEqual(controller.state, State.IDLE);
});
test('自动游泳:水中按住跳跃键,离开水后松开', async () => {
const { controller, botManager, config, bot } = setup();
botManager.grim = {
controls: {},
setControlState(control, state) { this.controls[control] = state; },
setLook() {}
};
await controller.start(config);
bot.entity.isInWater = true;
controller.tick();
assert.strictEqual(botManager.grim.controls.jump, true, '水中应按住跳跃键上浮');
bot.entity.isInWater = false;
controller.tick();
assert.strictEqual(botManager.grim.controls.jump, false, '离水后应松开跳跃键');
});
test('船上不自动游泳', async () => {
const { controller, botManager, config, bot } = setup();
botManager.grim = {
controls: {},
setControlState(control, state) { this.controls[control] = state; },
setLook() {}
};
await controller.start(config);
bot.entity.isInWater = true;
bot.vehicle = { id: 1 };
controller.tick();
assert.notStrictEqual(botManager.grim.controls.jump, true, '坐船时不应按跳跃键');
});
test('snapshot 暴露任务状态供控制台展示', async () => {
const { controller, config } = setup();
await controller.start(config);
const snapshot = controller.snapshot();
assert.strictEqual(snapshot.running, true);
assert.strictEqual(snapshot.state, State.PATHING);
assert.ok(snapshot.target);
assert.strictEqual(snapshot.completedClaims, 0);
assert.strictEqual(snapshot.withinClaimTime, true);
assert.strictEqual(snapshot.claimModeSent, true);
});
test('exportLogs 写出状态摘要与全部日志', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-log-'));
const file = path.join(dir, 'debug.log');
const { controller, config } = setup();
await controller.start(config);
const result = controller.exportLogs(file);
assert.strictEqual(result, file);
const content = fs.readFileSync(file, 'utf8');
assert.ok(content.includes('AutoStore Bot 调试日志'));
assert.ok(content.includes('状态:'));
assert.ok(content.includes('开始自动扩地'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('未启动任务时 tick 不做任何事', () => {
const { controller } = setup();
assert.doesNotThrow(() => controller.tick());
assert.strictEqual(controller.state, State.IDLE);
});
test('规划队列为空时直接进入 COMPLETE', async () => {
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
const { controller, config } = setup({
states: {
A: [[0, 0]],
B: [[1, 0], [-1, 0], [0, 1], [0, -1]]
},
selfName: 'A'
});
await controller.start(config);
assert.strictEqual(controller.state, State.COMPLETE);
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
});
+269
查看文件
@@ -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} 越界`);
}
});
+226
查看文件
@@ -0,0 +1,226 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { FakeBot } = require('./helpers/fakeBot');
const {
GrimCompat,
shortestAngleDelta,
eyeDistanceToEntity,
VANILLA_PHYSICS_BASELINE
} = require('../src/bot/grimCompat');
const { defaultConfig } = require('../src/config/expansionConfig');
/**
* 构造一个已安装合规层的假 bot。
* @param {object} [options]
*/
function setup(options) {
const opts = options || {};
const config = Object.assign(defaultConfig(), opts.config || {});
const bot = new FakeBot(opts.bot);
const grim = new GrimCompat(bot, config, null);
grim.install();
return { bot, grim, config };
}
test('shortestAngleDelta 走最短路径并处理环绕', () => {
assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
// 从 170° 到 -170° 应走 +20° 而不是 -340°
const from = (170 * Math.PI) / 180;
const to = (-170 * Math.PI) / 180;
assert.ok(Math.abs(shortestAngleDelta(from, to) - (20 * Math.PI) / 180) < 1e-9);
assert.ok(Math.abs(shortestAngleDelta(0, Math.PI * 2)) < 1e-9);
});
test('eyeDistanceToEntity 计算眼睛到碰撞箱的最近距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
const entity = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8 };
const distance = eyeDistanceToEntity(bot, entity);
assert.ok(Math.abs(distance - (2 - 0.3)) < 1e-6, `距离应为 1.7,实际 ${distance}`);
});
test('旋转被限制在每 tick 的最大步长内,且不会超前于服务端', async () => {
const { bot, grim } = setup();
const targetYaw = Math.PI / 2; // 90°
const promise = grim.setLook(targetYaw, 0);
assert.strictEqual(grim.isRotationSettled(), false, '刚设置目标时不应认为已同步');
bot.tick();
const step = grim.maxStepRad;
assert.ok(Math.abs(bot.entity.yaw - step) < 1e-9, `单 tick 只应前进 ${step} 弧度`);
assert.ok(bot.entity.yaw < targetYaw, '本地朝向不得一次跳到目标值(否则与服务端预测分歧)');
// 推进足够多 tick 后应到位
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(Math.abs(bot.entity.yaw - targetYaw) < 1e-4);
assert.strictEqual(grim.isRotationSettled(), true);
});
test('每 tick 旋转步长不超过 mineflayer 物理限速', () => {
const { grim } = setup();
// 物理限速 = 0.05s × 3.0 rad/s = 0.15 rad ≈ 8.59°
assert.ok(grim.maxStepRad <= 0.15 + 1e-9);
assert.ok(grim.maxStepRad > 0.14);
});
test('pitch 被钳制在 ±90° 内', async () => {
const { bot, grim } = setup();
grim.setLook(0, Math.PI); // 超出范围
for (let i = 0; i < 60; i++) bot.tick();
assert.ok(bot.entity.pitch <= Math.PI / 2 + 1e-9);
assert.ok(bot.entity.pitch >= -Math.PI / 2 - 1e-9);
});
test('拦截疾跑 + 潜行的非法组合(MultiActions)', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
grim.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), false, '疾跑时不允许潜行');
assert.strictEqual(grim.blockedActions, 1);
});
test('拦截疾跑 + 使用物品的非法组合', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
bot.usingHeldItem = true;
grim.setControlState('forward', true);
assert.strictEqual(bot.getControlState('forward'), false, '进食时不允许前进(NoSlow)');
});
test('默认放行疾跑跳跃(原版合法动作)', () => {
const { bot, grim } = setup();
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
});
test('blockSprintJump 开启时拦下疾跑跳跃', () => {
const config = defaultConfig();
config.grim.blockSprintJump = true;
const { bot, grim } = setup({ config });
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), false);
});
test('攻击冷却按手持武器攻速计算', () => {
const config = defaultConfig();
const swordBot = new FakeBot({ heldItem: { name: 'diamond_sword' } });
const swordGrim = new GrimCompat(swordBot, config, null);
assert.strictEqual(swordGrim.attackCooldownMs(), 625, '钻石剑 1.6 次/秒 → 625ms');
const axeBot = new FakeBot({ heldItem: { name: 'netherite_axe' } });
const axeGrim = new GrimCompat(axeBot, config, null);
assert.strictEqual(axeGrim.attackCooldownMs(), 1000, '下界合金斧 1.0 次/秒 → 1000ms');
const handBot = new FakeBot({ heldItem: null });
const handGrim = new GrimCompat(handBot, config, null);
assert.strictEqual(handGrim.attackCooldownMs(), 250, '空手 4 次/秒 → 250ms');
});
test('canAttack 校验距离、视线与冷却', async () => {
const { bot, grim } = setup();
bot.entity.position = new Vec3(0, 64, 0);
const far = { position: new Vec3(10, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(far), { ok: false, reason: 'out_of_reach' });
const near = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(near), { ok: true });
// 攻击后进入冷却
const attacked = await grim.attackEntity(near, { skipRotation: true });
assert.strictEqual(attacked, true);
assert.strictEqual(bot.lastAttack.entity, near);
assert.deepStrictEqual(grim.canAttack(near), { ok: false, reason: 'cooldown' });
});
test('canAttack 拒绝视线被遮挡的目标', () => {
const config = defaultConfig();
const bot = new FakeBot({
world: { raycast: () => ({ position: new Vec3(1, 64, 0) }) } // 总是命中方块
});
const grim = new GrimCompat(bot, config, null);
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'no_line_of_sight' });
config.grim.requireLineOfSight = false;
assert.deepStrictEqual(grim.canAttack(target), { ok: true });
});
test('canAttack 拒绝骑乘状态下的攻击', () => {
const { bot, grim } = setup();
bot.vehicle = { id: 1 };
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'riding' });
});
test('attackEntity 会先同步朝向再出手', async () => {
const { bot, grim } = setup();
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true, id: 7 };
const promise = grim.attackEntity(target);
// 旋转尚未完成时不应出手
bot.tick();
assert.strictEqual(bot.lastAttack, undefined, '朝向未同步前不得攻击');
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(bot.lastAttack, '朝向同步后应完成攻击');
});
test('关闭合规层后不做任何限速与拦截', () => {
const config = defaultConfig();
config.grim.enabled = false;
const bot = new FakeBot();
const grim = new GrimCompat(bot, config, null);
grim.install();
bot.setControlState('sprint', true);
bot.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), true, '关闭后不拦截');
assert.strictEqual(grim.blockedActions, 0);
});
test('audit 检出被篡改的物理常量与创造模式', () => {
const { bot, grim } = setup();
assert.deepStrictEqual(grim.audit(), { ok: true, issues: [] });
bot.physics.gravity = 0.02;
const tampered = grim.audit();
assert.strictEqual(tampered.ok, false);
assert.ok(tampered.issues.some((issue) => issue.includes('gravity')));
bot.physics.gravity = VANILLA_PHYSICS_BASELINE.gravity;
bot.game.gameMode = 'creative';
assert.ok(grim.audit().issues.some((issue) => issue.includes('创造模式')));
});
test('audit 检出被关闭的本地物理预测', () => {
const { bot, grim } = setup();
bot.physicsEnabled = false;
assert.ok(grim.audit().issues.some((issue) => issue.includes('physicsEnabled')));
});
test('snapshot 暴露合规状态供控制台展示', () => {
const { grim } = setup();
const snapshot = grim.snapshot();
assert.strictEqual(snapshot.enabled, true);
assert.strictEqual(snapshot.attackReach, 3.0);
assert.strictEqual(snapshot.physicsOk, true);
assert.ok(snapshot.effectiveStepDeg > 0 && snapshot.effectiveStepDeg <= 9);
assert.strictEqual(snapshot.blockedActions, 0);
});
test('releaseAllControls 松开全部控制键', () => {
const { bot, grim } = setup();
grim.setControlState('forward', true);
grim.setControlState('sprint', true);
grim.releaseAllControls();
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
assert.strictEqual(bot.getControlState(control), false, control + ' 应被松开');
}
});
+141
查看文件
@@ -0,0 +1,141 @@
'use strict';
const { EventEmitter } = require('events');
const { Vec3 } = require('vec3');
/** 真实的 1.21.4 注册表(mineflayer-pathfinder 的 Movements 依赖它) */
let cachedRegistry = null;
function defaultRegistry() {
if (!cachedRegistry) {
cachedRegistry = require('minecraft-data')('1.21.4');
}
return cachedRegistry;
}
/**
* 测试用的假 bot:只实现被测模块真正用到的接口,
* 避免测试依赖真实 Minecraft 服务器。
*/
class FakeBot extends EventEmitter {
constructor(options) {
super();
const opts = options || {};
this.username = opts.username || 'TestBot';
this.physicsEnabled = true;
this.usingHeldItem = false;
this.vehicle = null;
this.food = 20;
this.health = 20;
this.game = { gameMode: opts.gameMode || 'survival' };
this.physics = Object.assign({
gravity: 0.08,
playerSpeed: 0.1,
sprintSpeed: 0.3,
sneakSpeed: 0.3,
stepHeight: 0.6,
airborneInertia: 0.91,
waterInertia: 0.8,
yawSpeed: 3.0,
pitchSpeed: 3.0
}, opts.physics || {});
this.entity = {
position: new Vec3(opts.x || 0, opts.y || 64, opts.z || 0),
velocity: new Vec3(0, 0, 0),
yaw: 0,
pitch: 0,
eyeHeight: 1.62,
isInWater: false,
isInLava: false,
onGround: true
};
/** @type {Record<string, boolean>} */
this.controls = {};
this.heldItem = opts.heldItem || null;
this.inventory = { slots: opts.slots || [] };
this.entities = {};
this.world = opts.world || { raycast: () => null };
this.registry = opts.registry || defaultRegistry();
this.pathfinder = null;
this.blockMap = opts.blockMap || new Map();
this.creative = { flying: false };
}
getControlState(control) {
return this.controls[control] === true;
}
setControlState(control, state) {
this.controls[control] = state === true;
this.emit('controlState', control, state);
}
blockAt(pos) {
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
if (this.blockMap.has(key)) return this.blockMap.get(key);
return { name: 'air', boundingBox: 'empty' };
}
look(yaw, pitch) {
this.entity.yaw = yaw;
this.entity.pitch = pitch === undefined ? this.entity.pitch : pitch;
return Promise.resolve();
}
lookAt(point) {
return this.look(this.entity.yaw, this.entity.pitch);
}
attack(entity, swing) {
this.lastAttack = { entity, swing };
}
activateItem() {
this.usingHeldItem = true;
}
deactivateItem() {
this.usingHeldItem = false;
}
async waitForTicks(ticks) {
for (let i = 0; i < (ticks || 1); i++) this.emit('physicsTick');
}
chat(message) {
this.lastChat = message;
}
equip() {
return Promise.resolve();
}
consume() {
this.usingHeldItem = false;
this.food = 20;
return Promise.resolve();
}
mount() {
this.vehicle = { id: 99, position: this.entity.position.clone(), name: 'oak_boat' };
}
dismount() {
this.vehicle = null;
}
moveVehicle() {
this.lastVehicleMove = Array.from(arguments);
}
/** 推进一个物理 tick(触发合规层的旋转推进) */
tick() {
this.emit('physicsTick');
}
/** 推进 n 个物理 tick */
tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick');
}
}
module.exports = { FakeBot };
+138
查看文件
@@ -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(); // 不应抛异常
});
+105
查看文件
@@ -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);
});
+125
查看文件
@@ -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');
});
+135
查看文件
@@ -0,0 +1,135 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { TerritoryModel } = require('../src/core/territoryModel');
const chunkPos = require('../src/core/chunkPos');
/**
* 构造一个邦国状态。
* @param {string} name
* @param {Array<[number, number]>} coords
*/
function makeState(name, coords, capital) {
const chunks = new Set(coords.map(([x, z]) => chunkPos.key(x, z)));
return {
name,
capital: capital || { x: coords[0][0], z: coords[0][1] },
chunks,
color: 0x75bd20
};
}
test('构造时建立 allClaimed 并集', () => {
const model = new TerritoryModel(new Map([
['A', makeState('A', [[0, 0], [1, 0]])],
['B', makeState('B', [[5, 5]])]
]));
assert.strictEqual(model.stateCount, 2);
assert.strictEqual(model.allClaimedChunks().size, 3);
assert.ok(model.allClaimedChunks().has(chunkPos.key(1, 0)));
});
test('findContainingState 找到覆盖区块的邦国', () => {
const model = new TerritoryModel(new Map([
['A', makeState('A', [[0, 0], [1, 0]])],
['B', makeState('B', [[5, 5]])]
]));
assert.strictEqual(model.findContainingState({ x: 1, z: 0 }).name, 'A');
assert.strictEqual(model.findContainingState({ x: 5, z: 5 }).name, 'B');
assert.strictEqual(model.findContainingState({ x: 9, z: 9 }), null);
});
test('飞地识别:四周被同一邦国包围的空洞', () => {
// 3×3 中空方块:中心 (1,1) 是飞地
const coords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
coords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([['A', makeState('A', coords)]]));
const flying = model.selfFlyingChunks();
// 未设置己方邦国名时为空集
assert.strictEqual(flying.size, 0);
model.setSelfStateName('A');
const selfFlying = model.selfFlyingChunks();
assert.strictEqual(selfFlying.size, 1);
assert.ok(selfFlying.has(chunkPos.key(1, 1)));
assert.strictEqual(model.flyingChunks().size, 1);
});
test('飞地不包含被其他邦国占领的区块', () => {
const aCoords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
aCoords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([
['A', makeState('A', aCoords)],
['B', makeState('B', [[1, 1]])]
]));
model.setSelfStateName('A');
assert.strictEqual(model.selfFlyingChunks().size, 0);
});
test('addSessionClaim 同步更新会话集合、全局集合与己方邦国', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
model.setSelfStateName('A');
model.addSessionClaim({ x: 1, z: 0 });
assert.ok(model.sessionClaimsSet().has(chunkPos.key(1, 0)));
assert.ok(model.allClaimedChunks().has(chunkPos.key(1, 0)));
assert.ok(model.selfState().chunks.has(chunkPos.key(1, 0)));
});
test('addSessionClaim 后飞地缓存失效并重算', () => {
const coords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
coords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([['A', makeState('A', coords)]]));
model.setSelfStateName('A');
assert.strictEqual(model.selfFlyingChunks().size, 1);
// 填上飞地后飞地应为空
model.addSessionClaim({ x: 1, z: 1 });
assert.strictEqual(model.selfFlyingChunks().size, 0);
});
test('mergeApiData:API 独有邦国新增、共有邦国取并集且 Dynmap 颜色优先', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
const apiChunks = new Map([
['A', new Set([chunkPos.key(1, 0)])],
['C', new Set([chunkPos.key(9, 9)])]
]);
const apiCapitals = new Map([['C', { x: 9, z: 9 }]]);
model.mergeApiData(apiChunks, apiCapitals, 0xff43de);
const a = model.state('A');
assert.strictEqual(a.chunks.size, 2, 'A 的区块应为并集');
assert.strictEqual(a.color, 0x75bd20, 'Dynmap 已有邦国应保留原颜色');
const c = model.state('C');
assert.ok(c, 'API 独有邦国应被新增');
assert.strictEqual(c.color, 0xff43de);
assert.deepStrictEqual(c.capital, { x: 9, z: 9 });
assert.ok(model.allClaimedChunks().has(chunkPos.key(9, 9)));
});
test('mergeApiData:Dynmap 王城优先于 API 王城', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]], { x: 7, z: 7 })]]));
model.mergeApiData(new Map([['A', new Set([chunkPos.key(1, 0)])]]), new Map([['A', { x: 99, z: 99 }]]), 0);
assert.deepStrictEqual(model.state('A').capital, { x: 7, z: 7 });
});
test('mergeApiData:空输入直接返回不改动模型', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
model.mergeApiData(new Map(), new Map(), 0);
assert.strictEqual(model.stateCount, 1);
});
+198
查看文件
@@ -0,0 +1,198 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const chunkPos = require('../src/core/chunkPos');
test('flattenChunks 压平为 [x1,z1,x2,z2,...]', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4)]);
const flat = flattenChunks(keys);
assert.strictEqual(flat.length, 4);
const pairs = [];
for (let i = 0; i < flat.length; i += 2) pairs.push([flat[i], flat[i + 1]]);
pairs.sort((a, b) => a[0] - b[0]);
assert.deepStrictEqual(pairs, [[-3, 4], [1, 2]]);
});
test('boundsOf 计算外接矩形并对空集合返回 null', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4), chunkPos.key(5, -6)]);
assert.deepStrictEqual(boundsOf(keys), { minX: -3, maxX: 5, minZ: -6, maxZ: 4 });
assert.strictEqual(boundsOf(new Set()), null);
});
test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
assert.strictEqual(timingSafeEqual('abc', 'abc'), true);
assert.strictEqual(timingSafeEqual('abc', 'abd'), false);
assert.strictEqual(timingSafeEqual('abc', 'abcd'), false);
assert.strictEqual(timingSafeEqual(undefined, 'abc'), false);
});
/** 搭建一个使用桩依赖的控制台实例 */
function setupConsole() {
const map = new Map([[
'A',
{
name: 'A',
capital: { x: 0, z: 0 },
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
color: 0x2f81f7
}
]]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
model.addSessionClaim({ x: 1, z: 0 });
const logger = new Logger({ echo: false });
const botManager = {
snapshot: () => ({ state: 'online', username: 'TestBot' }),
sendCommand: () => {}
};
const controller = {
target: { x: 2, z: 0 },
playerChunk: { x: 0, z: 0 },
snapshot: () => ({ state: 'PATHING', running: true }),
exportLogs: () => 'data/debug.log',
stop: () => {},
start: async () => true,
status: '测试中'
};
const dataService = {
getModel: () => model,
snapshot: () => ({ stateCount: 1, claimedChunks: 2 }),
refreshAsync: () => {},
fetchAllTerritoryOnce: async () => ({ ok: true, message: '已拉取 0 个区块' })
};
const config = defaultConfig();
const webConsole = new WebConsole({
botManager,
controller,
dataService,
logger,
getConfig: () => config,
applyConfig: (next) => next
}, { host: '127.0.0.1', port: 0 });
return { webConsole, model, logger, controller };
}
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
const { webConsole } = setupConsole();
const payload = webConsole.mapPayload(false);
assert.strictEqual(payload.selfState, 'A');
assert.strictEqual(payload.self.length, 4, '2 个区块 → 4 个数字');
assert.strictEqual(payload.session.length, 2);
assert.deepStrictEqual(payload.target, { x: 2, z: 0 });
assert.strictEqual(payload.states.length, 1);
assert.strictEqual(payload.states[0].name, 'A');
assert.strictEqual(payload.states[0].color, '#2f81f7');
assert.strictEqual(payload.states[0].count, 2);
assert.strictEqual(payload.otherChunks, undefined, '默认不返回全量他国区块');
});
test('mapPayload(full) 额外返回他国区块', () => {
const { webConsole, model } = setupConsole();
model.states.set('B', {
name: 'B',
capital: { x: 9, z: 9 },
chunks: new Set([chunkPos.key(9, 9)]),
color: 0xff43de
});
const payload = webConsole.mapPayload(true);
assert.strictEqual(payload.full, true);
assert.strictEqual(payload.otherChunks.length, 1);
assert.strictEqual(payload.otherChunks[0].name, 'B');
assert.strictEqual(payload.otherChunks[0].color, '#ff43de');
});
test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
const { webConsole } = setupConsole();
const payload = webConsole.statusPayload();
assert.strictEqual(payload.bot.state, 'online');
assert.strictEqual(payload.task.state, 'PATHING');
assert.strictEqual(payload.data.stateCount, 1);
assert.strictEqual(payload.claimTime.start, 11);
assert.strictEqual(payload.claimTime.end, 22);
});
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const status = await fetch(base + '/api/status').then((r) => r.json());
assert.strictEqual(status.ok, true);
assert.strictEqual(status.data.bot.state, 'online');
const config = await fetch(base + '/api/config').then((r) => r.json());
assert.strictEqual(config.ok, true);
assert.strictEqual(config.data.strategy, 1);
const map = await fetch(base + '/api/map?full=1').then((r) => r.json());
assert.strictEqual(map.ok, true);
assert.strictEqual(map.data.full, true);
const html = await fetch(base + '/').then((r) => r.text());
assert.ok(html.includes('AutoStore Bot'), '首页应返回控制台 HTML');
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => {
const { webConsole } = setupConsole();
webConsole.token = 'secret-token';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const unauthorized = await fetch(base + '/api/status');
assert.strictEqual(unauthorized.status, 401);
const authorized = await fetch(base + '/api/status?token=secret-token');
assert.strictEqual(authorized.status, 200);
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
assert.strictEqual(viaHeader.status, 200);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/config 会走 applyConfig 并返回生效配置', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy: 4, targetCount: 12 })
}).then((r) => r.json());
assert.strictEqual(response.ok, true);
assert.strictEqual(response.data.strategy, 4);
assert.strictEqual(response.data.targetCount, 12);
// 未提供的字段保持默认
assert.strictEqual(response.data.retries, 2);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
assert.strictEqual(response.status, 400);
} finally {
await webConsole.stop();
}
});