更新战斗好感js (#3164)

* 更新战斗好感js

尝试优化了一下

* 修正代码

* 补充JSDoc

* Update main.js
This commit is contained in:
火山
2026-04-28 12:31:15 +08:00
committed by GitHub
parent 992dc72691
commit d3165db775
4 changed files with 475 additions and 155 deletions

View File

@@ -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** | 战斗触发检测,异步检测战斗结束 |

View File

@@ -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<void>}
*/
async function prepareForEnemy(enemyType) {
// 清理丘丘人(仅盗宝团需要)
if (settings.qiuQiuRen && enemyType === "盗宝团") {
@@ -183,6 +259,11 @@ async function prepareForEnemy(enemyType) {
}
}
/**
* 战后附加流程:按敌人配置跑 postBattlePath蕈兽包含对话交互。
* @param {string} enemyType
* @returns {Promise<void>}
*/
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<void>}
*/
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<boolean>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>}
*/
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<boolean>} 返回 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<void>}
*/
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<void>}
*/
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<number>}
*/
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<void>}
*/
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);
}
/**

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 1,
"name": "战斗好感:自动好感度&卡时间",
"version": "1.5.2",
"version": "1.5.3",
"bgi_version": "0.45.1",
"tags": [
"好感",

View File

@@ -26,7 +26,7 @@
{
"name": "disableAsyncFight",
"type": "checkbox",
"label": "禁用容易导致问题的异步战斗,改为正常的战斗结束逻辑,勾选后需要在配置组中正常配置战斗结束检测"
"label": "禁用异步战斗(更稳定;需配置“战斗结束检测”;对盗宝团无效)"
},
{
"name": "use1000Stars",