本服会周期性把机器人踢下线(实测约 1~6 分钟一次,踢出原因是 "An internal error occurred in your connection"),每次都会走到「世界已退出,任务停止」。 原来的行为是就此停住 —— 机器人虽然能被原点自救送回真实世界(实测 4/4 成功), 却再也不会继续扩地,于是永远停在原地。 现在世界退出时把本次任务的配置记下来(resumeConfig), 重新进入世界后自动 start() 接上;状态文案改为「世界已退出,等待重连后自动恢复」, 并打印「已重新进入世界,自动恢复扩地任务」。 只有因世界退出而停的那次会登记:手动停止 / 到开垦时段外 / 耐久保护停机 都会在 stop() 里清空 resumeConfig,不会被重连重新拉起。 开关 autoResumeAfterReconnect 默认开启,可关。 测试 470 → 473。
859 行
34 KiB
JavaScript
859 行
34 KiB
JavaScript
'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 { TargetLeaseTable } = require('../src/core/targetLease');
|
||
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;
|
||
/** 设为 false 模拟「世界已退出」(掉线 / 被踢 / 被切到登录服) */
|
||
this.spawned = true;
|
||
}
|
||
|
||
isReady() {
|
||
return true;
|
||
}
|
||
|
||
isSpawned() {
|
||
return this.spawned;
|
||
}
|
||
|
||
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,
|
||
botId: opts.botId,
|
||
botName: opts.botName,
|
||
targetLeases: opts.targetLeases
|
||
});
|
||
|
||
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('世界退出后任务暂停,重连进入世界时自动恢复', async () => {
|
||
// 本服会周期性把机器人踢下线(实测约 1~6 分钟一次)。没有自动恢复的话,
|
||
// 机器人虽然能被原点自救送回真实世界,任务却再也不会继续 —— 扩地永远跑不长。
|
||
const { controller, botManager, config } = setup();
|
||
await controller.start(config);
|
||
assert.strictEqual(controller.isRunning(), true);
|
||
|
||
botManager.spawned = false;
|
||
controller.tick();
|
||
assert.strictEqual(controller.isRunning(), false, '世界退出应停止任务');
|
||
assert.match(controller.status, /等待重连后自动恢复/);
|
||
|
||
botManager.spawned = true;
|
||
controller.tick();
|
||
assert.strictEqual(controller.isRunning(), true, '重连后应自动恢复任务');
|
||
assert.ok(controller.target, '恢复后应重新选出目标区块');
|
||
assert.strictEqual(botManager.pathing.pathCalls, 2, '恢复后应重新下发寻路');
|
||
});
|
||
|
||
test('自动恢复可以关闭', async () => {
|
||
const { controller, botManager, config } = setup({ config: { autoResumeAfterReconnect: false } });
|
||
await controller.start(config);
|
||
botManager.spawned = false;
|
||
controller.tick();
|
||
botManager.spawned = true;
|
||
controller.tick();
|
||
assert.strictEqual(controller.isRunning(), false, '关掉开关后不应自动恢复');
|
||
});
|
||
|
||
test('手动停止的任务不会被自动恢复', async () => {
|
||
const { controller, botManager, config } = setup();
|
||
await controller.start(config);
|
||
controller.stop();
|
||
botManager.spawned = false;
|
||
controller.tick();
|
||
botManager.spawned = true;
|
||
controller.tick();
|
||
assert.strictEqual(controller.isRunning(), false, '手动停止不应被重连重新拉起');
|
||
});
|
||
|
||
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('连续超时累计达到阈值时回城(需显式开启 returnOnTimeout)', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
|
||
}
|
||
});
|
||
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, botManager, config } = setup({
|
||
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
|
||
});
|
||
assert.strictEqual(config.returnOnTimeout, false, '默认应关闭自动回城');
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
assert.notStrictEqual(controller.state, State.RETURNING, '默认不应触发回城');
|
||
assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令');
|
||
});
|
||
|
||
test('回城保护期内即便开启回城也不执行,避免清掉刚摆好的位置', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 600
|
||
}
|
||
});
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
assert.notStrictEqual(controller.state, State.RETURNING, '保护期内不应回城');
|
||
assert.ok(!botManager.commands.includes('u return'));
|
||
});
|
||
|
||
test('回城指令可通过 returnCommand 自定义', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0,
|
||
returnCommand: '/spawn'
|
||
}
|
||
});
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
// 玩家可能顺手带上前导斜杠,发送时应被去掉
|
||
assert.ok(botManager.commands.includes('spawn'), '应发送自定义指令:' + botManager.commands.join(', '));
|
||
assert.ok(!botManager.commands.includes('u return'));
|
||
});
|
||
|
||
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
|
||
const { controller, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, 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, '耐久保护回城后应停止任务');
|
||
});
|
||
|
||
// ==================== 多机器人目标租约(4.3)====================
|
||
|
||
test('多机器人共享租约表:不会挑到同一个目标区块', async () => {
|
||
const table = new TargetLeaseTable();
|
||
const first = setup({ targetLeases: table, botId: 'BotA' });
|
||
const second = setup({ targetLeases: table, botId: 'BotB' });
|
||
await first.controller.start(first.config);
|
||
await second.controller.start(second.config);
|
||
|
||
assert.ok(first.controller.target, 'A 应有目标');
|
||
assert.ok(second.controller.target, 'B 也应有目标');
|
||
assert.notDeepStrictEqual(second.controller.target, first.controller.target,
|
||
'两个机器人不应挑到同一区块');
|
||
assert.strictEqual(table.isLeasedByOther(first.controller.target, 'BotB'), true,
|
||
'A 的目标应对外可见(B 不会再挑它)');
|
||
});
|
||
|
||
test('停止任务时归还租约,其他机器人立刻可以接手', async () => {
|
||
const table = new TargetLeaseTable();
|
||
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
|
||
await controller.start(config);
|
||
const target = controller.target;
|
||
assert.ok(target);
|
||
controller.stop();
|
||
assert.strictEqual(table.isLeasedByOther(target, 'BotB'), false, '停止后应释放租约');
|
||
assert.strictEqual(table.size, 0, 'A 不应留下任何占用');
|
||
});
|
||
|
||
test('运行中持续续租:租约不会在长距离寻路中途过期', async () => {
|
||
// 时序余量刻意放宽:这条用例要证明的是「续租把到期时间往后推了」,
|
||
// 即「总耗时 > 原始 TTL」但「距续租的耗时 < TTL」。
|
||
// 睡眠时长取 0.7 × TTL,两侧各留 0.3 × TTL(120ms)的调度余量 ——
|
||
// 早先用 TTL=40ms / 睡眠 25ms 只剩 15ms 余量,全量并行跑测试时必然偶发失败。
|
||
const ttlMs = 400;
|
||
const sleepMs = 280;
|
||
const table = new TargetLeaseTable({ ttlMs });
|
||
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
|
||
await controller.start(config);
|
||
const target = controller.target;
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||
controller.tick(); // 续租
|
||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||
// 总耗时已超过原始 TTL(400ms),但续租过,仍应归 A
|
||
assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走');
|
||
});
|
||
|
||
test('候选区块全被其他机器人预定时等待释放,而不是判「没有可用区块」', async () => {
|
||
const table = new TargetLeaseTable();
|
||
const first = setup({ targetLeases: table, botId: 'BotA' });
|
||
const second = setup({ targetLeases: table, botId: 'BotB' });
|
||
// A 先把 B 的所有候选区块都占了(模拟 A 的队列很长)
|
||
await first.controller.start(first.config);
|
||
const model = second.model;
|
||
for (const chunk of [...model.state('A').chunks, ...first.controller.queue, first.controller.target]
|
||
.filter(Boolean)) {
|
||
const c = typeof chunk === 'string'
|
||
? { x: chunkPos.decode(chunk).x, z: chunkPos.decode(chunk).z }
|
||
: chunk;
|
||
table.tryAcquire(c, 'BotA');
|
||
}
|
||
|
||
await second.controller.start(second.config);
|
||
assert.strictEqual(second.controller.state, State.NEXT, '应进入等待而不是 COMPLETE');
|
||
assert.ok(second.controller._nextReplanAt > Date.now(), '应登记稍后重新规划');
|
||
const lines = second.logger.list().map((e) => e.message).join('\n');
|
||
assert.ok(lines.includes('正被其他机器人预定'), '应说明为什么等待:' + lines);
|
||
});
|
||
|
||
test('连续超时达阈值但原地还能走 → 不回城,改为跳过目标', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
|
||
}
|
||
});
|
||
botManager.pathing.diagnostics = () => ({ neighborCount: 3 });
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
|
||
assert.notStrictEqual(controller.state, State.RETURNING, '原地还能走时不应回城');
|
||
assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令');
|
||
assert.strictEqual(controller.returnSkippedNotStuck, 1, '应记下「放弃回城」的次数');
|
||
assert.strictEqual(controller.lastReturn, null, '没有回城就不该有回城记录');
|
||
});
|
||
|
||
test('连续超时达阈值且原地无路可走 → 回城', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
|
||
}
|
||
});
|
||
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
|
||
assert.strictEqual(controller.state, State.RETURNING, '确实卡死时应回城');
|
||
assert.ok(botManager.commands.includes('u return'));
|
||
assert.strictEqual(controller.returnSkippedNotStuck, 0);
|
||
});
|
||
|
||
test('拿不到寻路诊断时保守按卡死处理(仍会回城)', async () => {
|
||
const { controller, botManager, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
|
||
}
|
||
});
|
||
// FakePathing 本来就没有 diagnostics 方法:模拟「寻路器未就绪,判断不了」
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
assert.strictEqual(controller.state, State.RETURNING);
|
||
});
|
||
|
||
test('回城完成后记录前后坐标与直线距离(控制台展示「从 A 挪到了 B」)', async () => {
|
||
const { controller, botManager, bot, logger, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
|
||
}
|
||
});
|
||
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
assert.strictEqual(controller.state, State.RETURNING);
|
||
|
||
// 模拟服务器把玩家传送到安全点
|
||
bot.entity.position.x = 1000;
|
||
bot.entity.position.y = 70;
|
||
bot.entity.position.z = 2000;
|
||
controller.returnStartMillis = Date.now() - 5000;
|
||
controller.tick();
|
||
|
||
const last = controller.lastReturn;
|
||
assert.ok(last, '应记录回城结果');
|
||
assert.deepStrictEqual(last.from, { x: 8, y: 64, z: 8 }, '应记录回城前坐标');
|
||
assert.deepStrictEqual(last.to, { x: 1000, y: 70, z: 2000 }, '应记录回城后坐标');
|
||
assert.ok(last.distance > 2000, `应记录直线距离,实际 ${last.distance}`);
|
||
assert.ok(logger.list().some((e) => e.message.includes('回城结果')), '应输出「从 A 挪到了 B」日志');
|
||
assert.ok(controller.snapshot().lastReturn, 'snapshot 应暴露给控制台');
|
||
});
|
||
|
||
test('回城几乎没有改变位置时给出明确提示', async () => {
|
||
const { controller, botManager, logger, config } = setup({
|
||
config: {
|
||
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
|
||
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
|
||
}
|
||
});
|
||
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
|
||
await controller.start(config);
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
controller.returnStartMillis = Date.now() - 5000;
|
||
controller.tick(); // 位置没变(模拟安全点就是原地)
|
||
|
||
assert.strictEqual(controller.lastReturn.distance, 0);
|
||
assert.ok(logger.list().some((e) => e.message.includes('几乎没有改变位置')), '应提示回城没有帮助');
|
||
});
|
||
|
||
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('寻路中只要在稳步接近目标,就不算超时(慢速推进不会被回城打断)', async () => {
|
||
const { controller, config, bot } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } });
|
||
await controller.start(config);
|
||
const target = controller.target;
|
||
assert.ok(target, '应选出目标区块');
|
||
|
||
// 先跑一次 tick 建立距离基准(plan() 会把基准清空,首次观察只记录不续期)
|
||
// 把机器人放在目标西侧 10 个区块处,后面每步靠近 2 格
|
||
const place = (offsetX) => {
|
||
bot.entity.position.x = (target.x + offsetX) * 16 + 8;
|
||
bot.entity.position.z = target.z * 16 + 8;
|
||
};
|
||
place(-10);
|
||
controller.tick();
|
||
|
||
// 模拟「走得很慢但在推进」:每次 tick 靠近 2 个区块,但耗时都超过超时阈值
|
||
for (let step = 1; step <= 4; step++) {
|
||
place(-10 + step * 2);
|
||
controller.stageStartedMillis = Date.now() - 10000; // 每次都「已超时」
|
||
controller.tick();
|
||
assert.strictEqual(controller.retryCount, 0,
|
||
`第 ${step} 次推进不应被判定为超时(retryCount=${controller.retryCount})`);
|
||
assert.notStrictEqual(controller.state, State.RETURNING, '不应触发回城');
|
||
}
|
||
assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '有推进时连续超时应清零');
|
||
});
|
||
|
||
test('寻路中原地不动时仍然会超时并重试(回归保护)', async () => {
|
||
const { controller, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } });
|
||
await controller.start(config);
|
||
controller.tick(); // 首次 tick 只记录距离基准
|
||
controller.stageStartedMillis = Date.now() - 10000;
|
||
controller.tick();
|
||
assert.strictEqual(controller.retryCount, 1, '原地不动必须照常判超时');
|
||
});
|
||
|
||
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, '没有可用的相邻无主区块');
|
||
});
|
||
|
||
// ==================== 多机器人:邦国归属与开拓日志 ====================
|
||
|
||
test('start 把识别到的邦国固化到 stateName,不依赖全局 selfStateName', async () => {
|
||
const { controller, config } = setup({ selfName: 'A' });
|
||
await controller.start(config);
|
||
assert.strictEqual(controller.stateName, 'A');
|
||
assert.strictEqual(controller.config.stateName, 'A', '应写回任务配置供规划器使用');
|
||
});
|
||
|
||
test('两个机器人各自开拓不同邦国时互不覆盖', async () => {
|
||
const map = new Map([
|
||
['A', { name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }],
|
||
['B', { name: 'B', capital: { x: 20, z: 20 }, chunks: new Set([chunkPos.key(20, 20)]), color: 0xff43de }]
|
||
]);
|
||
const model = new TerritoryModel(map);
|
||
const logger = new Logger({ echo: false });
|
||
|
||
const makeController = (name) => {
|
||
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||
const botManager = new FakeBotManager(bot, model);
|
||
botManager.connection = { username: name };
|
||
const dataService = new FakeDataService(model);
|
||
const controller = new ExpansionController({ botManager, dataService, logger, botId: name, botName: name });
|
||
const config = defaultConfig();
|
||
config.enforceClaimTime = false;
|
||
config.autoEat = false;
|
||
config.autoAttack = false;
|
||
config.durabilityGuard = false;
|
||
return { controller, config, model };
|
||
};
|
||
|
||
const first = makeController('BotA');
|
||
first.config.stateName = 'A';
|
||
const second = makeController('BotB');
|
||
second.config.stateName = 'B';
|
||
|
||
await first.controller.start(first.config);
|
||
await second.controller.start(second.config);
|
||
|
||
// 全局 selfStateName 会被最后启动的机器人改写,但各控制器仍按自己的邦国工作
|
||
assert.strictEqual(first.controller.stateName, 'A');
|
||
assert.strictEqual(second.controller.stateName, 'B');
|
||
|
||
first.controller.playerChunk = { x: 0, z: 0 };
|
||
second.controller.playerChunk = { x: 20, z: 20 };
|
||
first.controller.monitor.onChat('疆土(0,1) [8,24] 已为你的邦国开拓');
|
||
second.controller.monitor.onChat('疆土(20,21) [328,344] 已为你的邦国开拓');
|
||
|
||
assert.ok(model.state('A').chunks.has(chunkPos.key(0, 1)), 'A 的新占领应记到 A 名下');
|
||
assert.ok(!model.state('A').chunks.has(chunkPos.key(20, 21)));
|
||
assert.ok(model.state('B').chunks.has(chunkPos.key(20, 21)), 'B 的新占领应记到 B 名下');
|
||
});
|
||
|
||
test('占领成功时写入开拓日志(含玩家、区块、时间与来源)', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog-'));
|
||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||
|
||
const { controller, config, bot } = setup();
|
||
controller.expansionLog = expansionLog;
|
||
controller.botId = 'BotA';
|
||
controller.botName = 'BotA';
|
||
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 before = Date.now();
|
||
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16 + 1},${target.z * 16 + 2}] 已为你的邦国开拓`);
|
||
|
||
const rows = expansionLog.list();
|
||
assert.strictEqual(rows.length, 1);
|
||
const entry = rows[0];
|
||
assert.strictEqual(entry.bot, 'BotA');
|
||
assert.strictEqual(entry.botId, 'BotA');
|
||
assert.strictEqual(entry.state, 'A');
|
||
assert.strictEqual(entry.cx, target.x);
|
||
assert.strictEqual(entry.cz, target.z);
|
||
assert.strictEqual(entry.bx, target.x * 16 + 1);
|
||
assert.strictEqual(entry.bz, target.z * 16 + 2);
|
||
assert.strictEqual(entry.source, 'chat');
|
||
assert.ok(entry.t >= before && entry.t <= Date.now());
|
||
assert.strictEqual(entry.sessionCount, 1);
|
||
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
test('无坐标的通用成功消息记为 chat-generic 来源', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog2-'));
|
||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||
|
||
const { controller, config, bot } = setup();
|
||
controller.expansionLog = expansionLog;
|
||
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();
|
||
controller.monitor.onChat('你的疆土已开拓');
|
||
|
||
const rows = expansionLog.list();
|
||
assert.strictEqual(rows.length, 1);
|
||
assert.strictEqual(rows[0].source, 'chat-generic');
|
||
assert.strictEqual(rows[0].bx, null);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
test('同一区块重复成功只记一条开拓日志', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog3-'));
|
||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||
|
||
const { controller, config, bot } = setup();
|
||
controller.expansionLog = expansionLog;
|
||
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(expansionLog.list().length, 1);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
});
|