diff --git a/repo/js/背包材料统计/README.md b/repo/js/背包材料统计/README.md index aca28e379..84fc2575d 100644 --- a/repo/js/背包材料统计/README.md +++ b/repo/js/背包材料统计/README.md @@ -1,4 +1,4 @@ -# 背包材料统计 v2.52 +# 背包材料统计 v2.54 作者:吉吉喵 @@ -118,9 +118,10 @@ | 6. 仅 pathing 材料 | 仅扫描 `pathing` 文件夹内的材料,跳过其他分类,大幅缩短扫描时间 | 路径配置完成后开启,提升脚本运行效率 | | 7. 弹窗名 | 不填则默认循环执行 `assets\imageClick` 文件夹下所有弹窗;填写则仅执行指定弹窗 | 推荐默认,需单独适配某类弹窗时填写(例:月卡,复苏) | | 8. 采用的 CD 分类 | 不填则默认执行 `materialsCD` 文件夹内配置的CD分类;填写则仅执行指定CD分类 | 新增材料时,需在该文件夹同步配置CD规则(操作见「四、问题解答Q2」) | -| 9. 采用的识别名单 | 不填则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 新增名单时,需符合配置规则(操作见「四、问题解答Q4」) | -| 10. 超量阈值 | 首次扫描后,超量的路径材料,将从识别名单中剔除,默认5000 | 不推荐9999,怪物材料有几千就够了,采用默认数值,可自动避免爆背包 | -| 11. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 | +| 9. 终止时刻 | 不填则不执行定时终止;路径无时间记录时,会预判路径耗时5分钟,且预留2分钟空闲 | 填写需要按24小时格式(例:4:10) | +| 10. 采用的识别名单 | 不填则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 新增名单时,需符合配置规则(操作见「四、问题解答Q4」) | +| 11. 超量阈值 | 首次扫描后,超量的路径材料,将从识别名单中剔除,默认5000 | 不推荐9999,怪物材料有几千就够了,采用默认数值,可自动避免爆背包 | +| 12. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 | ## 四、注意事项 @@ -129,7 +130,7 @@ 3. **食物识别强制要求**:背包食物界面**第一行必须包含8种食物**(苹果、日落果、星蕈、活化的星蕈、枯焦的星蕈、泡泡桔、烛伞蘑菇、美味的宝石闪闪),缺少则这些食物无法识别; 4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或记录被污染后恢复如初; 5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto; -6. **手动终止运行**:如果要终止JS运行,推荐在当前路径采集前,或者采集完进入背包扫描时终止,以保护当前记录;如果是【取消扫描】模式,不会储存当前记录的材料数目,就随意。 +6. **手动终止运行**:如果要终止JS运行,推荐在当前路径采集到当前材料前,或者采集完进入背包扫描时终止(会在扫描结束后终止),以保护当前记录;如果是【取消扫描】模式,不会储存当前记录的材料数目,就随意。 ## 五、问题解答 @@ -149,9 +150,18 @@ A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\ 添加新材料操作截图2 -### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”“果园.json”)? -A:将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。 - 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。 +### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”、“果园.json”)? +A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。 + 2. 例:看「四、问题解答Q2」,① 把“纳塔食材一条龙”作为标准名,选择一个CD,② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!** + 3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾,将无CD记录!**; + 4. 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。 + 操作参考截图: +
+ 添加新路径文件夹操作截图1 + 添加新路径文件夹操作截图2 + 添加新路径文件夹操作截图2 +
+ ### Q4:如何自定义识别名单? A:1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`); @@ -213,4 +223,5 @@ A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各 | v2.42 | 新增“无路径间扫描”“noRecord模式”(适合成熟路径);新增怪物材料CD文件 | | v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 | | v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 | -| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 | \ No newline at end of file +| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 | +| v2.54 | 自定义设置新增“终止时刻”,修复bug,新增“添加不规范命名的路径文件夹”说明,新增一个“锄地”的怪物路径CD | \ No newline at end of file diff --git a/repo/js/背包材料统计/assets/Pic/Pic14.png b/repo/js/背包材料统计/assets/Pic/Pic14.png new file mode 100644 index 000000000..84be9f70a Binary files /dev/null and b/repo/js/背包材料统计/assets/Pic/Pic14.png differ diff --git a/repo/js/背包材料统计/assets/Pic/Pic15.png b/repo/js/背包材料统计/assets/Pic/Pic15.png new file mode 100644 index 000000000..a0ff74ac7 Binary files /dev/null and b/repo/js/背包材料统计/assets/Pic/Pic15.png differ diff --git a/repo/js/背包材料统计/assets/Pic/Pic16.png b/repo/js/背包材料统计/assets/Pic/Pic16.png new file mode 100644 index 000000000..5979aca2b Binary files /dev/null and b/repo/js/背包材料统计/assets/Pic/Pic16.png differ diff --git a/repo/js/背包材料统计/assets/Scroll.png b/repo/js/背包材料统计/assets/Scroll.png new file mode 100644 index 000000000..44306d256 Binary files /dev/null and b/repo/js/背包材料统计/assets/Scroll.png differ diff --git a/repo/js/背包材料统计/assets/滚轮下翻.json b/repo/js/背包材料统计/assets/滚轮下翻.json index 83995604b..6808800b8 100644 --- a/repo/js/背包材料统计/assets/滚轮下翻.json +++ b/repo/js/背包材料统计/assets/滚轮下翻.json @@ -1,3 +1,3 @@ -{"macroEvents":[{"type":6,"mouseX":0,"mouseY":-120,"time":0}, -{"type":6,"mouseX":0,"mouseY":0,"time":5}], -"info":{"name":"","description":"","x":0,"y":0,"width":1920,"height":1080,"recordDpi":1}} \ No newline at end of file +{"macroEvents":[ +{"type":6,"mouseX":0,"mouseY":-120,"time":25} +],"info":{"name":"","description":"","x":0,"y":0,"width":1920,"height":1080,"recordDpi":1}} \ No newline at end of file diff --git a/repo/js/背包材料统计/lib/autoPick.js b/repo/js/背包材料统计/lib/autoPick.js index 941db67c4..73804d597 100644 --- a/repo/js/背包材料统计/lib/autoPick.js +++ b/repo/js/背包材料统计/lib/autoPick.js @@ -158,29 +158,42 @@ async function findFIcon(recognitionObject, timeout = 10, ra = null) { return { success: true, x: result.x, y: result.y, width: result.width, height: result.height }; } } catch (error) { - log.error(`识别图像时发生异常: ${error.message}`); + log.error(`识别图标异常: ${error.message}`); if (state.cancelRequested) { - break; // 如果请求了取消,则退出循环 + break; } return null; } await sleep(5); // 每次检测间隔 5 毫秒 } if (state.cancelRequested) { - log.info("图像识别任务已取消"); + log.info("图标识别任务已取消"); } return null; } -// 对齐并交互目标 -async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, texttolerance, cachedFrame=null) { +// 定义Scroll.png识别对象(按需求使用TemplateMatch,包含指定范围) +const ScrollRo = RecognitionObject.TemplateMatch( + file.ReadImageMatSync("assets/Scroll.png"), + 1055, 521, 15, 35 // 识别范围:x=1055, y=521, width=15, height=35 +); + +/** + * 对齐并交互目标(直接用findFIcon识别Scroll.png) + * @param {string[]} targetTexts - 待匹配的目标文本列表 + * @param {Object} fDialogueRo - F图标的识别对象 + * @param {Object} textxRange - 文本识别的X轴范围 { min: number, max: number } + * @param {number} texttolerance - 文本与F图标Y轴对齐的容差 + * @param {Object} cachedFrame - 缓存的图像帧(可选) + */ +async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, texttolerance, cachedFrame = null) { let lastLogTime = Date.now(); // 记录每个材料的识别次数(文本+坐标 → 计数) const recognitionCount = new Map(); while (!state.completed && !state.cancelRequested) { const currentTime = Date.now(); - if (currentTime - lastLogTime >= 10000) { // 每5秒记录一次日志 + if (currentTime - lastLogTime >= 10000) { log.info("检测中..."); lastLogTime = currentTime; } @@ -191,13 +204,15 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text // 尝试找到 F 图标 let fRes = await findFIcon(fDialogueRo, 10, cachedFrame); if (!fRes) { - continue; + const scrollRes = await findFIcon(ScrollRo, 10, cachedFrame); // 复用findFIcon函数 + if (scrollRes) { + await keyMouseScript.runFile(`assets/滚轮下翻.json`); // 调用翻滚脚本 + } + continue; // 继续下一轮检测 } // 获取 F 图标的中心点 Y 坐标 let centerYF = fRes.y + fRes.height / 2; - - // 在当前屏幕范围内进行 OCR 识别 let ocrResults = await performOcr(targetTexts, textxRange, { min: fRes.y - 3, max: fRes.y + 37 }, 10, cachedFrame); // 检查所有目标文本是否在当前页面中 @@ -206,31 +221,25 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text let targetResult = ocrResults.find(res => res.text.includes(targetText)); if (targetResult) { - // 生成唯一标识并更新识别计数(文本+Y坐标) const materialId = `${targetText}-${targetResult.y}`; recognitionCount.set(materialId, (recognitionCount.get(materialId) || 0) + 1); let centerYTargetText = targetResult.y + targetResult.height / 2; if (Math.abs(centerYTargetText - centerYF) <= texttolerance) { - // log.info(`目标文本 '${targetText}' 和 F 图标水平对齐`); if (recognitionCount.get(materialId) >= 1) { - keyPress("F"); // 执行交互操作 + keyPress("F"); log.info(`交互或拾取: ${targetText}`); - - // F键后清除计数,确保单次交互 recognitionCount.delete(materialId); } foundTarget = true; - break; // 成功交互后退出当前循环,但继续检测 + break; } } } - // 如果在当前页面中没有找到任何目标文本,则滚动到下一页 if (!foundTarget) { await keyMouseScript.runFile(`assets/滚轮下翻.json`); - // verticalScroll(-20); } if (state.cancelRequested) { break; diff --git a/repo/js/背包材料统计/main.js b/repo/js/背包材料统计/main.js index 5d294ed94..713cebb84 100644 --- a/repo/js/背包材料统计/main.js +++ b/repo/js/背包材料统计/main.js @@ -51,6 +51,9 @@ var state = { completed: false, cancelRequested: false }; const timeCost = Math.min(300, Math.max(0, Math.floor(Number(settings.TimeCost) || 30))); const notify = settings.notify || false; const noRecord = settings.noRecord || false; +const targetCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.TargetCount) || 5000))); // 设定的目标数量 +const exceedCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.ExceedCount) || 5000))); // 设定的超量目标数量 +const endTimeStr = settings.CurrentTime ? settings.CurrentTime : null; // 解析需要处理的CD分类 const allowedCDCategories = (settings.CDCategories || "") @@ -326,6 +329,32 @@ function getCurrentTimeInHours() { return now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600; } +/** + * 计算当前时间到指定终止时间的剩余分钟数(处理跨天,单向倒计时) + * @param {string} endTimeStr - 指定终止时间(格式"HH:mm",如"4:00") + * @returns {number} 剩余分钟数(负数表示已过终止时间),无效格式返回-1 + */ +function getRemainingMinutesToEndTime(endTimeStr) { + // 1. 解析终止时间 + const [endHours, endMinutes] = endTimeStr.split(/[::]/).map(Number); + if (isNaN(endHours) || isNaN(endMinutes) || endHours < 0 || endHours >= 24 || endMinutes < 0 || endMinutes >= 60) { + log.error(`${CONSTANTS.LOG_MODULES.MAIN}无效终止时间格式:${endTimeStr},需为"HH:mm"(如"14:30")`); + return -1; // 无效格式视为“已过时间” + } + + // 2. 转换为时间戳(当天终止时间 & 次日终止时间,处理跨天) + const now = new Date(); + const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endHours, endMinutes); + const tomorrowEnd = new Date(todayEnd.getTime() + 24 * 60 * 60 * 1000); // 加1天 + + // 3. 确定有效终止时间(若当天已过,取次日) + const targetEndTime = now <= todayEnd ? todayEnd : tomorrowEnd; + + // 4. 计算剩余分钟数(毫秒转分钟,保留整数) + const remainingMs = targetEndTime - now; + return Math.floor(remainingMs / (1000 * 60)); +} + // ============================================== // 记录管理 // ============================================== @@ -403,9 +432,6 @@ function recordRunTime(resourceName, pathName, startTime, endTime, runTime, reco try { if (runTime >= 3) { // 运行时间≥3秒才处理记录 - // ============================================== - // 新增:怪物路径专用逻辑(判断对应材料总数量是否为0) - // ============================================== const isMonsterPath = monsterToMaterials.hasOwnProperty(resourceName); // 是否为怪物路径 if (isMonsterPath) { // 1. 获取当前怪物对应的所有目标材料(从已有映射中取) @@ -424,9 +450,6 @@ function recordRunTime(resourceName, pathName, startTime, endTime, runTime, reco } } - // ============================================== - // 原有:普通材料0记录逻辑(完全保留,不做修改) - // ============================================== for (const [material, count] of Object.entries(materialCountDifferences)) { if (material === resourceName && count === 0) { const zeroMaterialPath = `${recordDir}/${material}${CONSTANTS.ZERO_COUNT_SUFFIX}`; @@ -436,9 +459,6 @@ function recordRunTime(resourceName, pathName, startTime, endTime, runTime, reco } } - // ============================================== - // 原有:正常记录生成逻辑(完全保留,不做修改) - // ============================================== const hasZeroMaterial = Object.values(materialCountDifferences).includes(0); const isFinalCumulativeDistanceZero = finalCumulativeDistance === 0; @@ -504,102 +524,194 @@ function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir) { * @param {string} recordDir - 记录目录 * @returns {number|null} 时间成本(秒/中级单位),null=无法计算 */ -function calculatePerTime(resourceName, pathName, recordDir) { - const recordPath = `${recordDir}/${resourceName}.txt`; +function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, isFood = false, cache = {}) { + // 1. 生成唯一缓存键(确保不同路径/不同文件的记录不混淆) + const isFoodSuffix = isFood ? CONSTANTS.FOOD_EXP_RECORD_SUFFIX : ".txt"; + const recordFile = `${recordDir}/${resourceKey}${isFoodSuffix}`; + const cacheKey = `${recordFile}|${pathName}`; // 键格式:文件路径|路径名 + + // 2. 优先从缓存获取,命中则直接返回(不读文件) + if (cache[cacheKey]) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从缓存复用记录:${cacheKey}`); + return cache[cacheKey]; + } + + // 3. 缓存未命中,才读取文件 + const records = []; + let targetFile = recordFile; + let content = ""; + + // 读主目录→读备用目录 try { - const content = file.readTextSync(recordPath); - const lines = content.split('\n'); - const completeRecords = []; + content = file.readTextSync(targetFile); + } catch (mainErr) { + targetFile = `${noRecordDir}/${resourceKey}${isFoodSuffix}`; + try { + content = file.readTextSync(targetFile); + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从备用目录读取记录:${targetFile}`); + } catch (backupErr) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}无${resourceKey}的历史记录:${targetFile}`); + // 空记录也写入缓存,避免下次重复尝试读文件 + cache[cacheKey] = records; + return records; + } + } - // ============================================== - // 怪物路径:改为以中级材料为基准(最低级÷3) - // ============================================== - if (monsterToMaterials.hasOwnProperty(resourceName)) { - const monsterMaterials = monsterToMaterials[resourceName]; // 映射顺序:[最高级, 中级, 最低级] - // 新比例:最高级×3(1最高级=3中级),中级×1(本身),最低级×1/3(3最低级=1中级 → 最低级÷3) - const gradeRatios = [3, 1, 1/3]; + // 解析记录(按原有格式提取runTime和quantityChange) + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('路径名: ') && lines[i].split('路径名: ')[1] === pathName) { + const runTimeLine = lines[i + 3]; + const quantityChangeLine = lines[i + 4] || ""; + let runTime = 0; + let quantityChange = {}; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('路径名: ') && lines[i].split('路径名: ')[1] === pathName) { - const runTimeLine = lines[i + 3]; - const quantityChangeLine = lines[i + 4]; - - if (runTimeLine?.startsWith('运行时间: ') && quantityChangeLine?.startsWith('数量变化: ')) { - // 1. 提取运行时间 - const runTime = parseInt(runTimeLine.split('运行时间: ')[1].split('秒')[0], 10); - if (isNaN(runTime) || runTime <= 0) continue; - - // 2. 提取数量变化 - const quantityChange = JSON.parse(quantityChangeLine.split('数量变化: ')[1]); - - // 3. 按新比例计算“总中级单位数量”(最低级÷3) - let totalMiddleCount = 0; // 变量名改为中级单位 - monsterMaterials.forEach((mat, index) => { - const count = quantityChange[mat] || 0; - const ratio = gradeRatios[index] || 1; - totalMiddleCount += count * ratio; // 最低级此处等价于 count ÷ 3 - }); - // 保留两位小数(处理1/3导致的无限小数) - totalMiddleCount = parseFloat(totalMiddleCount.toFixed(2)); - - // 4. 过滤无效数据 - if (totalMiddleCount <= 0) continue; - - // 5. 计算时间成本(秒/中级单位) - const perTime = parseFloat((runTime / totalMiddleCount).toFixed(2)); - completeRecords.push(perTime); - // 日志更新为中级单位 - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}怪物【${resourceName}】路径${pathName}:${runTime}秒/${totalMiddleCount}中级单位 → ${perTime}秒/单位`); - } + // 提取运行时间(秒) + if (runTimeLine?.startsWith('运行时间: ')) { + runTime = parseInt(runTimeLine.split('运行时间: ')[1].split('秒')[0], 10) || 0; + } + // 提取数量变化(JSON格式) + if (quantityChangeLine.startsWith('数量变化: ')) { + try { + quantityChange = JSON.parse(quantityChangeLine.split('数量变化: ')[1]) || {}; + } catch (e) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}解析数量变化失败:${quantityChangeLine}`); } } - } - // ============================================== - // 普通材料:完全保留原有逻辑 - // ============================================== - else { - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('路径名: ') && lines[i].split('路径名: ')[1] === pathName) { - const runTimeLine = lines[i + 3]; - const quantityChangeLine = lines[i + 4]; - if (runTimeLine?.startsWith('运行时间: ') && quantityChangeLine?.startsWith('数量变化: ')) { - const runTime = parseInt(runTimeLine.split('运行时间: ')[1].split('秒')[0], 10); - const quantityChange = JSON.parse(quantityChangeLine.split('数量变化: ')[1]); - - if (quantityChange[resourceName] !== undefined && quantityChange[resourceName] !== 0) { - completeRecords.push(parseFloat((runTime / quantityChange[resourceName]).toFixed(2))); - } - } - } + if (runTime > 0) { + records.push({ runTime, quantityChange }); } } + } - // ============================================== - // 统一的异常值过滤和平均值计算(不变) - // ============================================== - if (completeRecords.length < 3) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效记录不足3条,无法计算时间成本`); - return null; - } + // 4. 将读取到的记录写入缓存,供后续复用 + cache[cacheKey] = records; + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取记录并缓存:${cacheKey}(${records.length}条)`); + return records; +} - const recentRecords = completeRecords.slice(-5).filter(record => !isNaN(record) && record !== Infinity); - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}最近记录: ${JSON.stringify(recentRecords)}`); +/** + * 基于历史runTime预估路径总耗时(默认5分钟) + * @param {Object} entry - 路径条目 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 备用目录 + * @param {Object} cache - 缓存对象 + * @returns {number} 预估耗时(秒) + */ +function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) { + const { resourceName, monsterName, path: pathingFilePath } = entry; + const pathName = basename(pathingFilePath); + const isFood = resourceName && isFoodResource(resourceName); + let resourceKey = isFood ? resourceName : (monsterName || resourceName); - const mean = recentRecords.reduce((acc, val) => acc + val, 0) / recentRecords.length; - const stdDev = Math.sqrt(recentRecords.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / recentRecords.length); - const filteredRecords = recentRecords.filter(record => Math.abs(record - mean) <= 1 * stdDev); + // 无资源关联时,默认5分钟(300秒) + if (!resourceKey) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无资源关联,默认按300秒(5分钟)预估`); + return 300; + } + + // 调用公共函数获取记录(复用缓存) + const historicalRecords = getHistoricalPathRecords( + resourceKey, + pathName, + recordDir, + noRecordDir, + isFood, + cache + ); + + // 无记录时,默认5分钟(300秒) + if (historicalRecords.length === 0) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无有效runTime记录,默认按300秒(5分钟)预估`); + return 300; + } + + // 取最近5条记录计算平均值 + const recentRecords = [...historicalRecords].reverse().slice(0, 5); + const avgRunTime = Math.round( + recentRecords.reduce((sum, record) => sum + record.runTime, 0) / recentRecords.length + ); + + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史runTime(最近5条):${recentRecords.map(r => r.runTime)}秒,预估耗时:${avgRunTime}秒`); + return avgRunTime; +} + +/** + * 计算单次时间成本(秒/单位材料)(复用缓存) + * @param {string} resourceName - 资源名 + * @param {string} pathName - 路径名 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 备用目录 + * @param {boolean} isFood - 是否为狗粮路径 + * @param {Object} cache - 缓存对象 + * @returns {number|null} 时间成本 + */ +function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}) { + const isMonster = monsterToMaterials.hasOwnProperty(resourceName); + // 调用公共函数获取记录(复用缓存) + const historicalRecords = getHistoricalPathRecords( + resourceName, + pathName, + recordDir, + noRecordDir, + isFood, + cache + ); + + // 有效记录不足3条,返回null + if (historicalRecords.length < 3) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效记录不足3条,无法计算时间成本`); + return null; + } + + const completeRecords = []; + if (isMonster) { + // 怪物路径:按中级单位计算 + const monsterMaterials = monsterToMaterials[resourceName]; + const gradeRatios = [3, 1, 1/3]; // 最高级×3,中级×1,最低级×1/3 + + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + let totalMiddleCount = 0; + + monsterMaterials.forEach((mat, index) => { + const count = quantityChange[mat] || 0; + totalMiddleCount += count * (gradeRatios[index] || 1); + }); + + totalMiddleCount = parseFloat(totalMiddleCount.toFixed(2)); + if (totalMiddleCount > 0) { + completeRecords.push(parseFloat((runTime / totalMiddleCount).toFixed(2))); + } + }); + } else { + // 普通材料路径:直接按材料数量计算 + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + if (quantityChange[resourceName] !== undefined && quantityChange[resourceName] !== 0) { + completeRecords.push(parseFloat((runTime / quantityChange[resourceName]).toFixed(2))); + } + }); + } + + // 异常值过滤与平均值计算(原有逻辑不变) + if (completeRecords.length < 3) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效效率记录不足3条,无法计算时间成本`); + return null; + } + + const recentRecords = completeRecords.slice(-5).filter(r => !isNaN(r) && r !== Infinity); + const mean = recentRecords.reduce((acc, val) => acc + val, 0) / recentRecords.length; + const stdDev = Math.sqrt(recentRecords.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / recentRecords.length); + const filteredRecords = recentRecords.filter(r => Math.abs(r - mean) <= 1 * stdDev); if (filteredRecords.length === 0) { log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}记录数据差异过大,无法计算有效时间成本`); return null; } - return parseFloat((filteredRecords.reduce((acc, val) => acc + val, 0) / filteredRecords.length).toFixed(2)); - } catch (error) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无有效记录,无法计算时间成本`); - } - return null; + return parseFloat((filteredRecords.reduce((acc, val) => acc + val, 0) / filteredRecords.length).toFixed(2)); } // ============================================== @@ -708,7 +820,7 @@ function matchImageAndGetCategory(resourceName, imagesDir) { const result = imageMapCache.get(imagesDir)[processedName] ?? null; if (result) { - log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`); + // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`); } else { log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`); } @@ -720,6 +832,54 @@ function matchImageAndGetCategory(resourceName, imagesDir) { return result; } +// ============================================== +// 特殊材料与超量判断(核心新增逻辑) +// ============================================== +const specialMaterials = [ + "水晶块", "魔晶块", "星银矿石", "紫晶块", "萃凝晶", "虹滴晶", "铁块", "白铁块", + "精锻用魔矿", "精锻用良矿", "精锻用杂矿" +]; + +let excessMaterialNames = []; // 超量材料名单 + +// 筛选低数量材料(保留原逻辑+修正超量判断) +function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { + // 超量阈值(普通材料9999,矿石处理后也是9999) + const EXCESS_THRESHOLD = exceedCount; + // 临时存储本次超量材料 + const tempExcess = []; + + // 提取所有需要扫描的材料(含怪物材料) + const allMaterials = Object.values(materialCategoryMap).flat(); + + const filteredMaterials = pathingMaterialCounts + .filter(item => + allMaterials.includes(item.name) && + (item.count < targetCount || item.count === "?") + ) + .map(item => { + // 矿石数量÷10 + let processedCount = item.count; + if (specialMaterials.includes(item.name) && item.count !== "?") { + processedCount = Math.floor(Number(item.count) / 10); + } + + // 判断是否超量(用处理后数量对比阈值) + if (item.count !== "?" && processedCount >= EXCESS_THRESHOLD) { + tempExcess.push(item.name); // 记录超量材料名 + } + + return { ...item, count: processedCount }; + }); + + tempExcess.push("OCR启动"); // 添加特殊标记,用于终止OCR等待 + // 更新全局超量名单(去重) + excessMaterialNames = [...new Set(tempExcess)]; + log.info(`【超量材料更新】共${excessMaterialNames.length}种:${excessMaterialNames.join("、")}`); + + return filteredMaterials; +} + // ============================================== // 路径处理(拆分巨型函数) // ============================================== @@ -785,8 +945,16 @@ async function processMonsterPathEntry(entry, context) { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, materialCategoryMap, flattenedLowCountMaterials, currentMaterialName: prevMaterialName, - materialAccumulatedDifferences, globalAccumulatedDifferences + materialAccumulatedDifferences, globalAccumulatedDifferences, + pathRecordCache // 新增:从上下文取缓存 } = context; + const monsterMaterials = monsterToMaterials[monsterName] || []; + const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); + if (allExcess) { + log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】所有材料已超量,跳过路径:${pathName}`); + await sleep(1); + return context; + } // 用怪物名查CD let refreshCD = null; @@ -811,7 +979,14 @@ async function processMonsterPathEntry(entry, context) { const currentTime = getCurrentTimeInHours(); const lastEndTime = getLastRunEndTime(monsterName, pathName, recordDir, noRecordDir); const isPathValid = checkPathNameFrequency(monsterName, pathName, recordDir); - const perTime = noRecord ? null : calculatePerTime(monsterName, pathName, recordDir); + const perTime = noRecord ? null : calculatePerTime( + monsterName, + pathName, + recordDir, + noRecordDir, + false, + pathRecordCache // 新增:传递缓存 + ); log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 单个材料耗时:${perTime ?? '忽略'}`); @@ -930,7 +1105,8 @@ async function processNormalPathEntry(entry, context) { CDCategories, timeCost, recordDir, noRecordDir, materialCategoryMap, flattenedLowCountMaterials, currentMaterialName: prevMaterialName, - materialAccumulatedDifferences, globalAccumulatedDifferences + materialAccumulatedDifferences, globalAccumulatedDifferences, + pathRecordCache // 新增:从上下文取缓存 } = context; // 用材料名查CD @@ -956,7 +1132,14 @@ async function processNormalPathEntry(entry, context) { const currentTime = getCurrentTimeInHours(); const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir); const isPathValid = checkPathNameFrequency(resourceName, pathName, recordDir); - const perTime = noRecord ? null : calculatePerTime(resourceName, pathName, recordDir); + const perTime = noRecord ? null : calculatePerTime( + resourceName, + pathName, + recordDir, + noRecordDir, + false, + pathRecordCache // 新增:传递缓存 + ); log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 单个材料耗时:${perTime ?? '忽略'}`); @@ -1066,42 +1249,84 @@ async function processNormalPathEntry(entry, context) { * @param {string} recordDir - 记录目录 * @param {string} noRecordDir - 无记录目录 * @param {string} imagesDir - 图像目录 + * @param {string} endTimeStr - 指定终止时间 * @returns {Object} 处理结果 */ -async function processAllPaths(allPaths, CDCategories, materialCategoryMap, timeCost, flattenedLowCountMaterials, currentMaterialName, recordDir, noRecordDir, imagesDir) { +async function processAllPaths(allPaths, CDCategories, materialCategoryMap, timeCost, flattenedLowCountMaterials, currentMaterialName, recordDir, noRecordDir, imagesDir, endTimeStr) { try { // 初始化累加器 let foodExpAccumulator = {}; const globalAccumulatedDifferences = {}; const materialAccumulatedDifferences = {}; + // 新增:单路径处理周期内的记录缓存(处理完所有路径后自动释放) + const pathRecordCache = {}; let context = { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, materialCategoryMap, flattenedLowCountMaterials, currentMaterialName, materialAccumulatedDifferences, - globalAccumulatedDifferences + globalAccumulatedDifferences, + pathRecordCache // 上下文加入缓存,供子函数使用 }; for (const entry of allPaths) { - if (state.cancelRequested) break; + // 优先响应手动终止指令(原有逻辑保留) + if (state.cancelRequested) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到手动终止指令,停止路径处理`); + break; + } + + // 核心修改:仅当endTimeStr有值时才执行定时终止判断(默认不执行) + let skipPath = false; + if (endTimeStr) { // 只有用户显式配置了终止时间,才进入判断 + const isValidEndTime = /^\d{1,2}[::]\d{1,2}$/.test(endTimeStr); + if (isValidEndTime) { + const remainingMinutes = getRemainingMinutesToEndTime(endTimeStr); + if (remainingMinutes <= 0) { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}已过指定终止时间(${endTimeStr}),停止路径处理`); + state.cancelRequested = true; + break; + } + + const pathTotalTimeSec = estimatePathTotalTime( + entry, + recordDir, + noRecordDir, + pathRecordCache + ); + const pathTotalTimeMin = pathTotalTimeSec / 60; + const requiredMin = pathTotalTimeMin + 2; + + if (remainingMinutes <= requiredMin) { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}时间不足:剩余${remainingMinutes}分钟,需${requiredMin}分钟(含2分钟空闲)`); + state.cancelRequested = true; + skipPath = true; + break; + } else { + log.debug(`${CONSTANTS.LOG_MODULES.MAIN}时间充足:剩余${remainingMinutes}分钟,需${requiredMin}分钟`); + } + } else { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}终止时间格式无效(${endTimeStr}),跳过定时判断`); + } + } // 若endTimeStr为null(默认),则完全跳过定时终止逻辑 + + if (skipPath) break; + + // 原有路径处理逻辑(仅新增缓存传递) + const { path: pathingFilePath, resourceName, monsterName } = entry; + log.info(`${CONSTANTS.LOG_MODULES.PATH}开始处理路径:${basename(pathingFilePath)}`); try { - const { path: pathingFilePath, resourceName, monsterName } = entry; - log.info(`${CONSTANTS.LOG_MODULES.PATH}开始处理路径:${basename(pathingFilePath)}`); - - // 区分路径类型并处理 if (resourceName && isFoodResource(resourceName)) { - // 狗粮路径 const result = await processFoodPathEntry(entry, { foodExpAccumulator, - currentMaterialName: context.currentMaterialName + currentMaterialName: context.currentMaterialName, + pathRecordCache // 传递缓存 }, recordDir, noRecordDir); foodExpAccumulator = result.foodExpAccumulator; context.currentMaterialName = result.currentMaterialName; } else if (monsterName) { - // 怪物路径 context = await processMonsterPathEntry(entry, context); } else if (resourceName) { - // 普通材料路径 context = await processNormalPathEntry(entry, context); } else { log.warn(`${CONSTANTS.LOG_MODULES.PATH}跳过无效路径条目:${JSON.stringify(entry)}`); @@ -1109,6 +1334,10 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } catch (singleError) { log.error(`${CONSTANTS.LOG_MODULES.PATH}处理路径出错,已跳过:${singleError.message}`); await sleep(1); + if (state.cancelRequested) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到终止指令,停止处理`); + break; + } } } @@ -1153,9 +1382,22 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMaterialNames, cdMaterialNames) { const pathingFilePaths = readAllFilePaths(pathingDir, 0, 3, ['.json']); const pathEntries = pathingFilePaths.map(path => { - const { materialName } = extractResourceNameFromPath(path, cdMaterialNames); - return { path, resourceName: materialName }; - }).filter(entry => entry.resourceName); + const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); + return { path, resourceName: materialName, monsterName }; // 新增monsterName字段 + }).filter(entry => { + // 新增:过滤超量材料对应的路径(包括怪物材料) + if (entry.monsterName) { + // 怪物路径:检查其所有材料是否都超量 + const monsterMaterials = monsterToMaterials[entry.monsterName] || []; + const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); + return !allExcess; // 若所有材料超量则过滤该路径 + } + if (entry.resourceName) { + // 普通材料路径:检查自身是否超量 + return !excessMaterialNames.includes(entry.resourceName); + } + return false; + }); if (pathEntries.length > 0) { log.info(`${CONSTANTS.LOG_MODULES.PATH}\n===== 匹配到的材料路径列表 =====`); @@ -1170,16 +1412,28 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri const prioritizedPaths = []; const normalPaths = []; for (const entry of pathEntries) { - if (targetResourceNames.includes(entry.resourceName)) { - prioritizedPaths.push(entry); - } else if (lowCountMaterialNames.includes(entry.resourceName)) { - normalPaths.push(entry); + if (entry.monsterName) { + // 怪物路径:检查是否包含有效目标材料 + const monsterMaterials = monsterToMaterials[entry.monsterName] || []; + const hasValidTarget = monsterMaterials.some(mat => targetResourceNames.includes(mat) || lowCountMaterialNames.includes(mat)); + if (hasValidTarget) { + prioritizedPaths.push(entry); + } else { + normalPaths.push(entry); + } + } else if (entry.resourceName) { + // 普通材料路径 + if (targetResourceNames.includes(entry.resourceName)) { + prioritizedPaths.push(entry); + } else if (lowCountMaterialNames.includes(entry.resourceName)) { + normalPaths.push(entry); + } } } // 按低数量排序 normalPaths.sort((a, b) => { - const indexA = lowCountMaterialNames.indexOf(a.resourceName); - const indexB = lowCountMaterialNames.indexOf(b.resourceName); + const indexA = lowCountMaterialNames.indexOf(a.resourceName) || lowCountMaterialNames.indexOf(a.monsterName ? monsterToMaterials[a.monsterName]?.[0] : ''); + const indexB = lowCountMaterialNames.indexOf(b.resourceName) || lowCountMaterialNames.indexOf(b.monsterName ? monsterToMaterials[b.monsterName]?.[0] : ''); return indexA - indexB; }); return prioritizedPaths.concat(normalPaths); @@ -1237,14 +1491,25 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames let processedMonsterPaths = monsterPaths; let pathingMaterialCounts = []; - if (normalPaths.length > 0) { - log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 执行背包扫描→低数量筛选`); - pathingMaterialCounts = await MaterialPath(materialCategoryMap); + if (normalPaths.length > 0 || monsterPaths.length > 0) { // 包含怪物路径时也需要扫描 + // 优化:一次扫描获取全量材料数量,同时服务于怪物和普通材料 + log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] 执行一次全量背包扫描(服务于怪物+普通路径)`); + const allMaterialCounts = await MaterialPath(materialCategoryMap); // 仅一次扫描 + pathingMaterialCounts = allMaterialCounts; // 普通材料直接复用扫描结果 + + // 1. 怪物材料筛选(复用全量扫描结果) + log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 基于全量扫描结果筛选有效材料`); + const filteredMonsterMaterials = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap); // 复用结果 + const validMonsterMaterialNames = filteredMonsterMaterials.map(m => m.name); + log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 筛选后有效材料:${validMonsterMaterialNames.join('、')}`); + + // 2. 普通材料筛选(同样复用全量扫描结果,无需再次扫描) if (pathingMode.onlyCategory) { state.cancelRequested = true; return { allPaths: [], pathingMaterialCounts }; - } - const lowCountMaterialsFiltered = filterLowCountMaterials(pathingMaterialCounts.flat(), materialCategoryMap); +} + log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 基于全量扫描结果筛选低数量材料`); + const lowCountMaterialsFiltered = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap); // 复用结果 const flattenedLowCountMaterials = lowCountMaterialsFiltered.flat().sort((a, b) => a.count - b.count); const lowCountMaterialNames = flattenedLowCountMaterials.map(material => material.name); @@ -1283,7 +1548,8 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames source: processedMonsterPaths, filter: e => { const materials = monsterToMaterials[e.monsterName] || []; - return !materials.some(mat => targetResourceNames.includes(mat)); + return !materials.some(mat => targetResourceNames.includes(mat)) && + materials.some(mat => !excessMaterialNames.includes(mat)); // 排除所有材料超量的怪物 } }, // 6. 剩余普通材料 @@ -1358,9 +1624,6 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 =========================================\n\n`; writeContentToFile(summaryPath, content); log.info(`${CONSTANTS.LOG_MODULES.RECORD}最终汇总已记录至 ${summaryPath}`); - // ============================================== - // 新增:汇总后强制触发结束指令,确保程序终止 - // ============================================== state.completed = true; // 标记任务完全完成 state.cancelRequested = true; // 终止所有后台任务(如图像点击、OCR) } @@ -1504,7 +1767,8 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 currentMaterialName, CONSTANTS.RECORD_DIR, CONSTANTS.NO_RECORD_DIR, - CONSTANTS.IMAGES_DIR + CONSTANTS.IMAGES_DIR, + endTimeStr // 传递终止时间 ); // 汇总结果 diff --git a/repo/js/背包材料统计/manifest.json b/repo/js/背包材料统计/manifest.json index ece08cf61..fa7ed6070 100644 --- a/repo/js/背包材料统计/manifest.json +++ b/repo/js/背包材料统计/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "背包统计采集系统", - "version": "2.53", + "version": "2.54", "bgi_version": "0.44.8", "description": "可统计背包养成道具、部分食物、素材的数量;根据设定数量、根据材料刷新CD执行挖矿、采集、刷怪等的路径。优势:\n+ 1. 自动判断材料CD,不需要管材料CD有没有好;\n+ 2. 可以随意添加路径,能自动排除低效、无效路径;\n+ 3. 有独立名单识别,不会交互路边的npc或是神像;可自定义识别名单,具体方法看【问题解答】增减识别名单\n+ 4. 有实时的弹窗模块,提供了常见的几种:路边信件、过期物品、月卡、调查;\n+ 5. 可识别爆满的路径材料,自动屏蔽;更多详细内容查看readme.md", "saved_files": [ diff --git a/repo/js/背包材料统计/materialsCD/怪物.txt b/repo/js/背包材料统计/materialsCD/怪物.txt index a25c9562d..327b276cc 100644 --- a/repo/js/背包材料统计/materialsCD/怪物.txt +++ b/repo/js/背包材料统计/materialsCD/怪物.txt @@ -1,2 +1,3 @@ -4点:丘丘人,丘丘萨满,丘丘人射手,丘丘暴徒,丘丘王,丘丘游侠,愚人众先遣队,萤术士,债务处理人,冬国仕女,愚人众风役人,愚人众特辖队,盗宝团,野伏众,海乱鬼,镀金旅团,黑蛇众,黯色空壳,部族龙形武士,遗迹机械,元能构装体,遗迹机兵,遗迹龙兽,发条机关,秘源机兵,巡陆艇,史莱姆,骗骗花,飘浮灵,蕈兽,浊水幻灵,原海异种,隙境原体,魔像禁卫,大灵显化身,熔岩游像,龙蜥,圣骸兽,玄文兽,纳塔龙众,炉壳山鼬,蕴光异兽,深渊法师,深渊使徒,兽境群狼,深邃拟覆叶,荒野狂猎,霜夜灵嗣,地脉花, +4点:丘丘人,丘丘萨满,丘丘人射手,丘丘暴徒,丘丘王,丘丘游侠,愚人众先遣队,萤术士,债务处理人,冬国仕女,愚人众风役人,愚人众特辖队,盗宝团,野伏众,海乱鬼,镀金旅团,黑蛇众,黯色空壳,部族龙形武士,遗迹机械,元能构装体,遗迹机兵,遗迹龙兽,发条机关,秘源机兵,巡陆艇,史莱姆,骗骗花,飘浮灵,蕈兽,浊水幻灵,原海异种,隙境原体,魔像禁卫,大灵显化身,熔岩游像,龙蜥,圣骸兽,玄文兽,纳塔龙众,炉壳山鼬,蕴光异兽,深渊法师,深渊使徒,兽境群狼,深邃拟覆叶,荒野狂猎,霜夜灵嗣,地脉花,锄地 + diff --git a/repo/js/背包材料统计/settings.json b/repo/js/背包材料统计/settings.json index a1dca168c..06e6dc5e4 100644 --- a/repo/js/背包材料统计/settings.json +++ b/repo/js/背包材料统计/settings.json @@ -109,10 +109,15 @@ "type": "input-text", "label": "====================\n\n采用的CD分类(默认:全部) 举例:采集,怪物,木材" }, + { + "name": "CurrentTime", + "type": "input-text", + "label": "根据CD分类来加载路径文件,具体看materialsCD目录\n====================\n\n终止时刻(默认:不执行) 例:4:00" + }, { "name": "PickCategories", "type": "input-text", - "label": "根据CD分类来加载路径文件,具体看materialsCD目录\n====================\n\n采用的识别名单(默认:全部) 举例:交互,采集,宝箱" + "label": "====================\n\n采用的识别名单(默认:全部) 举例:交互,采集,宝箱" }, { "name": "ExceedCount",