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,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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 后应可立即再次检查');
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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, '没有可用的相邻无主区块');
|
||||
});
|
||||
@@ -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} 越界`);
|
||||
}
|
||||
});
|
||||
@@ -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 + ' 应被松开');
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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(); // 不应抛异常
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户