mirror of
https://github.com/babalae/bettergi-scripts-list.git
synced 2026-05-06 00:16:03 +08:00
改进 AutoFriendshipFight 的稳定性与错误处理 (#3161)
* 改进 AutoFriendshipFight 的稳定性与错误处理 改进 AutoFriendshipFight 的稳定性与错误处理: - 新增 swimConsecutiveLimit 设置项,并通过 swim_stats.json 记录每日/连续触发“游泳”事件的次数。 - 重构战斗流程: runBattlePathAndFight 、 executeBattleTasks 、 waitForBattleResult 现在返回明确的状态(success/failure/out_of_area/cancelled),并更好地处理取消/超时等情况。 - 改进 OCR 与资源管理:复用并释放经验/摩拉模板 Mat(exp/mora),安全释放截图/OCR 对象,减少循环中的资源泄露风险。 - 增加 AutoPath 封装与安全的文件/路径工具( fileExistsSafe 、 normalizePathForCompare ),避免路径执行/ runFile 出错时出现未处理异常。 - 增加通用工具:安全读取地图坐标、取消检测辅助函数、每日游泳统计的加载/保存/重置,以及战斗点缓存等。 - 其它修复:超时参数校验、正确声明 runTimes 、改进切队逻辑,以及更健壮的日志与通知输出等。 这些改动旨在防止后台残留导致的资源占用问题,更好地检测与处理游泳相关失败,并让战斗/OCR 循环更确定、更可恢复。 * Change fight timeout default from 120 to 30 seconds * Update fight timeout default value to 120 seconds * 更改版本号
This commit is contained in:
@@ -1,13 +1,37 @@
|
||||
// ----------------------------
|
||||
// AutoFriendshipFight 主脚本
|
||||
// - 通过 OCR 识别“突发事件”任务文本来判定是否触发
|
||||
// - 通过地图路径文件导航到触发点/战斗点
|
||||
// - 通过自动战斗任务完成战斗,并用 OCR 判定胜负/是否离开触发区域
|
||||
// - 可选:后台检测是否出现经验/摩拉图标,用于提前终止循环
|
||||
// ----------------------------
|
||||
|
||||
// 默认配置(当 settings.json 未设置或设置非法时使用)
|
||||
const DEFAULT_RUNS = 10;
|
||||
const DEFAULT_OCR_TIMEOUT_SECONDS = 10;
|
||||
const DEFAULT_FIGHT_TIMEOUT_SECONDS = 120;
|
||||
const DEFAULT_SWIM_CONSECUTIVE_LIMIT = 5;
|
||||
|
||||
// 掉落检测状态
|
||||
// detectedExpOrMora:上一轮是否识别到经验/摩拉图标(用于判断“连续两轮都没有掉落则停”)
|
||||
// NoExpOrMoraCount:连续“没有识别到掉落”的计数器
|
||||
// running:后台循环 detectExpOrMora 的停止开关(必须在任何退出路径上关闭,避免后台占用)
|
||||
let detectedExpOrMora = true;
|
||||
let NoExpOrMoraCount = 0;
|
||||
let running = true;
|
||||
|
||||
// 默认突发任务 OCR 关键词(敌人配置未提供时使用)
|
||||
const DEFAULT_OCR_KEYWORDS = ["突发", "任务", "打倒", "消灭", "敌人", "所有"];
|
||||
|
||||
// 各敌人类型的参数配置
|
||||
// - ocrKeywords:用于 detectTaskTrigger / waitForBattleResult 的 OCR 关键字
|
||||
// - triggerPoint:触发点坐标(用于到位校验与二次导航)
|
||||
// - targetCoords:战斗点坐标(用于到位校验)
|
||||
// - preparePath:准备路径(通常是传送点->触发区域附近)
|
||||
// - postBattlePath:战斗后补充路径(如拾取/对话)
|
||||
// - failReturnPath:失败后的回退路径(通常回到准备点/触发点)
|
||||
// - failReturnSleepMs:失败回退后额外等待(给加载/稳定留时间)
|
||||
// - initialDelayMs:首轮额外等待(给地图/加载/触发留时间)
|
||||
const ENEMY_CONFIG = {
|
||||
"愚人众": {
|
||||
ocrKeywords: ["买卖", "不成", "正义存", "愚人众", "禁止", "危险", "运输", "打倒", "盗宝团", "丘丘人", "今晚", "伙食", "所有人"],
|
||||
@@ -46,48 +70,102 @@ const ENEMY_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
const expRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/exp.png"), 74, 341, 207 - 74, 803 - 341);
|
||||
// 经验/摩拉模板匹配资源
|
||||
// 这里保留 Mat 引用,便于脚本结束时主动释放,降低长时间运行的资源占用风险
|
||||
let expMat = file.ReadImageMatSync("assets/exp.png");
|
||||
const expRo = RecognitionObject.TemplateMatch(expMat, 74, 341, 207 - 74, 803 - 341);
|
||||
expRo.Threshold = 0.85;
|
||||
expRo.Use3Channels = true;
|
||||
expRo.InitTemplate();
|
||||
|
||||
const moraRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/mora.png"), 74, 341, 207 - 74, 803 - 341);
|
||||
let moraMat = file.ReadImageMatSync("assets/mora.png");
|
||||
const moraRo = RecognitionObject.TemplateMatch(moraMat, 74, 341, 207 - 74, 803 - 341);
|
||||
moraRo.Threshold = 0.85;
|
||||
moraRo.Use3Channels = true;
|
||||
moraRo.InitTemplate();
|
||||
|
||||
(async function () {
|
||||
const startTime = Date.now();
|
||||
// 启用自动拾取的实时任务
|
||||
if (convertToTrueIfNotBoolean(settings.pickupMode)) {
|
||||
dispatcher.addTimer(new RealtimeTimer("AutoPick"));
|
||||
log.info("已 启用 自动拾取任务");
|
||||
} else {
|
||||
log.info("已 禁用 自动拾取任务");
|
||||
try {
|
||||
// 可选:启用自动拾取实时任务
|
||||
if (convertToTrueIfNotBoolean(settings.pickupMode)) {
|
||||
dispatcher.addTimer(new RealtimeTimer("AutoPick"));
|
||||
log.info("已 启用 自动拾取任务");
|
||||
} else {
|
||||
log.info("已 禁用 自动拾取任务");
|
||||
}
|
||||
// 修复点:runTimes 必须用 let/const 声明,避免污染全局变量
|
||||
let runTimes = await calulateRunTimes();
|
||||
await switchPartyIfNeeded(settings.partyName);
|
||||
|
||||
// 选择敌人类型(默认盗宝团)
|
||||
const enemyType = settings.enemyType || "盗宝团";
|
||||
log.info(`当前选择的敌人类型: ${enemyType}`);
|
||||
log.info(`${enemyType}好感开始...`);
|
||||
|
||||
// 敌人准备流程(部分敌人需要先走准备路径/清理)
|
||||
await prepareForEnemy(enemyType);
|
||||
// 超时设置校验(非法则回退默认值)
|
||||
const ocrTimeout = validateTimeoutSetting(settings.ocrTimeout, DEFAULT_OCR_TIMEOUT_SECONDS, "OCR");
|
||||
const fightTimeout = validateTimeoutSetting(settings.fightTimeout, DEFAULT_FIGHT_TIMEOUT_SECONDS, "战斗");
|
||||
|
||||
// 主循环入口
|
||||
await AutoFriendshipDev(runTimes, ocrTimeout, fightTimeout, enemyType);
|
||||
log.info(`${enemyType}好感运行总时长:${LogTimeTaken(startTime)}`);
|
||||
} catch (error) {
|
||||
if (isCancellationError(error)) {
|
||||
resetDailySwimConsecutiveIfNeeded();
|
||||
log.info("脚本已取消");
|
||||
return;
|
||||
}
|
||||
log.error(`脚本运行出错: ${error && error.message ? error.message : error}`);
|
||||
notification.error(`脚本运行出错: ${error && error.message ? error.message : error}`);
|
||||
} finally {
|
||||
// 修复点:无论成功/失败/异常,都必须停止后台循环与释放模板资源
|
||||
running = false;
|
||||
try { expMat?.Dispose?.(); } catch { }
|
||||
try { moraMat?.Dispose?.(); } catch { }
|
||||
expMat = null;
|
||||
moraMat = null;
|
||||
}
|
||||
runTimes = await calulateRunTimes();
|
||||
await switchPartyIfNeeded(settings.partyName);
|
||||
|
||||
// 获取敌人类型设置,默认为盗宝团
|
||||
const enemyType = settings.enemyType || "盗宝团";
|
||||
log.info(`当前选择的敌人类型: ${enemyType}`);
|
||||
log.info(`${enemyType}好感开始...`);
|
||||
|
||||
await prepareForEnemy(enemyType);
|
||||
// 验证超时设置
|
||||
const ocrTimeout = validateTimeoutSetting(settings.ocrTimeout, DEFAULT_OCR_TIMEOUT_SECONDS, "OCR");
|
||||
const fightTimeout = validateTimeoutSetting(settings.fightTimeout, DEFAULT_FIGHT_TIMEOUT_SECONDS, "战斗");
|
||||
|
||||
// 好感循环开始
|
||||
await AutoFriendshipDev(runTimes, ocrTimeout, fightTimeout, enemyType);
|
||||
log.info(`${enemyType}好感运行总时长:${LogTimeTaken(startTime)}`);
|
||||
})();
|
||||
|
||||
function convertToTrueIfNotBoolean(value) {
|
||||
// settings 里可能是 undefined/字符串等非布尔值,此处按“未设置则视为开启”处理
|
||||
return typeof value === 'boolean' ? value : true;
|
||||
}
|
||||
|
||||
function isCancellationError(error) {
|
||||
const msg = error && error.message ? String(error.message) : "";
|
||||
return msg.includes("取消自动任务") || msg.includes("A task was canceled.");
|
||||
}
|
||||
|
||||
function isCtsCancellationRequested(cts) {
|
||||
try {
|
||||
const v =
|
||||
cts?.isCancellationRequested ??
|
||||
cts?.IsCancellationRequested ??
|
||||
cts?.token?.isCancellationRequested ??
|
||||
cts?.token?.IsCancellationRequested;
|
||||
return v === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeGetPositionFromMap() {
|
||||
try {
|
||||
return genshin.getPositionFromMap();
|
||||
} catch (error) {
|
||||
if (isCancellationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEnemyConfig(enemyType) {
|
||||
// 根据敌人类型返回配置对象(不存在则返回空对象,调用方负责兜底)
|
||||
return ENEMY_CONFIG[enemyType] || {};
|
||||
}
|
||||
|
||||
@@ -106,6 +184,7 @@ async function prepareForEnemy(enemyType) {
|
||||
}
|
||||
|
||||
async function runPostBattle(enemyType) {
|
||||
// 战斗后处理:按敌人配置执行附加路径;部分敌人需要对话交互
|
||||
const { postBattlePath } = getEnemyConfig(enemyType);
|
||||
if (postBattlePath) {
|
||||
await AutoPath(postBattlePath);
|
||||
@@ -121,16 +200,179 @@ async function runPostBattle(enemyType) {
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverAfterFailure(enemyType, skipTp = false) {
|
||||
if (!skipTp) {
|
||||
await genshin.tpToStatueOfTheSeven();
|
||||
}
|
||||
const { failReturnPath, failReturnSleepMs, preparePath } = getEnemyConfig(enemyType);
|
||||
if (failReturnPath) {
|
||||
await AutoPath(failReturnPath);
|
||||
} else if (preparePath) {
|
||||
await AutoPath(preparePath);
|
||||
} else {
|
||||
await AutoPath(`${enemyType}-触发点`);
|
||||
}
|
||||
if (failReturnSleepMs) {
|
||||
await sleep(failReturnSleepMs);
|
||||
}
|
||||
}
|
||||
// 执行 path 任务
|
||||
async function AutoPath(locationName) {
|
||||
// 统一包装路径执行:避免 runFile 抛错导致整个脚本中断
|
||||
try {
|
||||
const filePath = `assets/AutoPath/${locationName}.json`;
|
||||
await pathingScript.runFile(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isCancellationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
log.error(`执行 ${locationName} 路径时发生错误: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTodayKey() {
|
||||
const d = new Date();
|
||||
const yyyy = String(d.getFullYear());
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(p) {
|
||||
if (!p || typeof p !== "string") return "";
|
||||
return p.replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
function fileExistsSafe(filePath) {
|
||||
try {
|
||||
if (!filePath || typeof filePath !== "string") return false;
|
||||
if (file.isFolder(filePath)) return false;
|
||||
const normalized = normalizePathForCompare(filePath);
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
const name = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
const items = file.readPathSync(dir);
|
||||
if (!items || items.length === 0) return false;
|
||||
for (const item of items) {
|
||||
const n = normalizePathForCompare(item);
|
||||
if (n === normalized) return true;
|
||||
const nLastSlash = n.lastIndexOf("/");
|
||||
const nName = nLastSlash >= 0 ? n.slice(nLastSlash + 1) : n;
|
||||
if (nName === name) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadDailySwimStats() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
const battlePointCache = new Map();
|
||||
|
||||
function getBattlePointFromBattlePath(enemyType) {
|
||||
if (battlePointCache.has(enemyType)) {
|
||||
return battlePointCache.get(enemyType);
|
||||
}
|
||||
const filePath = `assets/AutoPath/${enemyType}-战斗点.json`;
|
||||
let point = null;
|
||||
try {
|
||||
if (fileExistsSafe(filePath)) {
|
||||
const raw = file.readTextSync(filePath);
|
||||
const parsed = JSON.parse(raw);
|
||||
const positions = parsed && Array.isArray(parsed.positions) ? parsed.positions : null;
|
||||
if (positions && positions.length > 0) {
|
||||
const last = positions[positions.length - 1];
|
||||
if (last && Number.isFinite(Number(last.x)) && Number.isFinite(Number(last.y))) {
|
||||
point = { x: Number(last.x), y: Number(last.y) };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
if (!point) {
|
||||
point = getTargetCoordinates(enemyType);
|
||||
}
|
||||
battlePointCache.set(enemyType, point);
|
||||
return point;
|
||||
}
|
||||
|
||||
async function runBattlePathAndFight(enemyType) {
|
||||
const filePath = `assets/AutoPath/${enemyType}-战斗点.json`;
|
||||
if (!fileExistsSafe(filePath)) {
|
||||
throw new Error(`缺少战斗点路径文件: ${filePath}`);
|
||||
}
|
||||
const raw = file.readTextSync(filePath);
|
||||
const parsed = JSON.parse(raw);
|
||||
const positions = parsed && Array.isArray(parsed.positions) ? parsed.positions : null;
|
||||
if (!positions || positions.length === 0) {
|
||||
throw new Error(`战斗点路径文件无有效 positions: ${filePath}`);
|
||||
}
|
||||
|
||||
const last = positions[positions.length - 1];
|
||||
const clonedPositions = positions.map(p => ({ ...p }));
|
||||
clonedPositions[clonedPositions.length - 1] = {
|
||||
...last,
|
||||
action: "fight",
|
||||
action_params: last && typeof last.action_params === "string" ? last.action_params : ""
|
||||
};
|
||||
|
||||
const payload = {
|
||||
...parsed,
|
||||
positions: clonedPositions
|
||||
};
|
||||
await pathingScript.run(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// 计算运行时长
|
||||
function LogTimeTaken(startTimeParam) {
|
||||
const currentTime = Date.now();
|
||||
@@ -156,7 +398,11 @@ function CalculateEstimatedCompletion(startTime, current, total) {
|
||||
async function navigateToTriggerPoint(enemyType) {
|
||||
await AutoPath(`${enemyType}-触发点`);
|
||||
const triggerPoint = getTriggerPoint(enemyType);
|
||||
const pos = genshin.getPositionFromMap();
|
||||
if (!triggerPoint) {
|
||||
log.warn(`未配置 ${enemyType} 的 triggerPoint,跳过触发点距离校验`);
|
||||
return;
|
||||
}
|
||||
const pos = safeGetPositionFromMap();
|
||||
|
||||
if (pos) {
|
||||
const distance = Math.sqrt(Math.pow(pos.x - triggerPoint.x, 2) + Math.pow(pos.y - triggerPoint.y, 2));
|
||||
@@ -171,24 +417,44 @@ async function navigateToTriggerPoint(enemyType) {
|
||||
|
||||
// OCR检测突发任务
|
||||
async function detectTaskTrigger(ocrTimeout, enemyType) {
|
||||
// 修复点:OCR 的截图对象与 findMulti 返回对象可能持有底层资源
|
||||
// 必须在 finally 中释放,避免长时间循环导致资源累积
|
||||
const ocrKeywords = getOcrKeywords(enemyType);
|
||||
let ocrStatus = false;
|
||||
let ocrStartTime = Date.now();
|
||||
|
||||
while (Date.now() - ocrStartTime < ocrTimeout * 1000 && !ocrStatus) {
|
||||
let captureRegion = captureGameRegion();
|
||||
let resList = captureRegion.findMulti(RecognitionObject.ocr(0, 200, 300, 300));
|
||||
captureRegion.dispose();
|
||||
for (let o = 0; o < resList.count; o++) {
|
||||
let res = resList[o];
|
||||
for (let keyword of ocrKeywords) {
|
||||
if (res.text.includes(keyword)) {
|
||||
ocrStatus = true;
|
||||
log.info("检测到突发任务触发");
|
||||
break;
|
||||
let captureRegion = null;
|
||||
let resList = null;
|
||||
try {
|
||||
captureRegion = captureGameRegion();
|
||||
resList = captureRegion.findMulti(RecognitionObject.ocr(0, 200, 300, 300));
|
||||
for (let o = 0; o < resList.count; o++) {
|
||||
let res = resList[o];
|
||||
try {
|
||||
for (let keyword of ocrKeywords) {
|
||||
if (res && res.text && String(res.text).includes(keyword)) {
|
||||
ocrStatus = true;
|
||||
log.info("检测到突发任务触发");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ocrStatus) break;
|
||||
} finally {
|
||||
try { res?.Dispose?.(); } catch { }
|
||||
try { res?.dispose?.(); } catch { }
|
||||
}
|
||||
}
|
||||
if (ocrStatus) break;
|
||||
} catch (error) {
|
||||
if (isCancellationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
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 { }
|
||||
}
|
||||
|
||||
if (!ocrStatus) {
|
||||
@@ -200,11 +466,11 @@ async function detectTaskTrigger(ocrTimeout, enemyType) {
|
||||
}
|
||||
|
||||
// 等待角色到达目标位置
|
||||
async function waitForTargetPosition(pathTask, targetCoords, maxWaitTime = 15000) {
|
||||
async function waitForTargetPosition(pathTask, targetCoords, maxWaitTime = 15000, maxDistance = 5) {
|
||||
const waitStartTime = Date.now();
|
||||
const maxDistance = 5;
|
||||
let isNearTarget = false;
|
||||
let pathTaskFinished = false;
|
||||
let sawPosition = false;
|
||||
|
||||
// 监控路径任务完成
|
||||
pathTask.then(() => {
|
||||
@@ -212,13 +478,18 @@ async function waitForTargetPosition(pathTask, targetCoords, maxWaitTime = 15000
|
||||
log.info("路径任务已完成");
|
||||
}).catch(error => {
|
||||
pathTaskFinished = true;
|
||||
if (isCancellationError(error)) {
|
||||
log.info("路径任务已取消");
|
||||
return;
|
||||
}
|
||||
log.error(`路径任务出错: ${error}`);
|
||||
});
|
||||
|
||||
// 等待角色到达目标位置或超时
|
||||
while (!isNearTarget && !pathTaskFinished && (Date.now() - waitStartTime < maxWaitTime)) {
|
||||
const pos = genshin.getPositionFromMap();
|
||||
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;
|
||||
@@ -229,57 +500,92 @@ async function waitForTargetPosition(pathTask, targetCoords, maxWaitTime = 15000
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
return { isNearTarget, pathTaskFinished };
|
||||
return { isNearTarget, pathTaskFinished, sawPosition };
|
||||
}
|
||||
|
||||
// 执行战斗任务(并发执行战斗和结果检测)
|
||||
async function executeBattleTasks(fightTimeout, enemyType, cts) {
|
||||
// 修复点:
|
||||
// - 以前只要“检测任务 fulfilled”就当成功;现在明确区分 success/failure/out_of_area 等状态
|
||||
// - 取消只意味着停止战斗任务,不等价于“本轮战斗成功”
|
||||
log.info("开始战斗!");
|
||||
|
||||
let battleTask;
|
||||
let battleResult = null;
|
||||
let fightResult = null;
|
||||
let battleDetectTask = null;
|
||||
let results = null;
|
||||
try {
|
||||
if (settings.disableAsyncFight) {
|
||||
battleTask = await dispatcher.RunTask(new SoloTask("AutoFight"));
|
||||
return { success: true };
|
||||
// 同步战斗模式:由宿主的战斗结束检测/策略决定何时返回
|
||||
await dispatcher.RunTask(new SoloTask("AutoFight"));
|
||||
const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000);
|
||||
const graceMs = Math.min(8000, maxDetectMs);
|
||||
try {
|
||||
const status = await waitForBattleResult(graceMs, enemyType, new CancellationTokenSource());
|
||||
return { success: status === "success", status };
|
||||
} catch (error) {
|
||||
if (error && error.message && String(error.message).includes("战斗超时")) {
|
||||
return { success: false, status: "auto_fight_ended" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 异步战斗模式:并发启动战斗 + OCR 检测结果;检测到结果后取消战斗任务
|
||||
battleTask = dispatcher.RunTask(new SoloTask("AutoFight"), cts);
|
||||
battleDetectTask = waitForBattleResult(fightTimeout * 1000, enemyType, cts);
|
||||
// 使用 Promise.allSettled 而不是 Promise.all,这样可以处理部分成功的情况
|
||||
results = await Promise.allSettled([
|
||||
battleTask.catch(error => {
|
||||
// 如果是取消错误(成功检测后的正常取消),不算真正的错误
|
||||
if (error.message && error.message.includes("取消自动任务")) {
|
||||
log.info("战斗任务已被成功取消");
|
||||
return { cancelled: true };
|
||||
}
|
||||
throw error; // 其他错误继续抛出
|
||||
}),
|
||||
battleDetectTask
|
||||
const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000);
|
||||
const graceMs = Math.min(8000, maxDetectMs);
|
||||
|
||||
const battleWrapped = battleTask
|
||||
.then(value => ({ kind: "battle_fulfilled", value }))
|
||||
.catch(error => ({ kind: "battle_rejected", error }));
|
||||
|
||||
const detectWrapped = battleDetectTask
|
||||
.then(status => ({ kind: "detect_fulfilled", status }))
|
||||
.catch(error => ({ kind: "detect_rejected", error }));
|
||||
|
||||
const first = await Promise.race([battleWrapped, detectWrapped]);
|
||||
|
||||
if (first.kind === "detect_fulfilled") {
|
||||
log.info("战斗检测任务完成");
|
||||
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;
|
||||
}
|
||||
|
||||
const second = await Promise.race([
|
||||
detectWrapped,
|
||||
sleep(graceMs).then(() => ({ kind: "detect_timeout" }))
|
||||
]);
|
||||
|
||||
battleResult = results[0];
|
||||
fightResult = results[1];
|
||||
|
||||
// 检查检测任务是否成功
|
||||
if (fightResult.status === 'fulfilled') {
|
||||
if (second.kind === "detect_fulfilled") {
|
||||
log.info("战斗检测任务完成");
|
||||
return { success: true, battleResult: battleResult.value, fightResult: fightResult.value };
|
||||
} else if (fightResult.status === 'rejected') {
|
||||
throw fightResult.reason;
|
||||
return { success: second.status === "success", status: second.status };
|
||||
}
|
||||
if (second.kind === "detect_rejected") {
|
||||
throw second.error;
|
||||
}
|
||||
|
||||
return { success: false, status: "auto_fight_ended" };
|
||||
}
|
||||
} catch (error) {
|
||||
// 过滤掉正常的取消错误
|
||||
if (error.message && error.message.includes("取消自动任务")) {
|
||||
log.info("战斗任务正常取消(战斗检测成功)");
|
||||
return { success: true, cancelled: true };
|
||||
if (isCancellationError(error)) {
|
||||
log.info("战斗任务已取消");
|
||||
return { success: false, status: "cancelled" };
|
||||
}
|
||||
log.error(`战斗执行过程中出错: ${error.message}`);
|
||||
await genshin.tpToStatueOfTheSeven();
|
||||
const msg = error && error.message ? String(error.message) : "";
|
||||
if (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 {
|
||||
// 确保战斗任务被等待完成(即使被取消)
|
||||
if (battleTask) {
|
||||
@@ -287,7 +593,7 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) {
|
||||
await battleTask;
|
||||
} catch (error) {
|
||||
// 忽略 finally 块中的取消错误
|
||||
if (!error.message || !error.message.includes("取消自动任务")) {
|
||||
if (!isCancellationError(error)) {
|
||||
log.warn(`清理战斗任务时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -298,6 +604,12 @@ async function executeBattleTasks(fightTimeout, enemyType, cts) {
|
||||
|
||||
// 执行单次好感任务循环
|
||||
async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout, enemyType) {
|
||||
// 单轮流程:
|
||||
// 1) 导航到触发点附近
|
||||
// 2) 通过 OCR 判断是否已触发突发任务
|
||||
// 3) 导航到战斗点(避免与失败回退路径并发冲突)
|
||||
// 4) 启动战斗 + OCR 判定胜负/是否离开区域
|
||||
// 5) 成功则执行战斗后流程;失败则回退到准备/触发点并进入下一轮
|
||||
// 导航到触发点
|
||||
await navigateToTriggerPoint(enemyType);
|
||||
const { initialDelayMs } = getEnemyConfig(enemyType);
|
||||
@@ -327,23 +639,48 @@ async function executeSingleFriendshipRound(roundIndex, ocrTimeout, fightTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// 启动路径导航任务(异步)
|
||||
let pathTask = AutoPath(`${enemyType}-战斗点`);
|
||||
const ocrStatus = await detectTaskTrigger(ocrTimeout, enemyType);
|
||||
|
||||
if (!ocrStatus) {
|
||||
// 本轮未检测到突发任务:按设计直接结束整个脚本循环
|
||||
notification.send(`未识别到突发任务,${enemyType}好感结束`);
|
||||
log.info(`未识别到突发任务,${enemyType}好感结束`);
|
||||
await pathTask; // 防止报错
|
||||
resetDailySwimConsecutiveIfNeeded();
|
||||
return false; // 返回 false 表示需要终止循环
|
||||
}
|
||||
|
||||
const cts = new CancellationTokenSource();
|
||||
// 修复点:先确认触发,再执行导航;避免“未触发时仍去战斗点”以及失败时多路径并发
|
||||
const battlePointCoords = getBattlePointFromBattlePath(enemyType);
|
||||
if (!battlePointCoords) {
|
||||
throw new Error(`未配置 ${enemyType} 的 targetCoords`);
|
||||
}
|
||||
|
||||
const targetCoords = getTargetCoordinates(enemyType);
|
||||
await waitForTargetPosition(pathTask, targetCoords);
|
||||
await executeBattleTasks(fightTimeout, enemyType, cts);
|
||||
await pathTask;
|
||||
const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000);
|
||||
const battleDetectCts = new CancellationTokenSource();
|
||||
const battleDetectPromise = waitForBattleResult(maxDetectMs, enemyType, battleDetectCts, battlePointCoords);
|
||||
|
||||
try {
|
||||
log.info("开始战斗!");
|
||||
await runBattlePathAndFight(enemyType);
|
||||
} 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);
|
||||
log.warn(`检测到游泳异常,今日累计 ${totalSwimCount} 次,连续 ${consecutiveSwimCount}/${consecutiveLimit} 次`);
|
||||
if (exceeded) {
|
||||
throw new Error(`当日连续触发游泳已达 ${consecutiveLimit} 次,战斗策略或配队严重不合理`);
|
||||
}
|
||||
throw new Error("检测到游泳且自动回七天神像,视为本轮失败");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const battleStatus = await battleDetectPromise;
|
||||
if (battleStatus === "cancelled") {
|
||||
throw new Error("战斗任务已取消");
|
||||
}
|
||||
resetDailySwimConsecutiveIfNeeded();
|
||||
|
||||
await runPostBattle(enemyType);
|
||||
|
||||
@@ -362,35 +699,60 @@ function logProgress(startTime, currentRound, totalRounds) {
|
||||
|
||||
// 执行 N 次好感任务并输出日志 - 重构后的主函数
|
||||
async function AutoFriendshipDev(times, ocrTimeout, fightTimeout, enemyType = "盗宝团") {
|
||||
// 主循环:执行指定次数或在 detectTaskTrigger 判定“未触发”时提前退出
|
||||
// 修复点:finally 中统一关闭 running 并等待 detectExpOrMoraTask 退出,避免后台循环残留
|
||||
const startFirstTime = Date.now();
|
||||
let detectExpOrMoraTask;
|
||||
if (settings.loopTillNoExpOrMora) {
|
||||
detectExpOrMoraTask = detectExpOrMora();
|
||||
}
|
||||
for (let i = 0; i < times; i++) {
|
||||
try { await sleep(1); } catch (e) { break; }
|
||||
try {
|
||||
const success = await executeSingleFriendshipRound(i, ocrTimeout, fightTimeout, enemyType);
|
||||
if (!success)
|
||||
break;
|
||||
logProgress(startFirstTime, i, times);
|
||||
} catch (error) {
|
||||
log.error(`第 ${i + 1} 轮好感任务失败: ${error.message}`);
|
||||
// 如果是战斗超时错误,直接终止整个任务
|
||||
if (error.message && error.message.includes("战斗超时")) {
|
||||
throw error;
|
||||
} // 战斗超时就是需要取消任务
|
||||
continue;
|
||||
let cancelled = false;
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
try {
|
||||
if (settings.loopTillNoExpOrMora) {
|
||||
detectExpOrMoraTask = detectExpOrMora();
|
||||
}
|
||||
for (let i = 0; i < times; i++) {
|
||||
try { await sleep(1); } catch (e) { break; }
|
||||
try {
|
||||
const success = await executeSingleFriendshipRound(i, ocrTimeout, fightTimeout, enemyType);
|
||||
if (!success)
|
||||
break;
|
||||
successCount++;
|
||||
logProgress(startFirstTime, i, times);
|
||||
} catch (error) {
|
||||
if (isCancellationError(error)) {
|
||||
cancelled = true;
|
||||
throw error;
|
||||
}
|
||||
failureCount++;
|
||||
log.error(`第 ${i + 1} 轮好感任务失败: ${error.message}`);
|
||||
if (error.message && error.message.includes("战斗超时")) {
|
||||
throw error;
|
||||
}
|
||||
if (error.message && error.message.includes("当日连续触发游泳")) {
|
||||
throw error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
log.info(`${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} 次`);
|
||||
}
|
||||
running = false;
|
||||
if (detectExpOrMoraTask) {
|
||||
try { await detectExpOrMoraTask; } catch { }
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
if (settings.loopTillNoExpOrMora) {
|
||||
await detectExpOrMoraTask;
|
||||
}
|
||||
log.info(`${enemyType} 好感已完成`);
|
||||
}
|
||||
|
||||
async function detectExpOrMora() {
|
||||
// 后台循环:通过模板匹配检测经验/摩拉图标
|
||||
// 注意:该循环依赖 running 停止;必须保证任何退出路径都会把 running=false
|
||||
while (running) {
|
||||
try { await sleep(1); } catch (e) { break; }
|
||||
let gameRegion;
|
||||
@@ -405,14 +767,15 @@ async function detectExpOrMora() {
|
||||
}
|
||||
const res2 = gameRegion.find(moraRo);
|
||||
if (res2.isExist()) {
|
||||
log.info("识别到经验");
|
||||
log.info("识别到摩拉");
|
||||
detectedExpOrMora = true;
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`检测经验和摩拉掉落过程中出现错误 ${e.message}`);
|
||||
} finally {
|
||||
gameRegion?.dispose();
|
||||
try { gameRegion?.dispose?.(); } catch { }
|
||||
try { gameRegion?.Dispose?.(); } catch { }
|
||||
}
|
||||
} else {
|
||||
//无需检测时额外等待200
|
||||
@@ -423,6 +786,7 @@ async function detectExpOrMora() {
|
||||
}
|
||||
|
||||
async function calulateRunTimes() {
|
||||
// 从 settings 读取次数并校验;非法则回退默认值
|
||||
log.info(`'请确保队伍满员,并为队伍配置相应的战斗策略'`);
|
||||
// 计算运行次数
|
||||
let runTimes = Number(settings.runTimes);
|
||||
@@ -443,6 +807,7 @@ function isPositiveInteger(value) {
|
||||
|
||||
// 根据敌人类型获取OCR关键词
|
||||
function getOcrKeywords(enemyType) {
|
||||
// OCR 关键词获取:优先使用敌人类型配置,否则回退到默认关键词
|
||||
const { ocrKeywords } = getEnemyConfig(enemyType);
|
||||
return ocrKeywords || DEFAULT_OCR_KEYWORDS;
|
||||
}
|
||||
@@ -460,6 +825,7 @@ function getTriggerPoint(enemyType) {
|
||||
|
||||
// 验证日期格式
|
||||
async function switchPartyIfNeeded(partyName) {
|
||||
// 可选队伍切换:为空则直接回到主界面,避免停留在菜单/对话等状态
|
||||
if (!partyName) {
|
||||
await genshin.returnMainUi();
|
||||
return;
|
||||
@@ -478,35 +844,51 @@ async function switchPartyIfNeeded(partyName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝团", cts = new CancellationTokenSource()) {
|
||||
let fightStartTime = Date.now();
|
||||
async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝团", cts = new CancellationTokenSource(), battlePointCoords = null) {
|
||||
// 战斗结果 OCR 判定:
|
||||
// - 返回 "success":识别到成功关键字/特殊条件
|
||||
// - 返回 "failure":识别到失败关键字
|
||||
// - 返回 "out_of_area":连续多次识别不到事件关键字,认为离开触发区域
|
||||
// - 超时:抛出 Error("战斗超时,未检测到结果")
|
||||
//
|
||||
// 修复点:这里不做 tp/跑路径,只做“结果判定 + cts.cancel()”
|
||||
// 失败后的恢复统一在 executeSingleFriendshipRound 中串行处理,避免路径并发冲突
|
||||
const fightStartTime = Date.now();
|
||||
const successKeywords = ["事件", "完成"];
|
||||
const failureKeywords = ["失败"];
|
||||
const eventKeywords = getOcrKeywords(enemyType);
|
||||
const pollIntervalMs = 1000;
|
||||
let notFind = 0;
|
||||
|
||||
while (Date.now() - fightStartTime < timeout) {
|
||||
if (isCtsCancellationRequested(cts)) {
|
||||
return "cancelled";
|
||||
}
|
||||
let capture = null;
|
||||
try {
|
||||
// 简化OCR检测,只使用一个try-catch块
|
||||
let capture = captureGameRegion();
|
||||
capture = captureGameRegion();
|
||||
// 沿用最初版写死的 OCR 框(1080p 下的“事件完成”识别区域)
|
||||
let result = capture.find(RecognitionObject.ocr(850, 150, 200, 80));
|
||||
let result2 = capture.find(RecognitionObject.ocr(0, 200, 300, 300));
|
||||
let text = result.text;
|
||||
let text2 = result2.text;
|
||||
capture.dispose();
|
||||
let text = result && result.text ? String(result.text) : "";
|
||||
text = text ? text.replace(/\s+/g, "") : "";
|
||||
let text2 = result2 && result2.text ? String(result2.text) : "";
|
||||
text2 = text2 ? text2.replace(/\s+/g, "") : "";
|
||||
if (enemyType === "蕈兽" && text2.includes("维沙瓦")) {
|
||||
log.info("战斗结果:成功");
|
||||
cts.cancel();
|
||||
return true;
|
||||
return "success";
|
||||
}
|
||||
|
||||
// 检查成功关键词
|
||||
for (let keyword of successKeywords) {
|
||||
if (text.includes(keyword)) {
|
||||
log.info("检测到战斗成功关键词: {0}", keyword);
|
||||
log.info("战斗结果:成功");
|
||||
cts.cancel(); // 取消任务
|
||||
return true;
|
||||
// 检查成功关键词:只要开战后识别到“事件/完成”等关键词即可认为本轮结束
|
||||
if (Date.now() - fightStartTime >= 2000) {
|
||||
for (let keyword of successKeywords) {
|
||||
if (text.includes(keyword)) {
|
||||
log.info("检测到战斗成功关键词: {0}", keyword);
|
||||
log.info("战斗结果:成功");
|
||||
cts.cancel(); // 取消任务
|
||||
return "success";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,14 +896,8 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝
|
||||
for (let keyword of failureKeywords) {
|
||||
if (text.includes(keyword)) {
|
||||
log.warn("检测到战斗失败关键词: {0}", keyword);
|
||||
log.warn("战斗结果:失败,回到七天神像重试");
|
||||
cts.cancel(); // 取消任务
|
||||
await genshin.tpToStatueOfTheSeven();
|
||||
const { failReturnPath } = getEnemyConfig(enemyType);
|
||||
if (failReturnPath) {
|
||||
await AutoPath(failReturnPath);
|
||||
}
|
||||
return false;
|
||||
return "failure";
|
||||
}
|
||||
}
|
||||
if (enemyType !== "蕈兽") {
|
||||
@@ -541,17 +917,26 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝
|
||||
}
|
||||
|
||||
if (notFind > 10) {
|
||||
let nearBattlePoint = false;
|
||||
if (battlePointCoords && Number.isFinite(Number(battlePointCoords.x)) && Number.isFinite(Number(battlePointCoords.y))) {
|
||||
const pos = safeGetPositionFromMap();
|
||||
if (pos && typeof pos === "object") {
|
||||
const dx = Number(pos.x) - Number(battlePointCoords.x);
|
||||
const dy = Number(pos.y) - Number(battlePointCoords.y);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
nearBattlePoint = Number.isFinite(dist) && dist <= 25;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearBattlePoint) {
|
||||
log.info("触发关键词消失但仍在战斗点附近,视为本轮结束");
|
||||
cts.cancel();
|
||||
return "success";
|
||||
}
|
||||
|
||||
log.warn("不在任务触发区域,战斗失败");
|
||||
cts.cancel(); // 取消任务
|
||||
const { failReturnPath, failReturnSleepMs } = getEnemyConfig(enemyType);
|
||||
if (failReturnPath) {
|
||||
log.warn(`回到${enemyType}准备点`);
|
||||
await AutoPath(failReturnPath);
|
||||
}
|
||||
if (failReturnSleepMs) {
|
||||
await sleep(failReturnSleepMs);
|
||||
}
|
||||
return false;
|
||||
return "out_of_area";
|
||||
|
||||
}
|
||||
}
|
||||
@@ -560,9 +945,13 @@ async function waitForBattleResult(timeout = 2 * 60 * 1000, enemyType = "盗宝
|
||||
log.error("OCR过程中出错: {0}", error);
|
||||
// 出错后继续循环,不进行额外嵌套处理
|
||||
}
|
||||
finally {
|
||||
try { capture?.dispose?.(); } catch { }
|
||||
try { capture?.Dispose?.(); } catch { }
|
||||
}
|
||||
|
||||
// 统一的检查间隔
|
||||
await sleep(1000);
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
log.warn("在超时时间内未检测到战斗结果");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "战斗好感:自动好感度&卡时间",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"bgi_version": "0.45.1",
|
||||
"tags": [
|
||||
"好感",
|
||||
|
||||
@@ -59,5 +59,11 @@
|
||||
"type": "input-text",
|
||||
"label": "战斗超时时间\n【选填,默认为120秒】",
|
||||
"default": 120
|
||||
},
|
||||
{
|
||||
"name": "swimConsecutiveLimit",
|
||||
"type": "input-text",
|
||||
"label": "游泳连续触发阈值\n【选填,默认5;当日连续触发达到阈值后会提示“战斗策略或配队严重不合理”并终止】",
|
||||
"default": 5
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
5
repo/js/AutoFriendshipFight/swim_stats.json
Normal file
5
repo/js/AutoFriendshipFight/swim_stats.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"date": "2026-04-26",
|
||||
"totalSwimCount": 0,
|
||||
"consecutiveSwimCount": 0
|
||||
}
|
||||
Reference in New Issue
Block a user