'use strict'; const chunkPos = require('../core/chunkPos'); const { ExpansionPlanner } = require('../core/expansionPlanner'); const { SubtitleChatMonitor } = require('../core/subtitleChatMonitor'); const { findTarget } = require('../bot/autoCombat'); const { PathEvent } = require('../bot/pathingService'); /** * 扩地任务状态机枚举(与原 Java 版一一对应)。 */ const State = { /** 空闲(未启动) */ IDLE: 'IDLE', /** 正在生成扩地计划 */ PLANNING: 'PLANNING', /** 正在寻路前往目标区块 */ PATHING: 'PATHING', /** 已到达,等待开垦完成 */ WAITING: 'WAITING', /** 当前区块开垦成功 */ SUCCESS: 'SUCCESS', /** 当前区块开垦失败 / 超时 */ FAIL: 'FAIL', /** 准备处理下一个目标 */ NEXT: 'NEXT', /** 已发送回城指令,等待传送完成 */ RETURNING: 'RETURNING', /** 全部目标完成 */ COMPLETE: 'COMPLETE', /** 被手动停止 */ STOPPED: 'STOPPED' }; /** 环形日志中「导出日志」用的状态摘要键名 */ const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度']; /** * 判定「寻路有进展」的最小距离变化(区块)。 * * 只要与目标的曼哈顿距离缩短了这么多,就认为是在稳步接近而不是卡死, * 从而重置超时计时。取 1 是因为矿区/建筑区里寻路速度可能远低于正常步行, * 阈值再大就会把慢速推进误判成超时。 */ const PATH_PROGRESS_MIN_CHUNKS = 1; /** * 候选区块全被其他机器人预定时,隔多久重新规划(毫秒)。 * 取 15 秒:既不会忙等,也不会在别人刚释放时错过太久。 */ const LEASE_RETRY_DELAY_MS = 15000; /** * 自动扩地控制器:扩地任务的核心状态机。 * * 工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功 / 失败 * (SUCCESS / FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。 * 由外部按 tick 驱动 {@link tick},通过寻路服务自动移动到目标区块, * 并监听聊天 / 字幕消息判断开垦是否成功。支持自动进食、自动攻击敌对生物、 * 低耐久自动回城、超时重试、手动停止等功能。 */ class ExpansionController { /** * @param {object} deps 依赖注入 * @param {import('../bot/botManager').BotManager} deps.botManager * @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService * @param {import('../util/logger').Logger} deps.logger * @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属) * @param {string} [deps.botName] 所属机器人游戏名 * @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志 * @param {import('../core/targetLease').TargetLeaseTable} [deps.targetLeases] 目标区块租约表(多机器人共享) */ constructor(deps) { this.botManager = deps.botManager; this.dataService = deps.dataService; this.logger = deps.logger; /** 所属机器人 id(默认取游戏名) */ this.botId = deps.botId || (deps.botManager && deps.botManager.connection ? deps.botManager.connection.username : 'bot'); /** 所属机器人游戏名 */ this.botName = deps.botName || (deps.botManager && deps.botManager.connection ? deps.botManager.connection.username : this.botId); /** @type {import('../core/expansionLog').ExpansionLog|null} */ this.expansionLog = deps.expansionLog || null; /** * 目标区块租约表(多机器人共享,可空 —— 单机器人场景不需要协调)。 * 详见 core/targetLease.js:抢到的机器人独占区块,其余机器人跳过。 * @type {import('../core/targetLease').TargetLeaseTable|null} */ this.targetLeases = deps.targetLeases || null; /** 候选区块全被其他机器人预定时,下一次重新规划的时间戳(避免忙等) */ this._nextReplanAt = 0; this.planner = new ExpansionPlanner(); /** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */ this.monitor = new SubtitleChatMonitor({ onProgress: (percent) => this.onProgress(percent), onSuccess: (chunk, blockX, blockZ) => this.onSuccess(chunk, blockX, blockZ), onGenericSuccess: () => this.onGenericSuccess() }); /** 当前扩地配置 */ this.config = null; /** 当前状态机状态 */ this.state = State.IDLE; /** 当前状态描述文本 */ this.status = '未启动'; /** @type {{x: number, z: number}|null} 当前目标区块 */ this.target = null; /** @type {{x: number, z: number}} 玩家当前所在区块(每 tick 更新) */ this.playerChunk = { x: 0, z: 0 }; /** 已成功开垦的区块数量 */ this.completedClaims = 0; /** 当前目标的连续重试次数 */ this.retryCount = 0; /** 当前阶段(寻路 / 等待)的开始时间戳 */ this.stageStartedMillis = 0; /** 本次连接/本次任务的开始时间戳,用于「刚连上时不许回城」的保护期 */ this.sessionStartedMillis = Date.now(); /** 上一次记录的「与目标的曼哈顿距离」,用于判断寻路是否在稳步推进 */ this._lastTargetDistance = null; /** 最近一次开垦进度(0-100) */ this.lastProgress = 0; /** @type {Array<{x: number, z: number}>} 待占领区块队列 */ this.queue = []; /** 已计入完成数的区块键集合(去重) */ this.claimedSet = new Set(); /** 等待开拓时段自动开始:不在时段时点击开始会暂存配置(null 表示无等待任务) */ this.pendingStart = null; /** * 被「世界退出」打断的任务配置:重连进入世界后用它自动恢复。 * * 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),而每次掉线都会走到 * 「世界已退出,任务停止」。没有这个字段的话,机器人虽然能被原点自救送回 * 真实世界,任务却再也不会继续 —— 扩地永远跑不长。 * 手动停止 / 到时段外 / 耐久保护停机都不会写入它。 */ this.resumeConfig = null; /** 连续寻路超时累计时长(毫秒) */ this.consecutiveTimeoutMillis = 0; /** 发送回城指令的时间戳 */ this.returnStartMillis = 0; /** 当前目标已触发「往边上走两步」的次数 */ this.sideStepCount = 0; /** 是否正在「往边上走两步」阶段 */ this.sideStepping = false; /** 走两步的方向符号(+1 左侧 / -1 右侧) */ this.sideStepSign = 1; /** 走两步的整体超时截止时间戳 */ this.sideStepDeadlineMillis = 0; /** 最近一次换向的时间戳 */ this.sideStepSinceMillis = 0; /** 走两步开始时的玩家方块坐标 */ this.sideStepStartPos = null; /** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */ this.returnThenStop = false; /** * 最近一次回城的前后坐标与距离。 * * 回城是破坏性操作(会把机器人拉回安全点、清掉玩家手动摆好的位置), * 用户必须能在控制台上一眼看出「这次回城把机器人从哪挪到了哪」, * 从而判断回城是否值得。 * @type {{at: number, reason: string, from: object|null, to: object|null, distance: number|null}|null} */ this.lastReturn = null; /** 待回城时记录的「回城前坐标」(传送完成后与当前位置组成前后对比) */ this.pendingReturn = null; /** * 因「原地还能走」而放弃回城的次数。 * * 连续超时达到回城阈值并不代表卡死 —— 位置还能走时回城毫无意义, * 只会白白丢掉当前位置,因此这种情况下改为跳过目标并记入这里。 */ this.returnSkippedNotStuck = 0; /** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */ this.combatTargetEntityId = -1; /** 当前是否正持续按住跳跃键(自动游泳用) */ this.holdingJump = false; /** * 本机器人实际开拓的邦国名(start() 时解析并固化)。 * * 多机器人场景下每个机器人可能属于不同邦国,绝不能依赖领土模型的全局 * selfStateName(那会被最后启动的机器人覆盖)。 */ this.stateName = ''; /** 是否已经发送过开垦模式指令 */ this._claimModeSent = false; /** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */ this.onStateChange = null; /** 最近一次错误 */ this.lastError = ''; } // ==================== 对外状态 ==================== /** @returns {boolean} 任务是否运行中 */ isRunning() { return this.state !== State.IDLE && this.state !== State.COMPLETE && this.state !== State.STOPPED; } /** @returns {boolean} 是否处于「等待开拓时段自动开始」状态 */ isWaitingForClaimTime() { return this.pendingStart !== null; } /** @returns {number} 队列中待占领区块数 */ get queuedTargets() { return this.queue.length; } /** * 判断当前现实时间(本地时区)是否在允许开拓的时间段内。 * @returns {boolean} */ isWithinClaimTime() { const cfg = this.config || { enforceClaimTime: false }; if (!cfg.enforceClaimTime) return true; const hour = new Date().getHours(); return hour >= cfg.claimStartHour && hour < cfg.claimEndHour; } /** * 启动自动扩地任务。 * * 前置校验:机器人必须已进入世界并完成登录、寻路器已就绪; * 邦国名称为空时自动识别玩家所在邦国,否则校验指定邦国是否存在。 * * @param {object} requestedConfig 本次任务的扩地配置 * @returns {Promise} 是否成功启动 */ async start(requestedConfig) { const bm = this.botManager; if (!bm.isReady()) { this.status = '机器人尚未就绪(未连接 / 未进入世界)'; this.state = State.IDLE; return false; } this.config = requestedConfig; // 现实时间限制:不在允许时段时登记「等待自动开始」,进入时段后由 tick() 自动启动 if (!this.isWithinClaimTime()) { this.pendingStart = requestedConfig; this.setState(State.IDLE, `等待开垦时段,${this.config.claimStartHour}:00 自动开始`); this._log(`不在开垦时段内(${this.config.claimStartHour}:00-${this.config.claimEndHour}:00),` + `已登记自动开始,当前现实时间 ${formatClock(new Date())}`); bm.sendCommand('', { immediate: true }); return true; } this.pendingStart = null; this.playerChunk = this._playerChunk(); // ===== 识别己方邦国 ===== // 解析结果固化到 this.stateName,并写回本次任务配置, // 使规划器不再依赖领土模型的全局 selfStateName(多机器人会互相覆盖)。 const model = this.dataService.getModel(); let resolvedName = ''; if (!this.config.stateName) { const auto = model.findContainingState(this.playerChunk); if (auto) { resolvedName = auto.name; } else { // 兜底:通过 SimpCore API 按玩家名识别 const detected = await this.dataService.detectStateByPlayer(bm.connection.username); if (detected) { resolvedName = detected; } else { this.status = '无法自动识别邦国,请在控制台手动选择'; this.state = State.IDLE; return false; } } } else if (!model.state(this.config.stateName)) { this.status = '所选邦国不存在:' + this.config.stateName; this.state = State.IDLE; return false; } else { resolvedName = this.config.stateName; } this.stateName = resolvedName; this.config = Object.assign({}, requestedConfig, { stateName: resolvedName }); // 全局选中邦国跟随最近启动的机器人,供控制台地图与数据源展示使用 this.dataService.setSelectedStateName(resolvedName); // ===== 复位任务状态 ===== this.completedClaims = 0; this.retryCount = 0; this.consecutiveTimeoutMillis = 0; // 保护期从「本次任务开始」算起:玩家常趁机器人掉线时在客户端把它摆到开阔地, // 刚启动就被回城拉走等于白摆 this.sessionStartedMillis = Date.now(); this.sideStepCount = 0; this.sideStepping = false; this.returnThenStop = false; this.combatTargetEntityId = -1; this.queue = []; this.claimedSet.clear(); this.target = null; this.lastProgress = 0; this.lastError = ''; this._claimModeSent = false; if (bm.guard) bm.guard.reset(); if (bm.pathing) bm.pathing.applyConfig(this.config); // ===== 开启服务器开垦模式 ===== if (this.config.server.claimCommand) { bm.sendCommand(this.config.server.claimCommand); this._claimModeSent = true; this._log('已发送开垦模式指令:' + this.config.server.claimCommand); } this.setState(State.PLANNING, '正在生成扩张计划'); this._log(`开始自动扩地,邦国:` + this.stateName); await this.plan(); return true; } /** 手动停止任务 */ stop() { const bm = this.botManager; // 任何显式停止都作废「掉线待恢复」——只有 tick() 里因世界退出而停的那次会重新登记 this.resumeConfig = null; if (this.pendingStart) { this.pendingStart = null; this.setState(State.IDLE, '已取消自动开始'); this._log('已取消等待自动开始'); return; } if (!this.isRunning()) return; if (bm.pathing) bm.pathing.cancel(); if (this.sideStepping) this.stopSideStep(); this.releaseJump(); if (bm.boat) bm.boat.reset(true); // 停止任务时归还全部目标租约,别的机器人可以立刻接手这些区块 if (this.targetLeases) this.targetLeases.releaseAll(this.botId); this._nextReplanAt = 0; this.setState(State.STOPPED, '已手动停止'); this._log('已手动停止'); } /** * 每 tick 驱动逻辑:更新玩家区块,依次处理等待时段、自动进食、耐久保护、 * 自动游泳、自动用船、自动攻击,再按当前状态分发到对应的 tick 方法。 */ tick() { const bm = this.botManager; if (!this.config) return; // 尚未启动过任务,没有可驱动的状态 if (!bm.isSpawned()) { if (this.isRunning()) { // 先记下这次任务的配置:本服会周期性把机器人踢下线,重连回来时若没人管, // 任务就永远停在这里。stop() 会清空 resumeConfig,所以要在它之后重新登记。 const interrupted = this.config; this.stop(); this.status = '世界已退出,等待重连后自动恢复'; if (interrupted && interrupted.autoResumeAfterReconnect !== false) { this.resumeConfig = interrupted; this._log('世界已退出,任务已暂停;重新进入世界后会自动恢复'); } } return; } // 重新进入世界:把被掉线打断的任务接上。 // 必须等 isReady()(含登录完成)才试,否则 start() 会直接返回 false; // 而且只有在真的启动成功后才清掉 resumeConfig —— 否则一次失败的尝试 // 就会把这次恢复永久丢掉(实测本服掉线频繁,很容易踩到)。 if (this.resumeConfig && !this.isRunning()) { if (!bm.isReady()) return; // 还没就绪,下一 tick 再试 const resume = this.resumeConfig; this._log('已重新进入世界,自动恢复扩地任务'); Promise.resolve(this.start(resume)) .then((ok) => { if (ok) { this.resumeConfig = null; } else { this._log('自动恢复未成功(' + this.status + '),稍后重试', 'warn'); } }) .catch((err) => this._log('自动恢复任务失败:' + err.message, 'error')); return; } this.playerChunk = this._playerChunk(); // 续租当前目标:长距离寻路可能要走几分钟,不能让租约中途过期被别人抢走 if (this.isRunning()) this._renewTargetLease(); // 等待开垦时段自动开始:进入时段后自动启动已登记的任务 if (this.pendingStart && this.isWithinClaimTime()) { const pending = this.pendingStart; this.pendingStart = null; this._log('已进入开垦时段,自动开始'); this.start(pending).catch((err) => this._log('自动开始失败:' + err.message, 'error')); } // 运行中超出允许时段则自动停止,避免服务器拒绝开拓导致超时 if (this.isRunning() && !this.isWithinClaimTime()) { this.stop(); this.status = `已到开垦时段外(${this.config.claimStartHour}:00-${this.config.claimEndHour}:00),任务停止`; this._log(`已到开垦时段外,停止任务(当前现实时间 ${formatClock(new Date())})`); return; } const boatActive = bm.boat && bm.boat.isActive(); // 自动用船流程进行中:暂停自动进食,避免进食中断航行 / 游泳按键 if (!boatActive && bm.eater && bm.eater.tick()) return; // 耐久保护优先于一切寻路 / 战斗:装备耐久过低时先回城,防止装备被怪物磨没 if (this._tickDurabilityGuard()) return; this._tickAutoSwim(); this._tickAutoBoat(); // 处于「往边上走两步」阶段:每 tick 驱动手动横向行走,走开后自动重试原目标 if (this.sideStepping) { this._tickSideStep(); return; } // 自动攻击:面向并攻击范围内的敌对生物(用船期间不攻击,避免抢转向) if (!(bm.boat && bm.boat.isActive())) { this._tickAutoAttack(); } switch (this.state) { case State.PLANNING: this.plan(); break; case State.PATHING: this._tickPathing(); break; case State.WAITING: this._tickWaiting(); break; case State.SUCCESS: this._tickSuccess(); break; case State.FAIL: this._tickFail(); break; case State.NEXT: this._tickNext(); break; case State.RETURNING: this._tickReturning(); break; default: break; // IDLE / COMPLETE / STOPPED 无需每 tick 驱动 } } // ==================== 规划与寻路 ==================== /** * 生成扩地计划:调用规划器计算候选区块,过滤已被他人占领的区块后入队。 * 若已达成目标数量或没有可用区块则直接完成。 */ async plan() { const bm = this.botManager; // 重新规划意味着目标可能变了(或被回城传送重置了位置),进度基准必须清空 this._lastTargetDistance = null; if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { this.finish(); return; } this.queue = []; const model = this.dataService.getModel(); const planned = this.planner.generate(model, this.config, this.playerChunk); let leasedByOthers = 0; for (const chunk of planned) { if (this._isClaimedByOthers(chunk)) continue; // 被其他机器人预定(租约未到期)的区块先不排队,等下轮规划再看 if (this.targetLeases && this.targetLeases.isLeasedByOther(chunk, this.botId)) { leasedByOthers++; continue; } this.queue.push(chunk); } // 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充) const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX; if (this.config.maxRings > 0 && !rectangleMode) { this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings); } this._log(`规划完成,共 ${this.queue.length} 个候选区块` + (leasedByOthers > 0 ? `(另有 ${leasedByOthers} 个被其他机器人预定)` : '')); if (this.queue.length === 0) { if (leasedByOthers > 0) { this._waitForLeaseRelease(leasedByOthers); return; } this.setState(State.COMPLETE, '没有可用的相邻无主区块'); this._log('没有可用的相邻无主区块'); return; } const picked = this._pickNextTarget(); if (!picked.target) { this.setState(State.COMPLETE, '没有可用的相邻无主区块'); this._log('没有可用的相邻无主区块'); return; } this.target = picked.target; this._startPathing(); } /** 让机器人寻路前往当前目标区块,并进入 PATHING 状态 */ _startPathing() { // 寻路前先校验目标区块是否仍与己方领土接壤(地图异步刷新后可能已不接壤) if (this._skipIfTargetNotAdjacent()) return; this.retryCount = 0; this.sideStepCount = 0; this.stageStartedMillis = Date.now(); this.lastProgress = 0; const ok = this.botManager.pathing.pathTo(this.target); if (!ok) { this._log('寻路器不可用,跳过目标 ' + this._describeChunk(this.target), 'warn'); this.setState(State.NEXT, '寻路器不可用'); return; } this.setState(State.PATHING, `正在前往目标区块 ${this.target.x},${this.target.z}`); this._log(`寻路前往 ${this.target.x},${this.target.z}(玩家区块 ${this.playerChunk.x},${this.playerChunk.z})`); } /** * 寻路阶段每 tick 逻辑:到达目标区块后进入等待开垦状态; * 若寻路超时则重新寻路;重试耗尽先「往边上走两步」再重试,仍失败则回城或跳过该目标。 */ _tickPathing() { const bm = this.botManager; if (!this.target) { this.setState(State.NEXT, '目标为空'); return; } if (this._skipIfTargetClaimed('目标已被占领')) return; // 已到达目标区块:寻路完成 if (chunkPos.equals(this.playerChunk, this.target)) { bm.pathing.cancel(); this.consecutiveTimeoutMillis = 0; this.stageStartedMillis = Date.now(); this.setState(State.WAITING, '已到达,等待领地开垦'); this._log(`已到达目标 ${this.target.x},${this.target.z},开始等待开垦`); return; } // 自动用船流程进行中:移动由船导航接管,跳过超时 / 重试 / 路径失败处理 if (bm.boat && bm.boat.isActive()) return; // 寻路器报告路径计算失败(目标不可达):先重新寻路一次,重试耗尽才走两步 / 跳过 if (bm.pathing.calcFailed) { bm.pathing.calcFailed = false; this.retryCount++; if (this.retryCount >= this.config.retries) { if (this.sideStepCount < this.config.sideStepMaxRounds) { this._startSideStep(); } else { this._giveUpTarget('寻路失败(目标不可达)'); } } else { this._log(`寻路失败(目标不可达),重新寻路 ${this.retryCount}/${this.config.retries}`); this.dataService.refreshAsync(); this._retrySameTarget('寻路失败,重新寻路'); } return; } if (this._skipIfTargetNotAdjacent()) return; if (chunkPos.equals(this.playerChunk, this.target)) { bm.pathing.cancel(); this.consecutiveTimeoutMillis = 0; this.stageStartedMillis = Date.now(); this.setState(State.WAITING, '已到达,等待领地开垦'); this._log(`已到达目标 ${this.target.x},${this.target.z},开始等待开垦`); return; } const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000); // 「有没有在推进」比「花了多久」更能说明问题: // 目标常常在几百格外,而寻路速度受地形与反作弊回弹影响可能只有正常步行的十分之一, // 只看墙钟时间会把「走得慢但在稳步接近」误判成卡死,然后回城传送把进度全部清零。 // 因此只要与目标的曼哈顿距离有实质缩短,就重置这一段计时。 const distance = chunkPos.manhattan(this.playerChunk, this.target); if (this._lastTargetDistance === null) { // 首次观察只记基准,不动计时 —— 否则每个目标的第一 tick 都会被无条件续期 this._lastTargetDistance = distance; } else if (distance <= this._lastTargetDistance - PATH_PROGRESS_MIN_CHUNKS) { this._lastTargetDistance = distance; this.stageStartedMillis = Date.now(); this.consecutiveTimeoutMillis = 0; return; } if (elapsedSeconds >= this.config.waitTimeoutSeconds) { this.retryCount++; this.dataService.refreshAsync(); // 累计「连续寻路超时」时长:多次寻路都超时说明被地形卡死,达阈值后回城传送 this.consecutiveTimeoutMillis += this.config.waitTimeoutSeconds * 1000; if (this.retryCount < this.config.retries) { this._log(`寻路超时,重新寻路 ${this.retryCount}/${this.config.retries}`); this._retrySameTarget('寻路超时,重新寻路'); } else if (this.sideStepCount < this.config.sideStepMaxRounds) { this._startSideStep(); } else { this._giveUpTarget('寻路超时'); } } } /** * 发送回城指令:取消当前寻路,向服务器发送回城指令(默认 `/u return`, * 约 10 秒后自动传送回安全位置),随后进入 RETURNING 状态等待传送完成。 * * 指令内容取自 `returnCommand`,方便不同服务器替换成自己的回城命令。 * * @param {string} reason 触发回城的原因 */ _sendReturnCommand(reason) { const bm = this.botManager; const command = (this.config.returnCommand || 'u return').replace(/^\/+/, ''); bm.pathing.cancel(); this._log(`${reason},发送 /${command} 回城,约 10 秒后自动传送` + '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)'); bm.sendCommand(command); this.returnStartMillis = Date.now(); // 记下回城前坐标:传送完成后与当前位置组成「从 A 挪到了 B」的对比 this.pendingReturn = { reason, from: this._precisePos(), at: Date.now() }; this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)'); } /** * 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后, * 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。 * * 传送完成后记录回城前后坐标(控制台据此展示「回城把机器人从 A 挪到了 B」), * 位置几乎没变时明确提示 —— 说明这个卡死点同时也是安全点,回城没有帮助。 */ _tickReturning() { if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return; this._recordReturnResult(); if (this.returnThenStop) { this.returnThenStop = false; this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地'); this.setState(State.STOPPED, '装备耐久过低已回城,请修复装备后重新开始'); return; } this.consecutiveTimeoutMillis = 0; this._log('回城传送完成,重新规划并从最高优先级区块开始'); this.plan(); } /** * 记录回城结果:前后坐标与直线距离,并写入明确日志。 * 没有待记录的回城(`pendingReturn` 为空)时什么也不做。 */ _recordReturnResult() { const pending = this.pendingReturn; if (!pending) return; this.pendingReturn = null; const to = this._precisePos(); const from = pending.from; const distance = from && to ? Math.round(Math.hypot(to.x - from.x, to.z - from.z) * 10) / 10 : null; this.lastReturn = { at: Date.now(), reason: pending.reason, from, to, distance }; if (distance === null) return; this._log(`回城结果:机器人从 ${formatPosition(from)} 挪到了 ${formatPosition(to)}(直线 ${distance} 格)`); if (distance < 1) { this._log('本次回城几乎没有改变位置 —— 说明卡死点同时也是安全点,回城没有帮助;' + '可考虑关闭「超时自动回城」,避免白白丢掉手动摆好的位置', 'warn'); } } /** * 目标寻路重试耗尽时的脱困策略入口:取消当前寻路,改为朝「目标连线垂直方向」 * 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。 */ _startSideStep() { this.botManager.pathing.cancel(); this.sideStepping = true; this.sideStepCount++; this.sideStepSign = 1; this.sideStepStartPos = this._blockPos(); this.sideStepDeadlineMillis = Date.now() + 10000; this.sideStepSinceMillis = Date.now(); this._log(`寻路重试耗尽,开始往边上走两步再重试原目标(第 ${this.sideStepCount}/${this.config.sideStepMaxRounds} 轮)`); this.setState(State.PATHING, '往边上走两步...'); } /** 走两步阶段每 tick 逻辑:朝目标连线垂直方向按住前进键行走,走开约 2.5 格后重试原目标 */ _tickSideStep() { const bm = this.botManager; const bot = bm.bot; if (!bot || !this.sideStepStartPos || !this.target) { this.stopSideStep(); return; } if (Date.now() >= this.sideStepDeadlineMillis) { this.stopSideStep(); this._giveUpTarget('往边上走两步超时'); return; } const playerPos = bot.entity.position; const dx = this.target.x * 16 + 8 + 0.5 - playerPos.x; const dz = this.target.z * 16 + 8 + 0.5 - playerPos.z; const length = Math.hypot(dx, dz) || 1; const dirX = dx / length; const dirZ = dz / length; // 垂直向量(左为 +,右为 -) const sideX = -dirZ * this.sideStepSign; const sideZ = dirX * this.sideStepSign; const yaw = Math.atan2(-sideX, -sideZ); if (bm.grim) { bm.grim.setLook(yaw, 0); bm.grim.setControlState('forward', true); } else { bot.look(yaw, 0, true); bot.setControlState('forward', true); } // 已走开足够距离(约 2.5 格):停止并重试原目标 const moved = Math.hypot( playerPos.x - this.sideStepStartPos.x, playerPos.z - this.sideStepStartPos.z ); if (moved >= 2.5) { this.stopSideStep(); this._retrySameTarget('往边上走两步完成,重新寻路'); return; } // 卡住检查:同一方向 5 秒没移动就换另一侧;两侧都试过仍不动则放弃 if (Date.now() - this.sideStepSinceMillis > 5000) { if (this.sideStepSign !== -1) { this.sideStepSign = -1; this.sideStepSinceMillis = Date.now(); this._log('往边上走被挡,取反方向继续'); } else { this.stopSideStep(); this._giveUpTarget('往边上走两侧均被挡'); } } } /** 结束走两步阶段:释放前进键 */ stopSideStep() { this.sideStepping = false; const bm = this.botManager; if (bm.grim) bm.grim.setControlState('forward', false); else if (bm.bot) bm.bot.setControlState('forward', false); } /** * 放弃当前目标(重试与走两步均已尝试)的统一出口: * 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。 * * 回城是**破坏性**操作,因此默认关闭(`returnOnTimeout`),且即便打开也有两道护栏: * - 刚建立连接的一段时间内不回城:玩家常常是趁机器人掉线时在客户端把它摆到开阔地, * 一连上就被拉回安全点等于白摆; * - 每次真正回城都明确记日志,避免事后只看到位置莫名其妙变了。 */ _giveUpTarget(reason) { const config = this.config; if (config.returnOnTimeout && this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000) { const sessionSeconds = Math.floor((Date.now() - this.sessionStartedMillis) / 1000); if (sessionSeconds < config.returnGraceSeconds) { this._log(`连续超时达阈值,但本次连接仅 ${sessionSeconds} 秒(保护期 ${config.returnGraceSeconds} 秒),` + '不回城以免清掉刚摆好的位置;改为跳过当前目标'); this.setState(State.FAIL, reason + '(保护期内不回城)'); return; } // 回城前先判断「原地是否真的更差」:位置还能走时回城毫无意义,只会白白丢位置 const assess = this._assessReturnWorth(); if (!assess.worth) { this.returnSkippedNotStuck++; this._log(`连续超时达阈值,但${assess.reason} —— 放弃回城,改为跳过当前目标` + '(回城会把机器人拉回安全点,可能丢掉玩家摆好的位置)', 'warn'); this.setState(State.FAIL, reason + '(原地还能走,未回城)'); return; } this._log(`连续超时达阈值,${assess.reason}`, 'warn'); this._sendReturnCommand(`连续寻路超时超过 ${config.returnThresholdSeconds} 秒`); return; } if (this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000 && !config.returnOnTimeout) { this._log('连续寻路超时已达回城阈值,但「超时自动回城」未启用,跳过当前目标(如需自动逃离卡死状态请在配置里打开)'); } this._log(this.target ? `${reason},跳过 ${this.target.x},${this.target.z}` : reason); this.setState(State.FAIL, reason); } /** * 回城前的「原地是否真的更差」评估。 * * 回城是破坏性操作:它把机器人拉回安全点,会清掉玩家手动摆好的位置。 * 如果当前位置还能走(寻路器能从脚下节点生成邻居),说明只是走得慢 / 目标不可达, * 回城不会带来任何好处 —— 这种情况应当跳过目标,而不是传送。 * * 判据取自 `pathing.diagnostics().neighborCount`(寻路器从脚下节点能生成的可走方向数): * - `0`:完全无路可走,确实卡死,回城值得一试; * - `≥1`:还能走,回城没有意义; * - 拿不到诊断(未连接 / 寻路器未就绪):保守起见仍按卡死处理。 * * @returns {{worth: boolean, neighborCount: number|null, reason: string}} */ _assessReturnWorth() { const bm = this.botManager; let neighborCount = null; try { if (bm.pathing && typeof bm.pathing.diagnostics === 'function') { const diag = bm.pathing.diagnostics(); if (diag && typeof diag.neighborCount === 'number') neighborCount = diag.neighborCount; } } catch { neighborCount = null; } if (neighborCount === null) { return { worth: true, neighborCount, reason: '拿不到寻路诊断(无法判断原地是否能走),按卡死处理' }; } if (neighborCount >= 1) { return { worth: false, neighborCount, reason: `当前位置还有 ${neighborCount} 个可走方向(不是卡死),回城不会更好` }; } return { worth: true, neighborCount, reason: `当前位置 0 个可走方向(疑似卡死),回城值得一试` }; } /** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */ _tickWaiting() { if (!this.target) { this.setState(State.NEXT, '目标为空'); return; } if (this._skipIfTargetClaimed('目标已被占领')) return; if (this._skipIfTargetNotAdjacent()) return; if (!chunkPos.equals(this.playerChunk, this.target)) { this._retrySameTarget(`离开目标区块,重新前往 ${this.target.x},${this.target.z}`); return; } const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000); if (elapsedSeconds >= this.config.waitTimeoutSeconds) { this.retryCount++; this.dataService.refreshAsync(); if (this.retryCount >= this.config.retries) { this._log(`等待超时,跳过 ${this.target.x},${this.target.z}`); this.setState(State.FAIL, '等待领地开垦超时'); } else { this._log(`等待超时,重试 ${this.retryCount}/${this.config.retries}`); this._retrySameTarget(`等待超时,重新前往 ${this.target.x},${this.target.z}`); } } } /** 重试当前目标:取消旧寻路,重新寻路到同一目标区块 */ _retrySameTarget(statusText) { const bm = this.botManager; if (this._skipIfTargetClaimed('重试时发现目标已被占领')) return; if (this._skipIfTargetNotAdjacent()) return; bm.pathing.cancel(); this.stageStartedMillis = Date.now(); bm.pathing.pathTo(this.target); this.setState(State.PATHING, statusText); } /** 成功后的过渡:进入 NEXT 状态 */ _tickSuccess() { this.setState(State.NEXT, `已完成 ${this.completedClaims}${this.config.targetCount > 0 ? '/' + this.config.targetCount : '/持续'}`); } /** 失败后的过渡:进入 NEXT 状态,跳过当前目标 */ _tickFail() { this.setState(State.NEXT, '准备下一个目标'); } /** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */ _tickNext() { this._releaseTargetLease(); this.target = null; // 候选区块正被其他机器人预定:等一会儿再重新规划(避免忙等,见 _waitForLeaseRelease) if (this._nextReplanAt && Date.now() < this._nextReplanAt) return; this._nextReplanAt = 0; if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { this.finish(); return; } const picked = this._pickNextTarget(); if (!picked.target) { if (picked.skippedLease > 0) { this._waitForLeaseRelease(picked.skippedLease); return; } // 队列真的空了:重新规划 this.plan(); return; } this.target = picked.target; if (this._isClaimedByOthers(this.target)) { this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`); this.setState(State.NEXT, '跳过已占领区块'); return; } this._startPathing(); } /** 完成任务 */ finish() { this._releaseTargetLease(); this.botManager.pathing.cancel(); this.setState(State.COMPLETE, this.config.targetCount > 0 ? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}` : `已无可扩区块,累计扩地 ${this.completedClaims}`); this._log('自动扩地完成'); } // ==================== 目标校验 ==================== /** * 若当前目标已被任何邦国占领(含己方与会话),则记录日志、跳过并进入 NEXT 状态。 * @returns {boolean} */ _skipIfTargetClaimed(reason) { if (this.target && this._isClaimedByOthers(this.target)) { this._log(`${reason} ${this.target.x},${this.target.z},跳过`); this.setState(State.NEXT, '跳过已占领区块'); return true; } return false; } /** * 若当前目标不再与己方领土(含会话占领)四正方向接壤,则跳过并进入 NEXT 状态。 * * 这是解决「走到不接壤区块」的关键:规划时目标必然接壤,但寻路 / 等待过程中 * 领土数据会异步刷新,导致目标可能变得不接壤。每 tick 校验可避免在无效区块上白费时间。 * * @returns {boolean} */ _skipIfTargetNotAdjacent() { if (this.target && !this._isAdjacentToOwn(this.target)) { this._log(`目标与己方领土不相邻,跳过 ${this.target.x},${this.target.z}`); this.setState(State.NEXT, '跳过不接壤区块'); return true; } return false; } /** * 判断区块是否与己方领土接壤(四个正方向,只算己方,绝不把其他邦国领土计入)。 * @param {{x: number, z: number}} chunk * @returns {boolean} */ _isAdjacentToOwn(chunk) { const model = this.dataService.getModel(); const self = model.state(this.stateName); const ownChunks = self ? self.chunks : null; const sessionClaims = model.sessionClaimsSet(); const x = chunk.x; const z = chunk.z; return this._isOwnAt(ownChunks, sessionClaims, x + 1, z) || this._isOwnAt(ownChunks, sessionClaims, x - 1, z) || this._isOwnAt(ownChunks, sessionClaims, x, z + 1) || this._isOwnAt(ownChunks, sessionClaims, x, z - 1); } _isOwnAt(ownChunks, sessionClaims, x, z) { const k = chunkPos.key(x, z); return (ownChunks !== null && ownChunks.has(k)) || sessionClaims.has(k); } /** @returns {boolean} 区块是否已被任何邦国占领(含己方与会话) */ _isClaimedByOthers(chunk) { return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z)); } // ==================== 目标区块租约(多机器人协调) ==================== /** * 从队列里取出下一个可用目标: * 跳过已被占领的区块,以及被其他机器人租约占用的区块(谁先抢到谁去)。 * * @returns {{target: {x: number, z: number}|null, skippedLease: number}} */ _pickNextTarget() { let skippedLease = 0; while (this.queue.length > 0) { const chunk = this.queue.shift(); if (this._isClaimedByOthers(chunk)) continue; if (this.targetLeases && !this.targetLeases.tryAcquire(chunk, this.botId)) { skippedLease++; this._log(`区块 ${chunk.x},${chunk.z} 已被其他机器人预定,跳过`); continue; } return { target: chunk, skippedLease }; } return { target: null, skippedLease }; } /** 续租当前目标(每个 tick 调用,保证长时间寻路时租约不过期) */ _renewTargetLease() { if (!this.targetLeases || !this.target) return; this.targetLeases.renew(this.target, this.botId); } /** 释放当前目标的租约(幂等:没有目标或没有租约表时什么都不做) */ _releaseTargetLease() { if (!this.targetLeases || !this.target) return; this.targetLeases.release(this.target, this.botId); } /** * 候选区块暂时都被其他机器人预定时的处理:等一会儿再重新规划。 * * 直接判 COMPLETE 会让机器人在别人还在开垦时提前收工 —— 那些区块可能是 * 别人的目标失败了才释放的,稍后再看更合理。这里只设一个时间戳, * 由 {@link _tickNext} 的时间闸门控制,不做定时器,避免并发状态。 */ _waitForLeaseRelease(skippedLease) { this._nextReplanAt = Date.now() + LEASE_RETRY_DELAY_MS; this._log(`${skippedLease} 个候选区块正被其他机器人预定,` + `${Math.round(LEASE_RETRY_DELAY_MS / 1000)} 秒后重新规划`, 'warn'); this.setState(State.NEXT, '候选区块被其他机器人预定,等待释放'); } /** * 计算区块到己方领土的「圈数」(切比雪夫距离的最小值),用于过滤扩圈上限。 * @param {{x: number, z: number}} chunk * @param {import('../core/territoryModel').TerritoryModel} model * @returns {number} */ _ringDistance(chunk, model) { let min = Infinity; const self = model.state(this.stateName); if (self) { for (const k of self.chunks) { const pos = chunkPos.decode(k); min = Math.min(min, Math.max(Math.abs(chunk.x - pos.x), Math.abs(chunk.z - pos.z))); } } for (const k of model.sessionClaimsSet()) { const pos = chunkPos.decode(k); min = Math.min(min, Math.max(Math.abs(chunk.x - pos.x), Math.abs(chunk.z - pos.z))); } return min === Infinity ? 0 : min; } // ==================== 消息回调 ==================== /** * 寻路事件回调:记录关键事件,并在「计算失败」时置标志供寻路阶段消费。 * @param {string} event * @param {object} [detail] */ onPathEvent(event, detail) { if (!this.isRunning()) return; if (event === PathEvent.CALC_FAILED) { this._log('寻路事件:CALC_FAILED(目标不可达)' + (detail && detail.timeout ? '(搜索超时)' : '')); } else if (event === PathEvent.AT_GOAL) { this._log('寻路事件:AT_GOAL'); } else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) { this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`); } else if (event === PathEvent.SEGMENT_REACHED) { // 长距离分段寻路走完一段:这是「有进展」而不是卡住, // 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时 this.stageStartedMillis = Date.now(); } } /** 开垦进度更新(0-100%) */ onProgress(percent) { if (!this.isRunning()) return; this.lastProgress = percent; this.status = `开垦中 ${percent}%`; } /** * 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。 * @param {{x: number, z: number}} chunk * @param {number} [blockX] 方块坐标 X(服务器回报值) * @param {number} [blockZ] 方块坐标 Z(服务器回报值) */ onSuccess(chunk, blockX, blockZ) { if (!this.isRunning()) return; const target = chunk || this.target; if (!target) return; const key = chunkPos.key(target.x, target.z); // 去重:同一区块只计一次 if (this.claimedSet.has(key)) return; this.claimedSet.add(key); this.dataService.getModel().addSessionClaim(target, this.stateName); this.dataService.addSessionClaim(target, this.stateName); // 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口 this.dataService.refreshAsync(); this.completedClaims++; this.consecutiveTimeoutMillis = 0; this.target = target; this.lastProgress = 100; this._log(`占领成功 ${target.x},${target.z}`); this._recordClaim(target, blockX, blockZ, 'chat'); this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`); } /** * 开垦成功(无具体坐标的通用消息):按当前目标区块记录。 * * 开垦只能发生在机器人所在区块,因此必须校验机器人确实位于目标区块上: * 通用成功文案采用模糊匹配,可能被服务器广播的其他玩家开垦消息误触发。 */ onGenericSuccess() { if (!this.isRunning() || !this.target) return; if (!chunkPos.equals(this.playerChunk, this.target)) return; const key = chunkPos.key(this.target.x, this.target.z); // 去重:同一区块只计一次 if (this.claimedSet.has(key)) return; this.claimedSet.add(key); this.dataService.getModel().addSessionClaim(this.target, this.stateName); this.dataService.addSessionClaim(this.target, this.stateName); this.dataService.refreshAsync(); this.completedClaims++; this.consecutiveTimeoutMillis = 0; this.lastProgress = 100; this._log(`占领成功 ${this.target.x},${this.target.z}`); this._recordClaim(this.target, null, null, 'chat-generic'); this.setState(State.SUCCESS, `疆土已开拓 ${this.target.x},${this.target.z}`); } /** * 写入开拓日志:详细记录开拓玩家、区块、时间与来源。 * * @param {{x: number, z: number}} chunk 区块坐标 * @param {number|null} blockX 方块坐标 X * @param {number|null} blockZ 方块坐标 Z * @param {string} source 判定来源 */ _recordClaim(chunk, blockX, blockZ, source) { if (!this.expansionLog) return; this.expansionLog.record({ botId: this.botId, bot: this.botName, state: this.stateName, chunk, block: Number.isFinite(blockX) && Number.isFinite(blockZ) ? { x: blockX, z: blockZ } : null, source, progress: 100, // 本轮任务已开拓区块数,便于在日志里看出第几个 sessionCount: this.completedClaims }); } // ==================== 生存辅助 ==================== /** * 自动游泳:只要机器人在水中(含没入 / 水面 / 浅水)、不在船中、无自动用船流程、 * 未在手动走两步,就持续按住跳跃键,使其不断浮到水面,避免溺水。 */ _tickAutoSwim() { const bm = this.botManager; const bot = bm.bot; if (!this.isRunning() || !bot || !bot.entity) { this.releaseJump(); return; } const inWater = bot.entity.isInWater === true; const inBoat = !!bot.vehicle; const boatActive = bm.boat && bm.boat.isActive(); if (inWater && !inBoat && !boatActive && !this.sideStepping) { this.pressJump(); } else { this.releaseJump(); } } /** 按住跳跃键(仅在当前未按时生效,避免重复赋值) */ pressJump() { if (this.holdingJump) return; const bm = this.botManager; if (bm.grim) bm.grim.setControlState('jump', true); else if (bm.bot) bm.bot.setControlState('jump', true); this.holdingJump = true; } /** 释放跳跃键 */ releaseJump() { if (!this.holdingJump) return; const bm = this.botManager; if (bm.grim) bm.grim.setControlState('jump', false); else if (bm.bot) bm.bot.setControlState('jump', false); this.holdingJump = false; } /** * 自动攻击:索敌半径内出现敌对生物时,面向该生物并在攻击距离内自动出手(满蓄力), * 不追击、不打断寻路;目标筛选由 autoCombat.isAttackTarget 保证 * 绝不攻击铁傀儡、僵尸猪灵等不会主动攻击玩家的中立生物。 */ _tickAutoAttack() { const bm = this.botManager; if (!this.config.autoAttack || !this.isRunning() || !bm.bot) { this.combatTargetEntityId = -1; return; } const target = findTarget(bm.bot, this.config.combat.scanRange, this.config); if (!target) { this.combatTargetEntityId = -1; return; } if (target.id !== this.combatTargetEntityId) { this.combatTargetEntityId = target.id; this._log(`发现敌对生物 ${target.name},自动攻击中`); } // 攻击是异步的(要先同步朝向),这里不 await,避免阻塞状态机 tick if (bm.grim) { bm.grim.attackEntity(target).catch(() => {}); } else { try { bm.bot.attack(target); } catch { // 忽略 } } } /** 自动用船过水 */ _tickAutoBoat() { const bm = this.botManager; if (!bm.boat) return; const wasActive = bm.boat.isActive(); const canStartFlow = this.state === State.PATHING && !this.sideStepping && this.target !== null; bm.boat.tick({ running: this.isRunning(), canStart: canStartFlow, target: this.target }); if (bm.boat.isActive()) { // 自动用船流程接管移动:取消寻路,避免与船的按键 / 方向冲突 bm.pathing.cancel(); const desc = bm.boat.describe(); if (desc) this.status = desc; } else if (wasActive) { // 用船流程刚结束:若仍处于寻路阶段且未到达目标,恢复寻路 if (this.state === State.PATHING && this.target && !chunkPos.equals(this.playerChunk, this.target) && !this.sideStepping) { this.stageStartedMillis = Date.now(); bm.pathing.pathTo(this.target); this._log('自动用船结束,恢复寻路'); } } } /** * 耐久保护:主手 / 副手 / 四件盔甲任意一件剩余耐久低于阈值时, * 发送 `/u return` 回城并停止扩地,防止装备在继续战斗中被怪物磨没。 * * @returns {boolean} true 表示本 tick 已触发回城 */ _tickDurabilityGuard() { const bm = this.botManager; if (!bm.guard || !this.config.durabilityGuard || !this.isRunning() || this.state === State.RETURNING) { return false; } const low = bm.guard.check(); if (!low) return false; const reason = `装备耐久过低(${low.name} 剩余耐久 ${low.percent}% ≤ ${this.config.durabilityThreshold}%)`; this.returnThenStop = true; this._log(reason + ',发送 /u return 回城并停止扩地,防止装备被怪物磨没'); this._sendReturnCommand(reason); return true; } // ==================== 内部工具 ==================== /** @returns {{x: number, z: number}} 机器人当前所在区块 */ _playerChunk() { const bot = this.botManager.bot; if (!bot || !bot.entity) return { x: 0, z: 0 }; return chunkPos.fromBlock(Math.floor(bot.entity.position.x), Math.floor(bot.entity.position.z)); } /** @returns {{x: number, y: number, z: number}|null} 机器人当前方块坐标 */ _blockPos() { const bot = this.botManager.bot; if (!bot || !bot.entity) return null; return { x: Math.floor(bot.entity.position.x), y: Math.floor(bot.entity.position.y), z: Math.floor(bot.entity.position.z) }; } /** * @returns {{x: number, y: number, z: number}|null} 机器人当前位置(两位小数) * * 与 `_blockPos` 的区别:这里是精确坐标,用于「回城前后挪了多少」这种对比展示, * 取整会把几格的位移抹掉。 */ _precisePos() { const bot = this.botManager.bot; if (!bot || !bot.entity) return null; const pos = bot.entity.position; return { x: Math.round(pos.x * 100) / 100, y: Math.round(pos.y * 100) / 100, z: Math.round(pos.z * 100) / 100 }; } _describeChunk(chunk) { return chunk ? `${chunk.x},${chunk.z}` : '无'; } /** 更新状态与状态描述文本,并通知订阅者 */ setState(newState, newStatus) { const changed = this.state !== newState || this.status !== newStatus; this.state = newState; this.status = newStatus; if (changed && this.onStateChange) { try { this.onStateChange(this.snapshot()); } catch { // 订阅者异常不影响状态机 } } } _log(message, level) { if (!this.logger) return; // 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源 this.logger.log(`[${this.botName}] ${message}`, level); } /** * @returns {object} 任务状态快照(供 Web 控制台展示) */ snapshot() { return { botId: this.botId, botName: this.botName, stateName: this.stateName, state: this.state, status: this.status, running: this.isRunning(), waitingForClaimTime: this.isWaitingForClaimTime(), target: this.target, playerChunk: this.playerChunk, queuedTargets: this.queue.length, completedClaims: this.completedClaims, retryCount: this.retryCount, lastProgress: this.lastProgress, consecutiveTimeoutMs: this.consecutiveTimeoutMillis, sideStepping: this.sideStepping, sideStepCount: this.sideStepCount, /** 因「原地还能走」而放弃回城的次数(回城前评估的结果) */ returnSkippedNotStuck: this.returnSkippedNotStuck, /** 最近一次回城的前后坐标与直线距离(控制台展示「从 A 挪到了 B」) */ lastReturn: this.lastReturn, autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()), withinClaimTime: this.isWithinClaimTime(), claimModeSent: this._claimModeSent, lastError: this.lastError }; } /** * 导出运行日志到文件。 * @param {string} filePath 目标路径 * @returns {string|null} 导出文件路径,失败返回 null */ exportLogs(filePath) { const snapshot = this.snapshot(); const summary = { 状态: `${snapshot.state} / ${snapshot.status}`, 目标区块: this._describeChunk(snapshot.target), 玩家区块: `${snapshot.playerChunk.x},${snapshot.playerChunk.z}`, 队列: `${snapshot.queuedTargets} | 已占领: ${snapshot.completedClaims} | 重试: ${snapshot.retryCount} | 最近进度: ${snapshot.lastProgress}%`, 数据源: JSON.stringify(this.dataService.snapshot()) }; return this.logger ? this.logger.exportTo(filePath, summary) : null; } } /** * 位置快照 → 日志里可读的文本。 * @param {{x: number, y: number, z: number}|null} pos * @returns {string} */ function formatPosition(pos) { return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)'; } /** * 格式化为 HH:mm:ss。 * @param {Date} date * @returns {string} */ function formatClock(date) { const pad = (n) => String(n).padStart(2, '0'); return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } module.exports = { ExpansionController, State, EXPORT_SUMMARY_KEYS };