文件
BotStore/src/controller/expansionController.js
T
lyizai 1b958cb804 fix: 自动恢复不再因一次失败的尝试而永久丢失
1.9.3 的 tick() 里先取走 resumeConfig 再调 start(),而 start() 在
「已进入世界但还没完成登录」时会直接返回 false(isReady() 要求 loggedIn)。
本服掉线频繁、重连后常常短暂处于这个状态,于是第一次失败的尝试就把这次恢复
永久丢掉了,任务再也不会自己接上 —— 实测监控里任务长时间停在
「机器人尚未就绪(未连接 / 未进入世界)」就是这个原因。

现在:① 等 bm.isReady() 才尝试;② 只有 start() 真的返回 true 才清掉登记,
失败时保留并在下一 tick 继续试,日志打「自动恢复未成功(…),稍后重试」。

顺带:服务器上保存的 pathing.maxDropDown 被改成了 64(钳制上限),
而原版摔落伤害超过 3 格后每格 1 点、23 格必死 —— 实测机器人正是
「fell from a high place」摔死并重生回王城,白走了 100 多格。
已在服务器配置上改回 4(代码默认本来就是 3)。

测试 477 → 478。
2026-10-01 17:22:11 +08:00

1370 行
56 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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<boolean>} 是否成功启动
*/
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 };