diff --git a/repo/js/背包材料统计/README.md b/repo/js/背包材料统计/README.md index 84fc2575d..a722bc2f3 100644 --- a/repo/js/背包材料统计/README.md +++ b/repo/js/背包材料统计/README.md @@ -1,4 +1,4 @@ -# 背包材料统计 v2.54 +# 背包材料统计 v2.60 作者:吉吉喵 @@ -128,7 +128,7 @@ 1. **禁止联机请求**:联机请求会遮挡背包菜单,导致材料识别失败。建议在本脚本前添加「AutoPermission」权限设置JS(仓库可查),默认禁止联机; 2. **文件夹层级限制**:`pathing` 文件夹仅支持3层子文件夹,超过需手动削减(否则路径无法读取); 3. **食物识别强制要求**:背包食物界面**第一行必须包含8种食物**(苹果、日落果、星蕈、活化的星蕈、枯焦的星蕈、泡泡桔、烛伞蘑菇、美味的宝石闪闪),缺少则这些食物无法识别; -4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或记录被污染后恢复如初; +4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或被污染后,记录能恢复如初; 5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto; 6. **手动终止运行**:如果要终止JS运行,推荐在当前路径采集到当前材料前,或者采集完进入背包扫描时终止(会在扫描结束后终止),以保护当前记录;如果是【取消扫描】模式,不会储存当前记录的材料数目,就随意。 @@ -184,7 +184,7 @@ A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各 - `overwrite_record`:所有历史记录(按材料分类储存); - `history_record`:勾选“材料分类”后的专属记录; - `latest_record.txt`:最近几种材料的记录(有上限,仅存最新数据); - - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录); + - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录,三次及以上同名路径记录,就会触发排除); 操作参考截图:
本地记录存放位置参考截图 @@ -224,4 +224,10 @@ A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各 | v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 | | v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 | | v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 | -| v2.54 | 自定义设置新增“终止时刻”,修复bug,新增“添加不规范命名的路径文件夹”说明,新增一个“锄地”的怪物路径CD | \ No newline at end of file +| v2.54 | 自定义设置新增“终止时刻”,修复bug,新增“添加不规范命名的路径文件夹”说明,新增一个“锄地”的怪物路径CD | +| v2.55 | 修复超量名单不生效 | +| v2.56 | 更新诺德卡莱图包 | +| v2.57 | 补全圣遗物无CD管理bug | +| v2.58 | 优化背包扫图逻辑 | +| v2.59 | 修复自动拾取匹配bug,改为双向匹配 | +| v2.60 | 手动终止路径会被记录成noRecord模式,只参与CD计算;增加当前路线预估时长日志;升级多选框UI,刚需bgi v0.55版本;优化文件是否存在逻辑;降级ReadTextSync报错 | \ No newline at end of file diff --git a/repo/js/背包材料统计/lib/autoPick.js b/repo/js/背包材料统计/lib/autoPick.js index f7aa33f71..ff1890a4d 100644 --- a/repo/js/背包材料统计/lib/autoPick.js +++ b/repo/js/背包材料统计/lib/autoPick.js @@ -1,10 +1,8 @@ /* - -// 自动拾取逻辑 - +// 自动拾取逻辑(基于最新版performOcr改造) */ -// 解析文件内容,提取分类信息 +// 解析文件内容,提取分类信息(无修改,与OCR无关) function parseCategoryContent(content) { if (!content) { log.warn(`文件内容为空`); @@ -25,9 +23,9 @@ function parseCategoryContent(content) { return categories; } -// 从 targetText 文件夹中读取分类信息 + +// 从 targetText 文件夹中读取分类信息(无修改,与OCR无关) function readtargetTextCategories(targetTextDir) { - // log.info(`开始读取材料分类信息:${targetTextDir}`); const targetTextFilePaths = readAllFilePaths(targetTextDir, 0, 1); const materialCategories = {}; @@ -35,33 +33,34 @@ function readtargetTextCategories(targetTextDir) { const pickTextNames = (settings.PickCategories || "") .split(/[,,、 \s]+/).map(n => n.trim()).filter(n => n); - // 【新增:兜底日志】确认pickTextNames是否为空,方便排查 + // 兜底日志:确认pickTextNames是否为空,方便排查 log.info(`筛选名单状态:${pickTextNames.length === 0 ? '未指定(空),将加载所有文件' : '指定了:' + pickTextNames.join(',')}`); for (const filePath of targetTextFilePaths) { if (state.cancelRequested) break; - const content = file.readTextSync(filePath); + const content = safeReadTextSync(filePath); if (!content) { log.error(`加载文件失败:${filePath}`); continue; } - const sourceCategory = basename(filePath).replace('.txt', ''); // 去掉文件扩展名 - // 【核心筛选:空名单直接跳过判断,加载所有】 + const sourceCategory = basename(filePath).replace('.txt', ''); + + // 核心筛选:空名单直接加载所有,非空名单仅加载指定 if (pickTextNames.length === 0) { - // 空名单时,直接保留当前文件,不跳过 + // 空名单时保留当前文件 } else if (!pickTextNames.includes(sourceCategory)) { - // 非空名单且不在列表里,才跳过 + // 非空名单且不在列表中则跳过 continue; } + materialCategories[sourceCategory] = parseCategoryContent(content); } + log.info(`完成读取,加载的分类:${Object.keys(materialCategories).join(',')}`); return materialCategories; } -// const OCRdelay = Math.min(100, Math.max(0, Math.floor(Number(settings.OcrDelay) || 2))); // F识别基准时长 - -// 尝试找到 F 图标并返回其坐标 +// 尝试找到目标图标(F图标/Scroll.png通用)并返回其坐标(无修改,与OCR无关) async function findFIcon(recognitionObject, timeout = 10, ra = null) { let startTime = Date.now(); while (Date.now() - startTime < timeout && !state.cancelRequested) { @@ -77,7 +76,7 @@ async function findFIcon(recognitionObject, timeout = 10, ra = null) { } return null; } - await sleep(5); // 每次检测间隔 5 毫秒 + await sleep(5); // 每次检测间隔5毫秒 } if (state.cancelRequested) { log.info("图标识别任务已取消"); @@ -85,14 +84,14 @@ async function findFIcon(recognitionObject, timeout = 10, ra = null) { return null; } -// 定义Scroll.png识别对象(按需求使用TemplateMatch,包含指定范围) +// 定义Scroll.png识别对象(无修改,与OCR无关) const ScrollRo = RecognitionObject.TemplateMatch( - file.ReadImageMatSync("assets/Scroll.png"), + file.ReadImageMatSync("assets/Scroll.png"), 1055, 521, 15, 35 // 识别范围:x=1055, y=521, width=15, height=35 ); /** - * 对齐并交互目标(直接用findFIcon识别Scroll.png) + * 对齐并交互目标(核心改造:适配最新版performOcr) * @param {string[]} targetTexts - 待匹配的目标文本列表 * @param {Object} fDialogueRo - F图标的识别对象 * @param {Object} textxRange - 文本识别的X轴范围 { min: number, max: number } @@ -106,6 +105,7 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text try { while (!state.completed && !state.cancelRequested) { + recognitionCount.clear(); // 每次循环开始时清空计数 const currentTime = Date.now(); // 每10秒输出检测日志(保留原逻辑) if (currentTime - lastLogTime >= 10000) { @@ -121,15 +121,16 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text } cachedFrame = captureGameRegion(); - // 尝试找到 F 图标 - let fRes = await findFIcon(fDialogueRo, 10, cachedFrame); - if (!fRes) { - const scrollRes = await findFIcon(ScrollRo, 10, cachedFrame); // 复用findFIcon函数 - if (scrollRes) { - await keyMouseScript.runFile(`assets/滚轮下翻.json`); // 调用翻滚脚本 + // 2. 识别F图标/Scroll.png(保留原逻辑) + let fRes = await findFIcon(fDialogueRo, 10, cachedFrame); + if (!fRes) { + const scrollRes = await findFIcon(ScrollRo, 10, cachedFrame); + if (scrollRes) { + log.debug("未识别到F图标,但识别到Scroll.png,执行翻滚操作"); + await keyMouseScript.runFile(`assets/滚轮下翻.json`); + } + continue; } - continue; // 继续下一轮检测 - } // 3. 核心改造:调用最新版performOcr // 适配点1:参数顺序调整为「targetTexts, xRange, yRange, ra, timeout, interval」 @@ -145,17 +146,19 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text ); ocrScreenshots.push(ocrScreenshot); // 收集截图,避免内存泄漏 - // 4. 文本匹配与交互(保留原逻辑,无修改) + // 4. 文本匹配与交互(双向匹配,增强容错性) let foundTarget = false; for (const targetText of targetTexts) { - const targetResult = ocrResults.find(res => res.text.includes(targetText)); + const targetResult = ocrResults.find(res => + res.text.includes(targetText) || targetText.includes(res.text) + ); if (!targetResult) continue; - // 计数防误触(原逻辑) + // 计数防误触 const materialId = `${targetText}-${targetResult.y}`; recognitionCount.set(materialId, (recognitionCount.get(materialId) || 0) + 1); - // Y轴对齐判断(原逻辑) + // Y轴对齐判断 const centerYTargetText = targetResult.y + targetResult.height / 2; if (Math.abs(centerYTargetText - (fRes.y + fRes.height / 2)) <= texttolerance) { if (recognitionCount.get(materialId) >= 1) { @@ -163,7 +166,6 @@ async function alignAndInteractTarget(targetTexts, fDialogueRo, textxRange, text log.info(`交互或拾取: ${targetText}`); recognitionCount.delete(materialId); } - foundTarget = true; break; } diff --git a/repo/js/背包材料统计/lib/backStats.js b/repo/js/背包材料统计/lib/backStats.js index 87e20d1ee..383c029b7 100644 --- a/repo/js/背包材料统计/lib/backStats.js +++ b/repo/js/背包材料统计/lib/backStats.js @@ -1,4 +1,4 @@ -eval(file.readTextSync("lib/region.js")); +eval(safeReadTextSync("lib/region.js")); const holdX = Math.min(1920, Math.max(0, Math.floor(Number(settings.HoldX) || 1050))); const holdY = Math.min(1080, Math.max(0, Math.floor(Number(settings.HoldY) || 750))); @@ -10,6 +10,7 @@ const pageScrollCount = 22; // 最多滑页次数 // 材料分类映射表 const materialTypeMap = { + "祝圣精华": "2", "锻造素材": "5", "怪物掉落素材": "3", "一般素材": "5", @@ -22,12 +23,11 @@ const materialTypeMap = { "角色天赋素材": "3", "武器突破素材": "3", "采集食物": "4", - "料理": "4", + "料理": "4" }; // 材料前位定义 const materialPriority = { - "养成道具": 1, "祝圣精华": 2, "锻造素材": 1, "怪物掉落素材": 1, @@ -41,7 +41,7 @@ const materialPriority = { "宝石": 4, "鱼饵鱼类": 5, "角色天赋素材": 5, - "武器突破素材": 6, + "武器突破素材": 6 }; // 2. 数字替换映射表(处理OCR识别误差) @@ -132,6 +132,89 @@ async function scrollPage(totalDistance, stepDistance = 10, delayMs = 5) { }); } +// 并行模板匹配函数 +async function parallelTemplateMatch(ra, materials, x, y, width, height, threshold = 0.8, enableMouseMove = true) { + const matchPromises = materials.map(({ name, mat }) => { + return new Promise((resolve) => { + try { + const recognitionObject = RecognitionObject.TemplateMatch(mat, x, y, width, height); + recognitionObject.threshold = threshold; + recognitionObject.Use3Channels = true; + + const result = ra.find(recognitionObject); + if (result.isExist() && result.x !== 0 && result.y !== 0) { + if (enableMouseMove) { + moveMouseTo(result.x, result.y); + } + resolve({ name, result }); + } else { + resolve({ name, result: null }); + } + } catch (error) { + log.error(`并行模板匹配错误: ${name} - ${error.message}`); + resolve({ name, result: null }); + } + }); + }); + + return await Promise.all(matchPromises); +} + +// 智能OCR识别函数 +async function smartRecognizeText(ocrRegion, ra, quickMode = true) { + if (quickMode) { + // 快速模式:超时50ms,只尝试1次 + return await recognizeText(ocrRegion, 50, 5, 1, 1, ra); + } else { + // 正常模式:超时100ms,尝试3次 + return await recognizeText(ocrRegion, 100, 10, 3, 2, ra); + } +} + +// 批量OCR处理函数 +async function batchRecognizeText(ocrRegions, ra, timeout = 200, retryInterval = 10, maxAttempts = 5, maxFailures = 2, quickMode = true) { + const ocrPromises = ocrRegions.map(({ region, name }) => { + return new Promise((resolve) => { + smartRecognizeText(region, ra, quickMode) + .then(result => resolve({ name, result })) + .catch(error => { + log.error(`批量OCR错误: ${name} - ${error.message}`); + resolve({ name, result: false }); + }); + }); + }); + + return await Promise.all(ocrPromises); +} + +// 合并OCR区域函数(用于密集区域的批量处理) +function mergeOcrRegions(regions) { + if (regions.length === 0) return []; + + // 按y坐标排序 + regions.sort((a, b) => a.region.y - b.region.y); + + const merged = []; + let currentMerge = { ...regions[0] }; + + for (let i = 1; i < regions.length; i++) { + const region = regions[i]; + // 检查是否可以合并(垂直距离小于阈值) + if (region.region.y - (currentMerge.region.y + currentMerge.region.height) < 20) { + // 合并区域 + currentMerge.region.width = Math.max(currentMerge.region.width, region.region.width); + currentMerge.region.height = region.region.y + region.region.height - currentMerge.region.y; + currentMerge.name += `,${region.name}`; + } else { + merged.push(currentMerge); + currentMerge = { ...region }; + } + } + merged.push(currentMerge); + + return merged; +} + // 通用鼠标拖动函数(提取重复逻辑) /** * 通用鼠标拖动工具函数 @@ -210,21 +293,23 @@ function filterMaterialsByPriority(materialsCategory) { throw new Error(`Invalid materialTypeMap for: ${materialsCategory}`); } - // 获取所有优先级更高的材料分类(前位材料) - const frontPriorityMaterials = Object.keys(materialPriority) - .filter(mat => materialPriority[mat] < currentPriority && materialTypeMap[mat] === currentType); - // 获取所有优先级更低的材料分类(后位材料) const backPriorityMaterials = Object.keys(materialPriority) .filter(mat => materialPriority[mat] > currentPriority && materialTypeMap[mat] === currentType); - // 合并当前和后位材料分类 - const finalFilteredMaterials = [...backPriorityMaterials,materialsCategory ];// 当前材料 + + // 合并当前和后位材料分类(只包含同位和后位,不包含前位) + // 只有同位或后位材料才会触发全列扫描 + const finalFilteredMaterials = [...backPriorityMaterials, materialsCategory]; return finalFilteredMaterials } - // 扫描材料 +// 扫描材料 async function scanMaterials(materialsCategory, materialCategoryMap) { - // 获取当前+后位材料名单 + // 材料图片缓存 + const materialImages = {}; // 用于缓存加载的图片 + const priorityMaterialImages = {}; // 用于缓存优先级材料图片 + + // 获取当前+后位材料名单(仅包含同位和后位,不包含前位) const priorityMaterialNames = []; const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory); for (const category of finalFilteredMaterials) { @@ -233,6 +318,14 @@ async function scanMaterials(materialsCategory, materialCategoryMap) { for (const filePath of materialIconFilePaths) { const name = basename(filePath).replace(".png", ""); // 去掉文件扩展名 priorityMaterialNames.push({ category, name }); + + // 预加载优先级材料图片 + if (!priorityMaterialImages[name]) { + const mat = file.readImageMatSync(filePath); + if (!mat.empty()) { + priorityMaterialImages[name] = mat; + } + } } } @@ -245,7 +338,6 @@ async function scanMaterials(materialsCategory, materialCategoryMap) { // 创建材料种类集合 const materialCategories = []; const allMaterials = new Set(); // 用于记录所有需要扫描的材料名称 - const materialImages = {}; // 用于缓存加载的图片 // 检查 materialCategoryMap 中当前分类的数组是否为空 const categoryMaterials = materialCategoryMap[materialsCategory] || []; @@ -285,7 +377,7 @@ async function scanMaterials(materialsCategory, materialCategoryMap) { const columnHeight = 680; const maxColumns = 8; // 跟踪已截图的区域(避免重复) - const capturedRegions = new Set(); + const capturedRegions = new Set(); // 扫描状态 let hasFoundFirstMaterial = false; @@ -320,80 +412,96 @@ async function scanMaterials(materialsCategory, materialCategoryMap) { let tempPhrases = [...scanPhrases]; tempPhrases.sort(() => Math.random() - 0.5); // 打乱数组顺序,确保随机性 let phrasesStartTime = Date.now(); + let previousScreenshot = null; // 用于存储上一次翻页前的截图 // 扫描背包中的材料 for (let scroll = 0; scroll <= pageScrollCount; scroll++) { const ra = captureGameRegion(); - if (!foundPriorityMaterial) { - for (const { category, name } of priorityMaterialNames) { - if (recognizedMaterials.has(name)) { - continue; // 如果已经识别过,跳过 - } + // 重置foundPriorityMaterial标志,每次翻页都重新检查 + foundPriorityMaterial = false; - const filePath = `assets/images/${category}/${name}.png`; - const mat = file.readImageMatSync(filePath); - if (mat.empty()) { - log.error(`加载材料图库失败:${filePath}`); - continue; // 跳过当前文件 - } + // 检查第八列是否有目标材料 + // priorityMaterialNames只包含当前和后位材料 + const priorityMaterialsToMatch = priorityMaterialNames + .filter(({ name }) => !recognizedMaterials.has(name)) + .map(({ name }) => { + const mat = priorityMaterialImages[name]; + return mat ? { name, mat } : null; + }) + .filter(Boolean); - const recognitionObject = RecognitionObject.TemplateMatch(mat, 1142, startY, columnWidth, columnHeight); - recognitionObject.threshold = 0.8; // 设置识别阈值 - recognitionObject.Use3Channels = true; + if (priorityMaterialsToMatch.length > 0) { + const matchResults = await parallelTemplateMatch(ra, priorityMaterialsToMatch, 1142, startY, columnWidth, columnHeight, 0.8); - const result = ra.find(recognitionObject); - if (result.isExist() && result.x !== 0 && result.y !== 0) { - foundPriorityMaterial = true; // 标记找到前位材料 - // drawAndClearRedBox(result, ra, 100);// 调用异步函数绘制红框并延时清除 - log.info(`发现当前或后位材料: ${name},开始全列扫描`); - break; // 发现前位材料后,退出当前循环 + for (const { name, result } of matchResults) { + if (result) { + foundPriorityMaterial = true; // 标记找到目标材料 + // log.info(`发现目标材料: ${name},开始全列扫描`); + break; } } } + // 只有发现目标材料时,才执行全列扫描 if (foundPriorityMaterial) { + log.info(`开始全列扫描`); + const ocrRegions = []; + const matchedMaterials = []; + for (let column = 0; column < maxColumns; column++) { const scanX0 = startX + column * OffsetWidth; const scanX = Math.round(scanX0); + // 准备当前列需要扫描的材料 + const materialsToMatch = materialCategories + .filter(({ name }) => !recognizedMaterials.has(name)) + .map(({ name }) => { + const mat = materialImages[name]; + return mat ? { name, mat } : null; + }) + .filter(Boolean); - for (let i = 0; i < materialCategories.length; i++) { - const { name } = materialCategories[i]; - if (recognizedMaterials.has(name)) { - continue; // 如果已经识别过,跳过 - } + if (materialsToMatch.length > 0) { + // 并行扫描当前列的所有材料 + const matchResults = await parallelTemplateMatch(ra, materialsToMatch, scanX, startY, columnWidth, columnHeight, 0.85); - const mat = materialImages[name]; - const recognitionObject = RecognitionObject.TemplateMatch(mat, scanX, startY, columnWidth, columnHeight); - recognitionObject.threshold = 0.85; - recognitionObject.Use3Channels = true; + // 收集匹配结果和OCR区域 + for (const { name, result } of matchResults) { + if (result) { + recognizedMaterials.add(name); - const result = ra.find(recognitionObject); + // drawAndClearRedBox(result, ra, 100);// 调用异步函数绘制红框并延时清除 + const ocrRegion = { + x: result.x - tolerance, + y: result.y + 97 - tolerance, + width: 66 + 2 * tolerance, + height: 22 + 2 * tolerance + }; + ocrRegions.push({ region: ocrRegion, name }); + matchedMaterials.push({ name, result }); - if (result.isExist() && result.x !== 0 && result.y !== 0) { - recognizedMaterials.add(name); - moveMouseTo(result.x, result.y); - - // drawAndClearRedBox(result, ra, 100);// 调用异步函数绘制红框并延时清除 - const ocrRegion = { - x: result.x - tolerance, - y: result.y + 97 - tolerance, - width: 66 + 2 * tolerance, - height: 22 + 2 * tolerance - }; - const ocrResult = await recognizeText(ocrRegion, 200, 10, 5, 2, ra); - materialInfo.push({ name, count: ocrResult || "?" }); - - if (!hasFoundFirstMaterial) { - hasFoundFirstMaterial = true; - lastFoundTime = Date.now(); - } else { - lastFoundTime = Date.now(); + if (!hasFoundFirstMaterial) { + hasFoundFirstMaterial = true; + lastFoundTime = Date.now(); + } else { + lastFoundTime = Date.now(); + } } } - await sleep(imageDelay); } } + + // 批量处理OCR + if (ocrRegions.length > 0) { + const ocrResults = await batchRecognizeText(ocrRegions, ra); + + // 处理OCR结果 + for (const { name, result } of ocrResults) { + materialInfo.push({ name, count: result || "?" }); + } + } + } else { + log.info(`未发现目标材料,跳过`); } // 每5秒输出一句俏皮话 @@ -421,21 +529,110 @@ async function scanMaterials(materialsCategory, materialCategoryMap) { break; } - // 检查是否到达最后一页 - const sliderBottomRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/SliderBottom.png"), 1284, 916, 9, 26); - sliderBottomRo.threshold = 0.8; - const sliderBottomResult = ra.find(sliderBottomRo); - if (sliderBottomResult.isExist()) { - log.info("已到达最后一页!"); - shouldEndScan = true; - break; + // 检查是否到达最后一页(使用画面比较替代滑条检测) + // 注:画面比较逻辑已集成到翻页操作中 + + + // 捕获当前翻页前的截图(用于与下一次翻页前的截图比较) + const currentScreenshot = { + region: { x: 400, y: 400, width: 600, height: 100 }, // 长条形状比较区域 + mat: ra.DeriveCrop(400, 400, 600, 100).SrcMat // 外扩1像素 + }; + + // 检查是否需要启动画面比较兜底逻辑 + let useScreenComparison = false; + if (!shouldEndScan && scroll < pageScrollCount && previousScreenshot) { + // 检查是否满足兜底条件: + // 1. 已找到至少一个材料,但长时间未发现新材料 + // 2. 或连续多次未发现任何材料 + if (recognizedMaterials.size > 0) { + const noNewMaterialTime = Date.now() - lastFoundTime; + if (noNewMaterialTime > 5000) { // 5秒未发现新材料 + useScreenComparison = true; + log.info(`5秒未发现新材料,启动画面比较兜底逻辑`); + } + } else if (scroll > 5) { // 连续翻页5次以上仍未发现任何材料 + useScreenComparison = true; + log.info(`连续翻页${scroll}次未发现任何材料,启动画面比较兜底逻辑`); + } } // 滑动到下一页 if (scroll < pageScrollCount) { + if (useScreenComparison && previousScreenshot) { + // 使用模板匹配比较两次翻页前的截图(兜底机制) + const matchRo = RecognitionObject.TemplateMatch( + previousScreenshot.mat, + previousScreenshot.region.x - 1, + previousScreenshot.region.y - 1, + previousScreenshot.region.width + 2, + previousScreenshot.region.height + 2 + ); + matchRo.threshold = 0.95; // 高阈值,确保区域变化足够明显 + matchRo.Use3Channels = true; + + const matchResult = ra.find(matchRo); + if (matchResult.isExist()) { + log.info("连续翻页画面无明显变化,执行最后一次全列扫描"); + + // 执行最后一次全列扫描 + log.info("执行最后一次全列扫描"); + for (let column = 0; column < maxColumns; column++) { + const scanX0 = startX + column * OffsetWidth; + const scanX = Math.round(scanX0); + for (let i = 0; i < materialCategories.length; i++) { + const { name } = materialCategories[i]; + if (recognizedMaterials.has(name)) { + continue; // 如果已经识别过,跳过 + } + + const mat = materialImages[name]; + const recognitionObject = RecognitionObject.TemplateMatch(mat, scanX, startY, columnWidth, columnHeight); + recognitionObject.threshold = 0.85; + recognitionObject.Use3Channels = true; + + const result = ra.find(recognitionObject); + + if (result.isExist() && result.x !== 0 && result.y !== 0) { + recognizedMaterials.add(name); + moveMouseTo(result.x, result.y); + + const ocrRegion = { + x: result.x - tolerance, + y: result.y + 97 - tolerance, + width: 66 + 2 * tolerance, + height: 22 + 2 * tolerance + }; + const ocrResult = await recognizeText(ocrRegion, 200, 10, 5, 2, ra); + materialInfo.push({ name, count: ocrResult || "?" }); + + if (!hasFoundFirstMaterial) { + hasFoundFirstMaterial = true; + lastFoundTime = Date.now(); + } else { + lastFoundTime = Date.now(); + } + } + await sleep(imageDelay); + } + } + + log.info("最后一次全列扫描完成,结束扫描"); + shouldEndScan = true; + break; + } else { + log.info("连续翻页画面有变化,继续扫描"); + } + } + + // 执行翻页 await scrollPage(-totalPageDistance, 10, 5); - await sleep(100); + // 减少等待时间,提高翻页速度 + await sleep(50); } + + // 更新上一次的截图 + previousScreenshot = currentScreenshot; } // 处理未匹配的材料 @@ -461,7 +658,7 @@ ${Array.from(unmatchedMaterialNames).join(",")} const overwriteFilePath = `overwrite_record/${materialsCategory}.txt`; // 所有的历史记录分类储存 const latestFilePath = "latest_record.txt"; // 所有的历史记录集集合 if (pathingMode.onlyCategory) { - writeFile(categoryFilePath, logContent); + writeFile(categoryFilePath, logContent); } writeFile(overwriteFilePath, logContent); writeFile(latestFilePath, logContent); // 覆盖模式? @@ -472,10 +669,40 @@ ${Array.from(unmatchedMaterialNames).join(",")} // 定义所有图标的图像识别对象,每个图片都有自己的识别区域 const BagpackRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/Bagpack.png"), 58, 31, 38, 38); +const XPRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/XP.png"), 653, 29, 38, 38); const MaterialsRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/Materials.png"), 941, 29, 38, 38); const CultivationItemsRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/CultivationItems.png"), 749, 30, 38, 38); const FoodRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/Food.png"), 845, 31, 38, 38); +/** + * 获取材料分类对应的CategoryObject + * @param {string} materialsCategory - 材料分类名称 + * @returns {Object|null} 对应的CategoryObject或null + */ +function getCategoryObject(materialsCategory) { + switch (materialsCategory) { + case "祝圣精华": + return XPRo; + case "锻造素材": + case "一般素材": + case "烹饪食材": + case "木材": + case "鱼饵鱼类": + return MaterialsRo; + case "采集食物": + case "料理": + return FoodRo; + case "怪物掉落素材": + case "周本素材": + case "角色突破素材": + case "宝石": + case "角色天赋素材": + case "武器突破素材": + return CultivationItemsRo; + default: + return null; + } +} function dynamicMaterialGrouping(materialCategoryMap) { // 初始化动态分组对象 @@ -528,11 +755,18 @@ async function MaterialPath(materialCategoryMap, cachedFrame = null) { let materialsCategory = ""; // 当前处理的材料分类名称 const allLowCountMaterials = []; // 用于存储所有识别到的低数量材料信息 + // 添加状态变量,记录上一个分类的信息 + let prevCategory = null; + let prevCategoryObject = null; + let prevPriority = null; + let prevGroup = null; + let skipSliderReset = false; // 是否跳过滑条重置 + const sortedGroups = dynamicMaterialGrouping(materialCategoryMap); -// log.info("材料 动态[分组]结果:"); + // log.info("材料 动态[分组]结果:"); sortedGroups.forEach(group => { - log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`); -}); + log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`); + }); let loopCount = 0; const maxLoopCount = 200; // 合理阈值,正常流程约50-100次循环 @@ -550,7 +784,7 @@ async function MaterialPath(materialCategoryMap, cachedFrame = null) { case 1: // 打开背包界面 // log.info("打开背包界面"); keyPress("B"); // 打开背包界面 - await sleep(1000); + await sleep(800); // 减少等待时间 cachedFrame?.dispose(); cachedFrame = captureGameRegion(); @@ -595,33 +829,13 @@ async function MaterialPath(materialCategoryMap, cachedFrame = null) { break; case 3: // 识别材料分类 - let CategoryObject; - switch (materialsCategory) { - case "锻造素材": - case "一般素材": - case "烹饪食材": - case "木材": - case "鱼饵鱼类": - CategoryObject = MaterialsRo; - break; - case "采集食物": - case "料理": - CategoryObject = FoodRo; - break; - case "怪物掉落素材": - case "周本素材": - case "角色突破素材": - case "宝石": - case "角色天赋素材": - case "武器突破素材": - CategoryObject = CultivationItemsRo; - break; - default: - log.error("未知的材料分类"); - // ===== 补充优化:异常时释放资源并退出 ===== - cachedFrame?.dispose(); - stage = 0; // 回退到阶段0 - return; + let CategoryObject = getCategoryObject(materialsCategory); + if (!CategoryObject) { + log.error("未知的材料分类"); + // ===== 补充优化:异常时释放资源并退出 ===== + cachedFrame?.dispose(); + stage = 0; // 回退到阶段0 + return; } const CategoryResult = await recognizeImage(CategoryObject, cachedFrame); @@ -639,18 +853,63 @@ async function MaterialPath(materialCategoryMap, cachedFrame = null) { case 4: // 扫描材料 log.info("芭芭拉,冲鸭!"); - await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端 - await sleep(200); - leftButtonDown(); // 长按左键重置材料滑条 - await sleep(300); - leftButtonUp(); - await sleep(200); + + // 判断是否需要重置滑条 + if (!skipSliderReset) { + await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端 + await sleep(200); + leftButtonDown(); // 长按左键重置材料滑条 + await sleep(300); + leftButtonUp(); + await sleep(200); + } else { + log.info("同一大类且为后位材料,跳过滑条重置"); + // 不重置滑条,直接从当前位置开始检查第八列 + } // 扫描材料并获取低于目标数量的材料 const lowCountMaterials = await scanMaterials(materialsCategory, materialCategoryMap); allLowCountMaterials.push(lowCountMaterials); + // 保存当前分类信息,用于下一个分类的判断 + prevCategory = materialsCategory; + prevPriority = materialPriority[materialsCategory]; + + // 获取当前分类的CategoryObject + const currentCategoryObject = getCategoryObject(materialsCategory); + prevCategoryObject = currentCategoryObject; + prevGroup = sortedGroups[currentGroupIndex]; + currentCategoryIndex++; + + // 判断下一个分类是否是同一个大类CategoryObject下的后位材料 + let nextCategory = null; + let nextCategoryObject = null; + let nextPriority = null; + + // 检查是否还有下一个分类 + if (currentGroupIndex < sortedGroups.length) { + const group = sortedGroups[currentGroupIndex]; + if (currentCategoryIndex < group.categories.length) { + nextCategory = group.categories[currentCategoryIndex]; + + // 获取下一个分类的CategoryObject + nextCategoryObject = getCategoryObject(nextCategory); + + // 获取下一个分类的优先级 + nextPriority = materialPriority[nextCategory]; + } + } + + // 判断是否跳过滑条重置:同一大类且为后位材料 + if (nextCategory && + nextCategoryObject === prevCategoryObject && + nextPriority > prevPriority) { + skipSliderReset = true; + } else { + skipSliderReset = false; + } + stage = 2; // 返回阶段2处理下一个分类 break; diff --git a/repo/js/背包材料统计/lib/file.js b/repo/js/背包材料统计/lib/file.js index b960e84a1..f566018ff 100644 --- a/repo/js/背包材料统计/lib/file.js +++ b/repo/js/背包材料统计/lib/file.js @@ -14,15 +14,6 @@ function basename(filePath) { return lastSlashIndex !== -1 ? normalizedPath.substring(lastSlashIndex + 1) : normalizedPath; } - -/* -// 如果路径存在且返回的是数组,则认为是目录Folder -function pathExists(path) { - try { return file.readPathSync(path)?.length >= 0; } - catch { return false; } -} -*/ - function pathExists(path) { try { return file.isFolder(path); @@ -31,6 +22,12 @@ function pathExists(path) { } } +/* +// 如果路径存在且返回的是数组,则认为是目录Folder +function pathExists(path) { + try { return file.readPathSync(path)?.length >= 0; } + catch { return false; } +} // 判断文件是否存在(非目录且能读取) function fileExists(filePath) { try { @@ -45,6 +42,93 @@ function fileExists(filePath) { return false; } } +*/ + +// 判断文件是否存在(基于readPathSync + 已有工具函数,不读取文件内容) +function fileExists(filePath) { + // 1. 基础参数校验(复用已有逻辑) + if (!filePath || typeof filePath !== 'string') { + log.debug(`[文件检查] 路径无效:${filePath}`); + return false; + } + + try { + // 2. 路径标准化(复用已有normalizePath,统一分隔符) + const normalizedFilePath = normalizePath(filePath); + + // 3. 拆分「文件所在目录」和「文件名」(核心步骤) + // 3.1 提取纯文件名(复用已有basename) + const fileName = basename(normalizedFilePath); + // 3.2 提取文件所在的目录路径(基于标准化路径拆分) + // 修复:当没有目录结构时,使用'.'表示当前目录,而不是'/'(避免越界访问) + const dirPath = normalizedFilePath.lastIndexOf('/') !== -1 + ? normalizedFilePath.substring(0, normalizedFilePath.lastIndexOf('/')) + : '.'; + + // 4. 先判断目录是否存在 + if (!pathExists(dirPath)) { + log.debug(`[文件检查] 文件所在目录不存在:${dirPath}`); + return false; + } + + // 5. 用readPathSync读取目录下的所有一级子项 + const dirItems = file.readPathSync(dirPath); + if (!dirItems || dirItems.length === 0) { + log.debug(`[文件检查] 目录为空,无目标文件:${dirPath}`); + return false; + } + + let isFileExist = false; + for (let i = 0; i < dirItems.length; i++) { + const item = dirItems[i]; + const normalizedItem = normalizePath(item); + if (pathExists(normalizedItem)) { + continue; + } + const itemFileName = basename(normalizedItem); + if (normalizedItem === normalizedFilePath || itemFileName === fileName) { + isFileExist = true; + break; + } + } + + // 7. 日志反馈结果 + if (isFileExist) { + // log.debug(`[文件检查] 文件存在:${filePath}`); + } else { + // log.debug(`[文件检查] 文件不存在:${filePath}`); + } + return isFileExist; + + } catch (error) { + // 捕获目录读取失败等异常 + log.debug(`[文件检查] 检查失败:${filePath},错误:${error.message}`); + return false; + } +} +/** + * 安全读取文本文件(封装存在性校验+异常处理) + * @param {string} filePath - 文件路径 + * @param {any} defaultValue - 读取失败/文件不存在时的默认返回值(默认空字符串) + * @returns {any} 文件内容(成功)| defaultValue(失败) + */ +function safeReadTextSync(filePath, defaultValue = "") { + try { + // 第一步:校验文件是否存在 + if (!fileExists(filePath)) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在,跳过读取: ${filePath}`); + return defaultValue; + } + // 第二步:读取文件(捕获读取异常) + const content = file.readTextSync(filePath); + // log.debug(`${CONSTANTS.LOG_MODULES.RECORD}成功读取文件: ${filePath}`); + return content; + } catch (error) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取文件失败: ${filePath} → 原因:${error.message}`); + return defaultValue; + } +} + // 带深度限制的非递归文件夹读取 function readAllFilePaths(dir, depth = 0, maxDepth = 3, includeExtensions = ['.png', '.json', '.txt'], includeDirs = false) { if (!pathExists(dir)) { @@ -55,11 +139,11 @@ function readAllFilePaths(dir, depth = 0, maxDepth = 3, includeExtensions = ['.p try { const filePaths = []; const stack = [[dir, depth]]; // 存储(路径, 当前深度)的栈 - + while (stack.length > 0) { const [currentDir, currentDepth] = stack.pop(); const entries = file.readPathSync(currentDir); - + for (const entry of entries) { const isDirectory = pathExists(entry); if (isDirectory) { @@ -85,36 +169,36 @@ function writeFile(filePath, content, isAppend = true, maxRecords = 36500) { // 读取现有内容,处理文件不存在的情况 let existingContent = ""; try { - existingContent = file.readTextSync(filePath); + existingContent = safeReadTextSync(filePath); } catch (err) { // 文件不存在时视为空内容 existingContent = ""; } - + // 分割现有记录并过滤空记录 const records = existingContent.split("\n\n").filter(Boolean); - + // 新内容放在最前面,形成完整记录列表 const allRecords = [content, ...records]; - + // 只保留最新的maxRecords条(超过则删除最老的) const keptRecords = allRecords.slice(0, maxRecords); - + // 拼接记录并写入文件 const finalContent = keptRecords.join("\n\n"); - const result = file.WriteTextSync(filePath, finalContent, false); - + const result = file.writeTextSync(filePath, finalContent, false); + // log.info(result ? `[追加] 成功写入: ${filePath}` : `[追加] 写入失败: ${filePath}`); return result; } else { // 覆盖模式直接写入 - const result = file.WriteTextSync(filePath, content, false); + const result = file.writeTextSync(filePath, content, false); // log.info(result ? `[覆盖] 成功写入: ${filePath}` : `[覆盖] 写入失败: ${filePath}`); return result; } } catch (error) { // 出错时尝试创建/写入文件 - const result = file.WriteTextSync(filePath, content, false); + const result = file.writeTextSync(filePath, content, false); log.info(result ? `[新建/恢复] 成功处理: ${filePath}` : `[新建/恢复] 处理失败: ${filePath}`); return result; } diff --git a/repo/js/背包材料统计/lib/imageClick.js b/repo/js/背包材料统计/lib/imageClick.js index 430de9df5..8e514eec1 100644 --- a/repo/js/背包材料统计/lib/imageClick.js +++ b/repo/js/背包材料统计/lib/imageClick.js @@ -4,7 +4,7 @@ async function preloadImageResources(specificNames) { function hasIconFolder(dirPath) { try { - const entries = readAllFilePaths(dirPath, 0, 0, [], true); + const entries = readAllFilePaths(dirPath, 0, 0, [], true); return entries.some(entry => normalizePath(entry).endsWith('/icon')); } catch (e) { log.error(`检查目录【${dirPath}】是否有icon文件夹失败:${e.message}`); @@ -24,7 +24,7 @@ async function preloadImageResources(specificNames) { const targetDirs = subDirs.filter(subDir => { const dirName = basename(subDir); - const hasIcon = hasIconFolder(subDir); + const hasIcon = hasIconFolder(subDir); const matchName = isAll ? true : preSpecificNames.includes(dirName); return hasIcon && matchName; }); @@ -60,16 +60,16 @@ async function preloadImageResources(specificNames) { if (fileExists(configPath)) { try { - const configContent = file.readTextSync(configPath); + const configContent = safeReadTextSync(configPath); popupConfig = { ...popupConfig, ...JSON.parse(configContent) }; - isSpecialModule = popupConfig.isSpecial === true - && typeof popupConfig.detectRegion === 'object' - && popupConfig.detectRegion !== null - && popupConfig.detectRegion.x != null - && popupConfig.detectRegion.y != null - && popupConfig.detectRegion.width != null - && popupConfig.detectRegion.height != null - && popupConfig.detectRegion.width > 0 + isSpecialModule = popupConfig.isSpecial === true + && typeof popupConfig.detectRegion === 'object' + && popupConfig.detectRegion !== null + && popupConfig.detectRegion.x != null + && popupConfig.detectRegion.y != null + && popupConfig.detectRegion.width != null + && popupConfig.detectRegion.height != null + && popupConfig.detectRegion.width > 0 && popupConfig.detectRegion.height > 0; specialDetectRegion = isSpecialModule ? popupConfig.detectRegion : null; // log.info(`【${dirName}】加载配置成功:${isFirstLevel ? '第一级' : '第二级'} | 模块类型:${isSpecialModule ? '特殊模块' : '普通模块'}`); @@ -83,7 +83,7 @@ async function preloadImageResources(specificNames) { const entries = readAllFilePaths(subDir, 0, 1, [], true); const iconDir = entries.find(entry => normalizePath(entry).endsWith('/icon')); const iconFilePaths = readAllFilePaths(iconDir, 0, 0, ['.png', '.jpg', '.jpeg']); - + if (iconFilePaths.length === 0) { log.warn(`【${dirName}】特殊模块无有效icon文件,跳过`); continue; @@ -96,7 +96,7 @@ async function preloadImageResources(specificNames) { log.error(`【${dirName}】特殊模块加载图标失败:${filePath}`); continue; } - iconRecognitionObjects.push({ + iconRecognitionObjects.push({ name: basename(filePath), ro: RecognitionObject.TemplateMatch(mat, 0, 0, 1920, 1080), iconDir, @@ -104,7 +104,6 @@ async function preloadImageResources(specificNames) { }); } - // 关键修改:遍历所有图标,为每个图标生成识别信息 const foundRegions = []; // 存储所有图标的识别配置 for (const targetIcon of iconRecognitionObjects) { // 遍历每个图标 const manualRegion = new ImageRegion(targetIcon.mat, specialDetectRegion.x, specialDetectRegion.y); @@ -132,7 +131,7 @@ async function preloadImageResources(specificNames) { const entries = readAllFilePaths(subDir, 0, 1, [], true); const iconDir = entries.find(entry => normalizePath(entry).endsWith('/icon')); const pictureDir = entries.find(entry => normalizePath(entry).endsWith('/Picture')); - + if (!pictureDir) { log.warn(`【${dirName}】普通模块无Picture文件夹,跳过`); continue; @@ -140,7 +139,7 @@ async function preloadImageResources(specificNames) { const iconFilePaths = readAllFilePaths(iconDir, 0, 0, ['.png', '.jpg', '.jpeg']); const pictureFilePaths = readAllFilePaths(pictureDir, 0, 0, ['.png', '.jpg', '.jpeg']); - + // 仅在资源为空时警告 if (iconFilePaths.length === 0) { log.warn(`【${dirName}】普通模块无有效icon文件,跳过`); @@ -158,10 +157,10 @@ async function preloadImageResources(specificNames) { log.error(`【${dirName}】加载图标失败:${filePath}`); continue; } - iconRecognitionObjects.push({ + iconRecognitionObjects.push({ name: basename(filePath), ro: RecognitionObject.TemplateMatch(mat, 0, 0, 1920, 1080), - iconDir + iconDir }); } @@ -172,9 +171,9 @@ async function preloadImageResources(specificNames) { log.error(`【${dirName}】加载图库图片失败:${filePath}`); continue; } - pictureRegions.push({ + pictureRegions.push({ name: basename(filePath), - region: new ImageRegion(mat, 0, 0) + region: new ImageRegion(mat, 0, 0) }); } @@ -217,7 +216,7 @@ async function imageClickBackgroundTask() { log.info("imageClick后台任务已启动"); // 配置参数 - const taskDelay = Math.min(999, Math.max(1, Math.floor(Number(settings.PopupClickDelay) || 15)))*1000; + const taskDelay = Math.min(999, Math.max(1, Math.floor(Number(settings.PopupClickDelay) || 15))) * 1000; const specificNamesStr = settings.PopupNames || ""; const specificNames = specificNamesStr .split(/[,,、 \s]+/) @@ -241,10 +240,10 @@ async function imageClickBackgroundTask() { // 打印资源检测结果 log.info("\n==================== 现有弹窗加载结果 ===================="); log.info("1. 一级弹窗(共" + firstLevelDirs.length + "个):"); - firstLevelDirs.forEach((res, idx) => log.info(` ${idx+1}. 【${res.dirName}】`)); + firstLevelDirs.forEach((res, idx) => log.info(` ${idx + 1}. 【${res.dirName}】`)); const secondLevelResources = preloadedResources.filter(res => !res.isFirstLevel); log.info("\n2. 二级弹窗(共" + secondLevelResources.length + "个):"); - secondLevelResources.forEach((res, idx) => log.info(` ${idx+1}. 【${res.dirName}】`)); + secondLevelResources.forEach((res, idx) => log.info(` ${idx + 1}. 【${res.dirName}】`)); log.info("=============================================================\n"); // 核心逻辑:外循环遍历所有一级弹窗 @@ -253,12 +252,11 @@ async function imageClickBackgroundTask() { // 遍历所有一级弹窗 for (const currentFirstLevel of firstLevelDirs) { - log.info(`【${currentFirstLevel.dirName}】准备识别...`); // 检查当前一级弹窗是否被触发 const levelResult = await imageClick([currentFirstLevel], null, [currentFirstLevel.dirName], true); if (levelResult.success) { - // log.info(`【${currentFirstLevel.dirName}】触发成功,进入内部流程...`); + log.info(`【${currentFirstLevel.dirName}】触发成功,进入内部流程...`); const levelStack = [currentFirstLevel]; // 内循环处理内部流程 @@ -304,7 +302,7 @@ async function imageClickBackgroundTask() { // log.info(`===== 外循环结束:等待${taskDelay/1000}秒后开始下一次循环 =====`); await sleep(taskDelay); } - + log.info("imageClick后台任务结束"); return { success: true }; } @@ -325,14 +323,14 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u for (const foundRegion of foundRegions) { const tolerance = 1; const iconMat = file.readImageMatSync(`${normalizePath(foundRegion.iconDir)}/${foundRegion.iconName}`); - + const { detectRegion } = popupConfig; const defaultX = foundRegion.region.x - tolerance; const defaultY = foundRegion.region.y - tolerance; const defaultWidth = foundRegion.region.width + 2 * tolerance; const defaultHeight = foundRegion.region.height + 2 * tolerance; const recognitionObject = RecognitionObject.TemplateMatch( - iconMat, + iconMat, detectRegion?.x ?? defaultX, detectRegion?.y ?? defaultY, detectRegion?.width ?? defaultWidth, @@ -349,7 +347,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u useNewScreenshot, dirName ); - + if (result.isDetected && result.x !== 0 && result.y !== 0) { hasAnyIconDetected = true; isAnySuccess = true; @@ -360,12 +358,12 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u log.info(`识别到【${dirName}】弹窗,偏移后位置(${actualX}, ${actualY})`); if (!popupConfig.isSpecial) { + // log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})`); // 新增:普通点击加循环(默认1次,0间隔,与原逻辑一致) const clickCount = popupConfig.loopCount; const clickDelay = popupConfig.loopDelay; for (let i = 0; i < clickCount; i++) { await click(actualX, actualY); // 保留原始点击逻辑 - // log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})${i+1}次`); if (i < clickCount - 1) await sleep(clickDelay); // 非最后一次加间隔 } } else { @@ -374,10 +372,10 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u const targetKey = popupConfig.keyCode || "VK_SPACE"; // 新增:key_press用循环(默认3次,1000ms间隔,与原硬编码逻辑一致) const pressCount = popupConfig.loopCount || 3; - const pressDelay = popupConfig.loopDelay || 500; + const pressDelay = popupConfig.loopDelay || 1000; for (let i = 0; i < pressCount; i++) { keyPress(targetKey); // 保留原始按键逻辑 - log.info(`【${dirName}】弹窗触发按键【${targetKey}】${i+1}次`); + log.info(`【${dirName}】弹窗触发按键【${targetKey}】${i + 1}次`); if (i < pressCount - 1) await sleep(pressDelay); // 非最后一次加间隔 } log.info(`【${dirName}】弹窗触发按键【${targetKey}】,共${pressCount}次,间隔${pressDelay}ms`); @@ -392,8 +390,8 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u } const ocrResults = await performOcr(targetTexts, xRange, yRange, timeout, ra); if (ocrResults.length > 0) { - const ocrActualX = Math.round(ocrResults[0].x + ocrResults[0].width/2) + xOffset; - const ocrActualY = Math.round(ocrResults[0].y + ocrResults[0].height/2) + yOffset; + const ocrActualX = Math.round(ocrResults[0].x + ocrResults[0].width / 2) + xOffset; + const ocrActualY = Math.round(ocrResults[0].y + ocrResults[0].height / 2) + yOffset; // 新增:OCR点击加循环(默认1次,0间隔,与原逻辑一致) const ocrCount = popupConfig.loopCount; const ocrDelay = popupConfig.loopDelay; @@ -443,7 +441,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u const defaultDelay = popupConfig.loopDelay; for (let i = 0; i < defaultCount; i++) { await click(actualX, actualY); // 保留原始默认点击逻辑 - log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})${i+1}次`); + log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})${i + 1}次`); if (i < defaultCount - 1) await sleep(defaultDelay); // 非最后一次加间隔 } isAnySuccess = true; diff --git a/repo/js/背包材料统计/lib/ocr.js b/repo/js/背包材料统计/lib/ocr.js index 44aef5aae..d8f0db10e 100644 --- a/repo/js/背包材料统计/lib/ocr.js +++ b/repo/js/背包材料统计/lib/ocr.js @@ -1,122 +1,124 @@ -// 定义替换映射表 -const replacementMap = { - "监": "盐", - "炽": "烬", - "盞": "盏", - "攜": "携", - "於": "于", - "卵": "卯" -}; - - -/** - * 执行OCR识别并匹配目标文本(失败自动重截图,返回结果+有效截图) - * @param {string[]} targetTexts - 待匹配的目标文本列表 - * @param {Object} xRange - X轴范围 { min: number, max: number } - * @param {Object} yRange - Y轴范围 { min: number, max: number } - * @param {Object} ra - 初始图像捕获对象(外部传入,需确保已初始化) - * @param {number} timeout - 超时时间(毫秒),默认200ms - * @param {number} interval - 重试间隔(毫秒),默认50ms - * @returns {Promise<{ - * results: Object[], // 识别结果数组(含文本、坐标) - * screenshot: Object // 有效截图(成功时用的截图/超时前最后一次截图) - * }>} - */ -async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, interval = 50) { - // 正则特殊字符转义工具函数(避免替换时的正则语法错误) - const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - const startTime = Date.now(); // 记录开始时间,用于超时判断 - let currentScreenshot = ra; // 跟踪当前有效截图(初始为外部传入的原图) - - // 1. 初始参数校验(提前拦截无效输入) - if (!currentScreenshot) { - throw new Error("初始图像捕获对象(ra)未初始化,请传入有效截图"); - } - const regionWidth = xRange.max - xRange.min; - const regionHeight = yRange.max - yRange.min; - if (regionWidth <= 0 || regionHeight <= 0) { - throw new Error(`无效的识别区域:宽=${regionWidth}, 高=${regionHeight}`); - } - - // 在超时时间内循环重试识别(处理临时失败,自动重截图) - while (Date.now() - startTime < timeout) { - // 额外增加空值检查,防止currentScreenshot变为null - if (!currentScreenshot) { - log.error("currentScreenshot为null,尝试重新捕获"); - currentScreenshot = captureGameRegion(); - await sleep(interval); - continue; - } - - try { - // 2. 执行OCR识别(基于当前有效截图的指定区域) - const resList = currentScreenshot.findMulti( - RecognitionObject.ocr(xRange.min, yRange.min, regionWidth, regionHeight) - ); - - // 3. 处理识别结果(文本修正 + 目标匹配) - const results = []; - for (let i = 0; i < resList.count; i++) { - const res = resList[i]; - let correctedText = res.text; // 修正后的文本 - const originalText = res.text; // 保留原始识别文本(便于调试) - log.debug(`OCR原始文本: ${res.text}`); - - // 3.1 修正OCR常见错误(基于替换映射表) - for (const [wrongChar, correctChar] of Object.entries(replacementMap)) { - const escapedWrong = escapeRegExp(wrongChar); - correctedText = correctedText.replace(new RegExp(escapedWrong, 'g'), correctChar); - } - - // 3.2 匹配目标文本(避免重复添加同一结果) - const isTargetMatched = targetTexts.some(target => correctedText.includes(target)); - if (isTargetMatched) { - results.push({ - text: correctedText, // 最终修正后的文本 - originalText: originalText, // 原始识别文本(调试用) - x: res.x, y: res.y, // 文本在截图中的X/Y坐标 - width: res.width, height: res.height // 文本区域尺寸 - }); - } - } - - // 4. 识别成功:返回「结果数组 + 本次成功用的截图」 - // log.info(`OCR识别完成,匹配到${results.length}个目标文本`); - return { - results: results, - screenshot: currentScreenshot // 成功截图(与结果对应的有效画面) - }; - - } catch (error) { - // 5. 识别失败:释放旧截图→重新捕获→更新当前截图 - if (currentScreenshot) { - // 检查是否存在释放方法,支持不同可能的命名 - if (typeof currentScreenshot.Dispose === 'function') { - currentScreenshot.Dispose(); - } else if (typeof currentScreenshot.dispose === 'function') { - currentScreenshot.dispose(); - } - log.debug("已释放旧截图资源,准备重新捕获"); - } - - // 重新捕获后增加null校验 - currentScreenshot = captureGameRegion(); - if (!currentScreenshot) { - log.error("重新捕获截图失败,返回了null值"); - } - - log.error(`OCR识别异常(已重新截图,将重试): ${error.message}`); - await sleep(5); // 短暂等待,避免高频截图占用CPU/内存 - } - - await sleep(interval); // 每次重试间隔(默认50ms) - } - - // 6. 超时未成功:返回「空结果 + 超时前最后一次截图」 - log.warn(`OCR识别超时(超过${timeout}ms)`); - return { - results: [], - screenshot: currentScreenshot // 超时前最后一次有效截图(可用于排查原因) - }; -} +// 定义替换映射表 +const replacementMap = { + "监": "盐", + "炽": "烬", + "盞": "盏", + "攜": "携", + "於": "于", + "卵": "卯" +}; + + +/** + * 执行OCR识别并匹配目标文本(失败自动重截图,返回结果+有效截图) + * @param {string[]} targetTexts - 待匹配的目标文本列表 + * @param {Object} xRange - X轴范围 { min: number, max: number } + * @param {Object} yRange - Y轴范围 { min: number, max: number } + * @param {Object} ra - 初始图像捕获对象(外部传入,需确保已初始化) + * @param {number} timeout - 超时时间(毫秒),默认200ms + * @param {number} interval - 重试间隔(毫秒),默认50ms + * @returns {Promise<{ + * results: Object[], // 识别结果数组(含文本、坐标) + * screenshot: Object // 有效截图(成功时用的截图/超时前最后一次截图) + * }>} + */ +async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, interval = 50) { + // 正则特殊字符转义工具函数(避免替换时的正则语法错误) + const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const startTime = Date.now(); // 记录开始时间,用于超时判断 + let currentScreenshot = ra; // 跟踪当前有效截图(初始为外部传入的原图) + + // 1. 初始参数校验(提前拦截无效输入) + if (!currentScreenshot) { + throw new Error("初始图像捕获对象(ra)未初始化,请传入有效截图"); + } + const regionWidth = xRange.max - xRange.min; + const regionHeight = yRange.max - yRange.min; + if (regionWidth <= 0 || regionHeight <= 0) { + throw new Error(`无效的识别区域:宽=${regionWidth}, 高=${regionHeight}`); + } + + // 在超时时间内循环重试识别(处理临时失败,自动重截图) + while (Date.now() - startTime < timeout) { + // 额外增加空值检查,防止currentScreenshot变为null + if (!currentScreenshot) { + log.error("currentScreenshot为null,尝试重新捕获"); + currentScreenshot = captureGameRegion(); + await sleep(interval); + continue; + } + + try { + // 2. 执行OCR识别(基于当前有效截图的指定区域) + const resList = currentScreenshot.findMulti( + RecognitionObject.ocr(xRange.min, yRange.min, regionWidth, regionHeight) + ); + + // 3. 处理识别结果(文本修正 + 目标匹配) + const results = []; + for (let i = 0; i < resList.count; i++) { + const res = resList[i]; + let correctedText = res.text; // 修正后的文本 + const originalText = res.text; // 保留原始识别文本(便于调试) + log.debug(`OCR原始文本: ${res.text}`); + + // 3.1 修正OCR常见错误(基于替换映射表) + for (const [wrongChar, correctChar] of Object.entries(replacementMap)) { + const escapedWrong = escapeRegExp(wrongChar); + correctedText = correctedText.replace(new RegExp(escapedWrong, 'g'), correctChar); + } + + // 3.2 匹配目标文本(双向匹配,避免重复添加同一结果) + const isTargetMatched = targetTexts.some(target => + correctedText.includes(target) || target.includes(correctedText) + ); + if (isTargetMatched) { + results.push({ + text: correctedText, // 最终修正后的文本 + originalText: originalText, // 原始识别文本(调试用) + x: res.x, y: res.y, // 文本在截图中的X/Y坐标 + width: res.width, height: res.height // 文本区域尺寸 + }); + } + } + + // 4. 识别成功:返回「结果数组 + 本次成功用的截图」 + // log.info(`OCR识别完成,匹配到${results.length}个目标文本`); + return { + results: results, + screenshot: currentScreenshot // 成功截图(与结果对应的有效画面) + }; + + } catch (error) { + // 5. 识别失败:释放旧截图→重新捕获→更新当前截图 + if (currentScreenshot) { + // 检查是否存在释放方法,支持不同可能的命名 + if (typeof currentScreenshot.Dispose === 'function') { + currentScreenshot.Dispose(); + } else if (typeof currentScreenshot.dispose === 'function') { + currentScreenshot.dispose(); + } + log.debug("已释放旧截图资源,准备重新捕获"); + } + + // 重新捕获后增加null校验 + currentScreenshot = captureGameRegion(); + if (!currentScreenshot) { + log.error("重新捕获截图失败,返回了null值"); + } + + log.error(`OCR识别异常(已重新截图,将重试): ${error.message}`); + await sleep(5); // 短暂等待,避免高频截图占用CPU/内存 + } + + await sleep(interval); // 每次重试间隔(默认50ms) + } + + // 6. 超时未成功:返回「空结果 + 超时前最后一次截图」 + log.warn(`OCR识别超时(超过${timeout}ms)`); + return { + results: [], + screenshot: currentScreenshot // 超时前最后一次有效截图(可用于排查原因) + }; +} diff --git a/repo/js/背包材料统计/lib/region.js b/repo/js/背包材料统计/lib/region.js index dd5461684..422d7b4a2 100644 --- a/repo/js/背包材料统计/lib/region.js +++ b/repo/js/背包材料统计/lib/region.js @@ -5,16 +5,16 @@ var globalLatestRa = null; async function recognizeImage( - recognitionObject, - ra, - timeout = 1000, - interval = 500, - useNewScreenshot = false, + recognitionObject, + ra, + timeout = 1000, + interval = 500, + useNewScreenshot = false, iconType = null ) { let startTime = Date.now(); - globalLatestRa = ra; - const originalRa = ra; + globalLatestRa = ra; + const originalRa = ra; let tempRa = null; // 用于管理临时创建的资源 try { @@ -73,3 +73,79 @@ async function recognizeImage( usedNewScreenshot: useNewScreenshot }; } + +// 定义一个异步函数来绘制红框并延时清除 +async function drawAndClearRedBox(searchRegion, ra, delay = 500) { + let drawRegion = null; + try { + // 创建绘制区域 + drawRegion = ra.DeriveCrop( + searchRegion.x, searchRegion.y, + searchRegion.width, searchRegion.height + ); + drawRegion.DrawSelf("icon"); // 绘制红框 + + // 等待指定时间 + await sleep(delay); + + // 清除红框 - 使用更可靠的方式 + if (drawRegion && typeof drawRegion.DrawSelf === 'function') { + // 可能需要使用透明绘制来清除,或者绘制一个0大小的区域 + ra.DeriveCrop(0, 0, 0, 0).DrawSelf("icon"); + } + } catch (e) { + log.error("红框绘制异常:" + e.message); + } finally { + // 正确释放资源,如果dispose方法存在的话 + if (drawRegion && typeof drawRegion.dispose === 'function') { + drawRegion.dispose(); + } + } +} + +// 截图保存函数 +function imageSaver(mat, saveFile) { + // 获取当前时间并格式化为 "YYYY-MM-DD_HH-MM-SS" + const now = new Date(); + const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}-${String(now.getSeconds()).padStart(2, '0')}`; + + // 获取当前脚本所在的目录 + const scriptDir = getScriptDirPath(); + if (!scriptDir) { + log.error("无法获取脚本目录"); + return; + } + + // 构建完整的目标目录路径和文件名 + const savePath = `${scriptDir}/${saveFile}/screenshot_${timestamp}.png`; + const tempFilePath = `${scriptDir}/${saveFile}`; + + // 检查临时文件是否存在,如果不存在则创建目录 + try { + // 尝试读取临时文件 + file.readPathSync(tempFilePath); + log.info("目录存在,继续执行保存图像操作"); + } catch (error) { + log.error(`确保目录存在时出错: ${error}`); + return; + } + + // 保存图像 + try { + mat.saveImage(savePath); + // log.info(`图像已成功保存到: ${savePath}`); + } catch (error) { + log.error(`保存图像失败: ${error}`); + } +} + +// 获取脚本目录 +function getScriptDirPath() { + try { + safeReadTextSync(`temp-${Math.random()}.txt`); + } catch (e) { + const match = e.toString().match(/'([^']+)'/); + return match ? match[1].replace(/\\[^\\]+$/, "") : null; + } + return null; +} \ No newline at end of file diff --git a/repo/js/背包材料统计/main.js b/repo/js/背包材料统计/main.js index 53721c5a6..b141cf57b 100644 --- a/repo/js/背包材料统计/main.js +++ b/repo/js/背包材料统计/main.js @@ -1,3 +1,74 @@ +// ============================================== +// 内容检测码生成(通用哈希逻辑) +// ============================================== +function generateContentCode(positions) { + try { + const serialized = JSON.stringify( + positions.map(pos => ({ + type: pos.type, + x: parseFloat(pos.x).toFixed(2), + y: parseFloat(pos.y).toFixed(2) + })) + ); + let hash = 0; + for (let i = 0; i < serialized.length; i++) { + const char = serialized.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // 转换为32位整数 + } + return ((hash >>> 0).toString(16).padStart(8, '0')).slice(-8); + } catch (error) { + log.warn(`生成检测码失败: ${error.message},使用默认值`); + return "00000000"; + } +} + +/** + * 从文件名中提取内容检测码 + * @param {string} fileName - 文件名 + * @returns {string|null} 内容检测码,未找到返回null + */ +function extractContentCodeFromFileName(fileName) { + const match = fileName.match(/_([0-9a-fA-F]{8})\.json$/); + return match ? match[1].toLowerCase() : null; +} + +/** + * 读取路径文件并生成内容检测码 + * @param {string} pathingFilePath - 路径文件路径 + * @returns {string} 内容检测码 + */ +function generatePathContentCode(pathingFilePath) { + try { + // 从文件名中提取检测码 + const fileName = basename(pathingFilePath); + const extractedCode = extractContentCodeFromFileName(fileName); + if (extractedCode) { + return extractedCode; + } + + // 如果文件名中没有检测码,生成新的 + const content = safeReadTextSync(pathingFilePath); + if (!content) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}路径文件为空: ${pathingFilePath}`); + return "00000000"; + } + + const pathData = JSON.parse(content); + const positions = pathData.positions || pathData.actions || []; + + if (!Array.isArray(positions) || positions.length === 0) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}路径文件无有效位置数据: ${pathingFilePath}`); + return "00000000"; + } + + return generateContentCode(positions); + } catch (error) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}生成路径检测码失败: ${error.message}`); + return "00000000"; + } +} + // ============================================== // 常量与配置(集中管理硬编码值) // ============================================== @@ -10,14 +81,15 @@ const CONSTANTS = { NO_RECORD_DIR: "pathing_record/noRecord", IMAGES_DIR: "assets/images", MONSTER_MATERIALS_PATH: "assets/Monster-Materials.txt", - + // 解析与处理配置 MAX_PATH_DEPTH: 3, // 路径解析最大深度 NOTIFICATION_CHUNK_SIZE: 500, // 通知拆分长度 FOOD_EXP_RECORD_SUFFIX: "_狗粮.txt", + FOOD_ZERO_EXP_SUFFIX: "_狗粮-0.txt", // 新增:狗粮0 EXP记录后缀 SUMMARY_FILE_NAME: "材料收集汇总.txt", ZERO_COUNT_SUFFIX: "-0.txt", - + // 日志模块标识 LOG_MODULES: { INIT: "[初始化]", @@ -34,12 +106,12 @@ const CONSTANTS = { // 引入外部脚本(源码不变) // ============================================== eval(file.readTextSync("lib/file.js")); -eval(file.readTextSync("lib/ocr.js")); -eval(file.readTextSync("lib/autoPick.js")); -eval(file.readTextSync("lib/exp.js")); -eval(file.readTextSync("lib/backStats.js")); -eval(file.readTextSync("lib/imageClick.js")); -eval(file.readTextSync("lib/displacement.js")); +eval(safeReadTextSync("lib/ocr.js")); +eval(safeReadTextSync("lib/autoPick.js")); +eval(safeReadTextSync("lib/exp.js")); +eval(safeReadTextSync("lib/backStats.js")); +eval(safeReadTextSync("lib/imageClick.js")); +eval(safeReadTextSync("lib/displacement.js")); // ============================================== // 全局状态(保持不变) @@ -53,8 +125,8 @@ const timeCost = Math.min(300, Math.max(0, Math.floor(Number(settings.TimeCost) 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; +const exceedCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.ExceedCount) || 9000))); // 设定的超量目标数量 +const endTimeStr = settings.CurrentTime ? settings.CurrentTime : null; // 解析需要处理的CD分类 const allowedCDCategories = (settings.CDCategories || "") @@ -71,20 +143,21 @@ if (allowedCDCategories.length > 0) { // ============================================== // 材料与怪物映射管理 // ============================================== -// 材料分类映射 +// 材料分类映射 - 适配新的settings.json结构(只保留新的中文分类名称映射) const material_mapping = { - "General": "一般素材", - "Drops": "怪物掉落素材", - "CookingIngs": "烹饪食材", - "ForagedFood": "采集食物", - "Weekly": "周本素材", - "Wood": "木材", - "CharAscension": "角色突破素材", - "Fishing": "鱼饵鱼类", - "Smithing": "锻造素材", - "Gems": "宝石", - "Talent": "角色天赋素材", - "WeaponAscension": "武器突破素材", + // 适配新的settings.json分类选项 + "矿石、原胚": "锻造素材", + "经验书、怪物掉落": "怪物掉落素材", + "一般素材": "一般素材", + "采集食物": "采集食物", + "烹饪用食材": "烹饪食材", + "世界BOSS": "角色突破素材", + "木材": "木材", + "鱼饵、鱼类": "鱼饵鱼类", + "宝石": "宝石", + "天赋素材": "角色天赋素材", + "武器突破": "武器突破素材", + "祝圣精华": "祝圣精华" }; // 怪物-材料映射(双向,优化为Set提高查找效率) @@ -97,16 +170,16 @@ let materialToMonsters = {}; // 材料名 -> Set(关联怪物列表) */ function parseMonsterMaterials() { try { - const content = file.readTextSync(CONSTANTS.MONSTER_MATERIALS_PATH); + const content = safeReadTextSync(CONSTANTS.MONSTER_MATERIALS_PATH); const lines = content.split('\n').map(line => line.trim()).filter(line => line); - + lines.forEach(line => { if (!line.includes(':')) return; const [monsterName, materialsStr] = line.split(':'); const materials = materialsStr.split(/[,,、 \s]+/) .map(mat => mat.trim()) .filter(mat => mat); - + if (monsterName && materials.length > 0) { monsterToMaterials[monsterName] = materials; materials.forEach(mat => { @@ -127,7 +200,8 @@ parseMonsterMaterials(); // 初始化怪物材料映射 // 路径模式配置 // ============================================== const pathingValue = settings.Pathing || ''; -const pathingPrefix = String(pathingValue).split('.')[0]; +// 从选择字符串中提取前缀数字,适配新的settings.json结构 +const pathingPrefix = pathingValue.match(/^(\d+)/) ? pathingValue.match(/^(\d+)/)[1] : ''; const pathingMode = { includeBoth: pathingPrefix === "1", @@ -148,38 +222,54 @@ if (pathingMode.onlyPathing) log.warn(`${CONSTANTS.LOG_MODULES.PATH}已开启【 // ============================================== // 材料分类处理 // ============================================== -/** - * 初始化并筛选选中的材料分类 - * @returns {string[]} 选中的材料分类列表 - */ +/** + * 初始化并筛选选中的材料分类(适配multi-checkbox的Categories配置) + * @returns {string[]} 选中的材料分类列表 + */ function getSelectedMaterialCategories() { - const initialSettings = Object.keys(material_mapping).reduce((acc, key) => { - acc[key] = false; - return acc; - }, {}); + // 使用Array.from()确保将settings.Categories转换为真正的数组,适配multi-checkbox返回的类数组对象 + let selectedCategories = []; - const finalSettings = Object.keys(initialSettings).reduce((acc, key) => { - // 若settings中有该键则使用其值,否则用默认的false(确保只处理material_mapping中的键) - acc[key] = settings.hasOwnProperty(key) ? settings[key] : initialSettings[key]; - return acc; - }, {}); + try { + selectedCategories = Array.from(settings.Categories || []); + } catch (e) { + log.error(`${CONSTANTS.LOG_MODULES.MATERIAL}获取分类设置失败: ${e.message}`); + } - return Object.keys(finalSettings) - .filter(key => key !== "unselected") - .filter(key => { - if (typeof finalSettings[key] !== 'boolean') { - log.warn(`${CONSTANTS.LOG_MODULES.MATERIAL}非布尔值的键: ${key}, 值: ${finalSettings[key]}`); - return false; + // 兼容旧的checkbox字段名 + if (!selectedCategories || selectedCategories.length === 0) { + const checkboxToCategory = { + "Smithing": "矿石、原胚", + "Drops": "经验书、怪物掉落", + "ForagedFood": "采集食物", + "General": "一般素材", + "CookingIngs": "烹饪用食材", + "Weekly": "周本素材", + "Wood": "木材", + "CharAscension": "世界BOSS", + "Fishing": "鱼饵、鱼类", + "Gems": "宝石", + "Talent": "天赋素材", + "WeaponAscension": "武器突破", + "XP": "祝圣精华" + }; + + Object.keys(checkboxToCategory).forEach(checkboxName => { + if (settings[checkboxName] === true) { + selectedCategories.push(checkboxToCategory[checkboxName]); } - return finalSettings[key]; - }) - .map(name => { - if (!material_mapping[name]) { - log.warn(`${CONSTANTS.LOG_MODULES.MATERIAL}material_mapping中缺失的键: ${name}`); - return null; - } - return material_mapping[name]; - }) + }); + } + + // 默认分类 + if (!selectedCategories || selectedCategories.length === 0) { + selectedCategories = ["一般素材", "烹饪用食材"]; + } + + // 过滤无效值并映射到实际分类 + return selectedCategories + .filter(cat => typeof cat === 'string' && cat !== "") + .map(name => material_mapping[name] || "锻造素材") .filter(name => name !== null); } @@ -302,7 +392,7 @@ function readMaterialCD() { for (const filePath of materialFilePaths) { if (state.cancelRequested) break; - const content = file.readTextSync(filePath); + const content = safeReadTextSync(filePath); if (!content) { log.error(`${CONSTANTS.LOG_MODULES.CD}加载文件失败:${filePath}`); continue; @@ -319,7 +409,7 @@ function readMaterialCD() { } // ============================================== -// 时间工具 +// 时间工具(核心修改:新增剩余时间计算) // ============================================== /** * 获取当前时间(小时,含小数) @@ -357,7 +447,7 @@ function getRemainingMinutesToEndTime(endTimeStr) { } // ============================================== -// 记录管理 +// 记录管理(核心修改:公共函数+缓存复用) // ============================================== /** * 写入内容到文件(追加模式) @@ -368,7 +458,7 @@ function writeContentToFile(filePath, content) { try { let existingContent = ''; try { - existingContent = file.readTextSync(filePath); + existingContent = safeReadTextSync(filePath); } catch (readError) { log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在或读取失败: ${filePath}`); } @@ -390,14 +480,20 @@ function writeContentToFile(filePath, content) { * @param {string} resourceName - 资源名 * @param {string} pathName - 路径名 * @param {string} recordDir - 记录目录 + * @param {boolean} isFood - 是否为狗粮路径(新增参数) * @returns {boolean} 是否允许运行(true=允许) */ -function checkPathNameFrequency(resourceName, pathName, recordDir) { - const recordPath = `${recordDir}/${resourceName}${CONSTANTS.ZERO_COUNT_SUFFIX}`; +function checkPathNameFrequency(resourceName, pathName, recordDir, isFood = false) { + // ========== 核心修改:适配狗粮0记录文件 ========== + let suffix = CONSTANTS.ZERO_COUNT_SUFFIX; // 普通材料默认-0.txt + if (isFood) { + suffix = CONSTANTS.FOOD_ZERO_EXP_SUFFIX; // 狗粮用_狗粮-0.txt + } + const recordPath = `${recordDir}/${resourceName}${suffix}`; let totalCount = 0; try { - const content = file.readTextSync(recordPath); + const content = safeReadTextSync(recordPath); const lines = content.split('\n'); lines.forEach(line => { @@ -406,11 +502,13 @@ function checkPathNameFrequency(resourceName, pathName, recordDir) { } }); } catch (error) { - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${recordDir}中无${resourceName}记录,跳过检查`); + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${recordDir}中无${resourceName}${suffix}记录,跳过检查`); } + // 重复次数≥3则禁止运行(仅统计0记录) if (totalCount >= 3) { - log.info(`${CONSTANTS.LOG_MODULES.RECORD}路径文件: ${pathName},普通模式累计0采集${totalCount}次,请清理记录后再执行`); + const typeDesc = isFood ? "狗粮" : "普通材料"; + log.info(`${CONSTANTS.LOG_MODULES.RECORD}${typeDesc}路径文件: ${pathName},累计0 EXP/0数量运行${totalCount}次,请清理记录后再执行`); return false; } return true; @@ -427,12 +525,15 @@ function checkPathNameFrequency(resourceName, pathName, recordDir) { * @param {Object} materialCountDifferences - 材料数量变化 * @param {number} finalCumulativeDistance - 累计移动距离 */ -function recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences = {}, finalCumulativeDistance) { +function recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences = {}, finalCumulativeDistance, pathingFilePath) { const recordPath = `${recordDir}/${resourceName}.txt`; - const normalContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; + // 生成内容检测码 + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; + const normalContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; try { if (runTime >= 3) { // 运行时间≥3秒才处理记录 + // 怪物路径专用逻辑(判断对应材料总数量是否为0) const isMonsterPath = monsterToMaterials.hasOwnProperty(resourceName); // 是否为怪物路径 if (isMonsterPath) { // 1. 获取当前怪物对应的所有目标材料(从已有映射中取) @@ -445,21 +546,23 @@ function recordRunTime(resourceName, pathName, startTime, endTime, runTime, reco // 3. 若总数量为0,生成怪物专用0记录文件(文件名含“总0”标识,避免混淆) if (monsterMaterialsTotal === 0) { const zeroMonsterPath = `${recordDir}/${resourceName}${CONSTANTS.ZERO_COUNT_SUFFIX}`; - const zeroMonsterContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; + const zeroMonsterContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; writeContentToFile(zeroMonsterPath, zeroMonsterContent); log.warn(`${CONSTANTS.LOG_MODULES.RECORD}怪物【${resourceName}】对应材料总数量为0,已写入单独文件: ${zeroMonsterPath}`); } } + // 普通材料0记录逻辑 for (const [material, count] of Object.entries(materialCountDifferences)) { if (material === resourceName && count === 0) { const zeroMaterialPath = `${recordDir}/${material}${CONSTANTS.ZERO_COUNT_SUFFIX}`; - const zeroMaterialContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; + const zeroMaterialContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`; writeContentToFile(zeroMaterialPath, zeroMaterialContent); log.warn(`${CONSTANTS.LOG_MODULES.RECORD}材料数目为0,已写入单独文件: ${zeroMaterialPath}`); } } + // 正常记录生成逻辑 const hasZeroMaterial = Object.values(materialCountDifferences).includes(0); const isFinalCumulativeDistanceZero = finalCumulativeDistance === 0; @@ -487,29 +590,56 @@ function recordRunTime(resourceName, pathName, startTime, endTime, runTime, reco * @param {string} noRecordDir - 无记录目录 * @returns {string|null} 上次结束时间字符串(null=无记录) */ -function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir) { +function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath) { const checkDirs = [recordDir, noRecordDir]; let latestEndTime = null; + // 生成内容检测码 + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null; + + // 清理路径名中的检测码 + const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + checkDirs.forEach(dir => { const recordPath = `${dir}/${resourceName}.txt`; try { - const content = file.readTextSync(recordPath); + const content = safeReadTextSync(recordPath); const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('路径名: ') && lines[i].split('路径名: ')[1] === pathName) { - const endTimeLine = lines[i + 2]; - if (endTimeLine?.startsWith('结束时间: ')) { - const endTimeStr = endTimeLine.split('结束时间: ')[1]; - const endTime = new Date(endTimeStr); + // 按空行分割成记录块 + const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); - if (!latestEndTime || endTime > new Date(latestEndTime)) { - latestEndTime = endTimeStr; - } + recordBlocks.forEach(block => { + const blockLines = block.split('\n'); + let blockPathName = ''; + let blockContentCode = '00000000'; + let blockEndTime = null; + + blockLines.forEach(line => { + if (line.startsWith('路径名: ')) { + blockPathName = line.split('路径名: ')[1]; + } else if (line.startsWith('内容检测码: ')) { + blockContentCode = line.split('内容检测码: ')[1] || '00000000'; + } else if (line.startsWith('结束时间: ')) { + blockEndTime = line.split('结束时间: ')[1]; + } + }); + + // 清理记录中的路径名检测码 + const cleanBlockPathName = blockPathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + + // 匹配条件:路径名相同 或者 内容检测码相同(新逻辑) + const isPathMatch = cleanBlockPathName === cleanPathName; + const isContentCodeMatch = contentCode && blockContentCode === contentCode; + const isMatch = isPathMatch || isContentCodeMatch; + + if (isMatch && blockEndTime) { + const endTime = new Date(blockEndTime); + if (!latestEndTime || endTime > new Date(latestEndTime)) { + latestEndTime = blockEndTime; } } - } + }); } catch (error) { log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${dir}中无${resourceName}记录,跳过检查`); } @@ -519,17 +649,26 @@ function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir) { } /** - * 计算单次时间成本(平均耗时/材料数量) - * @param {string} resourceName - 资源名(普通材料名/怪物名) + * 公共函数:读取路径历史记录(支持缓存复用,避免重复读文件) + * @param {string} resourceKey - 记录键(怪物名/材料名) * @param {string} pathName - 路径名 - * @param {string} recordDir - 记录目录 - * @returns {number|null} 时间成本(秒/中级单位),null=无法计算 + * @param {string} recordDir - 主记录目录 + * @param {string} noRecordDir - 备用记录目录 + * @param {boolean} isFood - 是否为狗粮路径 + * @param {Object} cache - 缓存对象(单次路径处理周期内有效) + * @returns {Array} 结构化记录列表(含runTime、quantityChange) */ -function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, isFood = false, cache = {}) { +function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) { + // 生成内容检测码 + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null; + + // 清理路径名中的检测码 + const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + // 1. 生成唯一缓存键(确保不同路径/不同文件的记录不混淆) const isFoodSuffix = isFood ? CONSTANTS.FOOD_EXP_RECORD_SUFFIX : ".txt"; const recordFile = `${recordDir}/${resourceKey}${isFoodSuffix}`; - const cacheKey = `${recordFile}|${pathName}`; // 键格式:文件路径|路径名 + const cacheKey = `${recordFile}|${cleanPathName}|${contentCode || "00000000"}`; // 键格式:文件路径|清理后的路径名|内容检测码 // 2. 优先从缓存获取,命中则直接返回(不读文件) if (cache[cacheKey]) { @@ -544,11 +683,11 @@ function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, // 读主目录→读备用目录 try { - content = file.readTextSync(targetFile); + content = safeReadTextSync(targetFile); } catch (mainErr) { targetFile = `${noRecordDir}/${resourceKey}${isFoodSuffix}`; try { - content = file.readTextSync(targetFile); + content = safeReadTextSync(targetFile); log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从备用目录读取记录:${targetFile}`); } catch (backupErr) { log.debug(`${CONSTANTS.LOG_MODULES.RECORD}无${resourceKey}的历史记录:${targetFile}`); @@ -558,33 +697,58 @@ function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, } } - // 解析记录(按原有格式提取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 = {}; + // 先按空行分割成独立的记录块,避免跨记录解析 + const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); - // 提取运行时间(秒) - 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}`); + recordBlocks.forEach(block => { + const blockLines = block.split('\n').map(line => line.trim()).filter(line => line); + let runTime = 0; + let quantityChange = {}; + let isTargetPath = false; + let recordContentCode = "00000000"; + + // 遍历当前记录块的每一行,找关键字 + blockLines.forEach(line => { + // 1. 判断是否是目标路径 + if (line.startsWith('路径名: ')) { + const recordPathName = line.split('路径名: ')[1]; + // 清理记录中的路径名检测码 + const cleanRecordPathName = recordPathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + if (cleanRecordPathName === cleanPathName) { + isTargetPath = true; } } - - if (runTime > 0) { - records.push({ runTime, quantityChange }); + // 2. 提取内容检测码 + if (line.startsWith('内容检测码: ')) { + recordContentCode = line.split('内容检测码: ')[1] || "00000000"; } + // 3. 提取运行时间 + if (line.startsWith('运行时间: ')) { + runTime = parseInt(line.split('运行时间: ')[1].split('秒')[0], 10) || 0; + } + // 4. 提取EXP(狗粮)或数量变化(普通材料) + if (line.startsWith('本次EXP获取: ')) { + const exp = parseInt(line.split('本次EXP获取: ')[1], 10) || 0; + quantityChange = { exp: exp }; + } else if (line.startsWith('数量变化: ')) { + try { + quantityChange = JSON.parse(line.split('数量变化: ')[1]) || {}; + } catch (e) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}解析数量变化失败:${line}`); + } + } + }); + + // 匹配条件:路径名相同 或者 内容检测码相同(新逻辑) + const isContentCodeMatch = contentCode && recordContentCode === contentCode; + const shouldInclude = (isTargetPath || isContentCodeMatch) && runTime > 0; + + if (shouldInclude) { + records.push({ runTime, quantityChange, contentCode: recordContentCode }); } - } + }); // 4. 将读取到的记录写入缓存,供后续复用 cache[cacheKey] = records; @@ -614,12 +778,13 @@ function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) { // 调用公共函数获取记录(复用缓存) const historicalRecords = getHistoricalPathRecords( - resourceKey, - pathName, - recordDir, - noRecordDir, - isFood, - cache + resourceKey, + pathName, + recordDir, + noRecordDir, + isFood, + cache, + pathingFilePath ); // 无记录时,默认5分钟(300秒) @@ -648,16 +813,17 @@ function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) { * @param {Object} cache - 缓存对象 * @returns {number|null} 时间成本 */ -function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}) { +function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) { const isMonster = monsterToMaterials.hasOwnProperty(resourceName); // 调用公共函数获取记录(复用缓存) const historicalRecords = getHistoricalPathRecords( - resourceName, - pathName, - recordDir, - noRecordDir, - isFood, - cache + resourceName, + pathName, + recordDir, + noRecordDir, + isFood, + cache, + pathingFilePath ); // 有效记录不足3条,返回null @@ -670,7 +836,7 @@ function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood if (isMonster) { // 怪物路径:按中级单位计算 const monsterMaterials = monsterToMaterials[resourceName]; - const gradeRatios = [3, 1, 1/3]; // 最高级×3,中级×1,最低级×1/3 + const gradeRatios = [3, 1, 1 / 3]; // 最高级×3,中级×1,最低级×1/3 historicalRecords.forEach(record => { const { runTime, quantityChange } = record; @@ -686,6 +852,16 @@ function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood completeRecords.push(parseFloat((runTime / totalMiddleCount).toFixed(2))); } }); + } else if (isFood) { + // 狗粮路径:按EXP计算时间成本 + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + const expValue = quantityChange.exp || 0; + if (expValue > 0) { + // 计算:秒/单位EXP + completeRecords.push(parseFloat((runTime / expValue).toFixed(2))); + } + }); } else { // 普通材料路径:直接按材料数量计算 historicalRecords.forEach(record => { @@ -696,7 +872,7 @@ function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood }); } - // 异常值过滤与平均值计算(原有逻辑不变) + // 异常值过滤与平均值计算 if (completeRecords.length < 3) { log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效效率记录不足3条,无法计算时间成本`); return null; @@ -707,10 +883,10 @@ function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood 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; - } + 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)); } @@ -742,18 +918,18 @@ function canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName) { log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRunTime.toLocaleString()}`); return canRun; } else if (refreshCD.type === 'specific') { - const specificHour = refreshCD.hour; - const currentDate = new Date(); - const lastDate = new Date(lastEndTimeDate); - const todayRefresh = new Date(currentDate); - todayRefresh.setHours(specificHour, 0, 0, 0); - if (currentDate > todayRefresh && currentDate.getDate() !== lastDate.getDate()) { - return true; - } - const nextRefreshTime = new Date(todayRefresh); - if (currentDate >= todayRefresh) nextRefreshTime.setDate(nextRefreshTime.getDate() + 1); - log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRefreshTime.toLocaleString()}`); - return false; + const specificHour = refreshCD.hour; + const currentDate = new Date(); + const lastDate = new Date(lastEndTimeDate); + const todayRefresh = new Date(currentDate); + todayRefresh.setHours(specificHour, 0, 0, 0); + if (currentDate > todayRefresh && currentDate.getDate() !== lastDate.getDate()) { + return true; + } + const nextRefreshTime = new Date(todayRefresh); + if (currentDate >= todayRefresh) nextRefreshTime.setDate(nextRefreshTime.getDate() + 1); + log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRefreshTime.toLocaleString()}`); + return false; } else if (refreshCD.type === 'instant') { return true; } @@ -785,7 +961,7 @@ const imageMapCache = new Map(); // 保持固定,不动态刷新 const createImageCategoryMap = (imagesDir) => { const map = {}; const imageFiles = readAllFilePaths(imagesDir, 0, 1, ['.png']); - + for (const imagePath of imageFiles) { const pathParts = imagePath.split(/[\\/]/); if (pathParts.length < 3) continue; @@ -794,12 +970,12 @@ const createImageCategoryMap = (imagesDir) => { .replace(/\.png$/i, '') .trim() .toLowerCase(); - + if (!(imageName in map)) { map[imageName] = pathParts[2]; } } - + return map; }; @@ -813,7 +989,7 @@ const loggedResources = new Set(); */ function matchImageAndGetCategory(resourceName, imagesDir) { const processedName = (MATERIAL_ALIAS[resourceName] || resourceName).toLowerCase(); - + if (!imageMapCache.has(imagesDir)) { log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}初始化图像分类缓存:${imagesDir}`); imageMapCache.set(imagesDir, createImageCategoryMap(imagesDir)); @@ -823,13 +999,13 @@ function matchImageAndGetCategory(resourceName, imagesDir) { if (result) { // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`); } else { - log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`); + // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`); } if (!loggedResources.has(processedName)) { loggedResources.add(processedName); } - + return result; } @@ -843,14 +1019,14 @@ const specialMaterials = [ let excessMaterialNames = []; // 超量材料名单 -// 筛选低数量材料(保留原逻辑+修正超量判断) +// 筛选低数量材料 + 平行标记超量材料(同源allMaterials) function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { - // 超量阈值(普通材料9999,矿石处理后也是9999) + // 超量阈值(普通材料/矿石处理后统一对比) const EXCESS_THRESHOLD = exceedCount; - // 临时存储本次超量材料 + // 临时存储超量材料(从原始数据提取,平行于低数量筛选) const tempExcess = []; - // 提取所有需要扫描的材料(含怪物材料) + // 提取所有需要扫描的材料(超量+低数量共用同一源) const allMaterials = Object.values(materialCategoryMap).flat(); log.info(`【材料基准】本次需扫描的全量材料:${allMaterials.join("、")}`); @@ -883,7 +1059,7 @@ function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { return 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); @@ -913,18 +1089,78 @@ async function runPathAndNotify(pathingFilePath, currentMaterialName) { // 路径处理(拆分巨型函数) // ============================================== /** - * 处理狗粮路径条目 + * 处理狗粮路径条目(完整校验:CD+时间成本+频率+运行时间+距离) * @param {Object} entry - 路径条目 { path, resourceName } * @param {Object} accumulators - 累加器 { foodExpAccumulator, currentMaterialName } * @param {string} recordDir - 记录目录 * @param {string} noRecordDir - 无记录目录 + * @param {Object} CDCategories - CD分类配置 + * @param {number} timeCost - 时间成本阈值 + * @param {Object} pathRecordCache - 记录缓存 * @returns {Object} 更新后的累加器 */ -async function processFoodPathEntry(entry, accumulators, recordDir, noRecordDir) { +async function processFoodPathEntry(entry, accumulators, recordDir, noRecordDir, CDCategories, timeCost, pathRecordCache) { const { path: pathingFilePath, resourceName } = entry; const pathName = basename(pathingFilePath); const { foodExpAccumulator, currentMaterialName: prevMaterialName } = accumulators; + // ========== 1. CD 冷却校验 ========== + let refreshCD = null; + for (const [categoryName, cdInfo] of Object.entries(CDCategories)) { + if (allowedCDCategories.length > 0 && !allowedCDCategories.includes(categoryName)) continue; + for (const [cdKey, cdItems] of Object.entries(cdInfo)) { + if (cdItems.includes(resourceName)) { + refreshCD = JSON.parse(cdKey); + break; + } + } + if (refreshCD) break; + } + + if (!refreshCD) { + log.debug(`${CONSTANTS.LOG_MODULES.CD}狗粮材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`); + await sleep(1); + return accumulators; + } + + // ========== 2. 路径0记录频率校验 ========== + const isPathValid = checkPathNameFrequency(resourceName, pathName, recordDir, true); // true=狗粮路径 + if (!isPathValid) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 0记录频率超限,跳过`); + await sleep(1); + return accumulators; + } + + // ========== 3. 时间成本校验 ========== + const currentTime = getCurrentTimeInHours(); + const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath); + const perTime = noRecord ? null : calculatePerTime( + resourceName, + pathName, + recordDir, + noRecordDir, + true, // 标记为狗粮路径 + pathRecordCache, + pathingFilePath + ); + + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 单位EXP耗时:${perTime ?? '忽略'}秒/EXP`); + + // 计算并输出预计耗时 + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir); + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 预计耗时:${estimatedTime}秒`); + + // 校验是否满足运行条件(CD + 0记录频率 + 时间成本) + const canRun = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName) + && isPathValid + && (noRecord || perTime === null || perTime <= timeCost); + + if (!canRun) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 不符合运行条件`); + await sleep(1); + return accumulators; + } + // 切换目标材料 let currentMaterialName = prevMaterialName; if (currentMaterialName !== resourceName) { @@ -946,18 +1182,35 @@ async function processFoodPathEntry(entry, accumulators, recordDir, noRecordDir) const endTime = new Date().toLocaleString(); const runTime = (new Date(endTime) - new Date(startTime)) / 1000; - // 处理分解与记录 + // 处理分解与EXP统计 const { success, totalExp } = await executeSalvageWithOCR(); foodExpAccumulator[resourceName] += totalExp; + // ========== 4. 运行时间≥3秒 + 累计距离>0 才记录 ========== const recordDirFinal = noRecord ? noRecordDir : recordDir; const foodRecordContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n移动距离: ${finalCumulativeDistance.toFixed(2)}\n分解状态: ${success ? "成功" : "失败"}\n本次EXP获取: ${totalExp}\n累计EXP获取: ${foodExpAccumulator[resourceName]}\n\n`; - writeContentToFile(`${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_EXP_RECORD_SUFFIX}`, foodRecordContent); - const foodMsg = `狗粮路径【${pathName}】执行完成\n耗时:${runTime.toFixed(1)}秒\n本次EXP:${totalExp}\n累计EXP:${foodExpAccumulator[resourceName]}`; - sendNotificationInChunks(foodMsg, notification.Send); + // 记录校验条件 + const canRecord = runTime >= 3 && finalCumulativeDistance > 0; + if (canRecord) { + if (totalExp === 0) { + // 情况1:EXP=0 → 写入专属0记录文件(_狗粮-0.txt),且先检查文件是否存在 + const zeroExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_ZERO_EXP_SUFFIX}`; + writeContentToFile(zeroExpFilePath, foodRecordContent); + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} EXP=0,写入0记录文件:${zeroExpFilePath}`); + } else { + // 情况2:EXP>0 → 写入普通狗粮记录文件(_狗粮.txt) + const normalExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_EXP_RECORD_SUFFIX}`; + writeContentToFile(normalExpFilePath, foodRecordContent); + // 发送有效运行通知 + const foodMsg = `狗粮路径【${pathName}】执行完成\n耗时:${runTime.toFixed(1)}秒\n本次EXP:${totalExp}\n累计EXP:${foodExpAccumulator[resourceName]}`; + sendNotificationInChunks(foodMsg, notification.Send); + } + } else { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} 不满足记录条件:运行时间${runTime.toFixed(1)}秒(需≥3秒)| 移动距离${finalCumulativeDistance.toFixed(2)}(需>0)`); + } - await sleep(1); // 保留sleep(1) + await sleep(1); return { ...accumulators, foodExpAccumulator, currentMaterialName }; } @@ -970,13 +1223,15 @@ async function processFoodPathEntry(entry, accumulators, recordDir, noRecordDir) async function processMonsterPathEntry(entry, context) { const { path: pathingFilePath, monsterName } = entry; const pathName = basename(pathingFilePath); - const { + const { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, - materialCategoryMap, flattenedLowCountMaterials, + materialCategoryMap, flattenedLowCountMaterials, currentMaterialName: prevMaterialName, materialAccumulatedDifferences, globalAccumulatedDifferences, - pathRecordCache // 新增:从上下文取缓存 + pathRecordCache } = context; + + // 检查怪物材料是否全部超量 const monsterMaterials = monsterToMaterials[monsterName] || []; const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); if (allExcess) { @@ -1006,19 +1261,24 @@ async function processMonsterPathEntry(entry, context) { // 检查是否可运行 const currentTime = getCurrentTimeInHours(); - const lastEndTime = getLastRunEndTime(monsterName, pathName, recordDir, noRecordDir); + const lastEndTime = getLastRunEndTime(monsterName, pathName, recordDir, noRecordDir, pathingFilePath); const isPathValid = checkPathNameFrequency(monsterName, pathName, recordDir); const perTime = noRecord ? null : calculatePerTime( - monsterName, - pathName, - recordDir, - noRecordDir, - false, - pathRecordCache // 新增:传递缓存 + monsterName, + pathName, + recordDir, + noRecordDir, + false, + pathRecordCache, + pathingFilePath ); log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 单个材料耗时:${perTime ?? '忽略'}`); + // 计算并输出预计耗时 + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, monsterName }, recordDir, noRecordDir); + log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 预计耗时:${estimatedTime}秒`); + if (!(canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName) && isPathValid && (noRecord || perTime === null || perTime <= timeCost))) { log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 不符合运行条件`); await sleep(1); @@ -1059,7 +1319,9 @@ async function processMonsterPathEntry(entry, context) { const endTime = new Date().toLocaleString(); const runTime = (new Date(endTime) - new Date(startTime)) / 1000; - const noRecordContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: noRecord模式忽略\n\n`; + // 生成内容检测码 + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; + const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: noRecord模式忽略\n\n`; writeContentToFile(`${noRecordDir}/${monsterName}.txt`, noRecordContent); } else { // 普通记录模式 @@ -1108,10 +1370,10 @@ async function processMonsterPathEntry(entry, context) { }); log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}怪物路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); - recordRunTime(monsterName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences, finalCumulativeDistance); + recordRunTime(monsterName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences, finalCumulativeDistance, pathingFilePath); } - await sleep(1); // 保留sleep(1) + await sleep(1); return { ...context, currentMaterialName, @@ -1130,12 +1392,12 @@ async function processMonsterPathEntry(entry, context) { async function processNormalPathEntry(entry, context) { const { path: pathingFilePath, resourceName } = entry; const pathName = basename(pathingFilePath); - const { + const { CDCategories, timeCost, recordDir, noRecordDir, - materialCategoryMap, flattenedLowCountMaterials, + materialCategoryMap, flattenedLowCountMaterials, currentMaterialName: prevMaterialName, materialAccumulatedDifferences, globalAccumulatedDifferences, - pathRecordCache // 新增:从上下文取缓存 + pathRecordCache } = context; // 用材料名查CD @@ -1159,19 +1421,24 @@ async function processNormalPathEntry(entry, context) { // 检查是否可运行 const currentTime = getCurrentTimeInHours(); - const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir); + const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath); const isPathValid = checkPathNameFrequency(resourceName, pathName, recordDir); const perTime = noRecord ? null : calculatePerTime( - resourceName, - pathName, - recordDir, - noRecordDir, - false, - pathRecordCache // 新增:传递缓存 + resourceName, + pathName, + recordDir, + noRecordDir, + false, + pathRecordCache, + pathingFilePath ); log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 单个材料耗时:${perTime ?? '忽略'}`); + // 计算并输出预计耗时 + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir); + log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 预计耗时:${estimatedTime}秒`); + if (!(canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName) && isPathValid && (noRecord || perTime === null || perTime <= timeCost))) { log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 不符合运行条件`); await sleep(1); @@ -1187,7 +1454,7 @@ async function processNormalPathEntry(entry, context) { } } - // 处理运行逻辑(同怪物路径,区别在于用resourceName作为记录键) + // 处理运行逻辑 let currentMaterialName = prevMaterialName; let updatedFlattened = flattenedLowCountMaterials; @@ -1206,7 +1473,9 @@ async function processNormalPathEntry(entry, context) { const endTime = new Date().toLocaleString(); const runTime = (new Date(endTime) - new Date(startTime)) / 1000; - const noRecordContent = `路径名: ${pathName}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: noRecord模式忽略\n\n`; + // 生成内容检测码 + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; + const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: noRecord模式忽略\n\n`; writeContentToFile(`${noRecordDir}/${resourceName}.txt`, noRecordContent); } else { if (currentMaterialName !== resourceName) { @@ -1254,10 +1523,10 @@ async function processNormalPathEntry(entry, context) { }); log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}材料路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); - recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences, finalCumulativeDistance); + recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences, finalCumulativeDistance, pathingFilePath); } - await sleep(1); // 保留sleep(1) + await sleep(1); return { ...context, currentMaterialName, @@ -1268,7 +1537,7 @@ async function processNormalPathEntry(entry, context) { } /** - * 批量处理所有路径 + * 批量处理所有路径(核心修改:时间预判+缓存传递) * @param {Object[]} allPaths - 所有路径条目 * @param {Object} CDCategories - CD分类配置 * @param {Object} materialCategoryMap - 材料分类映射 @@ -1287,26 +1556,26 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time let foodExpAccumulator = {}; const globalAccumulatedDifferences = {}; const materialAccumulatedDifferences = {}; - // 新增:单路径处理周期内的记录缓存(处理完所有路径后自动释放) - const pathRecordCache = {}; + // 单路径处理周期内的记录缓存 + const pathRecordCache = {}; let context = { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, materialCategoryMap, flattenedLowCountMaterials, currentMaterialName, materialAccumulatedDifferences, globalAccumulatedDifferences, - pathRecordCache // 上下文加入缓存,供子函数使用 + pathRecordCache }; for (const entry of allPaths) { - // 优先响应手动终止指令(原有逻辑保留) + // 优先响应手动终止指令 if (state.cancelRequested) { log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到手动终止指令,停止路径处理`); break; } - // 核心修改:仅当endTimeStr有值时才执行定时终止判断(默认不执行) + // 定时终止判断 let skipPath = false; - if (endTimeStr) { // 只有用户显式配置了终止时间,才进入判断 + if (endTimeStr) { const isValidEndTime = /^\d{1,2}[::]\d{1,2}$/.test(endTimeStr); if (isValidEndTime) { const remainingMinutes = getRemainingMinutesToEndTime(endTimeStr); @@ -1317,9 +1586,9 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } const pathTotalTimeSec = estimatePathTotalTime( - entry, - recordDir, - noRecordDir, + entry, + recordDir, + noRecordDir, pathRecordCache ); const pathTotalTimeMin = pathTotalTimeSec / 60; @@ -1336,32 +1605,57 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } 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 { if (resourceName && isFoodResource(resourceName)) { - const result = await processFoodPathEntry(entry, { - foodExpAccumulator, - currentMaterialName: context.currentMaterialName, - pathRecordCache // 传递缓存 - }, recordDir, noRecordDir); + // 狗粮路径:传递完整校验参数 + const result = await processFoodPathEntry( + entry, + { + foodExpAccumulator, + currentMaterialName: context.currentMaterialName + }, + recordDir, + noRecordDir, + CDCategories, + timeCost, + context.pathRecordCache + ); 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)}`); } } catch (singleError) { log.error(`${CONSTANTS.LOG_MODULES.PATH}处理路径出错,已跳过:${singleError.message}`); + + // 记录错误路径为noRecord模式 + try { + const pathName = basename(entry.path); + const resourceName = entry.monsterName || entry.resourceName || '未知资源'; + const startTime = new Date().toLocaleString(); + const endTime = new Date().toLocaleString(); + // 生成内容检测码 + const contentCode = entry.path ? generatePathContentCode(entry.path) : "00000000"; + const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: 0秒\n数量变化: ${singleError.message}\n\n`; + writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${resourceName}.txt`, noRecordContent); + log.info(`${CONSTANTS.LOG_MODULES.RECORD}已将错误路径记录为noRecord模式:${pathName}`); + } catch (recordError) { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录错误路径失败:${recordError.message}`); + } + await sleep(1); if (state.cancelRequested) { log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到终止指令,停止处理`); @@ -1370,7 +1664,7 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } } - // 最后一个目标收尾 + // 最后一个目标收尾通知 if (context.currentMaterialName) { if (isFoodResource(context.currentMaterialName) && foodExpAccumulator[context.currentMaterialName]) { const finalMsg = `狗粮材料[${context.currentMaterialName}]收集完成,累计EXP:${foodExpAccumulator[context.currentMaterialName]}`; @@ -1381,8 +1675,8 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } } - return { - currentMaterialName: context.currentMaterialName, + return { + currentMaterialName: context.currentMaterialName, flattenedLowCountMaterials: context.flattenedLowCountMaterials, globalAccumulatedDifferences: context.globalAccumulatedDifferences, foodExpAccumulator @@ -1398,7 +1692,7 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time } // ============================================== -// 路径分类与优先级(简化逻辑) +// 路径分类与优先级(简化逻辑,补全所有缺失代码) // ============================================== /** * 分类普通材料路径(按目标和低数量排序) @@ -1412,27 +1706,26 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri const pathingFilePaths = readAllFilePaths(pathingDir, 0, 3, ['.json']); const pathEntries = pathingFilePaths.map(path => { const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); - return { path, resourceName: materialName, monsterName }; // 新增monsterName字段 + return { path, resourceName: materialName, monsterName }; }).filter(entry => { - // 新增:过滤超量材料对应的路径(包括怪物材料) + // 过滤超量材料对应的路径 if (entry.monsterName) { - // 怪物路径:检查其所有材料是否都超量 const monsterMaterials = monsterToMaterials[entry.monsterName] || []; const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); - return !allExcess; // 若所有材料超量则过滤该路径 + return !allExcess; } if (entry.resourceName) { - // 普通材料路径:检查自身是否超量 return !excessMaterialNames.includes(entry.resourceName); } return false; }); if (pathEntries.length > 0) { - log.info(`${CONSTANTS.LOG_MODULES.PATH}\n===== 匹配到的材料路径列表 =====`); + // log.info(`${CONSTANTS.LOG_MODULES.PATH}\n===== 匹配到的材料路径列表 =====`); pathEntries.forEach((entry, index) => { + log.info(`${index + 1}. 材料:${entry.resourceName || entry.monsterName},路径:${entry.path}`); }); - log.info(`=================================\n`); + // log.info(`=================================\n`); } else { log.info(`${CONSTANTS.LOG_MODULES.PATH}未匹配到任何有效的材料路径`); } @@ -1442,7 +1735,6 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri const normalPaths = []; for (const entry of pathEntries) { if (entry.monsterName) { - // 怪物路径:检查是否包含有效目标材料 const monsterMaterials = monsterToMaterials[entry.monsterName] || []; const hasValidTarget = monsterMaterials.some(mat => targetResourceNames.includes(mat) || lowCountMaterialNames.includes(mat)); if (hasValidTarget) { @@ -1451,7 +1743,6 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri normalPaths.push(entry); } } else if (entry.resourceName) { - // 普通材料路径 if (targetResourceNames.includes(entry.resourceName)) { prioritizedPaths.push(entry); } else if (lowCountMaterialNames.includes(entry.resourceName)) { @@ -1459,12 +1750,14 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri } } } - // 按低数量排序 + + // 按低数量材料排序 normalPaths.sort((a, b) => { 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); } @@ -1495,51 +1788,50 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames log.info(`${CONSTANTS.LOG_MODULES.PATH}[路径分类] 狗粮:${foodPaths.length} 怪物:${monsterPaths.length} 普通:${normalPaths.length}`); - // 怪物路径关联材料到分类(扫描用) - log.info(`${CONSTANTS.LOG_MODULES.MONSTER}开始处理${monsterPaths.length}条怪物路径的材料分类关联...`); - monsterPaths.forEach((entry, index) => { - const materials = monsterToMaterials[entry.monsterName] || []; - if (materials.length === 0) { - log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物路径${index+1}] 怪物【${entry.monsterName}】无对应材料映射`); - return; - } - materials.forEach(mat => { - const category = matchImageAndGetCategory(mat, imagesDir); - if (!category) return; - if (!materialCategoryMap[category]) materialCategoryMap[category] = []; - if (!materialCategoryMap[category].includes(mat)) { - materialCategoryMap[category].push(mat); - log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${entry.monsterName}】的材料【${mat}】加入分类【${category}】`); + // 怪物路径关联材料到分类(扫描用)- 仅includeBoth和onlyPathing模式 + if (pathingMode.includeBoth || pathingMode.onlyPathing) { + log.info(`${CONSTANTS.LOG_MODULES.MONSTER}开始处理${monsterPaths.length}条怪物路径的材料分类关联...`); + monsterPaths.forEach((entry, index) => { + const materials = monsterToMaterials[entry.monsterName] || []; + if (materials.length === 0) { + log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物路径${index + 1}] 怪物【${entry.monsterName}】无对应材料映射`); + return; } + materials.forEach(mat => { + const category = matchImageAndGetCategory(mat, imagesDir); + if (!category) return; + if (!materialCategoryMap[category]) materialCategoryMap[category] = []; + if (!materialCategoryMap[category].includes(mat)) { + materialCategoryMap[category].push(mat); + // log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${entry.monsterName}】的材料【${mat}】加入分类【${category}】`); + } + }); }); - }); + } - // 处理普通材料路径 let processedFoodPaths = foodPaths; let processedNormalPaths = []; let processedMonsterPaths = monsterPaths; let pathingMaterialCounts = []; - if (normalPaths.length > 0 || monsterPaths.length > 0) { // 包含怪物路径时也需要扫描 + if (normalPaths.length > 0 || monsterPaths.length > 0) { // 优化:一次扫描获取全量材料数量,同时服务于怪物和普通材料 log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] 执行一次全量背包扫描(服务于怪物+普通路径)`); - const allMaterialCounts = await MaterialPath(materialCategoryMap); // 仅一次扫描 - pathingMaterialCounts = allMaterialCounts; // 普通材料直接复用扫描结果 + const allMaterialCounts = await MaterialPath(materialCategoryMap); + pathingMaterialCounts = allMaterialCounts; - // 1. 怪物材料筛选(复用全量扫描结果) + // 筛选低数量材料(同时生成超量名单) log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 基于全量扫描结果筛选有效材料`); - const filteredMaterials = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap); // 仅调用一次! - // 怪物材料复用结果 + const filteredMaterials = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap); const validMonsterMaterialNames = filteredMaterials.map(m => m.name); log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 筛选后有效材料:${validMonsterMaterialNames.join('、')}`); - // 2. 普通材料筛选(同样复用全量扫描结果,无需再次扫描) + // 普通材料筛选 if (pathingMode.onlyCategory) { - state.cancelRequested = true; return { allPaths: [], pathingMaterialCounts }; -} + } log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 基于全量扫描结果筛选低数量材料`); - const lowCountMaterialsFiltered = filteredMaterials; // 复用第一次的结果! + const lowCountMaterialsFiltered = filteredMaterials; const flattenedLowCountMaterials = lowCountMaterialsFiltered.flat().sort((a, b) => a.count - b.count); const lowCountMaterialNames = flattenedLowCountMaterials.map(material => material.name); @@ -1548,44 +1840,44 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 筛选后保留路径 ${processedNormalPaths.length} 条`); } - // 简化路径优先级逻辑:用规则数组定义优先级 + // 路径优先级规则数组 const PATH_PRIORITIES = [ // 1. 目标狗粮 - { - source: processedFoodPaths, - filter: e => targetResourceNames.includes(e.resourceName) + { + source: processedFoodPaths, + filter: e => targetResourceNames.includes(e.resourceName) }, // 2. 目标怪物(掉落材料含目标) - { - source: processedMonsterPaths, + { + source: processedMonsterPaths, filter: e => { const materials = monsterToMaterials[e.monsterName] || []; return materials.some(mat => targetResourceNames.includes(mat)); - } + } }, // 3. 目标普通材料 - { - source: processedNormalPaths, - filter: e => targetResourceNames.includes(e.resourceName) + { + source: processedNormalPaths, + filter: e => targetResourceNames.includes(e.resourceName) }, // 4. 剩余狗粮 - { - source: processedFoodPaths, - filter: e => !targetResourceNames.includes(e.resourceName) + { + source: processedFoodPaths, + filter: e => !targetResourceNames.includes(e.resourceName) }, - // 5. 剩余怪物 - { - source: processedMonsterPaths, + // 5. 剩余怪物(掉落材料未超量且低数量) + { + source: processedMonsterPaths, filter: e => { const materials = monsterToMaterials[e.monsterName] || []; - return !materials.some(mat => targetResourceNames.includes(mat)) && - materials.some(mat => !excessMaterialNames.includes(mat)); // 排除所有材料超量的怪物 - } + return !materials.some(mat => targetResourceNames.includes(mat)) && + materials.some(mat => !excessMaterialNames.includes(mat)); + } }, // 6. 剩余普通材料 - { - source: processedNormalPaths, - filter: e => !targetResourceNames.includes(e.resourceName) + { + source: processedNormalPaths, + filter: e => !targetResourceNames.includes(e.resourceName) } ]; @@ -1594,7 +1886,7 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames PATH_PRIORITIES.forEach(({ source, filter }, index) => { const filtered = source.filter(filter); allPaths.push(...filtered); - log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级${index+1}] 路径 ${filtered.length} 条`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级${index + 1}] 路径 ${filtered.length} 条`); }); log.info(`${CONSTANTS.LOG_MODULES.PATH}[最终路径] 共${allPaths.length}条:${allPaths.map(p => basename(p.path))}`); @@ -1621,13 +1913,13 @@ function sendNotificationInChunks(msg, sendFn) { const totalChunks = Math.ceil(msg.length / chunkSize); log.info(`${CONSTANTS.LOG_MODULES.MAIN}通知消息过长(${msg.length}字符),拆分为${totalChunks}段发送`); - + let start = 0; for (let i = 0; i < totalChunks; i++) { const end = Math.min(start + chunkSize, msg.length); - const chunkMsg = `【通知${i+1}/${totalChunks}】\n${msg.substring(start, end)}`; + const chunkMsg = `【通知${i + 1}/${totalChunks}】\n${msg.substring(start, end)}`; sendFn(chunkMsg); - log.info(`${CONSTANTS.LOG_MODULES.MAIN}已发送第${i+1}段通知(${chunkMsg.length}字符)`); + log.info(`${CONSTANTS.LOG_MODULES.MAIN}已发送第${i + 1}段通知(${chunkMsg.length}字符)`); start = end; } } @@ -1654,8 +1946,8 @@ ${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) + state.completed = true; + state.cancelRequested = true; } // ============================================== @@ -1679,7 +1971,7 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 const targetTextCategories = readtargetTextCategories(CONSTANTS.TARGET_TEXT_DIR); - // 并行任务:OCR交互 + // 并行任务:OCR交互(修正版) const ocrTask = (async () => { let allTargetTexts = []; for (const categoryName in targetTextCategories) { @@ -1687,18 +1979,17 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 allTargetTexts = allTargetTexts.concat(Object.values(targetTexts).flat()); } - // 关键补充:等待超量名单生成(由filterLowCountMaterials更新) + // 等待超量名单生成 let waitTimes = 0; - while (excessMaterialNames.length === 0 && !state.cancelRequested && waitTimes < 100) { - await sleep(1000); // 每1秒查一次 + while (excessMaterialNames.length === 0 && !state.cancelRequested && waitTimes < 100) { + await sleep(1000); waitTimes++; } - // 若收到终止信号,直接退出OCR任务(不再执行后续逻辑) if (state.cancelRequested) { log.info(`${CONSTANTS.LOG_MODULES.MAIN}OCR任务收到终止信号,已退出`); return; } - // 现在过滤才有效(确保excessMaterialNames已生成) + // 过滤超量材料 allTargetTexts = allTargetTexts.filter(name => !excessMaterialNames.includes(name)); log.info(`超量名单:${excessMaterialNames.join('、')}`); log.info(`OCR最终目标文本(已过滤超量):${allTargetTexts.join('、')}`); @@ -1721,9 +2012,21 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 } log.info(`${CONSTANTS.LOG_MODULES.CD}CD文件中材料名(已过滤):${Array.from(cdMaterialNames).join(', ')}`); - // 生成材料分类映射(含怪物掉落) + // 生成材料分类映射 let materialCategoryMap = {}; - if (!pathingMode.onlyCategory) { + + // 处理选中的材料分类 + if (selected_materials_array.length > 0) { + // 1. 初始化选中的分类 + selected_materials_array.forEach(selectedCategory => { + materialCategoryMap[selectedCategory] = []; + }); + } else { + log.warn(`${CONSTANTS.LOG_MODULES.MATERIAL}未选择【材料分类】,采用【路径材料】专注模式`); + } + + // 2. 处理路径相关材料(仅includeBoth和onlyPathing模式) + if ((pathingMode.includeBoth || pathingMode.onlyPathing) && Object.keys(materialCategoryMap).length > 0) { const pathingFilePaths = readAllFilePaths(CONSTANTS.PATHING_DIR, 0, 3, ['.json']); const pathEntries = pathingFilePaths.map(path => { const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); @@ -1740,35 +2043,40 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 }); // 构建分类映射 - materialCategoryMap = Array.from(allMaterials).reduce((acc, resourceName) => { + Array.from(allMaterials).forEach(resourceName => { const category = matchImageAndGetCategory(resourceName, CONSTANTS.IMAGES_DIR); - if (category) { - if (!acc[category]) acc[category] = []; - if (!acc[category].includes(resourceName)) { - acc[category].push(resourceName); + if (category && materialCategoryMap[category]) { + if (!materialCategoryMap[category].includes(resourceName)) { + materialCategoryMap[category].push(resourceName); } } - return acc; - }, {}); + }); } - // 处理选中的材料分类 - if (selected_materials_array.length > 0) { - selected_materials_array.forEach(selectedCategory => { - if (!materialCategoryMap[selectedCategory]) { - materialCategoryMap[selectedCategory] = []; - } - }); - } else { - log.warn(`${CONSTANTS.LOG_MODULES.MATERIAL}未选择【材料分类】,采用【路径材料】专注模式`); + // 3. 在onlyPathing或onlyCategory模式下,保留所有选中的分类 + if (pathingMode.onlyPathing || pathingMode.onlyCategory) { + // 对于onlyCategory模式,保留所有选中的分类,即使是空数组 + // 对于onlyPathing模式,删除空数组 + if (pathingMode.onlyPathing) { + Object.keys(materialCategoryMap).forEach(category => { + if (materialCategoryMap[category].length === 0) { + delete materialCategoryMap[category]; + } + }); + } } - if (pathingMode.onlyPathing) { - Object.keys(materialCategoryMap).forEach(category => { - if (materialCategoryMap[category].length === 0) { - delete materialCategoryMap[category]; - } + // 4. 在onlyCategory模式下,确保materialCategoryMap只包含选中的分类 + if (pathingMode.onlyCategory) { + const selectedCategoriesSet = new Set(selected_materials_array); + const filteredMap = {}; + + // 只保留选中的分类,即使是空数组 + selected_materials_array.forEach(category => { + filteredMap[category] = materialCategoryMap[category] || []; }); + + materialCategoryMap = filteredMap; } // 生成路径数组 @@ -1781,6 +2089,14 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 CONSTANTS.IMAGES_DIR ); + // onlyCategory模式:只扫描,不处理路径和末次扫描,直接结束 + if (pathingMode.onlyCategory) { + log.info(`${CONSTANTS.LOG_MODULES.MAIN}[onlyCategory模式] 扫描完成,直接结束`); + state.completed = true; + state.cancelRequested = true; + return; + } + // 处理所有路径 let currentMaterialName = null; let flattenedLowCountMaterials = []; @@ -1801,7 +2117,7 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 CONSTANTS.RECORD_DIR, CONSTANTS.NO_RECORD_DIR, CONSTANTS.IMAGES_DIR, - endTimeStr // 传递终止时间 + endTimeStr ); // 汇总结果 diff --git a/repo/js/背包材料统计/manifest.json b/repo/js/背包材料统计/manifest.json index 690ce68ea..bbbd8aacc 100644 --- a/repo/js/背包材料统计/manifest.json +++ b/repo/js/背包材料统计/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "name": "背包统计采集系统", - "version": "2.55", - "bgi_version": "0.44.8", + "name": "背包材料统计及采集管理系统", + "version": "2.60", + "bgi_version": "0.55", "description": "可统计背包养成道具、部分食物、素材的数量;根据设定数量、根据材料刷新CD执行挖矿、采集、刷怪等的路径。优势:\n+ 1. 自动判断材料CD,不需要管材料CD有没有好;\n+ 2. 可以随意添加路径,能自动排除低效、无效路径;\n+ 3. 有独立名单识别,不会交互路边的npc或是神像;可自定义识别名单,具体方法看【问题解答】增减识别名单\n+ 4. 有实时的弹窗模块,提供了常见的几种:路边信件、过期物品、月卡、调查;\n+ 5. 可识别爆满的路径材料,自动屏蔽;更多详细内容查看readme.md", "saved_files": [ "pathing/", diff --git a/repo/js/背包材料统计/settings.json b/repo/js/背包材料统计/settings.json index 06e6dc5e4..168abbe11 100644 --- a/repo/js/背包材料统计/settings.json +++ b/repo/js/背包材料统计/settings.json @@ -2,7 +2,7 @@ { "name": "TargetCount", "type": "input-text", - "label": "js目录下默认扫描的3层文件结构:\n./📁BetterGI/📁User/📁JsScript/\n📁背包材料统计/\n 📁pathing/\n 📁 食材与炼金/\n 📁 薄荷/\n 📄 薄荷1.json\n 📁 薄荷效率/\n 📄 薄荷-吉吉喵.json\n 📁 苹果/\n 📄 旅行者的果园.json\n----------------------------------\n目标数量,默认5000\n给📁pathing下材料设定的目标数" + "label": "js目录下默认扫描的文件结构:\n./📁BetterGI/📁User/📁JsScript/📁背包材料统计/\n 📁pathing/\n 📁 食材与炼金/\n 📁 薄荷/\n 📄 薄荷1.json\n 📁 薄荷效率/\n 📄 薄荷-吉吉喵.json\n 📁 苹果/\n 📄 旅行者的果园.json\n----------------------------------\n目标数量,默认5000\n给📁pathing下材料设定的目标数" }, { "name": "TargetresourceName", @@ -29,70 +29,33 @@ "type": "select", "label": "====================\n扫描📁pathing下的\n或勾选【材料分类】的材料。默认:仅📁pathing材料", "options": [ - "1.兼并:📁pathing材料+【材料分类】", - "2.仅📁pathing材料", - "3.仅【材料分类】勾选", - ] + "1.兼并:📁pathing材料+【材料分类】", + "2.仅📁pathing材料", + "3.仅【材料分类】勾选" + ] }, { - "name": "Smithing", - "type": "checkbox", - "label": "\n----------------------------------\n【锻造素材】" - }, - { - "name": "Drops", - "type": "checkbox", - "label": "如:矿石、原胚\n----------------------------------\n【怪物掉落素材】" - }, - { - "name": "ForagedFood", - "type": "checkbox", - "label": "如:经验书、怪物掉落\n----------------------------------\n【采集食物】,食用回血" - }, - { - "name": "General", - "type": "checkbox", - "label": "如:苹果、日落果、泡泡桔\n----------------------------------\n【一般素材】" - }, - { - "name": "CookingIngs", - "type": "checkbox", - "label": "如:特产、非食用素材\n----------------------------------\n\n【烹饪用食材】" - }, - { - "name": "Weekly", - "type": "checkbox", - "label": "----------------------------------\n\n【周本素材】" - }, - { - "name": "Wood", - "type": "checkbox", - "label": "----------------------------------\n\n【木材】" - }, - { - "name": "CharAscension", - "type": "checkbox", - "label": "----------------------------------\n\n【角色培养素材】世界BOSS树脂材料" - }, - { - "name": "Fishing", - "type": "checkbox", - "label": "----------------------------------\n\n【鱼饵、鱼类】" - }, - { - "name": "Gems", - "type": "checkbox", - "label": "----------------------------------\n\n【宝石】" - }, - { - "name": "Talent", - "type": "checkbox", - "label": "----------------------------------\n\n【角色天赋素材】天赋书" - }, - { - "name": "WeaponAscension", - "type": "checkbox", - "label": "----------------------------------\n\n【武器突破素材】" + "name": "Categories", + "type": "multi-checkbox", + "label": "\n----------------------------------\n【材料分类】", + "options": [ + "矿石、原胚", + "经验书、怪物掉落", + "采集食物", + "一般素材", + "烹饪用食材", + "周本素材", + "木材", + "世界BOSS", + "鱼饵、鱼类", + "宝石", + "天赋素材", + "武器突破" + ], + "default": [ + "一般素材", + "烹饪用食材" + ] }, { "name": "PopupNames", @@ -102,12 +65,12 @@ { "name": "PopupClickDelay", "type": "input-text", - "label": "如 过期物品,信件,自定义文件夹名。注意文件夹结构\n----------------------------------\n弹窗循环间隔(默认:15 秒)" + "label": "如 过期物品,信件,自定义文件夹名。注意分隔符和文件夹格式\n----------------------------------\n弹窗循环间隔(默认:5 秒)" }, { "name": "CDCategories", "type": "input-text", - "label": "====================\n\n采用的CD分类(默认:全部) 举例:采集,怪物,木材" + "label": "----------------------------------\n\n采用的CD分类(默认:全部) 举例:采集,怪物,木材" }, { "name": "CurrentTime", @@ -122,12 +85,7 @@ { "name": "ExceedCount", "type": "input-text", - "label": "根据拾取分类来加载OCR名单,具体看targetText目录\n----------------------------------\n\n超量阈值(默认:5000)超量的路径材料将不拾取" - }, - { - "name": "PageScrollDistance", - "type": "input-text", - "label": "====================\n拖动距离:(默认711像素点)" + "label": "举例:交互,采集,宝箱\n----------------------------------\n超量阈值(默认:9000)超量的路径材料将不拾取" }, { "name": "HoldX", @@ -138,5 +96,10 @@ "name": "HoldY", "type": "input-text", "label": "------------------------\n翻页拖动点Y坐标:0~1080(默认750)" + }, + { + "name": "PageScrollDistance", + "type": "input-text", + "label": "------------------------\n拖动距离:(默认711像素点)" } -] +] \ No newline at end of file