diff --git a/repo/js/AutoFriendshipFight/README.md b/repo/js/AutoFriendshipFight/README.md index 4fb0f25d7..8a5d546cd 100644 --- a/repo/js/AutoFriendshipFight/README.md +++ b/repo/js/AutoFriendshipFight/README.md @@ -1,6 +1,6 @@ ### 作者:[秋云](https://github.com/physligl) -> 自动化刷取角色好感度的脚本,支持盗宝团和愚人众两种敌人类型。通过自动触发和完成突发任务来获得好感度经验。 +> 自动化刷取角色好感度的脚本,支持盗宝团、愚人众、鳄鱼等敌人类型(蕈兽与雷萤术士为测试项)。通过自动触发和完成突发任务来获得好感度经验。 ### 目录 - [功能简介](#功能简介) @@ -14,7 +14,7 @@ ### 功能简介 -自动化刷取角色好感度的脚本,支持盗宝团和愚人众两种敌人类型。通过自动触发和完成突发任务来获得好感度经验。 +自动化刷取角色好感度的脚本,支持盗宝团、愚人众、鳄鱼等敌人类型(蕈兽与雷萤术士为测试项)。通过自动触发和完成突发任务来获得好感度经验。 @@ -64,7 +64,9 @@ - **战斗配置** - **战斗策略**: 根据队伍选择或者制定合适的战斗策略文件 - **练度要求**: 建议较高练度,纯好感队可能无法击败敌人 - - **重要设置**: ❌ 关闭"自动检测战斗结束" (如果勾选清理丘丘人,则需要打开) + - **重要设置**: + - 默认:❌ 关闭"自动检测战斗结束" + - 若勾选“禁用异步战斗(更稳定)”:✅ 需要开启并正确配置"自动检测战斗结束" - **推荐队伍配置** > 基于社区测试的高效角色推荐,其他效率高的角色欢迎通过 pull request 添加到本文档,需附带理由和演示视频。 @@ -90,15 +92,18 @@ - 队伍名称: - 填入您的战斗队伍名称 - 不填默认当前队伍 - - OCR超时: + - OCR超时: - OCR检测超时时间(默认10秒) - 过短可能识别失败导致结束任务 - 过长则会长时间检测导致等待时间过长 - 推荐保持默认值 - - 战斗超时: + - 战斗超时: - 单次战斗超时时间(默认120秒) - 战斗超时会直接取消任务,因此不建议过短 - 脚本会自动检测任务结束,识别精度很高 + - 禁用异步战斗(更稳定;需配置“战斗结束检测”;对盗宝团无效) + - 适用于愚人众/鳄鱼等:依赖“自动检测战斗结束”退出战斗 + - 盗宝团:仍使用路径战斗 + OCR 判定流程 #### 一键运行 配置完成后,直接运行脚本即可开始自动刷取好感度。 @@ -222,6 +227,7 @@ | 版本 | 更新内容 | |------|----------| +| **v1.5.3** | 稳定性提升:资源释放更完整;盗宝团游泳统计仅在盗宝团启用;“禁用异步战斗”增加超时保护避免卡死;设置说明更易懂 | | **v1.4.2** | 新增拾取模式,能选择不拾取 | | **v1.3** | 新增愚人众支持,优化敌人类型切换 | | **v1.2** | 战斗触发检测,异步检测战斗结束 | diff --git a/repo/js/AutoFriendshipFight/main.js b/repo/js/AutoFriendshipFight/main.js index 7ab59a608..964356634 100644 --- a/repo/js/AutoFriendshipFight/main.js +++ b/repo/js/AutoFriendshipFight/main.js @@ -12,14 +12,27 @@ const DEFAULT_OCR_TIMEOUT_SECONDS = 10; const DEFAULT_FIGHT_TIMEOUT_SECONDS = 120; const DEFAULT_SWIM_CONSECUTIVE_LIMIT = 5; +const ERROR_CODES = { + BATTLE_TIMEOUT: "BATTLE_TIMEOUT", + SWIM_LIMIT: "SWIM_LIMIT", + SWIM_RECOVERED: "SWIM_RECOVERED", +}; + +const ERR_MESSAGES = { + BATTLE_TIMEOUT: "战斗超时,未检测到结果", + SWIM_RECOVERED: "检测到游泳且自动回七天神像,视为本轮失败", +}; + // 掉落检测状态 // detectedExpOrMora:上一轮是否识别到经验/摩拉图标(用于判断“连续两轮都没有掉落则停”) -// NoExpOrMoraCount:连续“没有识别到掉落”的计数器 +// noExpOrMoraCount:连续“没有识别到掉落”的计数器 // running:后台循环 detectExpOrMora 的停止开关(必须在任何退出路径上关闭,避免后台占用) let detectedExpOrMora = true; -let NoExpOrMoraCount = 0; +let noExpOrMoraCount = 0; let running = true; +const warnedEnemyTypes = new Set(); + // 默认突发任务 OCR 关键词(敌人配置未提供时使用) const DEFAULT_OCR_KEYWORDS = ["突发", "任务", "打倒", "消灭", "敌人", "所有"]; @@ -84,8 +97,12 @@ moraRo.Threshold = 0.85; moraRo.Use3Channels = true; moraRo.InitTemplate(); +/** + * 脚本入口:读取配置并执行主循环;退出时停止后台检测并释放模板资源。 + */ (async function () { const startTime = Date.now(); + let enemyType = "盗宝团"; try { // 可选:启用自动拾取实时任务 if (convertToTrueIfNotBoolean(settings.pickupMode)) { @@ -95,11 +112,11 @@ moraRo.InitTemplate(); log.info("已 禁用 自动拾取任务"); } // 修复点:runTimes 必须用 let/const 声明,避免污染全局变量 - let runTimes = await calulateRunTimes(); + let runTimes = await calculateRunTimes(); await switchPartyIfNeeded(settings.partyName); // 选择敌人类型(默认盗宝团) - const enemyType = settings.enemyType || "盗宝团"; + enemyType = settings.enemyType || "盗宝团"; log.info(`当前选择的敌人类型: ${enemyType}`); log.info(`${enemyType}好感开始...`); @@ -114,7 +131,7 @@ moraRo.InitTemplate(); log.info(`${enemyType}好感运行总时长:${LogTimeTaken(startTime)}`); } catch (error) { if (isCancellationError(error)) { - resetDailySwimConsecutiveIfNeeded(); + SwimTracker(enemyType).resetConsecutiveIfNeeded(); log.info("脚本已取消"); return; } @@ -123,23 +140,63 @@ moraRo.InitTemplate(); } finally { // 修复点:无论成功/失败/异常,都必须停止后台循环与释放模板资源 running = false; - try { expMat?.Dispose?.(); } catch { } - try { moraMat?.Dispose?.(); } catch { } + safeDispose(expMat); + safeDispose(moraMat); expMat = null; moraMat = null; } })(); +/** + * 将 settings 中非布尔值按默认 true 处理(未设置视为开启)。 + * @param {*} value + * @returns {boolean} + */ function convertToTrueIfNotBoolean(value) { // settings 里可能是 undefined/字符串等非布尔值,此处按“未设置则视为开启”处理 return typeof value === 'boolean' ? value : true; } +/** + * 判断是否为取消任务导致的错误。 + * @param {*} error + * @returns {boolean} + */ function isCancellationError(error) { const msg = error && error.message ? String(error.message) : ""; return msg.includes("取消自动任务") || msg.includes("A task was canceled."); } +/** + * 安全释放宿主对象资源(兼容 Dispose/dispose)。 + * @param {*} obj + */ +function safeDispose(obj) { + if (!obj) return; + const fn = + (typeof obj.Dispose === "function" ? obj.Dispose : null) || + (typeof obj.dispose === "function" ? obj.dispose : null); + if (!fn) return; + try { fn.call(obj); } catch { } +} + +/** + * 创建带错误码的 Error,便于上层结构化处理。 + * @param {string} code + * @param {string} message + * @returns {Error} + */ +function createScriptError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +/** + * 判断 CancellationTokenSource 是否已请求取消(兼容不同字段命名)。 + * @param {*} cts + * @returns {boolean} + */ function isCtsCancellationRequested(cts) { try { const v = @@ -153,6 +210,10 @@ function isCtsCancellationRequested(cts) { } } +/** + * 获取地图坐标,失败时返回 null(取消错误会透传)。 + * @returns {{x:number,y:number}|null} + */ function safeGetPositionFromMap() { try { return genshin.getPositionFromMap(); @@ -164,11 +225,26 @@ function safeGetPositionFromMap() { } } +/** + * 获取敌人配置;未知 enemyType 仅提示一次并回退为空配置。 + * @param {string} enemyType + * @returns {object} + */ function getEnemyConfig(enemyType) { // 根据敌人类型返回配置对象(不存在则返回空对象,调用方负责兜底) - return ENEMY_CONFIG[enemyType] || {}; + const cfg = ENEMY_CONFIG[enemyType]; + if (!cfg && !warnedEnemyTypes.has(enemyType)) { + warnedEnemyTypes.add(enemyType); + log.warn(`未知 enemyType: ${enemyType},将使用默认配置`); + } + return cfg || {}; } +/** + * 敌人准备流程:执行 preparePath / 盗宝团可选清理丘丘人。 + * @param {string} enemyType + * @returns {Promise} + */ async function prepareForEnemy(enemyType) { // 清理丘丘人(仅盗宝团需要) if (settings.qiuQiuRen && enemyType === "盗宝团") { @@ -183,6 +259,11 @@ async function prepareForEnemy(enemyType) { } } +/** + * 战后附加流程:按敌人配置跑 postBattlePath;蕈兽包含对话交互。 + * @param {string} enemyType + * @returns {Promise} + */ async function runPostBattle(enemyType) { // 战斗后处理:按敌人配置执行附加路径;部分敌人需要对话交互 const { postBattlePath } = getEnemyConfig(enemyType); @@ -201,6 +282,12 @@ async function runPostBattle(enemyType) { } } +/** + * 失败恢复:回七天神像(可跳过)并走回退路径。 + * @param {string} enemyType + * @param {boolean} [skipTp=false] + * @returns {Promise} + */ async function recoverAfterFailure(enemyType, skipTp = false) { if (!skipTp) { await genshin.tpToStatueOfTheSeven(); @@ -218,6 +305,11 @@ async function recoverAfterFailure(enemyType, skipTp = false) { } } // 执行 path 任务 +/** + * 执行路径文件(AutoPath/*.json);失败返回 false,取消错误透传。 + * @param {string} locationName + * @returns {Promise} + */ async function AutoPath(locationName) { // 统一包装路径执行:避免 runFile 抛错导致整个脚本中断 try { @@ -233,6 +325,10 @@ async function AutoPath(locationName) { } } +/** + * 生成当天日期键(YYYY-MM-DD)。 + * @returns {string} + */ function getTodayKey() { const d = new Date(); const yyyy = String(d.getFullYear()); @@ -241,11 +337,21 @@ function getTodayKey() { return `${yyyy}-${mm}-${dd}`; } +/** + * 统一路径字符串用于比较(反斜杠转正斜杠并去重)。 + * @param {string} p + * @returns {string} + */ function normalizePathForCompare(p) { if (!p || typeof p !== "string") return ""; return p.replace(/\\/g, "/").replace(/\/+/g, "/"); } +/** + * 安全判断文件是否存在(宿主缺少 exists API 时的兜底实现)。 + * @param {string} filePath + * @returns {boolean} + */ function fileExistsSafe(filePath) { try { if (!filePath || typeof filePath !== "string") return false; @@ -269,57 +375,95 @@ function fileExistsSafe(filePath) { } } -function loadDailySwimStats() { +/** + * 游泳状态追踪(仅盗宝团启用):记录连续触发次数并支持阈值中断。 + * @param {string} enemyType + * @returns {{enabled:boolean, load:Function, resetConsecutiveIfNeeded:Function, recordAndCheck:Function}} + */ +function SwimTracker(enemyType) { + const enabled = enemyType === "盗宝团"; + const statsPath = "swim_stats.json"; const today = getTodayKey(); - const statsPath = "swim_stats.json"; - try { - if (!fileExistsSafe(statsPath)) { - return { date: today, totalSwimCount: 0, consecutiveSwimCount: 0 }; - } - const raw = file.readTextSync(statsPath); - const parsed = JSON.parse(raw); - if (parsed && parsed.date === today) { - const totalSwimCount = typeof parsed.totalSwimCount === "number" - ? parsed.totalSwimCount - : (typeof parsed.swimCount === "number" ? parsed.swimCount : 0); - const consecutiveSwimCount = typeof parsed.consecutiveSwimCount === "number" ? parsed.consecutiveSwimCount : 0; - return { date: today, totalSwimCount, consecutiveSwimCount }; - } - } catch { } - return { date: today, totalSwimCount: 0, consecutiveSwimCount: 0 }; -} -function saveDailySwimStats(stats) { - const statsPath = "swim_stats.json"; - try { - file.writeTextSync(statsPath, JSON.stringify(stats, null, 2), false); - } catch { } -} - -function resetDailySwimConsecutiveIfNeeded() { - const stats = loadDailySwimStats(); - if (stats.consecutiveSwimCount > 0) { - stats.consecutiveSwimCount = 0; - saveDailySwimStats(stats); + /** + * 读取当天统计。 + * @returns {{date:string,totalSwimCount:number,consecutiveSwimCount:number}} + */ + function load() { + if (!enabled) return { date: today, totalSwimCount: 0, consecutiveSwimCount: 0 }; + try { + if (!fileExistsSafe(statsPath)) { + return { date: today, totalSwimCount: 0, consecutiveSwimCount: 0 }; + } + const raw = file.readTextSync(statsPath); + const parsed = JSON.parse(raw); + if (parsed && parsed.date === today) { + const totalSwimCount = typeof parsed.totalSwimCount === "number" + ? parsed.totalSwimCount + : (typeof parsed.swimCount === "number" ? parsed.swimCount : 0); + const consecutiveSwimCount = typeof parsed.consecutiveSwimCount === "number" ? parsed.consecutiveSwimCount : 0; + return { date: today, totalSwimCount, consecutiveSwimCount }; + } + } catch { } + return { date: today, totalSwimCount: 0, consecutiveSwimCount: 0 }; } -} -function recordSwimEventAndCheck(consecutiveLimit) { - const stats = loadDailySwimStats(); - stats.totalSwimCount = (Number(stats.totalSwimCount) || 0) + 1; - stats.consecutiveSwimCount = (Number(stats.consecutiveSwimCount) || 0) + 1; - saveDailySwimStats(stats); - const limit = Number.isFinite(Number(consecutiveLimit)) ? Number(consecutiveLimit) : DEFAULT_SWIM_CONSECUTIVE_LIMIT; - return { - totalSwimCount: stats.totalSwimCount, - consecutiveSwimCount: stats.consecutiveSwimCount, - consecutiveLimit: limit, - exceeded: stats.consecutiveSwimCount >= limit - }; + /** + * 保存统计(仅启用时生效)。 + * @param {{date:string,totalSwimCount:number,consecutiveSwimCount:number}} stats + */ + function save(stats) { + if (!enabled) return; + try { + file.writeTextSync(statsPath, JSON.stringify(stats, null, 2), false); + } catch { } + } + + /** + * 若连续计数大于 0 则清零。 + */ + function resetConsecutiveIfNeeded() { + if (!enabled) return; + const stats = load(); + if (stats.consecutiveSwimCount > 0) { + stats.consecutiveSwimCount = 0; + save(stats); + } + } + + /** + * 记录一次游泳事件并检查是否超过阈值。 + * @param {number|string} consecutiveLimit + * @returns {{totalSwimCount:number,consecutiveSwimCount:number,consecutiveLimit:number,exceeded:boolean}} + */ + function recordAndCheck(consecutiveLimit) { + if (!enabled) { + const limit = Number.isFinite(Number(consecutiveLimit)) ? Number(consecutiveLimit) : DEFAULT_SWIM_CONSECUTIVE_LIMIT; + return { totalSwimCount: 0, consecutiveSwimCount: 0, consecutiveLimit: limit, exceeded: false }; + } + const stats = load(); + stats.totalSwimCount = (Number(stats.totalSwimCount) || 0) + 1; + stats.consecutiveSwimCount = (Number(stats.consecutiveSwimCount) || 0) + 1; + save(stats); + const limit = Number.isFinite(Number(consecutiveLimit)) ? Number(consecutiveLimit) : DEFAULT_SWIM_CONSECUTIVE_LIMIT; + return { + totalSwimCount: stats.totalSwimCount, + consecutiveSwimCount: stats.consecutiveSwimCount, + consecutiveLimit: limit, + exceeded: stats.consecutiveSwimCount >= limit + }; + } + + return { enabled, load, resetConsecutiveIfNeeded, recordAndCheck }; } const battlePointCache = new Map(); +/** + * 从战斗点路径文件中推导最后一个坐标(缓存);失败则回退配置的 targetCoords。 + * @param {string} enemyType + * @returns {{x:number,y:number}|null} + */ function getBattlePointFromBattlePath(enemyType) { if (battlePointCache.has(enemyType)) { return battlePointCache.get(enemyType); @@ -346,11 +490,21 @@ function getBattlePointFromBattlePath(enemyType) { return point; } -async function runBattlePathAndFight(enemyType) { +/** + * 执行战斗点路径;可选在最后节点注入 fight action(用于盗宝团旧流程)。 + * @param {string} enemyType + * @param {boolean} [injectFightAction=false] + * @returns {Promise} + */ +async function runBattlePathToBattlePoint(enemyType, injectFightAction = false) { const filePath = `assets/AutoPath/${enemyType}-战斗点.json`; if (!fileExistsSafe(filePath)) { throw new Error(`缺少战斗点路径文件: ${filePath}`); } + if (!injectFightAction) { + await pathingScript.runFile(filePath); + return; + } const raw = file.readTextSync(filePath); const parsed = JSON.parse(raw); const positions = parsed && Array.isArray(parsed.positions) ? parsed.positions : null; @@ -374,6 +528,11 @@ async function runBattlePathAndFight(enemyType) { } // 计算运行时长 +/** + * 计算从 startTime 到现在的耗时字符串。 + * @param {number} startTimeParam + * @returns {string} + */ function LogTimeTaken(startTimeParam) { const currentTime = Date.now(); const totalTimeInSeconds = (currentTime - startTimeParam) / 1000; @@ -383,6 +542,13 @@ function LogTimeTaken(startTimeParam) { } // 计算预估时间 +/** + * 估算完成时间(按平均单轮耗时线性推算)。 + * @param {number} startTime + * @param {number} current + * @param {number} total + * @returns {string} + */ function CalculateEstimatedCompletion(startTime, current, total) { if (current === 0) return "计算中..."; @@ -395,6 +561,11 @@ function CalculateEstimatedCompletion(startTime, current, total) { } // 检查并导航到触发点 +/** + * 导航到触发点并做一次距离校验,偏离则二次导航。 + * @param {string} enemyType + * @returns {Promise} + */ async function navigateToTriggerPoint(enemyType) { await AutoPath(`${enemyType}-触发点`); const triggerPoint = getTriggerPoint(enemyType); @@ -416,6 +587,12 @@ async function navigateToTriggerPoint(enemyType) { } // OCR检测突发任务 +/** + * OCR 检测是否触发突发任务。 + * @param {number} ocrTimeout 秒 + * @param {string} enemyType + * @returns {Promise} + */ async function detectTaskTrigger(ocrTimeout, enemyType) { // 修复点:OCR 的截图对象与 findMulti 返回对象可能持有底层资源 // 必须在 finally 中释放,避免长时间循环导致资源累积 @@ -441,8 +618,7 @@ async function detectTaskTrigger(ocrTimeout, enemyType) { } if (ocrStatus) break; } finally { - try { res?.Dispose?.(); } catch { } - try { res?.dispose?.(); } catch { } + safeDispose(res); } } } catch (error) { @@ -451,10 +627,8 @@ async function detectTaskTrigger(ocrTimeout, enemyType) { } log.error(`OCR检测突发任务过程中出错: ${error && error.message ? error.message : error}`); } finally { - try { resList?.dispose?.(); } catch { } - try { resList?.Dispose?.(); } catch { } - try { captureRegion?.dispose?.(); } catch { } - try { captureRegion?.Dispose?.(); } catch { } + safeDispose(resList); + safeDispose(captureRegion); } if (!ocrStatus) { @@ -465,65 +639,52 @@ async function detectTaskTrigger(ocrTimeout, enemyType) { return ocrStatus; } -// 等待角色到达目标位置 -async function waitForTargetPosition(pathTask, targetCoords, maxWaitTime = 15000, maxDistance = 5) { - const waitStartTime = Date.now(); - let isNearTarget = false; - let pathTaskFinished = false; - let sawPosition = false; - - // 监控路径任务完成 - pathTask.then(() => { - pathTaskFinished = true; - log.info("路径任务已完成"); - }).catch(error => { - pathTaskFinished = true; - if (isCancellationError(error)) { - log.info("路径任务已取消"); - return; - } - log.error(`路径任务出错: ${error}`); - }); - - // 等待角色到达目标位置或超时 - while (!isNearTarget && !pathTaskFinished && (Date.now() - waitStartTime < maxWaitTime)) { - const pos = safeGetPositionFromMap(); - if (pos) { - sawPosition = true; - const distance = Math.sqrt(Math.pow(pos.x - targetCoords.x, 2) + Math.pow(pos.y - targetCoords.y, 2)); - if (distance <= maxDistance) { - isNearTarget = true; - log.info(`已到达目标点附近,距离: ${distance.toFixed(2)}米`); - break; - } - } - await sleep(1000); - } - - return { isNearTarget, pathTaskFinished, sawPosition }; -} - // 执行战斗任务(并发执行战斗和结果检测) -async function executeBattleTasks(fightTimeout, enemyType, cts) { +/** + * 执行 AutoFight 并用 OCR 判定战斗结果(支持同步/异步模式与超时保护)。 + * @param {number} fightTimeout 秒 + * @param {string} enemyType + * @param {*} cts CancellationTokenSource + * @param {{x:number,y:number}|null} battlePointCoords + * @returns {Promise<{success:boolean,status:string,errorMessage?:string}>} + */ +async function executeBattleTasks(fightTimeout, enemyType, cts, battlePointCoords) { // 修复点: // - 以前只要“检测任务 fulfilled”就当成功;现在明确区分 success/failure/out_of_area 等状态 // - 取消只意味着停止战斗任务,不等价于“本轮战斗成功” log.info("开始战斗!"); let battleTask; - let battleResult = null; let battleDetectTask = null; + let awaitBattleTask = true; + const awaitBattleTaskMs = 2000; try { if (settings.disableAsyncFight) { - // 同步战斗模式:由宿主的战斗结束检测/策略决定何时返回 - await dispatcher.RunTask(new SoloTask("AutoFight")); + // 同步战斗模式:依赖配置组的“战斗结束检测”让 AutoFight 自行退出 + // 额外增加 watchdog:超过 fightTimeout 仍未退出则取消任务,避免无限战斗 const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); + battleTask = dispatcher.RunTask(new SoloTask("AutoFight"), cts); + const battleWrapped = battleTask + .then(value => ({ kind: "battle_fulfilled", value })) + .catch(error => ({ kind: "battle_rejected", error })); + const first = await Promise.race([ + battleWrapped, + sleep(maxDetectMs).then(() => ({ kind: "battle_timeout" })) + ]); + if (first.kind === "battle_timeout") { + try { cts.cancel(); } catch { } + awaitBattleTask = false; + return { success: false, status: "auto_fight_ended" }; + } + if (first.kind === "battle_rejected") { + throw first.error; + } const graceMs = Math.min(8000, maxDetectMs); try { - const status = await waitForBattleResult(graceMs, enemyType, new CancellationTokenSource()); + const status = await waitForBattleResult(graceMs, enemyType, cts, battlePointCoords); return { success: status === "success", status }; } catch (error) { - if (error && error.message && String(error.message).includes("战斗超时")) { + if (error && (error.code === ERROR_CODES.BATTLE_TIMEOUT || (error.message && String(error.message).includes("战斗超时")))) { return { success: false, status: "auto_fight_ended" }; } throw error; @@ -531,7 +692,7 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) { } else { // 异步战斗模式:并发启动战斗 + OCR 检测结果;检测到结果后取消战斗任务 battleTask = dispatcher.RunTask(new SoloTask("AutoFight"), cts); - battleDetectTask = waitForBattleResult(fightTimeout * 1000, enemyType, cts); + battleDetectTask = waitForBattleResult(fightTimeout * 1000, enemyType, cts, battlePointCoords); const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); const graceMs = Math.min(8000, maxDetectMs); @@ -547,13 +708,13 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) { if (first.kind === "detect_fulfilled") { log.info("战斗检测任务完成"); + try { cts.cancel(); } catch { } return { success: first.status === "success", status: first.status }; } if (first.kind === "detect_rejected") { throw first.error; } - battleResult = first; if (first.kind === "battle_rejected" && isCancellationError(first.error)) { throw first.error; } @@ -565,32 +726,36 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) { if (second.kind === "detect_fulfilled") { log.info("战斗检测任务完成"); + try { cts.cancel(); } catch { } return { success: second.status === "success", status: second.status }; } if (second.kind === "detect_rejected") { throw second.error; } + try { cts.cancel(); } catch { } return { success: false, status: "auto_fight_ended" }; } } catch (error) { - // 过滤掉正常的取消错误 - if (isCancellationError(error)) { - log.info("战斗任务已取消"); - return { success: false, status: "cancelled" }; - } + if (isCancellationError(error)) throw error; const msg = error && error.message ? String(error.message) : ""; - if (msg.includes("前往七天神像重试") || msg.includes("检测到游泳")) { + if (SwimTracker(enemyType).enabled && (msg.includes("前往七天神像重试") || msg.includes("检测到游泳"))) { log.warn(`战斗执行异常(已自动回七天神像): ${msg}`); return { success: false, status: "recovered_to_statue", errorMessage: msg }; } log.error(`战斗执行过程中出错: ${msg}`); return { success: false, status: "error", errorMessage: msg }; } finally { - // 确保战斗任务被等待完成(即使被取消) + // 避免因 AutoFight 不响应取消导致 finally 永久挂起 if (battleTask) { try { - await battleTask; + const done = await Promise.race([ + battleTask.then(() => true), + sleep(awaitBattleTask ? awaitBattleTaskMs : 0).then(() => false) + ]); + if (!done) { + battleTask.catch(() => { }); + } } catch (error) { // 忽略 finally 块中的取消错误 if (!isCancellationError(error)) { @@ -603,7 +768,16 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) { } // 执行单次好感任务循环 +/** + * 执行单轮好感流程(触发检测→导航→战斗→判定→战后/恢复)。 + * @param {number} roundIndex + * @param {number} ocrTimeout 秒 + * @param {number} fightTimeout 秒 + * @param {string} enemyType + * @returns {Promise} 返回 false 表示整体应提前结束 + */ async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout, enemyType) { + const swim = SwimTracker(enemyType); // 单轮流程: // 1) 导航到触发点附近 // 2) 通过 OCR 判断是否已触发突发任务 @@ -618,17 +792,17 @@ async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout } let initialDetected = false; if (roundIndex === 0) { - initialDetected = await detectTaskTrigger(3, enemyType); + initialDetected = await detectTaskTrigger(Math.min(Number(ocrTimeout) || DEFAULT_OCR_TIMEOUT_SECONDS, DEFAULT_OCR_TIMEOUT_SECONDS), enemyType); } if (!detectedExpOrMora && settings.loopTillNoExpOrMora) { - NoExpOrMoraCount++; + noExpOrMoraCount++; log.warn("上次运行未检测到经验或摩拉"); - if (NoExpOrMoraCount >= 2) { + if (noExpOrMoraCount >= 2) { log.warn("连续两次循环没有经验或摩拉掉落,提前终止"); return false; } } else { - NoExpOrMoraCount = 0; + noExpOrMoraCount = 0; detectedExpOrMora = false; } if (!initialDetected || roundIndex > 0) { @@ -645,7 +819,7 @@ async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout // 本轮未检测到突发任务:按设计直接结束整个脚本循环 notification.send(`未识别到突发任务,${enemyType}好感结束`); log.info(`未识别到突发任务,${enemyType}好感结束`); - resetDailySwimConsecutiveIfNeeded(); + swim.resetConsecutiveIfNeeded(); return false; // 返回 false 表示需要终止循环 } @@ -655,40 +829,113 @@ async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout throw new Error(`未配置 ${enemyType} 的 targetCoords`); } - const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); - const battleDetectCts = new CancellationTokenSource(); - const battleDetectPromise = waitForBattleResult(maxDetectMs, enemyType, battleDetectCts, battlePointCoords); + if (enemyType === "盗宝团") { + const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); + const battleDetectCts = new CancellationTokenSource(); + const battleDetectPromise = waitForBattleResult(maxDetectMs, enemyType, battleDetectCts, battlePointCoords); + log.info("开始战斗!"); + const pathPromise = runBattlePathToBattlePoint(enemyType, true); + const pathWrapped = pathPromise + .then(() => ({ kind: "path_fulfilled" })) + .catch(error => ({ kind: "path_rejected", error })); + const detectWrapped = battleDetectPromise + .then(status => ({ kind: "detect_fulfilled", status })) + .catch(error => ({ kind: "detect_rejected", error })); + + const first = await Promise.race([pathWrapped, detectWrapped]); + if (first.kind === "path_rejected") { + try { battleDetectCts.cancel(); } catch { } + const e = first.error; + if (isCancellationError(e)) throw e; + const msg = e && e.message ? String(e.message) : ""; + if (swim.enabled && (msg.includes("前往七天神像重试") || msg.includes("检测到游泳"))) { + const { totalSwimCount, consecutiveSwimCount, consecutiveLimit, exceeded } = swim.recordAndCheck(settings.swimConsecutiveLimit); + log.warn(`检测到游泳异常,今日累计 ${totalSwimCount} 次,连续 ${consecutiveSwimCount}/${consecutiveLimit} 次`); + if (exceeded) { + throw createScriptError(ERROR_CODES.SWIM_LIMIT, `当日连续触发游泳已达 ${consecutiveLimit} 次,战斗策略或配队严重不合理`); + } + throw createScriptError(ERROR_CODES.SWIM_RECOVERED, ERR_MESSAGES.SWIM_RECOVERED); + } + throw e; + } + + const battleStatus = first.kind === "detect_fulfilled" + ? first.status + : await battleDetectPromise; + + if (first.kind === "detect_fulfilled") { + const pathSettled = await Promise.race([ + pathPromise.then(() => true).catch(() => true), + sleep(maxDetectMs).then(() => false) + ]); + if (!pathSettled) { + throw createScriptError(ERROR_CODES.BATTLE_TIMEOUT, ERR_MESSAGES.BATTLE_TIMEOUT); + } + } + + if (battleStatus === "cancelled") { + throw new Error("战斗任务已取消"); + } + if (battleStatus === "success") { + swim.resetConsecutiveIfNeeded(); + await runPostBattle(enemyType); + return true; + } + await recoverAfterFailure(enemyType, false); + throw new Error(`战斗失败: ${battleStatus}`); + } try { - log.info("开始战斗!"); - await runBattlePathAndFight(enemyType); + await runBattlePathToBattlePoint(enemyType, false); } catch (e) { - try { battleDetectCts.cancel(); } catch { } if (isCancellationError(e)) throw e; const msg = e && e.message ? String(e.message) : ""; - if (msg.includes("前往七天神像重试") || msg.includes("检测到游泳")) { - const { totalSwimCount, consecutiveSwimCount, consecutiveLimit, exceeded } = recordSwimEventAndCheck(settings.swimConsecutiveLimit); + if (swim.enabled && (msg.includes("前往七天神像重试") || msg.includes("检测到游泳"))) { + const { totalSwimCount, consecutiveSwimCount, consecutiveLimit, exceeded } = swim.recordAndCheck(settings.swimConsecutiveLimit); log.warn(`检测到游泳异常,今日累计 ${totalSwimCount} 次,连续 ${consecutiveSwimCount}/${consecutiveLimit} 次`); if (exceeded) { - throw new Error(`当日连续触发游泳已达 ${consecutiveLimit} 次,战斗策略或配队严重不合理`); + throw createScriptError(ERROR_CODES.SWIM_LIMIT, `当日连续触发游泳已达 ${consecutiveLimit} 次,战斗策略或配队严重不合理`); } - throw new Error("检测到游泳且自动回七天神像,视为本轮失败"); + throw createScriptError(ERROR_CODES.SWIM_RECOVERED, ERR_MESSAGES.SWIM_RECOVERED); } throw e; } - const battleStatus = await battleDetectPromise; - if (battleStatus === "cancelled") { - throw new Error("战斗任务已取消"); + + const battleCts = new CancellationTokenSource(); + const battleResult = await executeBattleTasks(fightTimeout, enemyType, battleCts, battlePointCoords); + + if (battleResult.status === "success") { + swim.resetConsecutiveIfNeeded(); + await runPostBattle(enemyType); + return true; } - resetDailySwimConsecutiveIfNeeded(); - await runPostBattle(enemyType); + if (battleResult.status === "recovered_to_statue") { + if (swim.enabled) { + const { totalSwimCount, consecutiveSwimCount, consecutiveLimit, exceeded } = swim.recordAndCheck(settings.swimConsecutiveLimit); + log.warn(`检测到游泳异常,今日累计 ${totalSwimCount} 次,连续 ${consecutiveSwimCount}/${consecutiveLimit} 次`); + if (exceeded) { + throw createScriptError(ERROR_CODES.SWIM_LIMIT, `当日连续触发游泳已达 ${consecutiveLimit} 次,战斗策略或配队严重不合理`); + } + await recoverAfterFailure(enemyType, true); + throw createScriptError(ERROR_CODES.SWIM_RECOVERED, ERR_MESSAGES.SWIM_RECOVERED); + } + await recoverAfterFailure(enemyType, false); + throw new Error(battleResult.errorMessage ? String(battleResult.errorMessage) : `战斗失败: ${battleResult.status}`); + } - // 返回 true 表示成功完成这一轮 - return true; + await recoverAfterFailure(enemyType, false); + const msg = battleResult.errorMessage ? String(battleResult.errorMessage) : `战斗失败: ${battleResult.status}`; + throw new Error(msg); } // 记录进度信息 +/** + * 输出当前进度与预计完成时间。 + * @param {number} startTime + * @param {number} currentRound + * @param {number} totalRounds + */ function logProgress(startTime, currentRound, totalRounds) { const estimatedCompletion = CalculateEstimatedCompletion(startTime, currentRound + 1, totalRounds); const currentTime = LogTimeTaken(startTime); @@ -698,6 +945,14 @@ function logProgress(startTime, currentRound, totalRounds) { } // 执行 N 次好感任务并输出日志 - 重构后的主函数 +/** + * 主循环:执行指定次数;在未触发/连续无掉落等条件下提前退出。 + * @param {number} times + * @param {number} ocrTimeout 秒 + * @param {number} fightTimeout 秒 + * @param {string} enemyType + * @returns {Promise} + */ async function AutoFriendshipDev(times, ocrTimeout, fightTimeout, enemyType = "盗宝团") { // 主循环:执行指定次数或在 detectTaskTrigger 判定“未触发”时提前退出 // 修复点:finally 中统一关闭 running 并等待 detectExpOrMoraTask 退出,避免后台循环残留 @@ -725,6 +980,12 @@ async function AutoFriendshipDev(times, ocrTimeout, fightTimeout, enemyType = " } failureCount++; log.error(`第 ${i + 1} 轮好感任务失败: ${error.message}`); + if (error && error.code === ERROR_CODES.BATTLE_TIMEOUT) { + throw error; + } + if (error && error.code === ERROR_CODES.SWIM_LIMIT) { + throw error; + } if (error.message && error.message.includes("战斗超时")) { throw error; } @@ -739,9 +1000,14 @@ async function AutoFriendshipDev(times, ocrTimeout, fightTimeout, enemyType = " } } finally { if (!cancelled) { - const stats = loadDailySwimStats(); - const limit = Number.isFinite(Number(settings.swimConsecutiveLimit)) ? Number(settings.swimConsecutiveLimit) : DEFAULT_SWIM_CONSECUTIVE_LIMIT; - log.info(`本次运行统计:成功 ${successCount} 次,失败 ${failureCount} 次,今日游泳累计 ${stats.totalSwimCount} 次,连续游泳 ${stats.consecutiveSwimCount}/${limit} 次`); + const swim = SwimTracker(enemyType); + if (swim.enabled) { + const stats = swim.load(); + const limit = Number.isFinite(Number(settings.swimConsecutiveLimit)) ? Number(settings.swimConsecutiveLimit) : DEFAULT_SWIM_CONSECUTIVE_LIMIT; + log.info(`本次运行统计:成功 ${successCount} 次,失败 ${failureCount} 次,今日游泳累计 ${stats.totalSwimCount} 次,连续游泳 ${stats.consecutiveSwimCount}/${limit} 次`); + } else { + log.info(`本次运行统计:成功 ${successCount} 次,失败 ${failureCount} 次`); + } } running = false; if (detectExpOrMoraTask) { @@ -750,6 +1016,10 @@ async function AutoFriendshipDev(times, ocrTimeout, fightTimeout, enemyType = " } } +/** + * 后台循环:模板匹配检测经验/摩拉掉落图标(用于提前终止循环)。 + * @returns {Promise} + */ async function detectExpOrMora() { // 后台循环:通过模板匹配检测经验/摩拉图标 // 注意:该循环依赖 running 停止;必须保证任何退出路径都会把 running=false @@ -757,15 +1027,17 @@ async function detectExpOrMora() { try { await sleep(1); } catch (e) { break; } let gameRegion; if (!detectedExpOrMora) { + let res1 = null; + let res2 = null; try { gameRegion = captureGameRegion(); - const res1 = gameRegion.find(expRo); + res1 = gameRegion.find(expRo); if (res1.isExist()) { log.info("识别到经验"); detectedExpOrMora = true; continue; } - const res2 = gameRegion.find(moraRo); + res2 = gameRegion.find(moraRo); if (res2.isExist()) { log.info("识别到摩拉"); detectedExpOrMora = true; @@ -774,8 +1046,9 @@ async function detectExpOrMora() { } catch (e) { log.error(`检测经验和摩拉掉落过程中出现错误 ${e.message}`); } finally { - try { gameRegion?.dispose?.(); } catch { } - try { gameRegion?.Dispose?.(); } catch { } + safeDispose(res1); + safeDispose(res2); + safeDispose(gameRegion); } } else { //无需检测时额外等待200 @@ -785,7 +1058,11 @@ async function detectExpOrMora() { } } -async function calulateRunTimes() { +/** + * 读取并校验运行次数设置,非法则回退默认值。 + * @returns {Promise} + */ +async function calculateRunTimes() { // 从 settings 读取次数并校验;非法则回退默认值 log.info(`'请确保队伍满员,并为队伍配置相应的战斗策略'`); // 计算运行次数 @@ -801,11 +1078,21 @@ async function calulateRunTimes() { } // 验证输入是否是正整数 +/** + * 判断是否为正整数。 + * @param {*} value + * @returns {boolean} + */ function isPositiveInteger(value) { return Number.isInteger(value) && value > 0; } // 根据敌人类型获取OCR关键词 +/** + * 获取 OCR 关键词(优先敌人配置,否则回退默认)。 + * @param {string} enemyType + * @returns {string[]} + */ function getOcrKeywords(enemyType) { // OCR 关键词获取:优先使用敌人类型配置,否则回退到默认关键词 const { ocrKeywords } = getEnemyConfig(enemyType); @@ -813,17 +1100,32 @@ function getOcrKeywords(enemyType) { } // 根据敌人类型获取目标战斗点坐标 +/** + * 获取目标战斗点坐标配置。 + * @param {string} enemyType + * @returns {{x:number,y:number}|undefined} + */ function getTargetCoordinates(enemyType) { const { targetCoords } = getEnemyConfig(enemyType); return targetCoords; } +/** + * 获取触发点坐标配置。 + * @param {string} enemyType + * @returns {{x:number,y:number}|undefined} + */ function getTriggerPoint(enemyType) { const { triggerPoint } = getEnemyConfig(enemyType); return triggerPoint; } // 验证日期格式 +/** + * 可选切换队伍;失败则尝试回七天神像后重试。 + * @param {string} partyName + * @returns {Promise} + */ async function switchPartyIfNeeded(partyName) { // 可选队伍切换:为空则直接回到主界面,避免停留在菜单/对话等状态 if (!partyName) { @@ -844,7 +1146,15 @@ async function switchPartyIfNeeded(partyName) { } } -async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝团", cts = new CancellationTokenSource(), battlePointCoords = null) { +/** + * OCR 轮询战斗结果:success/failure/out_of_area/cancelled;超时抛 BATTLE_TIMEOUT。 + * @param {number} timeout 毫秒 + * @param {string} enemyType + * @param {*} cts CancellationTokenSource + * @param {{x:number,y:number}|null} battlePointCoords + * @returns {Promise<"success"|"failure"|"out_of_area"|"cancelled">} + */ +async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝团", cts = null, battlePointCoords = null) { // 战斗结果 OCR 判定: // - 返回 "success":识别到成功关键字/特殊条件 // - 返回 "failure":识别到失败关键字 @@ -861,15 +1171,18 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝 let notFind = 0; while (Date.now() - fightStartTime < timeout) { + if (!cts) cts = new CancellationTokenSource(); if (isCtsCancellationRequested(cts)) { return "cancelled"; } let capture = null; + let result = null; + let result2 = null; try { capture = captureGameRegion(); // 沿用最初版写死的 OCR 框(1080p 下的“事件完成”识别区域) - let result = capture.find(RecognitionObject.ocr(850, 150, 200, 80)); - let result2 = capture.find(RecognitionObject.ocr(0, 200, 300, 300)); + result = capture.find(RecognitionObject.ocr(850, 150, 200, 80)); + result2 = capture.find(RecognitionObject.ocr(0, 200, 300, 300)); let text = result && result.text ? String(result.text) : ""; text = text ? text.replace(/\s+/g, "") : ""; let text2 = result2 && result2.text ? String(result2.text) : ""; @@ -946,8 +1259,9 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝 // 出错后继续循环,不进行额外嵌套处理 } finally { - try { capture?.dispose?.(); } catch { } - try { capture?.Dispose?.(); } catch { } + safeDispose(result); + safeDispose(result2); + safeDispose(capture); } // 统一的检查间隔 @@ -956,7 +1270,7 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝 log.warn("在超时时间内未检测到战斗结果"); cts.cancel(); // 取消任务 - throw new Error("战斗超时,未检测到结果"); + throw createScriptError(ERROR_CODES.BATTLE_TIMEOUT, ERR_MESSAGES.BATTLE_TIMEOUT); } /** diff --git a/repo/js/AutoFriendshipFight/manifest.json b/repo/js/AutoFriendshipFight/manifest.json index 455b727bf..924e55230 100644 --- a/repo/js/AutoFriendshipFight/manifest.json +++ b/repo/js/AutoFriendshipFight/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "战斗好感:自动好感度&卡时间", - "version": "1.5.2", + "version": "1.5.3", "bgi_version": "0.45.1", "tags": [ "好感", diff --git a/repo/js/AutoFriendshipFight/settings.json b/repo/js/AutoFriendshipFight/settings.json index d12e00ae9..3f812e171 100644 --- a/repo/js/AutoFriendshipFight/settings.json +++ b/repo/js/AutoFriendshipFight/settings.json @@ -26,7 +26,7 @@ { "name": "disableAsyncFight", "type": "checkbox", - "label": "禁用容易导致问题的异步战斗,改为正常的战斗结束逻辑,勾选后需要在配置组中正常配置战斗结束检测" + "label": "禁用异步战斗(更稳定;需配置“战斗结束检测”;对盗宝团无效)" }, { "name": "use1000Stars",