diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 78b881e..295078d 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -852,6 +852,10 @@ class BotManager extends EventEmitter { packetStats: Object.assign({}, this.packetStats), world: this._worldDiagnostics(), pathing: this.pathing ? this.pathing.isPathing() : false, + /** 寻路诊断:邻居生成数、世界数据可读性、四向可见距离 */ + pathingDiag: this.pathing && typeof this.pathing.diagnostics === 'function' + ? this.pathing.diagnostics() + : null, eating: this.eater ? this.eater.eating : false, grim: this.grim ? this.grim.snapshot() : null }; diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 3cb6e7c..1302d1a 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -421,17 +421,43 @@ class BotPool extends EventEmitter { ? normalizeConfig(overrideConfig, this.getConfigFor(id)) : this.getConfigFor(id); if (overrideConfig) { - // 控制台点「开始扩地」时顺带保存该机器人的配置 + // 控制台点「开始扩地」且勾了「仅对当前机器人生效」时,顺带把这份配置存为该机器人的专属配置。 + // 注意这是一份**完整快照**:存下之后全局配置的改动就不再影响它了, + // 所以这里明确记一条日志,避免以后排查时一头雾水。 + const previous = this.settings[id] && this.settings[id].config ? '(覆盖已有)' : '(新建)'; this.settings[id] = Object.assign({}, this.settings[id] || {}, { config }); this._persistSettings(); entry.config = config; entry.manager.applyConfig(config); + this._log(`${entry.connection.username} 已保存专属配置${previous}:` + + `策略 ${config.strategy}、目标邦国「${config.stateName || '自动'}」、目标数量 ${config.targetCount};` + + '此后全局配置的改动不会影响它,可在配置面板点「清除专属配置」恢复继承'); } entry.controller.config = config; const ok = await entry.controller.start(config); return { ok, message: ok ? '任务已启动' : entry.controller.status, entry }; } + /** + * 清除某机器人的专属配置覆盖,恢复「继承全局默认配置」。 + * + * 专属配置是一份完整快照,一旦存下,之后改全局配置就再也影响不到这个机器人 —— + * 很容易出现「我明明改了全局设置,它却还是老行为」的困惑,所以需要一条明确的退路。 + * + * @param {string} id + * @returns {object} 生效后的设置快照 + */ + clearBotConfigOverride(id) { + const entry = this.require(id); + if (this.settings[id]) delete this.settings[id].config; + this._persistSettings(); + entry.config = this.getConfigFor(id); + entry.manager.applyConfig(entry.config); + entry.controller.config = entry.config; + this._log(`${entry.connection.username} 的专属配置已清除,恢复继承全局默认配置`); + return this.settingSnapshot(id); + } + /** 停止某机器人的扩地任务 */ stopTask(id) { const entry = this.require(id); diff --git a/src/bot/pathingService.js b/src/bot/pathingService.js index 709c78e..55732a9 100644 --- a/src/bot/pathingService.js +++ b/src/bot/pathingService.js @@ -325,9 +325,70 @@ class PathingService { return lo; } + /** + * 寻路诊断:直接问寻路器「从当前位置能往哪些格子走」。 + * + * 这是排查「A* 只探索几十个节点就 noPath」的关键手段: + * 如果 `neighbors` 为 0 或极少,说明不是目标太远,而是**寻路器读不到世界数据** + * (Movements 依赖 bot.world 的方块查询),此时调任何搜索参数都没用。 + * + * @returns {object|null} + */ + diagnostics() { + const bot = this.bot; + if (!bot || !bot.entity || !bot.pathfinder) return null; + const pos = bot.entity.position; + const feet = { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) }; + let neighbors = []; + let error = ''; + try { + const movements = bot.pathfinder.movements; + // 寻路器内部用的就是这种节点结构(x/y/z + 代价字段) + neighbors = movements.getNeighbors({ + x: feet.x, + y: feet.y, + z: feet.z, + remainingBlocks: 0, + cost: 0, + hash: `${feet.x},${feet.y},${feet.z}` + }) || []; + } catch (err) { + error = err && err.message ? err.message : String(err); + } + const world = bot.world; + const worldApi = { + hasGetBlock: typeof world.getBlock === 'function', + hasGetBlockStateId: typeof world.getBlockStateId === 'function', + hasGetColumn: typeof world.getColumn === 'function' + }; + // 用寻路器同款 API 采样脚下方块,看它到底能不能读到世界 + let blockViaWorld = null; + try { + const b = world.getBlock ? world.getBlock(bot.entity.position.offset(0, -1, 0)) : null; + blockViaWorld = b ? b.name : null; + } catch (err) { + blockViaWorld = 'ERR:' + (err && err.message ? err.message : String(err)); + } + return { + feet, + neighborCount: neighbors.length, + neighbors: neighbors.slice(0, 8).map((n) => ({ x: n.x, y: n.y, z: n.z, cost: n.cost })), + error, + worldApi, + blockViaWorld, + blockViaBlockAt: bot.blockAt ? (bot.blockAt(bot.entity.position.offset(0, -1, 0)) || {}).name : null, + /** 四个方向能「看到」多远(方块):反映已加载区块范围 */ + visibleDistance: { + north: Math.round(this._probeLoadedDistance(0, -1, 256)), + south: Math.round(this._probeLoadedDistance(0, 1, 256)), + east: Math.round(this._probeLoadedDistance(1, 0, 256)), + west: Math.round(this._probeLoadedDistance(-1, 0, 256)) + } + }; + } + /** 取消当前寻路 */ - cancel() { - if (!this.isAvailable()) return; + cancel() { if (!this.isAvailable()) return; this._pathing = false; this.currentTarget = null; this.finalTarget = null; diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js index fdd6e3a..036fee4 100644 --- a/src/controller/expansionController.js +++ b/src/controller/expansionController.js @@ -35,6 +35,15 @@ const State = { /** 环形日志中「导出日志」用的状态摘要键名 */ const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度']; +/** + * 判定「寻路有进展」的最小距离变化(区块)。 + * + * 只要与目标的曼哈顿距离缩短了这么多,就认为是在稳步接近而不是卡死, + * 从而重置超时计时。取 1 是因为矿区/建筑区里寻路速度可能远低于正常步行, + * 阈值再大就会把慢速推进误判成超时。 + */ +const PATH_PROGRESS_MIN_CHUNKS = 1; + /** * 自动扩地控制器:扩地任务的核心状态机。 * @@ -90,6 +99,8 @@ class ExpansionController { this.retryCount = 0; /** 当前阶段(寻路 / 等待)的开始时间戳 */ this.stageStartedMillis = 0; + /** 上一次记录的「与目标的曼哈顿距离」,用于判断寻路是否在稳步推进 */ + this._lastTargetDistance = null; /** 最近一次开垦进度(0-100) */ this.lastProgress = 0; @@ -344,6 +355,8 @@ class ExpansionController { */ async plan() { const bm = this.botManager; + // 重新规划意味着目标可能变了(或被回城传送重置了位置),进度基准必须清空 + this._lastTargetDistance = null; if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { this.finish(); return; @@ -436,6 +449,20 @@ class ExpansionController { 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(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 3be42ac..967bac3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -510,19 +510,42 @@ function collectConfig() { async function loadConfig() { try { - if ($('chkBotOverride').checked && selectedBotId) { + if (selectedBotId) { const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings'); - currentConfig = res.data.config; - } else { - const res = await api('/api/config'); - currentConfig = res.data; + updateOverrideHint(res.data); + if ($('chkBotOverride').checked) { + currentConfig = res.data.config; + renderConfig(); + return; + } } + const res = await api('/api/config'); + currentConfig = res.data; renderConfig(); } catch (err) { toast('读取配置失败:' + err.message, true); } } +/** + * 提示「该机器人是否有一份专属配置覆盖」。 + * + * 专属配置是一份完整快照,存下之后全局配置的改动就再也影响不到这个机器人 —— + * 不提示的话,很容易出现「我明明改了全局设置,它却还是老行为」的困惑。 + * + * @param {object} setting `/api/bots/:id/settings` 的 data + */ +function updateOverrideHint(setting) { + const hint = $('overrideHint'); + const text = $('overrideHintText'); + if (!hint || !text) return; + const has = !!(setting && setting.hasOverrides); + hint.classList.toggle('hidden', !has); + if (has) { + text.textContent = '该机器人有一份专属配置(完整快照),全局配置的改动不会影响它。'; + } +} + async function saveConfig() { try { const config = collectConfig(); @@ -2494,6 +2517,19 @@ function bindEvents() { $('btnSaveConfig').addEventListener('click', saveConfig); $('btnReloadConfig').addEventListener('click', loadConfig); $('chkBotOverride').addEventListener('change', loadConfig); + $('btnClearOverride').addEventListener('click', async () => { + if (!selectedBotId) return; + try { + const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings/clear-config', { + method: 'POST' + }); + toast(res.message || '已清除'); + updateOverrideHint(res.data); + await loadConfig(); + } catch (err) { + toast('清除失败:' + err.message, true); + } + }); $('configTabs').addEventListener('click', (e) => { const tab = e.target.closest('.tab'); diff --git a/src/web/public/index.html b/src/web/public/index.html index 132ed96..bc31bf7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -309,6 +309,10 @@ 仅对当前机器人生效(不勾选则写入全局默认配置) +
diff --git a/src/web/server.js b/src/web/server.js index bac1c09..b65e6d4 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -305,6 +305,19 @@ class WebConsole { })); }); + // 清除专属配置覆盖,恢复继承全局默认配置 + app.post('/api/bots/:id/settings/clear-config', (req, res) => { + if (typeof this.pool.clearBotConfigOverride !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持清除专属配置' }); + return; + } + this._withEntry(req, res, (entry) => { + const saved = this.pool.clearBotConfigOverride(entry.id); + res.json({ ok: true, data: saved, message: '已清除专属配置,恢复继承全局默认配置' }); + return null; + }); + }); + app.post('/api/bots/:id/settings', (req, res) => { this._withEntry(req, res, (entry) => { const body = req.body || {}; diff --git a/test/botPool.test.js b/test/botPool.test.js index e821f2e..a57bf3a 100644 --- a/test/botPool.test.js +++ b/test/botPool.test.js @@ -257,6 +257,42 @@ test('startTask 带配置覆盖时写入该机器人的专属配置', async () = assert.strictEqual(config.targetCount, 5); // 另一个机器人不受影响 assert.strictEqual(pool.getConfigFor('BotA').strategy, 1); + assert.strictEqual(pool.settingSnapshot('BotB').hasOverrides, true); +}); + +test('clearBotConfigOverride 清除专属配置后重新继承全局', () => { + const { pool } = setup({ config: { strategy: 1, targetCount: 10 } }); + pool.setBotSetting('BotB', { config: { strategy: 3, targetCount: 5 } }); + assert.strictEqual(pool.getConfigFor('BotB').strategy, 3); + assert.strictEqual(pool.settingSnapshot('BotB').hasOverrides, true); + + const saved = pool.clearBotConfigOverride('BotB'); + + assert.strictEqual(saved.hasOverrides, false); + assert.strictEqual(pool.getConfigFor('BotB').strategy, 1, '应重新继承全局'); + assert.strictEqual(pool.getConfigFor('BotB').targetCount, 10); + // 清除后应下发到运行时 + assert.strictEqual(pool.get('BotB').manager.config.strategy, 1); +}); + +test('专属配置是完整快照:存下后全局改动不再影响它(这正是需要「清除」按钮的原因)', () => { + const { pool } = setup({ config: { allowBreak: false } }); + pool.setBotSetting('BotA', { config: { allowBreak: true } }); + + // 改全局 + const next = normalizeConfig({ allowBreak: false, targetCount: 42 }, pool.config); + pool.applyGlobalConfig(next); + + assert.strictEqual(pool.getConfigFor('BotA').allowBreak, true, '专属配置优先于全局'); + assert.strictEqual(pool.getConfigFor('BotB').targetCount, 42, '没有覆盖的机器人跟随全局'); + + pool.clearBotConfigOverride('BotA'); + assert.strictEqual(pool.getConfigFor('BotA').allowBreak, false, '清除后跟随全局'); +}); + +test('clearBotConfigOverride 对不存在的机器人抛 404', () => { + const { pool } = setup(); + assert.throws(() => pool.clearBotConfigOverride('不存在'), (err) => err.statusCode === 404); }); test('applyGlobalConfig 下发到全部机器人并保留各自的覆盖', () => { diff --git a/test/expansionController.test.js b/test/expansionController.test.js index 8a3d734..7653b73 100644 --- a/test/expansionController.test.js +++ b/test/expansionController.test.js @@ -415,6 +415,42 @@ test('未启动任务时 tick 不做任何事', () => { assert.strictEqual(controller.state, State.IDLE); }); +test('寻路中只要在稳步接近目标,就不算超时(慢速推进不会被回城打断)', async () => { + const { controller, config, bot } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } }); + await controller.start(config); + const target = controller.target; + assert.ok(target, '应选出目标区块'); + + // 先跑一次 tick 建立距离基准(plan() 会把基准清空,首次观察只记录不续期) + // 把机器人放在目标西侧 10 个区块处,后面每步靠近 2 格 + const place = (offsetX) => { + bot.entity.position.x = (target.x + offsetX) * 16 + 8; + bot.entity.position.z = target.z * 16 + 8; + }; + place(-10); + controller.tick(); + + // 模拟「走得很慢但在推进」:每次 tick 靠近 2 个区块,但耗时都超过超时阈值 + for (let step = 1; step <= 4; step++) { + place(-10 + step * 2); + controller.stageStartedMillis = Date.now() - 10000; // 每次都「已超时」 + controller.tick(); + assert.strictEqual(controller.retryCount, 0, + `第 ${step} 次推进不应被判定为超时(retryCount=${controller.retryCount})`); + assert.notStrictEqual(controller.state, State.RETURNING, '不应触发回城'); + } + assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '有推进时连续超时应清零'); +}); + +test('寻路中原地不动时仍然会超时并重试(回归保护)', async () => { + const { controller, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } }); + await controller.start(config); + controller.tick(); // 首次 tick 只记录距离基准 + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + assert.strictEqual(controller.retryCount, 1, '原地不动必须照常判超时'); +}); + test('规划队列为空时直接进入 COMPLETE', async () => { // 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面 const { controller, config } = setup({