'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>, 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, '没有可用的相邻无主区块'); }); // ==================== 多机器人:邦国归属与开拓日志 ==================== 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 }); });