From dc986d6f250c21ee3569f629aaa8bf278d8689fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E5=90=89=E5=96=B5?= Date: Fri, 20 Mar 2026 08:43:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E9=87=8F=E6=9B=B4=E6=96=B0=E3=80=81?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=92=8C=E4=BF=AE=E5=A4=8D=20(#3013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo/js/背包材料统计/README.md | 480 ++--- repo/js/背包材料统计/assets/regions/1.png | Bin 0 -> 1263 bytes repo/js/背包材料统计/assets/regions/11.png | Bin 0 -> 1329 bytes repo/js/背包材料统计/assets/regions/17.png | Bin 0 -> 1713 bytes repo/js/背包材料统计/assets/regions/2.png | Bin 0 -> 1671 bytes repo/js/背包材料统计/assets/regions/4.png | Bin 0 -> 1598 bytes repo/js/背包材料统计/assets/regions/5.png | Bin 0 -> 1606 bytes repo/js/背包材料统计/assets/regions/6.png | Bin 0 -> 1603 bytes repo/js/背包材料统计/assets/regions/7.png | Bin 0 -> 1625 bytes repo/js/背包材料统计/lib/autoPick.js | 50 +- repo/js/背包材料统计/lib/backStats.js | 889 ++++----- repo/js/背包材料统计/lib/colorDetection.js | 417 ++++ repo/js/背包材料统计/lib/exp.js | 401 ++-- repo/js/背包材料统计/lib/file.js | 494 +++-- repo/js/背包材料统计/lib/imageClick.js | 66 +- repo/js/背包材料统计/lib/ocr.js | 143 +- repo/js/背包材料统计/lib/pathProcessor.js | 827 ++++++++ repo/js/背包材料统计/lib/recordManager.js | 438 +++++ repo/js/背包材料统计/lib/region.js | 77 +- repo/js/背包材料统计/lib/timeCostSystem.js | 317 ++++ repo/js/背包材料统计/lib/updateSettings.js | 77 +- repo/js/背包材料统计/lib/writeImage.js | 233 +++ repo/js/背包材料统计/main.js | 1984 +++++--------------- repo/js/背包材料统计/manifest.json | 3 +- repo/js/背包材料统计/settings.json | 56 +- 25 files changed, 4203 insertions(+), 2749 deletions(-) create mode 100644 repo/js/背包材料统计/assets/regions/1.png create mode 100644 repo/js/背包材料统计/assets/regions/11.png create mode 100644 repo/js/背包材料统计/assets/regions/17.png create mode 100644 repo/js/背包材料统计/assets/regions/2.png create mode 100644 repo/js/背包材料统计/assets/regions/4.png create mode 100644 repo/js/背包材料统计/assets/regions/5.png create mode 100644 repo/js/背包材料统计/assets/regions/6.png create mode 100644 repo/js/背包材料统计/assets/regions/7.png create mode 100644 repo/js/背包材料统计/lib/colorDetection.js create mode 100644 repo/js/背包材料统计/lib/pathProcessor.js create mode 100644 repo/js/背包材料统计/lib/recordManager.js create mode 100644 repo/js/背包材料统计/lib/timeCostSystem.js create mode 100644 repo/js/背包材料统计/lib/writeImage.js diff --git a/repo/js/背包材料统计/README.md b/repo/js/背包材料统计/README.md index 86f99f8cb..4c5426462 100644 --- a/repo/js/背包材料统计/README.md +++ b/repo/js/背包材料统计/README.md @@ -1,237 +1,243 @@ -# 背包材料统计 v2.62 -作者:吉吉喵 - - - - - -## 一、简介 -可统计背包内养成道具、部分食物、素材的数量,并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。 - -### 核心优势 -1. **自动CD判断**:无需手动关注材料刷新状态,脚本自动识别CD是否就绪; -2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径; -3. **独立名单识别**:不与路边NPC、神像交互;可自定义识别名单(操作见「四、问题解答Q4」); -4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。 -5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。 -6. **路径检测码**:根据路径生成检测码,可识别改名单坐标未变的路径文件,自动匹配带检测码的路径记录。 -7. **怪物材料防爆仓**:对怪物材料路径添加多重机制,防止蓝紫怪物材料爆仓(怪物材料路径执行时,不会拾其他的怪物材料,除非pathing文件夹里存在且未超量)。 - - -## 二、用前须知 -1. 需具备基础电脑操作能力(如文件夹复制、路径查找); -2. 非1080p显示器,使用前需要根据显示器调整背包物品界面的 拖动距离 ,推荐“一次划页稍小于4行材料的距离”; -3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。 - - -## 三、使用方法 -### 3.1 基础教程(路径配置) -#### 步骤1:订阅路径文件 -在仓库中订阅所需的路径文件,参考以下截图操作: -
- 订阅路径文件操作截图1 - 订阅路径文件操作截图2 - 订阅路径文件操作截图3 - 订阅路径文件操作截图4 -
- -#### 步骤2:复制路径到目标文件夹 -1. 打开**路径源文件夹**(存放订阅的路径文件): - ``` - BetterGI\Repos\bettergi-scripts-list-git\repo\pathing - ``` -2. 根据需求,复制以下类型的路径文件夹(按需选择): - - 地方特产 - - 敌人与魔物 - - 矿物 - - 食材与炼金 -3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录): - ``` - BetterGI\User\JsScript\背包材料统计\pathing - ``` - -#### 步骤2关键注意点 -- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图: -
- 删除重复路径参考截图 -
-- `pathing` 文件夹仅支持**3层子文件夹**,若超过3层需手动削减(否则无法读取); -- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。 - -#### 步骤3:按队伍分组管理路径 -建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。示例如下: - -| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 | -|------------------------|-----------------------------------|---------------------------|-----------------------------------| -| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时,需批量搜索路径中“nahida_collect”并排除 | -| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 | -| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 | - -分组示例与无草神排除路径操作截图: -
- 队伍分组管理路径示例截图 - 无草神时排除路径操作截图 -
- -#### 步骤4:打开脚本自定义设置 -1. 找到配置组内已添加的「背包材料统计」JS; -2. 右键点击该文件,选择「修改JS 脚本自定义设置」; -3. 操作参考截图: -
- 打开脚本自定义设置操作截图 -
- - -### 3.2 JS 自定义设置(核心配置项) -| 配置项 | 功能说明 | 操作建议 | -|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------| -| 1. 目标数量 | 仅当背包材料数量**低于此值**时,该材料的路径才会被纳入执行序列 | 这是个统一值,管理路径下全部材料的目标数量 | -| 2. 优先级材料 | 无视“目标数量”限制,直接纳入执行序列顶层(最高优先级) | 填写当前急需材料(例:虹滴晶,巡陆艇) | -| 3. 时间成本 | 当一个路径有3-5次运行记录后,自动计算“单材料获取时间”;超过30秒则跳过该路径 | 保持默认30秒即可,无需频繁修改(可过滤低效路径) | -| 4. 发送通知 | ① 每类材料跑完通知一次;② 全部材料跑完汇总通知一次(需开启BGI通知) | 建议开启,方便实时了解进度(接收端如企业微信需自行配置) | -| 5. 取消扫描 | 取消“每个路径执行后”的背包扫描,仅保留“全部执行前/后”2次扫描 | 有效路径记录达3条以上时可以开启,可节约运行时间 | -| 6. 仅 pathing 材料 | 仅扫描 `pathing` 文件夹内的材料,跳过其他分类,大幅缩短扫描时间 | 路径配置完成后开启,提升脚本运行效率 | -| 7. 弹窗名 | 不选则默认循环执行 `assets\imageClick` 文件夹下所有弹窗;填写则仅执行指定弹窗 | 推荐默认,需单独适配某类弹窗时填写(例:月卡,复苏) | -| 8. 采用的 CD 分类 | 不选则默认执行 `materialsCD` 文件夹内配置的CD分类;填写则仅执行指定CD分类 | 新增材料时,需在该文件夹同步配置CD规则(操作见「四、问题解答Q2」) | -| 9. 终止时刻 | 不填则不执行定时终止;路径无时间记录时,会预判路径耗时5分钟,且预留2分钟空闲 | 填写需要按24小时格式(例:4:10) | -| 10. 采用的识别名单 | 不选则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 新增名单时,需符合配置规则(操作见「四、问题解答Q4」) | -| 11. 超量阈值 | 首次扫描后,超量的路径材料,将从识别名单中剔除,默认9000 | 不推荐9999,怪物材料有几千就够了,采用默认数值,可自动避免爆背包 | -| 12. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 | - - -## 四、注意事项 -1. **禁止联机请求**:联机请求会遮挡背包菜单,导致材料识别失败。建议在本脚本前添加「AutoPermission」权限设置JS(仓库可查),默认禁止联机; -2. **文件夹层级限制**:`pathing` 文件夹仅支持3层子文件夹,超过需手动削减(否则路径无法读取); -3. **食物识别强制要求**:背包食物界面**第一行必须包含8种食物**(苹果、日落果、星蕈、活化的星蕈、枯焦的星蕈、泡泡桔、烛伞蘑菇、美味的宝石闪闪),缺少则这些食物无法识别; -4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或被污染后,记录能恢复如初; -5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto; -6. **手动终止运行**:手动终止路径会被记录成noRecord模式,只参与CD计算,如果是【取消扫描】模式,不会储存当前记录的材料数目,两种情况都随意。 - - -## 五、问题解答 - -### Q1:如何排除不想要的路径? -A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`); - 2. 直接删除/移走目标材料/怪物的路径文件夹; - **其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。 - -### Q2:如何添加新材料? -A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`); - 2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名; - 3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹); - 4. 操作参考截图: -
- 添加新材料操作截图1 - 添加新材料操作截图2 -
- -### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”、“果园.json”)? -A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。 - 2. 例:看「四、问题解答Q2」,① 把“纳塔食材一条龙”作为标准名,选择一个CD,② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!** - 3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾,将无CD记录!**; - 4. 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。 - 操作参考截图: -
- 添加新路径文件夹操作截图1 - 添加新路径文件夹操作截图2 - 添加新路径文件夹操作截图2 -
- - -### Q4:如何自定义识别名单? -A:1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`); - 2. 新建/编辑txt文件,按格式填写:`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”); - 3. 若需排除怪物掉落材料:找到“掉落.txt”,删除对应材料名即可; - 4. 操作参考截图: -
- 自定义识别名单操作截图1 - 自定义识别名单操作截图2 -
- -### Q5:如何取消路径执行后扫描背包? -A:在「JS自定义设置」中勾选“取消扫描”(依旧会保留“全部材料执行始/末”的2次扫描)。 - -### Q6:扫描背包少一行、拖动距离异常怎么办? -A:在「JS自定义设置」中调整“拖动距离”,推荐“一次划页稍小于4行材料的距离”(拖动点建议选第5行材料附近)。 - -### Q7:本地记录保存在哪里? -A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各文件功能如下: - - `overwrite_record`:所有历史记录(按材料分类储存); - - `history_record`:勾选“材料分类”后的专属记录; - - `latest_record.txt`:最近几种材料的记录(有上限,仅存最新数据); - - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录,三次及以上同名路径记录,就会触发排除); - 操作参考截图: -
- 本地记录存放位置参考截图 -
- - -## 六、后言 -本脚本目前处于测试阶段,欢迎反馈问题至 QQ 频道号:bettergiv1。 - - -## 七、更新日志 -| 版本号 | 更新内容 | -|---------|--------------------------------------------------------------------------| -| v0.1 | 新增OCR名单功能,输出图片名与材料名 | -| v1.0 | 新增图包(仅含素材) | -| v1.1 | 图包扩展(素材+养成道具) | -| v1.2 | 新增识图分类功能 | -| v1.3 | 优化:加速材料寻找(新增前位材料识别) | -| v1.31 | 调整本地记录存储逻辑 | -| v1.32 | 新增后位材料识别功能 | -| v2.0 | 开发版:支持多组材料、多个分类;移除前/后位材料识别 | -| v2.1 | 新增CD管理功能 | -| v2.2 | 优化路径顺序、材料数量判断逻辑 | -| v2.21 | 修改路径储存路径 | -| v2.22 | 精简日志输出内容 | -| v2.23 | 优化部分函数性能 | -| v2.24 | 修复“空路径无法使用背包统计”等bug | -| v2.25 | 恢复前/后位材料识别(加速扫描);新增“仅扫描路径材料”选项(降低内存占用) | -| v2.26 | 修复材料时间读取错误;新增路径材料时间成本计算功能 | -| v2.27 | 修复“材料数计算错误”“目标数量临界值异常”“3识别成三”等bug | -| v2.28 | 材料变更时自动更新初始数量;排除0位移/0数量路径记录;新增材料名0后缀本地记录;新增背包弹窗识别 | -| v2.29 | 新增排除提示;调整平均时间成本计算逻辑;过滤异常值记录 | -| v2.30 | 更改路径专注模式默认值;增加日志提示;移除调试日志 | -| v2.40 | 优化背包识别时的内存占用;新增通知功能 | -| v2.41 | 修复“勾选分类的本地记录”bug;新增“仅背包统计”选项;补充记录损坏处理说明 | -| v2.42 | 新增“无路径间扫描”“noRecord模式”(适合成熟路径);新增怪物材料CD文件 | -| v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 | -| v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 | -| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 | -| 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报错;检测码识别路径 | -| v2.61 | 背包材料识别机制加速;修复手动终止路径noRecord模式的额外条件判断不生效;全局图片缓存;识别名单、CD文件和弹窗升级多选框UI!!!!!!注意UI修改了,配置组里需要删除并重新添加该js!!!!!! | -| v2.62 | 修复凌晨CD计算bug,修复超量名单潜在记录污染bug | | \ No newline at end of file +# 背包材料统计 v2.62 +作者:吉吉喵 + + + + + +## 一、简介 +可统计背包内养成道具、部分食物、素材的数量,并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。 + +### 核心优势 +1. **自动CD判断**:无需手动关注材料刷新状态,脚本自动识别CD是否就绪; +2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径; +3. **独立名单识别**:不与路边NPC、神像交互;可自定义识别名单(操作见「四、问题解答Q4」); +4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。 +5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。 +6. **路径检测码**:根据路径生成检测码,可识别改名但坐标未变的路径文件,自动匹配带检测码的路径记录。 +7. **怪物材料防爆仓**:对怪物材料路径添加多重机制,防止蓝紫怪物材料爆仓(怪物材料路径执行时,不会拾其他的怪物材料,除非pathing文件夹里存在且未超量)。 + + +## 二、用前须知 +1. 需具备基础电脑操作能力(如文件夹复制、路径查找); +2. 非1080p显示器,使用前需要根据显示器调整背包物品界面的 拖动距离 ,推荐“一次划页稍小于4行材料的距离”; +3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。 + + +## 三、使用方法 +### 3.1 基础教程(路径配置) +#### 步骤1:订阅路径文件 +在仓库中订阅所需的路径文件,参考以下截图操作: +
+ 订阅路径文件操作截图1 + 订阅路径文件操作截图2 + 订阅路径文件操作截图3 + 订阅路径文件操作截图4 +
+ +#### 步骤2:复制路径到目标文件夹 +1. 打开**路径源文件夹**(存放订阅的路径文件): + ``` + BetterGI\Repos\bettergi-scripts-list-git\repo\pathing + ``` +2. 根据需求,复制以下类型的路径文件夹(按需选择): + - 地方特产 + - 敌人与魔物 + - 矿物 + - 食材与炼金 +3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录): + ``` + BetterGI\User\JsScript\背包材料统计\pathing + ``` + +#### 步骤2关键注意点 +- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图: +
+ 删除重复路径参考截图 +
+- `pathing` 文件夹仅支持**6层子文件夹**,若超过6层需手动削减(否则无法读取); +- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。 + +#### 步骤3:按队伍分组管理路径 +建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。**例如:把该JS复制三份,改名后,分别放入不同的配置组。给每个JS所在的配置组,配置对应的切队等(也可以单独添加切队JS)。每个JS放置该队能适配的路径文件。分路径挺费时间,按自己需要,琢磨好队伍来。队伍适合的话,包括抓鱼、禽肉、挖矿等等路径都可以适配,不一定按采集刷怪附魔来。**示例如下: + +| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 | +|------------------------|-----------------------------------|---------------------------|-----------------------------------| +| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时,需批量搜索路径中“nahida_collect”并排除 | +| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 | +| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 | + +分组示例与无草神排除路径操作截图: +
+ 队伍分组管理路径示例截图 + 无草神时排除路径操作截图 +
+ +#### 步骤4:打开脚本自定义设置 +1. 找到配置组内已添加的「背包材料统计」JS; +2. 右键点击该文件,选择「修改JS 脚本自定义设置」; +3. 操作参考截图: +
+ 打开脚本自定义设置操作截图 +
+ + +### 3.2 JS 自定义设置(核心配置项) +| 配置项 | 功能说明 | 操作建议 | +|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------| +| 1. 目标数量 | 仅当背包材料数量**低于此值**时,该材料的路径才会被纳入执行序列 | 推荐默认,管理路径下全部材料的目标数量 | +| 2. 优先级材料 | 无视“目标数量”限制,直接纳入执行序列顶层(最高优先级) | 填写当前急需材料(例:虹滴晶,巡陆艇);无则默认 | +| 3. 时间成本 | 当一个路径有3~5次运行记录后,自动计算“单材料获取时间”;50代表该材料类型的前50%的中高效路径 | 保持默认即可,无需频繁修改(可过滤低效路径) | +| 4. 发送通知 | ① 每类材料跑完通知一次;② 全部材料跑完汇总通知一次(需开启BGI通知) | 建议开启,方便实时了解进度(接收端如企业微信需自行配置) | +| 5. 取消扫描 | 取消“每个路径执行后”的背包扫描,仅保留“全部执行前/后”2次扫描 | 推荐默认;有效路径记录达3条以上时可以开启,可节约运行时间 | +| 6. 怪物材料过滤 | 默认只过滤掉📁pathing中已有的超量材料,若勾选,则只拾取📁pathing已有的未超量材料,不拾取其他材料 | 推荐背包爆炸的玩家开启; | +| 7. 超量阈值 | 首次扫描后,超量的材料,将从识别名单中剔除,默认9000 | 推荐默认,可自动避免爆背包 | +| 8. 统计选择 | 📁pathing材料:包含一般材料和怪物材料路径;【扫描额外的分类】:背包材料统计,联动生成超量材料;【测算模式】:可预估执行时间,无记录默认2分钟 | 根据需要选择,【扫描额外的分类】扫描到的超量材料会在自动拾取里排除; | +| 9. 弹窗名 | 不选则默认循环执行 `assets\imageClick` 文件夹下所有弹窗;填写则仅执行指定弹窗 | 推荐默认,需单独适配某类弹窗时填写(例:月卡,复苏) | +| 10. 弹窗循环间隔 | 不选则默认15秒 | 推荐默认 | +| 11. 采用的 CD 分类 | 不选则默认执行 `materialsCD` 文件夹内配置的CD分类;填写则仅执行指定CD分类 | 推荐默认;新增材料时,需在该文件夹同步配置CD规则(操作见「四、问题解答Q2」) | +| 12. 终止时刻 | 不填则不执行定时终止;路径无时间记录时,会预判路径耗时5分钟,且预留2分钟空闲 | 推荐默认;填写需要按24小时格式(例:4:10) | +| 13. 采用的识别名单 | 不选则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 推荐默认;新增名单时,需符合配置规则(操作见「四、问题解答Q4」) | +| 14. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 | + + +## 四、注意事项 +1. **禁止联机请求**:联机请求会遮挡背包菜单,导致材料识别失败。建议在本脚本前添加「AutoPermission」权限设置JS(仓库可查),默认禁止联机; +2. **文件夹层级限制**:`pathing` 文件夹仅支持6层子文件夹,超过需手动削减(否则路径无法读取); +3. **仓库路径交叉资源点**:把明显重复的同资源路径删除,减少提炼路径的时间; +4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或被污染后,记录能恢复如初; +5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto; +6. **手动终止运行**:手动终止路径会被记录成noRecord模式,只参与CD计算,如果是【取消扫描】模式,不会储存当前记录的材料数目,两种情况都随意。 + + +## 五、问题解答 + +### Q1:如何排除不想要的路径? +A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`); + 2. 直接删除/移走目标材料/怪物的路径文件夹; + **其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。 + +### Q2:如何添加新材料? +A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`); + 2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名; + 3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹); + 4. 操作参考截图: +
+ 添加新材料操作截图1 + 添加新材料操作截图2 +
+ +### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”、“果园.json”)? +A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。 + 2. 例:看「四、问题解答Q2」,① 把“纳塔食材一条龙”作为标准名,选择一个CD,② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!** + 3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾,将无CD记录!**; + 4. 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。 + 操作参考截图: +
+ 添加新路径文件夹操作截图1 + 添加新路径文件夹操作截图2 + 添加新路径文件夹操作截图2 +
+ + +### Q4:如何自定义识别名单? +A:1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`); + 2. 新建/编辑txt文件,按格式填写:`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”); + 3. 若需排除怪物掉落材料:找到“掉落.txt”,删除对应材料名即可; + 4. 操作参考截图: +
+ 自定义识别名单操作截图1 + 自定义识别名单操作截图2 +
+ +### Q5:如何取消路径执行后扫描背包? +A:在「JS自定义设置」中勾选“取消扫描”(依旧会保留“全部材料执行始/末”的2次扫描)。 + +### Q6:扫描背包少一行、拖动距离异常怎么办? +A:在「JS自定义设置」中调整“拖动距离”,推荐“一次划页稍小于4行材料的距离”(拖动点建议选第5行材料附近)。 + +### Q7:本地记录保存在哪里? +A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各文件功能如下: + - `overwrite_record`:所有历史记录(按材料分类储存); + - `history_record`:勾选“材料分类”后的专属记录; + - `latest_record.txt`:最近几种材料的记录(有上限,仅存最新数据); + - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录,三次及以上同名路径记录,就会触发排除); + 操作参考截图: +
+ 本地记录存放位置参考截图 +
+ +## 六、后言 +本脚本目前处于测试阶段,欢迎反馈问题至 QQ 频道号:bettergiv1。 + + +## 七、更新日志 +| 版本号 | 更新内容 | +|---------|--------------------------------------------------------------------------| +| v0.1 | 新增OCR名单功能,输出图片名与材料名 | +| v1.0 | 新增图包(仅含素材) | +| v1.1 | 图包扩展(素材+养成道具) | +| v1.2 | 新增识图分类功能 | +| v1.3 | 优化:加速材料寻找(新增前位材料识别) | +| v1.31 | 调整本地记录存储逻辑 | +| v1.32 | 新增后位材料识别功能 | +| v2.0 | 开发版:支持多组材料、多个分类;移除前/后位材料识别 | +| v2.1 | 新增CD管理功能 | +| v2.2 | 优化路径顺序、材料数量判断逻辑 | +| v2.21 | 修改路径储存路径 | +| v2.22 | 精简日志输出内容 | +| v2.23 | 优化部分函数性能 | +| v2.24 | 修复“空路径无法使用背包统计”等bug | +| v2.25 | 恢复前/后位材料识别(加速扫描);新增“仅扫描路径材料”选项(降低内存占用) | +| v2.26 | 修复材料时间读取错误;新增路径材料时间成本计算功能 | +| v2.27 | 修复“材料数计算错误”“目标数量临界值异常”“3识别成三”等bug | +| v2.28 | 材料变更时自动更新初始数量;排除0位移/0数量路径记录;新增材料名0后缀本地记录;新增背包弹窗识别 | +| v2.29 | 新增排除提示;调整平均时间成本计算逻辑;过滤异常值记录 | +| v2.30 | 更改路径专注模式默认值;增加日志提示;移除调试日志 | +| v2.40 | 优化背包识别时的内存占用;新增通知功能 | +| v2.41 | 修复“勾选分类的本地记录”bug;新增“仅背包统计”选项;补充记录损坏处理说明 | +| v2.42 | 新增“无路径间扫描”“noRecord模式”(适合成熟路径);新增怪物材料CD文件 | +| v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 | +| v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 | +| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 | +| 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报错;检测码识别路径 | +| v2.61 | 背包材料识别机制加速;修复手动终止路径noRecord模式的额外条件判断不生效;全局图片缓存;识别名单、CD文件和弹窗升级多选框UI!!!!!!注意UI修改了,配置组里需要删除并重新添加该js!!!!!! | +| v2.62 | 修复凌晨CD计算bug,修复超量名单潜在记录污染bug | +| v2.63 | !!!重要更新!!!时间成本机制修改以及对应UI修改 | +| v2.64 | 移除位移检测模块 | +| v2.65 | 修复测算模式、0记录检测码等bug,优化汇总日志,调整默认目标数量至1000 | +| v2.66 | 修复翻页兜底逻辑,增加材料数量模板匹配,材料数量OCR失败后截图保存,【扫描额外的分类】联动超量材料 | +| v2.67 | 修复怪物材料映射斜杠逻辑,更新OCR错字映射表,优先级材料按自定义顺序,修复怪物材料过滤bug | \ No newline at end of file diff --git a/repo/js/背包材料统计/assets/regions/1.png b/repo/js/背包材料统计/assets/regions/1.png new file mode 100644 index 0000000000000000000000000000000000000000..f2b09924d8f253528126519d486d161a2da14a45 GIT binary patch literal 1263 zcmbVMTWs586gDavRbfJCnl$=kxes(e;`~nH#->@7*vV#SLP#?$>sm28{?nM$m)Xve z79J32Q^p<`NC+w+#9%@}n*a?&RSgNmO-+TwMVdAsy-d9_G3gs)sM2;Xyl+wukhrT@+1-g=AfV zxS)z-FbPF@;zSkpV^~L4Nu}*{Y>>_9s#|ny+$GgOXbkHgC>dfV4{cn6V~Q3eHm-b5 z;EEh1j`(Aqn8CrEa%j?oLzD4TW-^~)WMbd|-d|!7feLLAFRA02#g>A^CNGQT&NfNl zn_x= zFp831u!98hv=xGC#A4gTnzfZElrpj;8YFOgNL6*>+O)RpB>cyX9j&d@gaOGUwDf|R zLFbX}X@gPj?kwaOB5&9yO$8l_IL_;tf(kWT;DZGE;+7Q|rHBgoe4z-%06q!;2+#p< znDWqJh6($)Fw^GP3G4OpjGy8>fTuVBq973Q@*&{$_@m(f@9*2j3Yuk$S_Zat6{Oq3 zhNE}HvYZJ;TQ^g>KHjc?L{7JLE2kScH$>yTM-)xgi`IVUcv{i&&{R%BIcn-EzL{TE z*+HKQ{8!v9to(m+Mj~fOr#SvqEbT2+fzEWh^wDCwd7y?m#zd{r`^=?NsI4lA!BnKN zTAP`fsjt*-)ff9BBcVa@m|SSC%{SL-^_5>&Zv6b%!O^e2{Nkq{ziY16Xzp0yx$^qW zdH$(fb@uC9jYTp4>~Hmj##${c*}_nki%Zw8{;+;?VMIO|j)_O*;_c?rT=m;W4h;8k zqqiGNkEe3W3)dEFR~xJI!*b#IQ!iI%zgb?qzOjDe?77zm2BlYCdo!AlUo5{$^CQbk z*FT+}4n#%=o_PA+KlcnHpYsAAN|k<*j^iC4S%z}){8{iuZ}+g3PaM8XUJ literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/assets/regions/11.png b/repo/js/背包材料统计/assets/regions/11.png new file mode 100644 index 0000000000000000000000000000000000000000..c1657cdc424531f35930b282eaaa9aaa76da986f GIT binary patch literal 1329 zcmbVMeN5bB9Ijb32D7+iYSjJHbQ(1o*N^)ctsKkUm4i+W;W)iB4x!h+K;^EzwPjZ> zlFZFvFq=r0oY6)6+TxZF93P9xz$|J&S)5Be=LGL#=mE!o7!!>e6Z9=`=pSSzHf`UJ zKF{;}cz?h4)YVpPFL|&8L6GfSHS34tz2Q^s zl#BAMzz=w_^WYRHN04nTQXr&uo3M|kBRf_47 zBBQ1vA67baCk8!T4A{plH)`m`CSx+%iprfIj$7q;j(-(PaSK+UIo&LMxY%qSAj6JPVQVC(mx8dZ zChKYfm5X!fH{W`XscO7Am(I_Aw_M1**xl#knwAUMoAa5)e7a(PliSB9-+0H#H80L* z_6MYqk7|9t%3^MAcIJb#1I=yml|uIO z$Gc z`r*)U*U{d~QAGjsm3 z=G^?u#Fqna51_mJ^%ZxY`6c|&V4PYGj^Fci=G?>ip3^0voNZ^)NLLTmUHItdt0(sa z&#nlosjsp6#LT7ZzYa&fx$ye)`tbuV|IxYY#3(0^|CxN`+24iNKEC~aY&4+|M^Xxt)%wC5J=Z{w-2lYcc$9z%Az{E~$~6-_8=pR{%cJIjZ6+O< zhLAaY`ixU)as?p5a0G~CM1<>EAP7JZ1LQ;h zM=d0ZnSp>Jti$q1KArM(CIpj3E`KR(CT0^wrHpBVEliNXVwy~WxTetrsY3p7d^+`oVL%O3ir4}U zM<8KyL9rAB!DuKtLda%8LM~Ux5ed0-8n5C;K%gK>07ijgwg?2JppXT!xB`w4VoNxn zAVNCFm6-_=HtUf&yC`LMmJ7X>%M;-UOk%hi!*b_3K#`4+7?F)x08t7AtVu=92FyyV z4U}glS}}s7`G`S^VI_G?IYN;B z)HnKMa^#tB6t#TGmt~H+hP>mBDX$w6Z&7YGxgHE=V>`NTT=!47b!b8Dne$xfmJNxS zJgKJdR=5ALD{&**-PKWC`nB6}H(rTIlv#KBdfZO?h;vAT5OXHt(xA)Y;jdnQe1B23^C0$4=BTVmD+>M7=K0h^#ircqnp5p4*0$9imK`%dmmm{qD(j4AG$Nxf9tD~1F2fPzTxtS$Jd|to)MN72c5V3-GwO2K5JFJ9ow9me-zx6;0vGf}=lO5PznD z9la6tcUL9n@2?zRwCK@-E8mns4MuHnd7a6qQ!YKwnDA}o`DOROzWhY3R(t8`XqFDn z^1Q1%3DG80#{MNhr3j6q;r4N$EmmRvk^JAZ4?M?f`_B|i1>*;De7#g;c9@<&-PIb-O zi(e?#rO3*P_s(COBzt2Z^smN{JSR)JweIj28ONKNniu^!b-a_Em9xD^ qR=nSuvI@wRW@LOge_&o*(LCC(QLM{5xyi$UpNLG7EWRYv7XJ;TUcKi4 literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/assets/regions/2.png b/repo/js/背包材料统计/assets/regions/2.png new file mode 100644 index 0000000000000000000000000000000000000000..36e4d919d7ea054985697f97104b16b76ef93af4 GIT binary patch literal 1671 zcmbVNTTl~c6i$oNah&=c4R9l$~B4W{aNsfyPDMJf7V6;Qb-;T8zD!~j8LrVsX|(+v>Y2glBIXLtYG`M&R* z^PhirpEh~x$`zljU@#af6B87vbo&YYe7ro2ekW8l-lm(?l+r-ya0}&t2!tVpaT5Y0 z+Mrw{6@lQQooz@ggAryy^#;nIQH#yEjRgg5SWcUrMl%?(@lHEr&O<1`gyf=_ggJMo zl?kA*gqa?#;b`n~#EK>q6Ns)jS#K`RGmBtmd>jz#6w?GYgn|I4EgvJrP6=~?S4{VV z(`+WNU_#|dn2SysG+IE869^E^;zgM`AP5KqERY{95P+KjE(heYIrJ@v0{P--ftVWu zEL}`mjesrURE26uiyldsR*JHV*=&cy!E*3eIFZW+MIup47f!Zk)$ABOsWWoz536u78F$9}kqj^J&kxPl9Q^s~eb~ec3ux++rTnpADm5Ti1 z##^mPeUTkury?X?K$z+Mu&fJ#>D;|rC}>D~BTgYudQ+f$1#T{|AsCgYkTB^L7L3Al zinvlfU#jGaK!pkfL7_m%lW{o$nMfq#%VnYv$J?+xfl{H2;Y&dUR}O+IP|8!rNI61L zw1N{Ois48@*hGw^Ak2(}bWvJ&2}`TK11pvj2t?t89>?=T6`-}^6i!-kJ0RByfQ{)W z2ICGgGPpg9(JBxEEks}yf!lzE{EE@H=yQet755?*{y#Zm)6TGi#qqCV2~E)z80@|& zeR}Zb@E{m{VhH+Zgdh2pWH8=q)vEQ%DX)9lH$3xVxZl;4ffUM9%rcE>#y3147=JZ0 zHtluy_jYa7S!Z61_$P;^yu*I)(3T{lN(&nx(&KhXl1xuJ9{w@s)n)Ir6_j)0(hX)3 z&fnG4)a0KSO3lJ9H(Z;W9_?uVohQw_cH?Gs?FpVdQ;`DuC*9(0Mn}nkZE0xzsk2fw z)cLfnz3sv5lxJtr-n4A{Z!Imy>Q0;TzI{10T2@)T!|I$E?fa?bxN3VYS-f{-@cFo> z|5)9bUrwFNvN*mnlJkMF`}bPC;{&sPPjAoDyLa#0YQFuf`>{ZtDN{pIwXu9(O?hRF zRBcMm$hX?RPf9~uT3RxZ!tTzF$a3-|J4$qr1uu9y)xI9cO54Z0vvD z87bBU{3Fx85yH8rtfG3_=N=n&&G};W_%;1d#c2W?Bdb`?*5(^lS8#f>oeg3XUYC7OJTAh ze+OE`*^)6kJu20}`2}UK0^<{&zVjC@<=RWiE2{f?yFBjRXnCe4{hRsO34cM}6Z#7o zNK{Dm&OwvWXdE>gS6ywY97AVoY(}^=jP;knhJ4=Iwmx=6!}9GnMz+jdlHIB5F+q-; zTN^9ZUOZp2;n1lISX~7p>*io&d-MJy(paXW=G>Yr{)4s2TYu<|QX90oeP6KJtw+00 zSD(1DKEjJ12rO5rl3!X}uh(3mK7h4nd(OI0ZrPX0tMatC$Vcl>ge$uSHVLn%{20Em zG6JhIMi{Wi_wNrpIhws})ybOnO=oiY>Mc3zo(aPnnq|$dzKYtY(u0>8lPEFh*ZSVRb?Wjt7Ep%FhQU^{HuDz#lN_*vcv<1Nh z5p1>UL@`W!fvSK`9Wpg8&LCzpXdF6~QhB$OLV1~t`DL3+b_MJ9!MJ3XyL;aLzu*7; zaU~}u%$@zgYz~JrSDlDy*)cl&&6>gfPu$sXnjIE1%5)}`GBS3Y2Ap_;(gR3s!n1)E z;6y>*9T3Oi%rKI=bS7Q1PHLb`Ts$npb(kzHn!|}(?Xcj6ZGeIFAe%JH;AhvHVTdGT zaGFTN*H{!FhfFM_L26-=&QQ3`AR*w@tDrcClqE0$28SG`T(ec`kik>DQg$BR=E2aE z2(wKFPaBo4Nrn^@4ImL$5M$sY2qYGB5ur#dMpi&5A3=G1_7%qNaR9=ToU1U9abO`A+Zu6AHh%sf~XLQJQh`80!$$i3Ir07 zB7#+$tqg89fQVj_)jN$9sNRT`DrkT+6s@DE+(-o^=THn~&7mxiB2^46Pb19)Ww(A5 zPS3Qr7@$cTAXGGEf~MjtC10Z-jr}jbr?JHUi5ZVIh8Hf5e-%q)i><)$^i}Dzi&xD9 z%xuTdY-_-CiBb;dEqC%doia4oJreAZs5XxTy9fGwsuWPZ_weXYZoZQGNgUK={J* zsuy zc_mWdagNMB8|*k-FK~IBAOF6HEZf@X+0(hK-MDaYvJw4mlz%Jg$&FNc={a}woT^)8 znwhv~@)`2(j;I&MjGm8v g??W36zXRRme7|K$G{5y>OZY}tE0eH`@~oYI1GCwOw*UYD literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/assets/regions/5.png b/repo/js/背包材料统计/assets/regions/5.png new file mode 100644 index 0000000000000000000000000000000000000000..67403be4d9a8b9f9f0679343473920074a1b89bf GIT binary patch literal 1606 zcmaJ>X;2eq7>+u%43(K$M`yI1b|=^w+LB{)VYWmKECH0zl!QYJg&a%Rnq-sB<{+X1 zBg9I)Dpc@5VelvwBH%%YP-|OD0KpqM3_>c%A=GxJzuHbW2(&*+cXszX=6Rm?`QG=P z?+Zy{Y)J6BUl#X7qpE0S9+6c$YCuJlSW9fY{II*5sFZaDG?xEr_dlt zh(euf^dgZI$}%b{OC!?636KiY(G>v<-KH~;YzifElg*$|?LY`XiD*#0fI8#(mI|P1 z0X3B?W{M5bNDdlj#}TPLQKqu*Q1R8&O&fto8$=4|5JCaibXvU$vI(fOx)3=JY%{39 zEQHu0puRGdCYAuv7>)p3I-90qf*`=-(LoNE#{c&fgFg-gIE#3+(RX! z;p%KC2^P)8B3A-x4nY_o2E%H#(yeSdhHDrgpU)3yuvjz_K{Ms*35AWOH-*kCz=%nO zqXq)S^guvSp~TFDfJ!=jbp)MZK~`^?OB0zehD~8$fOKZyNb^9k`2V3g-2&P~Bq9I! z{ZC<&EZ2ZAk`NPS##LnDvO@!*3{W(VCv z>iN*j#8cMhZ8MX@Znyj7>6)qMzF#JXrzf5k?LTVC+xK#6UO-)C?szkJT_WY8E_q!PzQ^))R&CSi79Y0l8o$K)4x_b3m zaY^af^WW6gxymXkwRk=^dV5#rt>32oBfj41n)6d*zL|;Pea3!Ifkb+9K zX@>?MdaiqTu-tK=jIbA{Wtn0m$n7p~Yg;R3E;x3)>eT6)@zE!AL8{Bu*mU{osK4KJ zsbOP+Dkalcw7*n??M}`#5_yg+jqQ^(EuLFE@oaE%?1?$uIH^XO zq2F2Hyw}soiAtBsF)l1GbR0I?iawQN-Cf?VYwO$EuD5tR7i#O8-R@&2s-6w^b+rHJ zfBHDbxCb@t9`pATdBuf}BQKs0t>;N^e*c{$U3EBDzI!YGaLFsm~hWgndctHu`(b@bAlyHZoIb$DF|aq5PZSTCFxb!4|p%xIx)c W`_Y5GsGiio%^n|<2-int75)u__mH9h literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/assets/regions/6.png b/repo/js/背包材料统计/assets/regions/6.png new file mode 100644 index 0000000000000000000000000000000000000000..7def2a40015cfe310aefcd3df7208e4b2a5b9008 GIT binary patch literal 1603 zcmaJ>eM}Q~7_K8T8;Q$|F;V80Tiu*f>2Yl-wo;+Av^2D(&@zfp?9scHqx7!!uCy>+ zV4H>k&iRG%A&6g_AHylK%qc}Tr?kq)6eU7wMLtUTn2RRM{@fJ-_s7`f?tUM6p67jk z@B8HUm0BfR78w=EU@(@+QzRMmxH9~`5kdbOJ(;KIVKtSkqcntxa>90$Awr0J6p&kC zGn#?INXZTl8qZ)vFfpx;(kT@JBVlF1VGPS@q_9S6)k zOgfq!F$ppx(z#glO2{mrD4PHTolYmq$zc(8GYIkd{ICX_9Ze&m$r7A`UC}tXc3we( zl14jbqc8#o!isP{;h=;}I@6a&u-X=6adNIqbiqIuYy%-yO!!FiK&A5kp;qexnxrz& zSNZ;@FsUuEpH}yC2UB-VMCA zh8z2@M$d22?g>v9Y)dZ|jE~~9+wHWiL)APbgR!JTtU*+6M@doTa)8{O)eoxM>Tnb+ui=CuX`pG5J>gcuU`xJ9!>}S z_j~U;c2-OVhkARwA{8Q1A`OkF#)cn1n|xv+%ds70N9vm=$9*=}Zn9)gY9{tH(9ciS zdp)<$o@?1=a-F}>GBrL_d#w5550`@@gFRi|XH&z!-nIpNkE5ESxk5C{q?)$yl+uGLF(cvB)emorvC_X8a zrK8)7j*&pW-`5u$8F zgRNKE_)>k60?xO5nxsMx9Bw$)e0obt?vc8tM1`Sxf9=ULXR{1s4pN+FD(<`ACD&qn znO>HTnDEcMp5MT&I_ItmxUi(R%X9PQjfpW|S~jlI6{)j}#N^(JdcbV1II4D;D)4sy?gR#&DDY^^U@WC$Dv9N$!TRj-TPV@BZBcFag(LM@wX~` Ot;>^Dl9nXH=l=i@_m5Bj literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/assets/regions/7.png b/repo/js/背包材料统计/assets/regions/7.png new file mode 100644 index 0000000000000000000000000000000000000000..dde911ea0119b171e3a3bb4ce664f01ede6842d5 GIT binary patch literal 1625 zcmbVMdrT8|96#oCjKyTh;-V%qw=#^IqwQUdO;q7#|2 zi8@6b+l+2=IyFe2_i(HV-0>0+gVuOW?0Ra}N?H<|$&g0gb#W|d|$LBnc7Z!ifMPdcwLV1rh` zSkG0mm1Z$fX;{65A_}+U7HPI@*5F!3PBxrn=K}&GL91Z9vC3rO+XajnUOt$|w^V2pP++GP4jSn`Jb{{hE=s&;`W5W;|Eg zQdDauSOtWItf4d@AG*{87{uiv+z6hvUkX z&K0Z5yn}c4G<_8fc_V?LSil>e80x+IOGTyK+1nEfxFZvub>$RSym6zJK6n0NeyMqd zyu7wF_maohCpd6Q$(pz`A`@4{pC7IGvOt^tBE7Oy8o}s=U zFFBf8j%;sey5w-wHSB9``ypFiUP;ys_B+FYp>~Hux<>nCa%{ra*Z1o^pLf9Be|y5; z9}A8Q4mfY$yxw~H%*`9uQDHGFdwu;U2in^mdUD%jXe1Kw5T(1>Tak1s1ddi<17 zQE{N<2v4@jJN!@}FW>dq7o+a`e(!@bXU{*1`h(-cO$QIxf7%=lc!Og@QxV@(*mv&y zg;>Zp=yFB^ZnL$4BPqSt(=9C^PF?+T9k}{Ja*43W?ol5&co$ptd1G^9P#ZxrtlZ`M zTwji>pqH3wC5Bw9OsBJU%8QO)1izi_1qnQz23RFb)opJ?KQ^)Z!B!rT|#cP zY&g+*A?f3-c~|R}uoUU9+2lXfx3<==Y1_3nEmhI-cIy88;zP~nZuH$g+LW{)Ijv;r z-@6wcOF`e1x3#vtdhFeO$(B1czn@)IcsMn-=<)K#Q|&u8j+SKfE>;dK&A)XzZO@|K z`sjPrXP_n6$@?jXyQb$xyDz@;^4{gI{Be9Y)fhWz??Jxb>uH;6tY&|=Q`THnIPW5~ bvOWn~@=e;;ogeu%@ylK=&6S)LRqXf&`d6VJ literal 0 HcmV?d00001 diff --git a/repo/js/背包材料统计/lib/autoPick.js b/repo/js/背包材料统计/lib/autoPick.js index dc90dafd3..dd910d1bd 100644 --- a/repo/js/背包材料统计/lib/autoPick.js +++ b/repo/js/背包材料统计/lib/autoPick.js @@ -106,7 +106,7 @@ async function findFIcon(recognitionObject, timeout = 10, ra = null) { // 定义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 ); @@ -131,8 +131,8 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange } recognitionCount.clear(); const currentTime = Date.now(); - - if (currentTime - lastLogTime >= 10000) { + + if (currentTime - lastLogTime >= 15000) { log.info("独立OCR识别中..."); lastLogTime = currentTime; } @@ -156,20 +156,24 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange const targetTexts = typeof targetTextsOrFunc === 'function' ? targetTextsOrFunc() : targetTextsOrFunc; - const yRange = { min: fRes.y - 3, max: fRes.y + 37 }; - const { results: ocrResults, screenshot: ocrScreenshot } = await performOcr( + const region = { + x: textxRange.min, + y: fRes.y - 3, + width: textxRange.max - textxRange.min, + height: 40 + }; + const { results: ocrResults, screenshot: ocrScreenshot, shouldDispose } = await performOcr( targetTexts, - textxRange, - yRange, + region, cachedFrame, 10, 5 ); - ocrScreenshots.push(ocrScreenshot); + ocrScreenshots.push({ screenshot: ocrScreenshot, shouldDispose }); let foundTarget = false; for (const targetText of targetTexts) { - const targetResult = ocrResults.find(res => + const targetResult = ocrResults.find(res => res.text.includes(targetText) || targetText.includes(res.text) ); if (!targetResult) continue; @@ -190,6 +194,14 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange } if (!foundTarget) { + if (ocrResults.length > 0) { + const fCenterY = fRes.y + fRes.height / 2; + for (const res of ocrResults) { + const centerY = res.y + res.height / 2; + const yDiff = Math.abs(centerY - fCenterY); + log.debug(`未匹配: "${res.text}" Y中心: ${centerY.toFixed(1)}, F图标Y中心: ${fCenterY.toFixed(1)}, 差值: ${yDiff.toFixed(1)}, 容忍度: ${texttolerance}`); + } + } await keyMouseScript.runFile(`assets/滚轮下翻.json`); } } @@ -197,13 +209,21 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange log.error(`对齐交互异常: ${error.message}`); } finally { if (cachedFrame) { - if (cachedFrame.Dispose) cachedFrame.Dispose(); - else if (cachedFrame.dispose) cachedFrame.dispose(); + try { + if (cachedFrame.Dispose) cachedFrame.Dispose(); + else if (cachedFrame.dispose) cachedFrame.dispose(); + } catch (e) { + log.debug(`释放缓存帧失败(可能已释放): ${e.message}`); + } } - for (const screenshot of ocrScreenshots) { - if (screenshot) { - if (screenshot.Dispose) screenshot.Dispose(); - else if (screenshot.dispose) screenshot.dispose(); + for (const { screenshot, shouldDispose } of ocrScreenshots) { + if (screenshot && shouldDispose) { + try { + if (screenshot.Dispose) screenshot.Dispose(); + else if (screenshot.dispose) screenshot.dispose(); + } catch (e) { + log.debug(`释放OCR截图失败(可能已释放): ${e.message}`); + } } } if (state.cancelRequested) { diff --git a/repo/js/背包材料统计/lib/backStats.js b/repo/js/背包材料统计/lib/backStats.js index 6d060f7cf..ab60eabae 100644 --- a/repo/js/背包材料统计/lib/backStats.js +++ b/repo/js/背包材料统计/lib/backStats.js @@ -1,5 +1,3 @@ -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))); const totalPageDistance = Math.max(10, Math.floor(Number(settings.PageScrollDistance) || 711)); @@ -31,19 +29,19 @@ const materialTypeMap = { // 材料前位定义 const materialPriority = { - "祝圣精华": 2, - "锻造素材": 1, - "怪物掉落素材": 1, - "采集食物": 1, - "一般素材": 2, - "周本素材": 2, - "料理": 2, - "烹饪食材": 3, - "角色突破素材": 3, - "木材": 4, - "宝石": 4, - "鱼饵鱼类": 5, - "角色天赋素材": 5, + "祝圣精华": 2, + "锻造素材": 1, + "怪物掉落素材": 1, + "采集食物": 1, + "一般素材": 2, + "周本素材": 2, + "料理": 2, + "烹饪食材": 3, + "角色突破素材": 3, + "木材": 4, + "宝石": 4, + "鱼饵鱼类": 5, + "角色天赋素材": 5, "武器突破素材": 6 }; @@ -118,9 +116,53 @@ async function recognizeText(ocrRegion, timeout = 100, retryInterval = 20, maxAt } const sortedResults = Object.keys(frequencyMap).sort((a, b) => frequencyMap[b] - frequencyMap[a]); + if (sortedResults.length === 0) { + const templateMatchResult = await recognizeNumberByTemplate(ra, ocrRegion); + if (templateMatchResult) { + return templateMatchResult; + } + saveFailedOcrRegion(ra, ocrRegion); + } return sortedResults.length > 0 ? sortedResults[0] : false; } +// 模板匹配识别数字 +async function recognizeNumberByTemplate(ra, ocrRegion) { + const regionDir = "assets/regions"; + const numberFiles = readAllFilePaths(regionDir, 0, 1, ['.png']); + + for (const filePath of numberFiles) { + const num = parseInt(basename(filePath).replace('.png', ''), 10); + if (isNaN(num)) continue; + + const templateMat = file.readImageMatSync(filePath); + if (templateMat.empty()) continue; + + const expandPx = 5; + const matchX = ocrRegion.x; + const matchY = ocrRegion.y - expandPx; + const matchW = ocrRegion.width; + const matchH = ocrRegion.height + 2 * expandPx; + + const matchRo = RecognitionObject.TemplateMatch( + templateMat, + matchX, + matchY, + matchW, + matchH + ); + matchRo.threshold = 0.8; + + const result = ra.find(matchRo); + if (result.isExist()) { + log.info(`[模板匹配] ${num}.png 识别成功`); + return String(num); + } + } + + return null; +} + // 优化后的滑动页面函数(基于通用函数) async function scrollPage(totalDistance, stepDistance = 10, delayMs = 5) { await mouseDrag({ @@ -151,16 +193,24 @@ async function scrollBackPage(totalDistance, stepDistance = 10, delayMs = 5) { } // 并行模板匹配函数 -async function parallelTemplateMatch(ra, materials, x, y, width, height, threshold = 0.8, enableMouseMove = true) { +async function parallelTemplateMatch(ra, materials, x, y, width, height, threshold = 0.8, enableMouseMove = true, enableColorCheck = 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; - + // recognitionObject.Use3Channels = true; + const result = ra.find(recognitionObject); if (result.isExist() && result.x !== 0 && result.y !== 0) { + if (enableColorCheck && typeof compareColor === 'function') { + const colorCheck = compareColor(mat, ra, result); + if (!colorCheck.isMatch) { + if (debugLog) log.debug(`颜色验证失败 [${name}]: avgDiff=${JSON.stringify(colorCheck.avgDiff)}, stdDiff=${JSON.stringify(colorCheck.stdDiff)}`); + resolve({ name, result: null }); + return; + } + } if (enableMouseMove) { moveMouseTo(result.x, result.y); } @@ -174,7 +224,7 @@ async function parallelTemplateMatch(ra, materials, x, y, width, height, thresho } }); }); - + return await Promise.all(matchPromises); } @@ -201,20 +251,20 @@ async function batchRecognizeText(ocrRegions, ra, timeout = 200, retryInterval = }); }); }); - + 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]; // 检查是否可以合并(垂直距离小于阈值) @@ -229,7 +279,7 @@ function mergeOcrRegions(regions) { } } merged.push(currentMerge); - + return merged; } @@ -316,26 +366,62 @@ function filterMaterialsByPriority(materialsCategory) { return finalFilteredMaterials } -// 扫描材料 + // 扫描材料 +async function performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance) { + const ocrRegions = []; + + 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); + + if (materialsToMatch.length > 0) { + const matchResults = await parallelTemplateMatch(ra, materialsToMatch, scanX, startY, columnWidth, columnHeight, 0.85); + + for (const { name, result } of matchResults) { + if (result) { + recognizedMaterials.add(name); + const ocrRegion = { + x: result.x - tolerance, + y: result.y + 97 - tolerance, + width: 66 + 2 * tolerance, + height: 22 + 2 * tolerance + }; + ocrRegions.push({ region: ocrRegion, name }); + } + } + } + } + + return ocrRegions; +} + async function scanMaterials(materialsCategory, materialCategoryMap, isPostPriority = false) { // 使用全局图片缓存 const materialImages = globalMaterialImageCache; const currentType = materialTypeMap[materialsCategory]; const currentPriority = materialPriority[materialsCategory]; - + const priorityMaterialNames = []; const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory); - + const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType); - + for (const category of currentTypeMaterials) { const materialIconDir = `assets/images/${category}`; const materialIconFilePaths = file.ReadPathSync(materialIconDir); for (const filePath of materialIconFilePaths) { const name = basename(filePath).replace(".png", ""); priorityMaterialNames.push({ category, name }); - + if (!materialImages[name]) { const mat = getCachedImageMat(filePath); if (!mat.empty()) { @@ -390,16 +476,17 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior const startY = 121; const OffsetWidth = 146.428; // 146.428 const columnWidth = 123; - const columnHeight = 680; + const columnHeight = 710; const maxColumns = 8; // 跟踪已截图的区域(避免重复) - const capturedRegions = new Set(); + const capturedRegions = new Set(); // 扫描状态 let hasFoundFirstMaterial = false; let lastFoundTime = null; let shouldEndScan = false; let foundPriorityMaterial = false; + let isEighthColumnScan = false; // 当前是否为第八列扫描(用于跳过5秒超时终止) // 俏皮话逻辑 const scanPhrases = [ @@ -429,62 +516,21 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior tempPhrases.sort(() => Math.random() - 0.5); // 打乱数组顺序,确保随机性 let phrasesStartTime = Date.now(); let previousScreenshot = null; // 用于存储上一次翻页前的截图 - + // 后续优先级材料扫描:滑条重置后先检查第八列是否有前位材料 if (isPostPriority) { - log.info(`后续优先级材料扫描 - 检查第八列前位材料`); - const ra = captureGameRegion(); - - const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority); - log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); - const lowerPriorityMatches = []; - - for (const category of lowerPriorityMaterials) { - const categoryMaterials = priorityMaterialNames - .filter(({ name, category: cat }) => cat === category) - .map(({ name }) => { - const mat = materialImages[name]; - return mat ? { name, mat } : null; - }) - .filter(Boolean); - - if (categoryMaterials.length > 0) { - const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8); - const foundMaterials = matchResults.filter(r => r.result).map(r => r.name); - if (foundMaterials.length > 0) { - log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`); - } - lowerPriorityMatches.push(...matchResults.filter(r => r.result)); - } - } - - log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`); - if (lowerPriorityMatches.length === 0) { - log.info(`未发现前位材料,回退一页`); - await scrollBackPage(totalPageDistance, 10, 5); - await sleep(500); - } else { - log.info(`发现前位材料,照常继续扫描`); - } - } - - // 扫描背包中的材料 - for (let scroll = 0; scroll <= pageScrollCount; scroll++) { - - const ra = captureGameRegion(); - foundPriorityMaterial = false; - - const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory); - - const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType); - - log.info(`第八列扫描 - 当前分类: ${materialsCategory}, 优先级: ${currentPriority}`); - - if (currentPriority <= 2) { + if (debugLog) log.info(`后续优先级材料扫描 - 检查第八列前位材料`); + + let backPageCount = 0; + let foundLowerPriority = false; + + while (backPageCount < pageScrollCount && !foundLowerPriority) { + const ra = captureGameRegion(); + const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority); - log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); + if (debugLog) log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); const lowerPriorityMatches = []; - + for (const category of lowerPriorityMaterials) { const categoryMaterials = priorityMaterialNames .filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name)) @@ -493,29 +539,82 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior return mat ? { name, mat } : null; }) .filter(Boolean); - + if (categoryMaterials.length > 0) { const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8); const foundMaterials = matchResults.filter(r => r.result).map(r => r.name); if (foundMaterials.length > 0) { - log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`); + if (debugLog) log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`); } lowerPriorityMatches.push(...matchResults.filter(r => r.result)); } } + + if (lowerPriorityMatches.length > 0) { + foundLowerPriority = true; + if (debugLog) log.info(`发现前位材料,照常继续扫描`); + } else { + backPageCount++; + if (backPageCount < pageScrollCount) { + log.info(`未发现前位材料,回退一页 (${backPageCount}/${pageScrollCount})`); + await scrollBackPage(totalPageDistance, 10, 5); + await sleep(500); + } else { + log.info(`已回退${pageScrollCount}页,仍未发现前位材料,继续扫描`); + } + } + } + } + + // 扫描背包中的材料 + for (let scroll = 0; scroll <= pageScrollCount; scroll++) { - log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`); + const ra = captureGameRegion(); + foundPriorityMaterial = false; + + const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory); + + const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType); + + if (debugLog) log.info(`第八列扫描 - 当前分类: ${materialsCategory}, 优先级: ${currentPriority}`); + isEighthColumnScan = true; // 标记当前为第八列扫描 + + if (currentPriority <= 2) { + const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority); + if (debugLog) log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); + const lowerPriorityMatches = []; + + for (const category of lowerPriorityMaterials) { + const categoryMaterials = priorityMaterialNames + .filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name)) + .map(({ name }) => { + const mat = materialImages[name]; + return mat ? { name, mat } : null; + }) + .filter(Boolean); + + if (categoryMaterials.length > 0) { + const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8); + const foundMaterials = matchResults.filter(r => r.result).map(r => r.name); + if (foundMaterials.length > 0 && debugLog) { + if (debugLog) log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`); + } + lowerPriorityMatches.push(...matchResults.filter(r => r.result)); + } + } + + if (debugLog) log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`); if (lowerPriorityMatches.length < 4) { - log.info(`前位材料少于4张,触发全列扫描`); + if (debugLog) log.info(`前位材料少于4张,触发全列扫描`); foundPriorityMaterial = true; } else { - log.info(`4张都是前位材料,继续翻页`); + if (debugLog) log.info(`4张都是前位材料,继续翻页`); } } else { const currentOrHigherPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] >= currentPriority); - log.info(`检查同位/后位材料分类: ${currentOrHigherPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); + if (debugLog) log.info(`检查同位/后位材料分类: ${currentOrHigherPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`); const currentOrHigherMatches = []; - + for (const category of currentOrHigherPriorityMaterials) { const categoryMaterials = priorityMaterialNames .filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name)) @@ -524,87 +623,43 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior return mat ? { name, mat } : null; }) .filter(Boolean); - + if (categoryMaterials.length > 0) { const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8); const foundMaterials = matchResults.filter(r => r.result).map(r => r.name); - if (foundMaterials.length > 0) { - log.info(`第八列识别到同位/后位材料 [${category}]: ${foundMaterials.join(', ')}`); + if (foundMaterials.length > 0 && debugLog) { + if (debugLog) log.info(`第八列识别到同位/后位材料 [${category}]: ${foundMaterials.join(', ')}`); } currentOrHigherMatches.push(...matchResults.filter(r => r.result)); } } - - log.info(`第八列同位/后位材料总数: ${currentOrHigherMatches.length}`); + + if (debugLog) log.info(`第八列同位/后位材料总数: ${currentOrHigherMatches.length}`); if (currentOrHigherMatches.length > 0) { - log.info(`发现同位/后位材料,触发全列扫描`); + if (debugLog) log.info(`发现同位/后位材料,触发全列扫描`); foundPriorityMaterial = true; } else { - log.info(`未发现同位/后位材料,继续翻页`); + if (debugLog) log.info(`未发现同位/后位材料,继续翻页`); } } - - // 只有发现目标材料时,才执行全列扫描 - 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); - - if (materialsToMatch.length > 0) { - // 并行扫描当前列的所有材料 - const matchResults = await parallelTemplateMatch(ra, materialsToMatch, scanX, startY, columnWidth, columnHeight, 0.85); - - // 收集匹配结果和OCR区域 - for (const { name, result } of matchResults) { - if (result) { - recognizedMaterials.add(name); - - // 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 (!hasFoundFirstMaterial) { - hasFoundFirstMaterial = true; - lastFoundTime = Date.now(); - } else { - lastFoundTime = Date.now(); - } - } - } - } + + // 只有发现目标材料时,才执行全列扫描 + if (foundPriorityMaterial) { + if (debugLog) log.info(`开始全列扫描`); + const ocrRegions = await performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance); + + // 批量处理OCR + if (ocrRegions.length > 0) { + const ocrResults = await batchRecognizeText(ocrRegions, ra); + + // 处理OCR结果 + for (const { name, result } of ocrResults) { + materialInfo.push({ name, count: result || "?" }); } - - // 批量处理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(`未发现目标材料,跳过`); } + } else { + log.info(`未发现目标材料,跳过`); + } // 每5秒输出一句俏皮话 const phrasesTime = Date.now(); @@ -625,7 +680,29 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior break; } - if (hasFoundFirstMaterial && Date.now() - lastFoundTime > 5000) { + // 检查是否需要启动画面比较兜底逻辑 + let useScreenComparison = false; + if (!shouldEndScan && scroll < pageScrollCount && previousScreenshot) { + // 检查是否满足兜底条件: + // 1. 已找到至少一个材料,但长时间未发现新材料 + // 2. 或连续多次未发现任何材料 + // 注意:第八列扫描时不触发兜底逻辑 + if (recognizedMaterials.size > 0) { + const noNewMaterialTime = Date.now() - lastFoundTime; + if (noNewMaterialTime > 5000 && !isEighthColumnScan) { // 5秒未发现新材料,且不是第八列扫描 + useScreenComparison = true; + log.info(`5秒未发现新材料,启动画面比较兜底逻辑`); + } + } else if (scroll > 2 && !isEighthColumnScan) { // 连续翻页2次以上仍未发现任何材料,且不是第八列扫描 + useScreenComparison = true; + log.info(`连续翻页${scroll}次未发现任何材料,启动画面比较兜底逻辑`); + } + } + + isEighthColumnScan = false; // 第八列扫描结束,重置标志 + + // 5秒未发现新材料时结束扫描,但如果触发兜底逻辑或第八列扫描则跳过此检查 + if (hasFoundFirstMaterial && Date.now() - lastFoundTime > 5000 && !useScreenComparison && !isEighthColumnScan) { log.info("未发现新的材料,结束扫描"); shouldEndScan = true; break; @@ -640,25 +717,7 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior 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 && !shouldEndScan) { if (useScreenComparison && previousScreenshot) { @@ -670,55 +729,22 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior previousScreenshot.region.width + 2, previousScreenshot.region.height + 2 ); - matchRo.threshold = 0.95; // 高阈值,确保区域变化足够明显 - matchRo.Use3Channels = true; - + matchRo.threshold = 0.85; // 高阈值,确保区域变化足够明显 + // 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); + + const ocrRegions = await performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance); + + if (ocrRegions.length > 0) { + const ocrResults = await batchRecognizeText(ocrRegions, ra); + for (const { name, result } of ocrResults) { + materialInfo.push({ name, count: result || "?" }); } } - + log.info("最后一次全列扫描完成,结束扫描"); shouldEndScan = true; break; @@ -726,13 +752,14 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior log.info("连续翻页画面有变化,继续扫描"); } } - + // 执行翻页 await scrollPage(-totalPageDistance, 10, 5); - // 减少等待时间,提高翻页速度 - await sleep(50); + // 兜底逻辑启动时增加等待时间,确保画面稳定 + const scrollDelay = useScreenComparison ? 500 : 50; + await sleep(scrollDelay); } - + // 更新上一次的截图 previousScreenshot = currentScreenshot; } @@ -760,7 +787,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); // 覆盖模式? @@ -836,230 +863,232 @@ function dynamicMaterialGrouping(materialCategoryMap) { // 主逻辑函数 async function MaterialPath(materialCategoryMap, cachedFrame = null) { try { - // 1. 先记录原始名称与别名的映射关系(用于最后反向转换) - const nameMap = new Map(); - Object.values(materialCategoryMap).flat().forEach(originalName => { - const aliasName = MATERIAL_ALIAS[originalName] || originalName; - nameMap.set(aliasName, originalName); // 存储:别名→原始名 - }); + // 1. 先记录原始名称与别名的映射关系(用于最后反向转换) + const nameMap = new Map(); + Object.values(materialCategoryMap).flat().forEach(originalName => { + const aliasName = MATERIAL_ALIAS[originalName] || originalName; + nameMap.set(aliasName, originalName); // 存储:别名→原始名 + }); - // 2. 转换materialCategoryMap为别名(用于内部处理) - const processedMap = {}; - Object.entries(materialCategoryMap).forEach(([category, names]) => { - processedMap[category] = names.map(name => MATERIAL_ALIAS[name] || name); - }); - materialCategoryMap = processedMap; + // 2. 转换materialCategoryMap为别名(用于内部处理) + const processedMap = {}; + Object.entries(materialCategoryMap).forEach(([category, names]) => { + processedMap[category] = names.map(name => MATERIAL_ALIAS[name] || name); + }); + materialCategoryMap = processedMap; - const maxStage = 4; // 最大阶段数 - let stage = 0; // 当前阶段 - let currentGroupIndex = 0; // 当前处理的分组索引 - let currentCategoryIndex = 0; // 当前处理的分类索引 - let materialsCategory = ""; // 当前处理的材料分类名称 - const allLowCountMaterials = []; // 用于存储所有识别到的低数量材料信息 + const maxStage = 4; // 最大阶段数 + let stage = 0; // 当前阶段 + let currentGroupIndex = 0; // 当前处理的分组索引 + let currentCategoryIndex = 0; // 当前处理的分类索引 + 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("材料 动态[分组]结果:"); + sortedGroups.forEach(group => { + log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`); +}); - // 添加状态变量,记录上一个分类的信息 - let prevCategory = null; - let prevCategoryObject = null; - let prevPriority = null; - let prevGroup = null; - let skipSliderReset = false; // 是否跳过滑条重置 + let loopCount = 0; + const maxLoopCount = 200; // 合理阈值,正常流程约50-100次循环 - const sortedGroups = dynamicMaterialGrouping(materialCategoryMap); - // log.info("材料 动态[分组]结果:"); - sortedGroups.forEach(group => { - log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`); - }); + while (stage <= maxStage && loopCount <= maxLoopCount) { // ===== 补充优化:加入循环次数限制 ===== + loopCount++; + switch (stage) { + case 0: // 返回主界面 + log.info("返回主界面"); + await genshin.returnMainUi(); + await sleep(500); + stage = 1; // 进入下一阶段 + break; - let loopCount = 0; - const maxLoopCount = 200; // 合理阈值,正常流程约50-100次循环 + case 1: // 打开背包界面 + // log.info("打开背包界面"); + keyPress("B"); // 打开背包界面 + // 暂时注释掉OCR暂停逻辑 + // state.ocrPaused = true; + // log.info("背包扫描开始,已暂停OCR拾取任务"); + await sleep(800); // 减少等待时间 - while (stage <= maxStage && loopCount <= maxLoopCount) { // ===== 补充优化:加入循环次数限制 ===== - loopCount++; - switch (stage) { - case 0: // 返回主界面 - log.info("返回主界面"); - await genshin.returnMainUi(); - await sleep(500); - stage = 1; // 进入下一阶段 - break; - - case 1: // 打开背包界面 - // log.info("打开背包界面"); - keyPress("B"); // 打开背包界面 - state.ocrPaused = true; - log.info("背包扫描开始,已暂停OCR拾取任务"); - await sleep(800); // 减少等待时间 + cachedFrame?.dispose(); + cachedFrame = captureGameRegion(); + const backpackResult = await recognizeImage(BagpackRo, cachedFrame, 2000, 500, true, "背包"); + if (backpackResult.isDetected) { + // log.info("成功识别背包图标"); + stage = 2; // 进入下一阶段 + } else { + log.warn("未识别到背包图标,重新尝试"); + // ===== 补充优化:连续回退时释放资源 ===== cachedFrame?.dispose(); - cachedFrame = captureGameRegion(); + stage = 0; // 回退 + } + break; + + case 2: // 按分组处理材料分类 + if (currentGroupIndex < sortedGroups.length) { + const group = sortedGroups[currentGroupIndex]; + + if (currentCategoryIndex < group.categories.length) { + materialsCategory = group.categories[currentCategoryIndex]; + const offset = materialTypeMap[materialsCategory]; + const menuClickX = Math.round(575 + (offset - 1) * 96.25); + // log.info(`点击坐标 (${menuClickX},75)`); + click(menuClickX, 75); + + await sleep(500); - const backpackResult = await recognizeImage(BagpackRo, cachedFrame, 2000, 500, true, "背包"); - if (backpackResult.isDetected) { - // log.info("成功识别背包图标"); - stage = 2; // 进入下一阶段 - } else { - log.warn("未识别到背包图标,重新尝试"); - // ===== 补充优化:连续回退时释放资源 ===== cachedFrame?.dispose(); - stage = 0; // 回退 - } - break; + cachedFrame = captureGameRegion(); - case 2: // 按分组处理材料分类 - if (currentGroupIndex < sortedGroups.length) { - const group = sortedGroups[currentGroupIndex]; - - if (currentCategoryIndex < group.categories.length) { - materialsCategory = group.categories[currentCategoryIndex]; - const offset = materialTypeMap[materialsCategory]; - const menuClickX = Math.round(575 + (offset - 1) * 96.25); - // log.info(`点击坐标 (${menuClickX},75)`); - click(menuClickX, 75); - - await sleep(500); - - cachedFrame?.dispose(); - cachedFrame = captureGameRegion(); - - stage = 3; // 进入下一阶段 - } else { - currentGroupIndex++; - currentCategoryIndex = 0; // 重置分类索引 - stage = 2; // 继续处理下一组 - } + stage = 3; // 进入下一阶段 } else { - stage = 5; // 跳出循环 + currentGroupIndex++; + currentCategoryIndex = 0; // 重置分类索引 + stage = 2; // 继续处理下一组 } - break; + } else { + stage = 5; // 跳出循环 + } + break; - case 3: // 识别材料分类 - let CategoryObject = getCategoryObject(materialsCategory); - if (!CategoryObject) { - log.error("未知的材料分类"); - // ===== 补充优化:异常时释放资源并退出 ===== - cachedFrame?.dispose(); - stage = 0; // 回退到阶段0 - return; + case 3: // 识别材料分类 + let CategoryObject = getCategoryObject(materialsCategory); + if (!CategoryObject) { + log.error("未知的材料分类"); + // ===== 补充优化:异常时释放资源并退出 ===== + cachedFrame?.dispose(); + stage = 0; // 回退到阶段0 + return; + } + + const CategoryResult = await recognizeImage(CategoryObject, cachedFrame, 2000, 500, true, materialsCategory); + if (CategoryResult.isDetected) { + log.info(`识别到${materialsCategory} 所在分类。`); + stage = 4; // 进入下一阶段 + } else { + log.warn("未识别到材料分类图标,重新尝试"); + // log.warn(`识别结果:${JSON.stringify(CategoryResult)}`); + // ===== 补充优化:连续回退时释放资源 ===== + cachedFrame?.dispose(); + stage = 2; // 回退到阶段2 + } + break; + + case 4: // 扫描材料 + log.info("芭芭拉,冲鸭!"); + + // 判断是否需要重置滑条 + if (!skipSliderReset) { + await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端 + await sleep(200); + leftButtonDown(); // 长按左键重置材料滑条 + await sleep(300); + leftButtonUp(); + await sleep(200); + } else { + log.info("同一大类且为后位材料,跳过滑条重置"); + // 不重置滑条,直接从当前位置开始检查第八列 + } + + // 判断是否是后续优先级材料(优先级高于前一个分类) + const currentPriority = materialPriority[materialsCategory]; + const isPostPriority = prevPriority !== null && currentPriority > prevPriority; + if (isPostPriority) { + log.info(`后续优先级材料扫描 - 当前优先级: ${currentPriority}, 前位优先级: ${prevPriority}`); + } + + // 扫描材料并获取低于目标数量的材料 + const lowCountMaterials = await scanMaterials(materialsCategory, materialCategoryMap, isPostPriority); + 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; - const CategoryResult = await recognizeImage(CategoryObject, cachedFrame, 2000, 500, true, materialsCategory); - if (CategoryResult.isDetected) { - log.info(`识别到${materialsCategory} 所在分类。`); - stage = 4; // 进入下一阶段 - } else { - log.warn("未识别到材料分类图标,重新尝试"); - // log.warn(`识别结果:${JSON.stringify(CategoryResult)}`); - // ===== 补充优化:连续回退时释放资源 ===== - cachedFrame?.dispose(); - stage = 2; // 回退到阶段2 - } - break; - - case 4: // 扫描材料 - log.info("芭芭拉,冲鸭!"); - - // 判断是否需要重置滑条 - if (!skipSliderReset) { - await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端 - await sleep(200); - leftButtonDown(); // 长按左键重置材料滑条 - await sleep(300); - leftButtonUp(); - await sleep(200); - } else { - log.info("同一大类且为后位材料,跳过滑条重置"); - // 不重置滑条,直接从当前位置开始检查第八列 - } - - // 判断是否是后续优先级材料(优先级高于前一个分类) - const currentPriority = materialPriority[materialsCategory]; - const isPostPriority = prevPriority !== null && currentPriority > prevPriority; - if (isPostPriority) { - log.info(`后续优先级材料扫描 - 当前优先级: ${currentPriority}, 前位优先级: ${prevPriority}`); - } - - // 扫描材料并获取低于目标数量的材料 - const lowCountMaterials = await scanMaterials(materialsCategory, materialCategoryMap, isPostPriority); - 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; - - case 5: // 所有分组处理完毕 - log.info("所有分组处理完毕,返回主界面"); - await genshin.returnMainUi(); - stage = maxStage + 1; // 确保退出循环 - break; - } + case 5: // 所有分组处理完毕 + log.info("所有分组处理完毕,返回主界面"); + await genshin.returnMainUi(); + stage = maxStage + 1; // 确保退出循环 + break; } + } - // ===== 补充优化:循环超限处理,防止卡死 ===== - if (loopCount > maxLoopCount) { - log.error(`主循环次数超限(${maxLoopCount}次),强制退出`); - cachedFrame?.dispose(); - await genshin.returnMainUi(); - return []; - } - - await genshin.returnMainUi(); // 返回主界面 - log.info("扫描流程结束"); - - - // 3. 处理完成后,将输出结果转换回原始名称 - const finalResult = allLowCountMaterials.map(categoryMaterials => { - return categoryMaterials.map(material => { - // 假设material包含name属性,将别名转回原始名 - return { - ...material, - name: nameMap.get(material.name) || material.name // 反向映射 - }; - }); - }); - + // ===== 补充优化:循环超限处理,防止卡死 ===== + if (loopCount > maxLoopCount) { + log.error(`主循环次数超限(${maxLoopCount}次),强制退出`); cachedFrame?.dispose(); - return finalResult; // 返回转换后的结果(如"晶蝶") + await genshin.returnMainUi(); + return []; + } + + await genshin.returnMainUi(); // 返回主界面 + log.info("扫描流程结束"); + + + // 3. 处理完成后,将输出结果转换回原始名称 + const finalResult = allLowCountMaterials.map(categoryMaterials => { + return categoryMaterials.map(material => { + // 假设material包含name属性,将别名转回原始名 + return { + ...material, + name: nameMap.get(material.name) || material.name // 反向映射 + }; + }); + }); + + cachedFrame?.dispose(); + return finalResult; // 返回转换后的结果(如"晶蝶") } finally { - state.ocrPaused = false; - log.info("背包扫描结束,已恢复OCR拾取任务"); + // 暂时注释掉OCR恢复逻辑 + // state.ocrPaused = false; + // log.info("背包扫描结束,已恢复OCR拾取任务"); } } diff --git a/repo/js/背包材料统计/lib/colorDetection.js b/repo/js/背包材料统计/lib/colorDetection.js new file mode 100644 index 000000000..1c6791e61 --- /dev/null +++ b/repo/js/背包材料统计/lib/colorDetection.js @@ -0,0 +1,417 @@ +const COLOR_TOLERANCE = 20; +const PIXEL_SAMPLE_RADIUS = 2; +const STD_TOLERANCE = 20; +const OUTLIER_THRESHOLD = 1.5; +const POSITION_TOLERANCE = 200; + +function compareColorTolerance(pixel1, pixel2, tolerance) { + const bDiff = Math.abs(pixel1.Item0 - pixel2.Item0); + const gDiff = Math.abs(pixel1.Item1 - pixel2.Item1); + const rDiff = Math.abs(pixel1.Item2 - pixel2.Item2); + return bDiff <= tolerance && gDiff <= tolerance && rDiff <= tolerance; +} + +function compareColor(templateMat, cachedFrame, result, detectRange = null) { + const regionMat = cachedFrame.SrcMat; + + if (!detectRange) { + detectRange = { + x: 0, + y: 0, + width: regionMat.cols, + height: regionMat.rows + }; + } + + const resultCenterX = Math.floor(result.x + result.width / 2); + const resultCenterY = Math.floor(result.y + result.height / 2); + + const templateSize = Math.min(templateMat.cols, templateMat.rows); + const radius = Math.min(PIXEL_SAMPLE_RADIUS, Math.floor(templateSize / 2) - 1); + + const { bestPosition } = getBestRegionPosition( + templateMat, + regionMat, + resultCenterX, + resultCenterY, + radius, + detectRange + ); + + const templateCenterX = Math.floor(templateMat.cols / 2); + const templateCenterY = Math.floor(templateMat.rows / 2); + + const { templatePixels, regionPixels, matchedPixels } = getMatchedPixelsAroundCenter( + templateMat, templateCenterX, templateCenterY, + regionMat, bestPosition.x, bestPosition.y, + radius, COLOR_TOLERANCE + ); + + const matchedTemplatePixels = matchedPixels.map(p => p.template); + const matchedRegionPixels = matchedPixels.map(p => p.region); + + const templateDist = analyzeColorDistribution(matchedTemplatePixels); + const regionDist = analyzeColorDistribution(matchedRegionPixels); + + const matchedCount = matchedPixels.length; + const totalCount = templatePixels.length; + + const bDiff = Math.abs(templateDist.avg.Item0 - regionDist.avg.Item0); + const gDiff = Math.abs(templateDist.avg.Item1 - regionDist.avg.Item1); + const rDiff = Math.abs(templateDist.avg.Item2 - regionDist.avg.Item2); + + const stdBDiff = Math.abs(templateDist.std.Item0 - regionDist.std.Item0); + const stdGDiff = Math.abs(templateDist.std.Item1 - regionDist.std.Item1); + const stdRDiff = Math.abs(templateDist.std.Item2 - regionDist.std.Item2); + + const isMatch = stdBDiff <= STD_TOLERANCE && stdGDiff <= STD_TOLERANCE && stdRDiff <= STD_TOLERANCE; + + return { + isMatch: isMatch, + avgDiff: { b: bDiff, g: gDiff, r: rDiff }, + stdDiff: { b: stdBDiff, g: stdGDiff, r: stdRDiff }, + matchedCount: matchedCount, + totalCount: totalCount + }; +} + +function getPixelsAroundCenter(mat, centerX, centerY, radius) { + const pixels = []; + for (let y = centerY - radius; y <= centerY + radius; y++) { + for (let x = centerX - radius; x <= centerX + radius; x++) { + if (x >= 0 && y >= 0 && x < mat.cols && y < mat.rows) { + try { + const pixel = mat.Get(OpenCvSharp.OpenCvSharp.Vec3b, y, x); + pixels.push(pixel); + } catch (error) { + log.debug(`获取像素(${x}, ${y})失败: ${error.message}`); + } + } + } + } + return pixels; +} + +function getMatchedPixelsAroundCenter(templateMat, templateCenterX, templateCenterY, regionMat, regionCenterX, regionCenterY, radius, tolerance) { + const templatePixels = []; + const regionPixels = []; + const matchedPixels = []; + + for (let y = -radius; y <= radius; y++) { + for (let x = -radius; x <= radius; x++) { + const tx = templateCenterX + x; + const ty = templateCenterY + y; + const rx = regionCenterX + x; + const ry = regionCenterY + y; + + if (tx >= 0 && ty >= 0 && tx < templateMat.cols && ty < templateMat.rows && + rx >= 0 && ry >= 0 && rx < regionMat.cols && ry < regionMat.rows) { + try { + const templatePixel = templateMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, ty, tx); + const regionPixel = regionMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, ry, rx); + + templatePixels.push(templatePixel); + regionPixels.push(regionPixel); + + const bDiff = Math.abs(templatePixel.Item0 - regionPixel.Item0); + const gDiff = Math.abs(templatePixel.Item1 - regionPixel.Item1); + const rDiff = Math.abs(templatePixel.Item2 - regionPixel.Item2); + + if (bDiff <= tolerance && gDiff <= tolerance && rDiff <= tolerance) { + matchedPixels.push({ template: templatePixel, region: regionPixel }); + } + } catch (error) { + log.debug(`获取像素失败: ${error.message}`); + } + } + } + } + + return { templatePixels, regionPixels, matchedPixels }; +} + +function removeOutliers(pixels, threshold) { + if (pixels.length <= 1) return pixels; + + let sumB = 0, sumG = 0, sumR = 0; + for (const pixel of pixels) { + sumB += pixel.Item0; + sumG += pixel.Item1; + sumR += pixel.Item2; + } + const meanB = sumB / pixels.length; + const meanG = sumG / pixels.length; + const meanR = sumR / pixels.length; + + let sumSqB = 0, sumSqG = 0, sumSqR = 0; + for (const pixel of pixels) { + sumSqB += Math.pow(pixel.Item0 - meanB, 2); + sumSqG += Math.pow(pixel.Item1 - meanG, 2); + sumSqR += Math.pow(pixel.Item2 - meanR, 2); + } + const stdB = Math.sqrt(sumSqB / pixels.length); + const stdG = Math.sqrt(sumSqG / pixels.length); + const stdR = Math.sqrt(sumSqR / pixels.length); + + const filteredPixels = []; + for (const pixel of pixels) { + const bDiff = Math.abs(pixel.Item0 - meanB); + const gDiff = Math.abs(pixel.Item1 - meanG); + const rDiff = Math.abs(pixel.Item2 - meanR); + + if (bDiff <= threshold * stdB && gDiff <= threshold * stdG && rDiff <= threshold * stdR) { + filteredPixels.push(pixel); + } + } + + return filteredPixels.length > 0 ? filteredPixels : pixels; +} + +function calculateAverageColor(pixels) { + if (pixels.length === 0) { + return { Item0: 0, Item1: 0, Item2: 0 }; + } + + let sumB = 0, sumG = 0, sumR = 0; + for (const pixel of pixels) { + sumB += pixel.Item0; + sumG += pixel.Item1; + sumR += pixel.Item2; + } + + return { + Item0: Math.round(sumB / pixels.length), + Item1: Math.round(sumG / pixels.length), + Item2: Math.round(sumR / pixels.length) + }; +} + +function analyzeColorDistribution(pixels) { + if (pixels.length === 0) { + return { + count: 0, + min: { Item0: 0, Item1: 0, Item2: 0 }, + max: { Item0: 0, Item1: 0, Item2: 0 }, + avg: { Item0: 0, Item1: 0, Item2: 0 }, + std: { Item0: 0, Item1: 0, Item2: 0 } + }; + } + + let minB = 255, minG = 255, minR = 255; + let maxB = 0, maxG = 0, maxR = 0; + let sumB = 0, sumG = 0, sumR = 0; + + for (const pixel of pixels) { + minB = Math.min(minB, pixel.Item0); + minG = Math.min(minG, pixel.Item1); + minR = Math.min(minR, pixel.Item2); + + maxB = Math.max(maxB, pixel.Item0); + maxG = Math.max(maxG, pixel.Item1); + maxR = Math.max(maxR, pixel.Item2); + + sumB += pixel.Item0; + sumG += pixel.Item1; + sumR += pixel.Item2; + } + + const avgB = sumB / pixels.length; + const avgG = sumG / pixels.length; + const avgR = sumR / pixels.length; + + let sumSqB = 0, sumSqG = 0, sumSqR = 0; + for (const pixel of pixels) { + sumSqB += Math.pow(pixel.Item0 - avgB, 2); + sumSqG += Math.pow(pixel.Item1 - avgG, 2); + sumSqR += Math.pow(pixel.Item2 - avgR, 2); + } + + const stdB = Math.sqrt(sumSqB / pixels.length); + const stdG = Math.sqrt(sumSqG / pixels.length); + const stdR = Math.sqrt(sumSqR / pixels.length); + + return { + count: pixels.length, + min: { Item0: minB, Item1: minG, Item2: minR }, + max: { Item0: maxB, Item1: maxG, Item2: maxR }, + avg: { Item0: Math.round(avgB), Item1: Math.round(avgG), Item2: Math.round(avgR) }, + std: { Item0: Math.round(stdB), Item1: Math.round(stdG), Item2: Math.round(stdR) } + }; +} + +function analyzeRowColumnDifferences(templateMat, regionMat, centerX, centerY, radius, detectRect = null) { + const templateRows = []; + const regionRows = []; + + for (let y = -radius; y <= radius; y++) { + const templateRow = []; + const regionRow = []; + + for (let x = -radius; x <= radius; x++) { + const templateX = Math.floor(templateMat.cols / 2) + x; + const templateY = Math.floor(templateMat.rows / 2) + y; + if (templateX >= 0 && templateY >= 0 && templateX < templateMat.cols && templateY < templateMat.rows) { + try { + templateRow.push(templateMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, templateY, templateX)); + } catch (e) {} + } + + const regionX = centerX + x; + const regionY = centerY + y; + + let isInDetectRange = true; + if (detectRect) { + if (regionX < detectRect.x || regionX >= detectRect.x + detectRect.width || + regionY < detectRect.y || regionY >= detectRect.y + detectRect.height) { + isInDetectRange = false; + } + } + + if (isInDetectRange && regionX >= 0 && regionY >= 0 && regionX < regionMat.cols && regionY < regionMat.rows) { + try { + regionRow.push(regionMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, regionY, regionX)); + } catch (e) {} + } + } + + if (templateRow.length > 0 && regionRow.length > 0) { + templateRows.push(templateRow); + regionRows.push(regionRow); + } + } + + const rowDiffs = []; + for (let i = 0; i < Math.min(templateRows.length, regionRows.length); i++) { + const templateRow = templateRows[i]; + const regionRow = regionRows[i]; + + let totalDiff = 0; + const minLength = Math.min(templateRow.length, regionRow.length); + + for (let j = 0; j < minLength; j++) { + const tPixel = templateRow[j]; + const rPixel = regionRow[j]; + totalDiff += Math.abs(tPixel.Item0 - rPixel.Item0) + + Math.abs(tPixel.Item1 - rPixel.Item1) + + Math.abs(tPixel.Item2 - rPixel.Item2); + } + + rowDiffs.push(totalDiff / minLength); + } + + const colDiffs = []; + const maxCol = Math.min( + templateRows[0]?.length || 0, + regionRows[0]?.length || 0 + ); + + for (let j = 0; j < maxCol; j++) { + let totalDiff = 0; + const minRow = Math.min(templateRows.length, regionRows.length); + + for (let i = 0; i < minRow; i++) { + const tPixel = templateRows[i][j]; + const rPixel = regionRows[i][j]; + totalDiff += Math.abs(tPixel.Item0 - rPixel.Item0) + + Math.abs(tPixel.Item1 - rPixel.Item1) + + Math.abs(tPixel.Item2 - rPixel.Item2); + } + + colDiffs.push(totalDiff / minRow); + } + + return { + rowDiffs, + colDiffs, + avgRowDiff: rowDiffs.length > 0 ? rowDiffs.reduce((a, b) => a + b, 0) / rowDiffs.length : 0, + avgColDiff: colDiffs.length > 0 ? colDiffs.reduce((a, b) => a + b, 0) / colDiffs.length : 0 + }; +} + +function getBestRegionPosition(templateMat, regionMat, initialCenterX, initialCenterY, radius, detectRect = null, maxAttempts = 4) { + const initialDiffs = analyzeRowColumnDifferences(templateMat, regionMat, initialCenterX, initialCenterY, radius, detectRect); + + let positions; + if (initialDiffs.avgColDiff > initialDiffs.avgRowDiff) { + const leftColDiff = initialDiffs.colDiffs[Math.floor(initialDiffs.colDiffs.length / 2) - 1] || 0; + const rightColDiff = initialDiffs.colDiffs[Math.floor(initialDiffs.colDiffs.length / 2) + 1] || 0; + + if (leftColDiff > rightColDiff) { + positions = [ + { x: initialCenterX, y: initialCenterY, name: "原始位置" }, + { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" }, + { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" }, + { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" }, + { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" }, + { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" } + ]; + } else { + positions = [ + { x: initialCenterX, y: initialCenterY, name: "原始位置" }, + { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" }, + { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" }, + { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" }, + { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" }, + { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" } + ]; + } + } else { + const topRowDiff = initialDiffs.rowDiffs[Math.floor(initialDiffs.rowDiffs.length / 2) - 1] || 0; + const bottomRowDiff = initialDiffs.rowDiffs[Math.floor(initialDiffs.rowDiffs.length / 2) + 1] || 0; + + if (topRowDiff > bottomRowDiff) { + positions = [ + { x: initialCenterX, y: initialCenterY, name: "原始位置" }, + { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" }, + { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" }, + { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" }, + { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" } + ]; + } else { + positions = [ + { x: initialCenterX, y: initialCenterY, name: "原始位置" }, + { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" }, + { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" }, + { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" }, + { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" }, + { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" }, + { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" } + ]; + } + } + + let bestPosition = positions[0]; + let bestDiff = Infinity; + + const positionDiffs = []; + + for (const pos of positions) { + const diffs = analyzeRowColumnDifferences(templateMat, regionMat, pos.x, pos.y, radius, detectRect); + const totalDiff = diffs.avgRowDiff + diffs.avgColDiff; + positionDiffs.push({ name: pos.name, diff: totalDiff }); + + if (totalDiff < bestDiff) { + bestDiff = totalDiff; + bestPosition = pos; + } + } + + // log.debug(`位置调整分析: 原始位置行列差异 - 行: ${Math.round(initialDiffs.avgRowDiff)}, 列: ${Math.round(initialDiffs.avgColDiff)}`); + // for (const pd of positionDiffs) { + // log.debug(` ${pd.name}: 总差异 = ${Math.round(pd.diff)}`); + // } + + return { bestPosition, bestDiff }; +} \ No newline at end of file diff --git a/repo/js/背包材料统计/lib/exp.js b/repo/js/背包材料统计/lib/exp.js index 5816c5ed5..e0330d918 100644 --- a/repo/js/背包材料统计/lib/exp.js +++ b/repo/js/背包材料统计/lib/exp.js @@ -1,197 +1,206 @@ -// ===================== 狗粮模式专属函数 ===================== -// 1. 狗粮分解配置与OCR区域(保留原配置,无修改) -const AUTO_SALVAGE_CONFIG = { - autoSalvage3: settings.autoSalvage3 || "是", - autoSalvage4: settings.autoSalvage4 || "是" -}; -const EXP_OCRREGIONS = { - expStorage: { x: 1472, y: 883, width: 170, height: 34 }, - expCount: { x: 1472, y: 895, width: 170, height: 34 } -}; - -// 处理数字文本:保留原逻辑(复用全局数字替换能力) -function processNumberText(text) { - let correctedText = text || ""; - let removedSymbols = []; - - // 替换错误字符(依赖全局 numberReplaceMap) - for (const [wrong, correct] of Object.entries(numberReplaceMap)) { - correctedText = correctedText.replace(new RegExp(wrong, 'g'), correct); - } - - // 提取纯数字 - let finalText = ''; - for (const char of correctedText) { - if (/[0-9]/.test(char)) { - finalText += char; - } else if (char.trim() !== '') { - removedSymbols.push(char); - } - } - - return { - processedText: finalText, - removedSymbols: [...new Set(removedSymbols)] - }; -} - -// 4. OCR识别EXP(核心改造:用 performOcr 替代重复OCR逻辑) -async function recognizeExpRegion(regionName, initialRa = null, timeout = 2000) { - // 1. 基础校验(保留原逻辑) - const ocrRegion = EXP_OCRREGIONS[regionName]; - if (!ocrRegion) { - log.error(`[狗粮OCR] 无效区域:${regionName}`); - return { success: false, expCount: 0, screenshot: null }; // 新增返回截图(便于调试) - } - - log.info(`[狗粮OCR] 识别${regionName}(x=${ocrRegion.x}, y=${ocrRegion.y})`); - let ocrScreenshot = null; // 存储performOcr返回的有效截图 - - try { - // 2. 转换OCR区域格式:ocrRegion(x,y,width,height) → xRange/yRange(min/max) - const xRange = { - min: ocrRegion.x, - max: ocrRegion.x + ocrRegion.width - }; - const yRange = { - min: ocrRegion.y, - max: ocrRegion.y + ocrRegion.height - }; - - // 3. 调用新版 performOcr(自动重截图、资源管理、异常处理) - // 目标文本传空数组:识别数字无需匹配特定文本,仅需提取内容 - const { results, screenshot } = await performOcr( - [""], // targetTexts:空数组(数字识别无特定目标) - xRange, // 转换后的X轴范围 - yRange, // 转换后的Y轴范围 - initialRa, // 初始截图(外部传入) - timeout, // 超时时间(复用原参数) - 50 // 重试间隔(默认50ms,比原500ms更灵敏) - ); - ocrScreenshot = screenshot; // 暂存截图,后续返回或释放 - - // 4. 处理OCR结果(保留原数字处理+日志逻辑) - if (results.length > 0) { - const { originalText, text: correctedText } = results[0]; // 从performOcr拿原始/修正文本 - log.info(`[狗粮OCR] 原始文本:${originalText}`); // 保持原日志格式 - - // 用原processNumberText提纯数字 - const { processedText, removedSymbols } = processNumberText(correctedText); - if (removedSymbols.length > 0) { - log.info(`[狗粮OCR] 去除无效字符:${removedSymbols.join(', ')}`); // 保留原日志 - } - - const expCount = processedText ? parseInt(processedText, 10) : 0; - log.info(`[狗粮OCR] ${regionName}结果:${expCount}`); // 保留原日志 - return { success: true, expCount, screenshot: ocrScreenshot }; // 返回截图(调试用) - } - - } catch (error) { - // 捕获performOcr未处理的异常(如参数错误) - log.error(`[狗粮OCR] ${regionName}识别异常:${error.message}`); - // 异常时释放截图资源 - if (ocrScreenshot) { - if (ocrScreenshot.Dispose) ocrScreenshot.Dispose(); - else if (ocrScreenshot.dispose) ocrScreenshot.dispose(); - } - } - - // 5. 识别失败/超时(保留原逻辑) - log.error(`[狗粮OCR] ${regionName}超时未识别,默认0`); - return { success: false, expCount: 0, screenshot: ocrScreenshot }; // 超时也返回截图(排查用) -} - -// 5. 狗粮分解流程(调整:适配recognizeExpRegion的新返回值,优化资源释放) -async function executeSalvageWithOCR() { - log.info("[狗粮分解] 开始执行分解流程"); - let storageExp = 0; - let countExp = 0; - let cachedFrame = null; - let ocrScreenshots = []; // 存储识别过程中产生的截图(统一释放) - - try { - keyPress("B"); - await sleep(1000); - - const coords = [ - [670, 40], // 打开背包 - [660, 1010], // 打开分解 - [300, 1020], // 打开分解选项页面 - [200, 300, 500, AUTO_SALVAGE_CONFIG.autoSalvage3 !== '否'], // 3星(按配置) - [200, 380, 500, AUTO_SALVAGE_CONFIG.autoSalvage4 !== '否'], // 4星(按配置) - [340, 1000], // 确认选择 - [1720, 1015], // 点击是否分解 - [1320, 756], // 确认分解 - [1840, 45, 1500], // 退出 - [1840, 45], // 退出 - [1840, 45], // 退出 - ]; - - for (const coord of coords) { - const [x, y, delay = 1000, condition = true] = coord; - if (condition) { - click(x, y); - await sleep(delay); - log.debug(`[狗粮分解] 点击(${x},${y}),延迟${delay}ms`); - - // 分解前识别储存EXP(适配新的recognizeExpRegion返回值) - if (x === 660 && y === 1010) { - // 释放旧缓存帧 - if (cachedFrame) { - if (cachedFrame.Dispose) cachedFrame.Dispose(); - else if (cachedFrame.dispose) cachedFrame.dispose(); - } - // 捕获新帧 - cachedFrame = captureGameRegion(); - // 调用改造后的recognizeExpRegion(接收expCount和screenshot) - const { expCount, screenshot } = await recognizeExpRegion("expStorage", cachedFrame, 1000); - storageExp = expCount; - ocrScreenshots.push(screenshot); // 收集截图(后续统一释放) - } - - // 分解后识别新增EXP(同上,适配新返回值) - if (x === 340 && y === 1000) { - if (cachedFrame) { - if (cachedFrame.Dispose) cachedFrame.Dispose(); - else if (cachedFrame.dispose) cachedFrame.dispose(); - } - cachedFrame = captureGameRegion(); - const { expCount, screenshot } = await recognizeExpRegion("expCount", cachedFrame, 1000); - countExp = expCount; - ocrScreenshots.push(screenshot); // 收集截图 - } - } - } - - // 计算并返回结果(保留原逻辑) - const totalExp = countExp - storageExp; - log.info(`[狗粮分解] 完成,新增EXP:${totalExp}(分解前:${storageExp},分解后:${countExp})`); - return { success: true, totalExp: Math.max(totalExp, 0) }; - - } catch (error) { - log.error(`[狗粮分解] 失败:${error.message}`); - return { success: false, totalExp: 0 }; - - } finally { - // 最终统一释放所有资源(避免内存泄漏) - // 1. 释放缓存帧 - if (cachedFrame) { - if (cachedFrame.Dispose) cachedFrame.Dispose(); - else if (cachedFrame.dispose) cachedFrame.dispose(); - } - // 2. 释放OCR过程中产生的截图 - for (const screenshot of ocrScreenshots) { - if (screenshot) { - if (screenshot.Dispose) screenshot.Dispose(); - else if (screenshot.dispose) screenshot.dispose(); - } - } - log.debug("[狗粮分解] 所有资源已释放"); - } -} - -// 6. 判断是否为狗粮资源(保留原逻辑,无修改) -function isFoodResource(resourceName) { - const foodKeywords = ["12h狗粮", "24h狗粮"]; - return resourceName && foodKeywords.some(keyword => resourceName.includes(keyword)); +// ===================== 狗粮模式专属函数 ===================== +// 1. 狗粮分解配置与OCR区域(保留原配置,无修改) +const AUTO_SALVAGE_CONFIG = { + autoSalvage3: settings.autoSalvage3 || "是", + autoSalvage4: settings.autoSalvage4 || "是" +}; +const EXP_OCRREGIONS = { + expStorage: { x: 1472, y: 883, width: 170, height: 34 }, + expCount: { x: 1472, y: 895, width: 170, height: 34 } +}; + +// 处理数字文本:保留原逻辑(复用全局数字替换能力) +function processNumberText(text) { + let correctedText = text || ""; + let removedSymbols = []; + + // 替换错误字符(依赖全局 numberReplaceMap) + for (const [wrong, correct] of Object.entries(numberReplaceMap)) { + correctedText = correctedText.replace(new RegExp(wrong, 'g'), correct); + } + + // 提取纯数字 + let finalText = ''; + for (const char of correctedText) { + if (/[0-9]/.test(char)) { + finalText += char; + } else if (char.trim() !== '') { + removedSymbols.push(char); + } + } + + return { + processedText: finalText, + removedSymbols: [...new Set(removedSymbols)] + }; +} + +// 4. OCR识别EXP(核心改造:用 performOcr 替代重复OCR逻辑) +async function recognizeExpRegion(regionName, initialRa = null, timeout = 2000) { + // 1. 基础校验(保留原逻辑) + const ocrRegion = EXP_OCRREGIONS[regionName]; + if (!ocrRegion) { + log.error(`[狗粮OCR] 无效区域:${regionName}`); + return { success: false, expCount: 0, screenshot: null }; // 新增返回截图(便于调试) + } + + log.info(`[狗粮OCR] 识别${regionName}(x=${ocrRegion.x}, y=${ocrRegion.y})`); + let ocrScreenshot = null; // 存储performOcr返回的有效截图 + let shouldDispose = false; // 标记是否需要释放截图 + + try { + // 2. 调用新版 performOcr(自动重截图、资源管理、异常处理) + // 目标文本传空数组:识别数字无需匹配特定文本,仅需提取内容 + const { results, screenshot, shouldDispose: disposeFlag } = await performOcr( + [""], // targetTexts:空数组(数字识别无特定目标) + ocrRegion, // 识别区域 { x, y, width, height } + initialRa, // 初始截图(外部传入) + timeout, // 超时时间(复用原参数) + 500 // 重试间隔(默认500ms) + ); + ocrScreenshot = screenshot; // 暂存截图,后续返回或释放 + shouldDispose = disposeFlag; // 记录是否需要释放 + + // 4. 处理OCR结果(保留原数字处理+日志逻辑) + if (results.length > 0) { + log.info(`[狗粮OCR] 共识别到${results.length}个文本块`); + let maxExpCount = 0; + let bestResult = null; + let hasValidNumber = false; + + // 遍历所有识别结果,找到最大的数字 + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const { originalText, text: correctedText } = result; + log.info(`[狗粮OCR] [${i+1}/${results.length}] 原始文本:"${originalText}"`); + + // 用原processNumberText提纯数字 + const { processedText, removedSymbols } = processNumberText(correctedText); + if (removedSymbols.length > 0) { + log.info(`[狗粮OCR] [${i+1}/${results.length}] 去除无效字符:${removedSymbols.join(', ')}`); + } + + const expCount = processedText ? Math.abs(parseInt(processedText, 10)) : 0; + log.info(`[狗粮OCR] [${i+1}/${results.length}] 提取数字:${expCount}(processedText="${processedText}")`); + + if (!isNaN(expCount) && (expCount > maxExpCount || !hasValidNumber)) { + maxExpCount = expCount; + bestResult = result; + hasValidNumber = true; + log.info(`[狗粮OCR] [${i+1}/${results.length}] 更新最大值:${maxExpCount}`); + } + } + + log.info(`[狗粮OCR] ${regionName}最终结果:${maxExpCount}`); + return { success: true, expCount: maxExpCount, screenshot: ocrScreenshot }; + } + + } catch (error) { + // 捕获performOcr未处理的异常(如参数错误) + log.error(`[狗粮OCR] ${regionName}识别异常:${error.message}`); + // 异常时释放截图资源(仅当需要释放时) + if (ocrScreenshot && shouldDispose) { + if (ocrScreenshot.Dispose) ocrScreenshot.Dispose(); + else if (ocrScreenshot.dispose) ocrScreenshot.dispose(); + } + } + + // 5. 识别失败/超时(保留原逻辑) + log.error(`[狗粮OCR] ${regionName}未识别到文本,默认0`); + return { success: false, expCount: 0, screenshot: ocrScreenshot, shouldDispose }; // 超时也返回截图(排查用) +} + +// 5. 狗粮分解流程(调整:适配recognizeExpRegion的新返回值,优化资源释放) +async function executeSalvageWithOCR() { + log.info("[狗粮分解] 开始执行分解流程"); + let storageExp = 0; + let countExp = 0; + let cachedFrame = null; + let ocrScreenshots = []; // 存储识别过程中产生的截图(统一释放) + + try { + keyPress("B"); + await sleep(1000); + + const coords = [ + [670, 40], // 打开背包 + [660, 1010], // 打开分解 + [300, 1020], // 打开分解选项页面 + [200, 300, 500, AUTO_SALVAGE_CONFIG.autoSalvage3 !== '否'], // 3星(按配置) + [200, 380, 500, AUTO_SALVAGE_CONFIG.autoSalvage4 !== '否'], // 4星(按配置) + [340, 1000], // 确认选择 + [1720, 1015], // 点击是否分解 + [1320, 756], // 确认分解 + [1840, 45, 1500], // 退出 + [1840, 45], // 退出 + [1840, 45], // 退出 + ]; + + for (const coord of coords) { + const [x, y, delay = 1000, condition = true] = coord; + if (condition) { + click(x, y); + await sleep(delay); + log.debug(`[狗粮分解] 点击(${x},${y}),延迟${delay}ms`); + + // 分解前识别储存EXP(适配新的recognizeExpRegion返回值) + if (x === 660 && y === 1010) { + cachedFrame?.dispose(); + cachedFrame = captureGameRegion(); + // 调用改造后的recognizeExpRegion(接收expCount、screenshot和shouldDispose) + const { expCount, screenshot, shouldDispose } = await recognizeExpRegion("expStorage", cachedFrame, 1000); + storageExp = expCount; + ocrScreenshots.push({ screenshot, shouldDispose }); // 收集截图和释放标记 + } + + // 分解后识别新增EXP(同上,适配新返回值) + if (x === 340 && y === 1000) { + cachedFrame?.dispose(); + cachedFrame = captureGameRegion(); + const { expCount, screenshot, shouldDispose } = await recognizeExpRegion("expCount", cachedFrame, 1000); + countExp = expCount; + ocrScreenshots.push({ screenshot, shouldDispose }); // 收集截图 + } + } + } + + // 计算并返回结果(保留原逻辑) + const totalExp = countExp - storageExp; + log.info(`[狗粮分解] 完成,新增EXP:${totalExp}(分解前:${storageExp},分解后:${countExp})`); + return { success: true, totalExp: Math.max(totalExp, 0) }; + + } catch (error) { + log.error(`[狗粮分解] 失败:${error.message}`); + return { success: false, totalExp: 0 }; + + } finally { + // 最终统一释放所有资源(避免内存泄漏) + // 1. 释放缓存帧 + if (cachedFrame) { + try { + if (cachedFrame.Dispose) cachedFrame.Dispose(); + else if (cachedFrame.dispose) cachedFrame.dispose(); + } catch (e) { + log.debug(`[狗粮分解] 释放缓存帧失败(可能已释放): ${e.message}`); + } + } + // 2. 释放OCR过程中产生的截图(仅释放需要释放的) + for (const { screenshot, shouldDispose } of ocrScreenshots) { + if (screenshot && shouldDispose) { + try { + if (screenshot.Dispose) screenshot.Dispose(); + else if (screenshot.dispose) screenshot.dispose(); + } catch (e) { + log.debug(`[狗粮分解] 释放OCR截图失败(可能已释放): ${e.message}`); + } + } + } + log.debug("[狗粮分解] 所有资源已释放"); + } +} + +// 6. 判断是否为狗粮资源(保留原逻辑,无修改) +function isFoodResource(resourceName) { + const foodKeywords = ["12h狗粮", "24h狗粮"]; + return resourceName && foodKeywords.some(keyword => resourceName.includes(keyword)); } \ No newline at end of file diff --git a/repo/js/背包材料统计/lib/file.js b/repo/js/背包材料统计/lib/file.js index f566018ff..352b4c64d 100644 --- a/repo/js/背包材料统计/lib/file.js +++ b/repo/js/背包材料统计/lib/file.js @@ -1,206 +1,288 @@ -// ======================== 全局工具函数(只定义1次,所有函数共用)======================== -// 1. 路径标准化函数(统一处理,消除重复) -function normalizePath(path) { - if (!path || typeof path !== 'string') return ''; - let standardPath = path.replace(/\\/g, '/').replace(/\/+/g, '/'); - return standardPath.endsWith('/') ? standardPath.slice(0, -1) : standardPath; -} - -// 2. 提取路径最后一级名称 -function basename(filePath) { - if (!filePath || typeof filePath !== 'string') return ''; - const normalizedPath = normalizePath(filePath); - const lastSlashIndex = normalizedPath.lastIndexOf('/'); - return lastSlashIndex !== -1 ? normalizedPath.substring(lastSlashIndex + 1) : normalizedPath; -} - -function pathExists(path) { - try { - return file.isFolder(path); - } catch { - return false; - } -} - -/* -// 如果路径存在且返回的是数组,则认为是目录Folder -function pathExists(path) { - try { return file.readPathSync(path)?.length >= 0; } - catch { return false; } -} -// 判断文件是否存在(非目录且能读取) -function fileExists(filePath) { - try { - // 先排除目录(是目录则不是文件) - if (file.isFolder(filePath)) { - return false; - } - // 尝试读取文件(能读则存在) - file.readTextSync(filePath); - return true; - } catch { - 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)) { - log.error(`目录 ${dir} 不存在`); - return []; - } - - 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) { - if (includeDirs) filePaths.push(entry); - if (currentDepth < maxDepth) stack.push([entry, currentDepth + 1]); - } else { - const ext = entry.substring(entry.lastIndexOf('.')).toLowerCase(); - if (includeExtensions.includes(ext)) filePaths.push(entry); - } - } - } - // log.info(`完成目录 ${dir} 的递归读取,共找到 ${filePaths.length} 个文件`); - return filePaths; - } catch (error) { - log.error(`读取目录 ${dir} 错误: ${error}`); - return []; - } -} -// 新记录在最上面20250531 isAppend默认就是true追加 -function writeFile(filePath, content, isAppend = true, maxRecords = 36500) { - try { - if (isAppend) { - // 读取现有内容,处理文件不存在的情况 - let existingContent = ""; - try { - 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); - - // log.info(result ? `[追加] 成功写入: ${filePath}` : `[追加] 写入失败: ${filePath}`); - return result; - } else { - // 覆盖模式直接写入 - const result = file.writeTextSync(filePath, content, false); - // log.info(result ? `[覆盖] 成功写入: ${filePath}` : `[覆盖] 写入失败: ${filePath}`); - return result; - } - } catch (error) { - // 出错时尝试创建/写入文件 - const result = file.writeTextSync(filePath, content, false); - log.info(result ? `[新建/恢复] 成功处理: ${filePath}` : `[新建/恢复] 处理失败: ${filePath}`); - return result; - } -} - +// ======================== 全局工具函数(只定义1次,所有函数共用)======================== +// 1. 路径标准化函数(统一处理,消除重复) +function normalizePath(path) { + if (!path || typeof path !== 'string') return ''; + let standardPath = path.replace(/\\/g, '/').replace(/\/+/g, '/'); + return standardPath.endsWith('/') ? standardPath.slice(0, -1) : standardPath; +} + +// 2. 提取路径最后一级名称 +function basename(filePath) { + if (!filePath || typeof filePath !== 'string') return ''; + const normalizedPath = normalizePath(filePath); + const lastSlashIndex = normalizedPath.lastIndexOf('/'); + return lastSlashIndex !== -1 ? normalizedPath.substring(lastSlashIndex + 1) : normalizedPath; +} + +function pathExists(path) { + try { + return file.isFolder(path); + } catch { + return false; + } +} + +/* +// 如果路径存在且返回的是数组,则认为是目录Folder +function pathExists(path) { + try { return file.readPathSync(path)?.length >= 0; } + catch { return false; } +} +// 判断文件是否存在(非目录且能读取) +function fileExists(filePath) { + try { + // 先排除目录(是目录则不是文件) + if (file.isFolder(filePath)) { + return false; + } + // 尝试读取文件(能读则存在) + file.readTextSync(filePath); + return true; + } catch { + 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 = 6, includeExtensions = ['.png', '.json', '.txt'], includeDirs = false) { + if (!pathExists(dir)) { + log.error(`目录 ${dir} 不存在`); + return []; + } + + 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) { + if (includeDirs) filePaths.push(entry); + if (currentDepth < maxDepth) stack.push([entry, currentDepth + 1]); + } else { + const ext = entry.substring(entry.lastIndexOf('.')).toLowerCase(); + if (includeExtensions.includes(ext)) filePaths.push(entry); + } + } + } + // log.info(`完成目录 ${dir} 的递归读取,共找到 ${filePaths.length} 个文件`); + return filePaths; + } catch (error) { + log.error(`读取目录 ${dir} 错误: ${error}`); + return []; + } +} + +// ============================================== +// 全局图片缓存(避免重复加载) +// ============================================== +const globalImageCache = new Map(); + +/** + * 获取缓存的图像Mat(避免重复加载) + * @param {string} filePath - 图像文件路径 + * @returns {Mat} 图像Mat对象 + */ +function getCachedImageMat(filePath) { + if (globalImageCache.has(filePath)) { + return globalImageCache.get(filePath); + } + const mat = file.readImageMatSync(filePath); + if (!mat.empty()) { + globalImageCache.set(filePath, mat); + } + return mat; +} + +// ============================================== +// 图像分类映射 +// ============================================== +const imageMapCache = new Map(); // 图像分类映射缓存 +const loggedResources = new Set(); // 已记录的资源名 + +/** + * 创建图像分类映射(目录到分类的映射) + * @param {string} imagesDir - 图像目录 + * @returns {Object} 图像名到分类的映射 + */ +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; + + const imageName = pathParts.pop() + .replace(/\.png$/i, '') + .trim() + .toLowerCase(); + + if (!(imageName in map)) { + map[imageName] = pathParts[2]; + } + } + + return map; +}; + +/** + * 匹配图像并获取材料分类 + * @param {string} resourceName - 资源名 + * @param {string} imagesDir - 图像目录 + * @returns {string|null} 材料分类(null=未匹配) + */ +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)); + } + + const result = imageMapCache.get(imagesDir)[processedName] ?? null; + if (result) { + // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`); + } else { + // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`); + } + + if (!loggedResources.has(processedName)) { + loggedResources.add(processedName); + } + + return result; +} + +// 新记录在最上面20250531 isAppend默认就是true追加 +function writeFile(filePath, content, isAppend = true, maxRecords = 36500) { + try { + if (isAppend) { + // 读取现有内容,处理文件不存在的情况 + let existingContent = ""; + try { + 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); + + // log.info(result ? `[追加] 成功写入: ${filePath}` : `[追加] 写入失败: ${filePath}`); + return result; + } else { + // 覆盖模式直接写入 + const result = file.writeTextSync(filePath, content, false); + // log.info(result ? `[覆盖] 成功写入: ${filePath}` : `[覆盖] 写入失败: ${filePath}`); + return result; + } + } catch (error) { + // 出错时尝试创建/写入文件 + 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 eb019618c..603e836b0 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; }); @@ -62,14 +62,14 @@ async function preloadImageResources(specificNames) { try { 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, @@ -131,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; @@ -139,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文件,跳过`); @@ -157,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 }); } @@ -171,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) }); } @@ -216,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; let specificNames = []; try { specificNames = Array.from(settings.PopupNames || []); @@ -265,10 +265,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"); // 核心逻辑:外循环遍历所有一级弹窗 @@ -327,7 +327,7 @@ async function imageClickBackgroundTask() { // log.info(`===== 外循环结束:等待${taskDelay/1000}秒后开始下一次循环 =====`); await sleep(taskDelay); } - + log.info("imageClick后台任务结束"); return { success: true }; } @@ -348,14 +348,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, @@ -372,7 +372,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; @@ -400,7 +400,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u 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`); @@ -413,10 +413,16 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u log.error(`【${dirName}】弹窗OCR配置不全,跳过`); break; } - const ocrResults = await performOcr(targetTexts, xRange, yRange, timeout, ra); + const region = { + x: xRange.min, + y: yRange.min, + width: xRange.max - xRange.min, + height: yRange.max - yRange.min + }; + const { results: ocrResults } = await performOcr(targetTexts, region, ra, timeout); 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; @@ -466,7 +472,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 d8f0db10e..1774c1938 100644 --- a/repo/js/背包材料统计/lib/ocr.js +++ b/repo/js/背包材料统计/lib/ocr.js @@ -5,38 +5,104 @@ const replacementMap = { "盞": "盏", "攜": "携", "於": "于", - "卵": "卯" + "卵": "卯", + "亥": "刻", + "脈": "脉", + "黄": "夤", + "黃": "夤", + "问": "间", + "谭": "镡" }; +/** + * 计算两个字符串的相似度(基于最长公共子序列) + * @param {string} str1 - 字符串1 + * @param {string} str2 - 字符串2 + * @returns {number} 相似度(0-1之间的小数) + */ +function calculateSimilarity(str1, str2) { + if (!str1 || !str2) return 0; + + const len1 = str1.length; + const len2 = str2.length; + + // 动态规划计算最长公共子序列长度 + const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0)); + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + const lcsLength = dp[len1][len2]; + const maxLength = Math.max(len1, len2); + + return maxLength === 0 ? 0 : lcsLength / maxLength; +} + +/** + * 保存OCR无法识别的区域截图 + * @param {Object} screenshot - 截图对象 + * @param {Object} xRangeOrRegion - X轴范围 { min, max } 或区域 { x, y, width, height } + * @param {Object} yRange - Y轴范围 { min, max }(可选,如果xRangeOrRegion是区域则不需要) + * @param {string} saveDir - 保存目录,默认 'user/regions' + */ +async function saveFailedOcrRegion(screenshot, xRangeOrRegion, yRange = null, saveDir = 'user/regions') { + log.info(`[saveFailedOcrRegion] 开始保存, screenshot: ${!!screenshot}`); + if (!screenshot) { + log.warn("[保存失败OCR] 截图为空,跳过保存"); + return; + } + + let region; + if (yRange === null) { + region = xRangeOrRegion; + } else { + region = { + x: xRangeOrRegion.min, + y: yRange.min, + width: xRangeOrRegion.max - xRangeOrRegion.min, + height: yRange.max - yRange.min + }; + } + + log.info(`[saveFailedOcrRegion] region: ${JSON.stringify(region)}`); + await saveAllOcrRegionImages(screenshot, [region], {}, saveDir); +} + /** * 执行OCR识别并匹配目标文本(失败自动重截图,返回结果+有效截图) * @param {string[]} targetTexts - 待匹配的目标文本列表 - * @param {Object} xRange - X轴范围 { min: number, max: number } - * @param {Object} yRange - Y轴范围 { min: number, max: number } + * @param {Object} region - 识别区域 { x: number, y: number, width: number, height: number } * @param {Object} ra - 初始图像捕获对象(外部传入,需确保已初始化) * @param {number} timeout - 超时时间(毫秒),默认200ms * @param {number} interval - 重试间隔(毫秒),默认50ms * @returns {Promise<{ * results: Object[], // 识别结果数组(含文本、坐标) - * screenshot: Object // 有效截图(成功时用的截图/超时前最后一次截图) + * screenshot: Object, // 有效截图(成功时用的截图/超时前最后一次截图) + * shouldDispose: boolean // 是否需要释放此截图(true=需要释放,false=外部传入的截图) * }>} */ -async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, interval = 50) { +async function performOcr(targetTexts, region, ra = null, timeout = 200, interval = 50) { // 正则特殊字符转义工具函数(避免替换时的正则语法错误) const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const startTime = Date.now(); // 记录开始时间,用于超时判断 let currentScreenshot = ra; // 跟踪当前有效截图(初始为外部传入的原图) + let isExternalRa = true; // 标记当前截图是否为外部传入(不释放外部资源) // 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}`); + if (region.width <= 0 || region.height <= 0) { + throw new Error(`无效的识别区域:宽=${region.width}, 高=${region.height}`); } // 在超时时间内循环重试识别(处理临时失败,自动重截图) @@ -45,6 +111,7 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, if (!currentScreenshot) { log.error("currentScreenshot为null,尝试重新捕获"); currentScreenshot = captureGameRegion(); + isExternalRa = false; // 重新捕获的截图不是外部传入的 await sleep(interval); continue; } @@ -52,9 +119,11 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, try { // 2. 执行OCR识别(基于当前有效截图的指定区域) const resList = currentScreenshot.findMulti( - RecognitionObject.ocr(xRange.min, yRange.min, regionWidth, regionHeight) + RecognitionObject.ocr(region.x, region.y, region.width, region.height) ); + // log.debug(`OCR识别到${resList.count}个文本块`); + // 3. 处理识别结果(文本修正 + 目标匹配) const results = []; for (let i = 0; i < resList.count; i++) { @@ -69,10 +138,22 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, correctedText = correctedText.replace(new RegExp(escapedWrong, 'g'), correctChar); } - // 3.2 匹配目标文本(双向匹配,避免重复添加同一结果) - const isTargetMatched = targetTexts.some(target => - correctedText.includes(target) || target.includes(correctedText) - ); + // 3.2 匹配目标文本(双向匹配 + 相似度特殊通道) + const isTargetMatched = targetTexts.length === 0 || targetTexts.some(target => { + // 通道1:双向匹配(要求至少2个字符) + if (correctedText.length >= 2 && (correctedText.includes(target) || target.includes(correctedText))) { + return true; + } + + // 通道2:相似度大于75%(特殊通道) + const similarity = calculateSimilarity(correctedText, target); + if (similarity >= 0.75) { + log.debug(`相似度匹配: "${correctedText}" vs "${target}" = ${(similarity * 100).toFixed(1)}%`); + return true; + } + + return false; + }); if (isTargetMatched) { results.push({ text: correctedText, // 最终修正后的文本 @@ -83,31 +164,39 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, } } + // log.debug(`OCR最终返回${results.length}个匹配结果`); + // 4. 识别成功:返回「结果数组 + 本次成功用的截图」 // log.info(`OCR识别完成,匹配到${results.length}个目标文本`); return { results: results, - screenshot: currentScreenshot // 成功截图(与结果对应的有效画面) + screenshot: currentScreenshot, // 成功截图(与结果对应的有效画面) + shouldDispose: !isExternalRa // 标记是否需要释放(true=需要释放,false=外部传入的截图) }; } catch (error) { // 5. 识别失败:释放旧截图→重新捕获→更新当前截图 - if (currentScreenshot) { - // 检查是否存在释放方法,支持不同可能的命名 - if (typeof currentScreenshot.Dispose === 'function') { - currentScreenshot.Dispose(); - } else if (typeof currentScreenshot.dispose === 'function') { - currentScreenshot.dispose(); + if (currentScreenshot && !isExternalRa) { + // 只释放非外部传入的截图 + try { + if (typeof currentScreenshot.Dispose === 'function') { + currentScreenshot.Dispose(); + } else if (typeof currentScreenshot.dispose === 'function') { + currentScreenshot.dispose(); + } + log.debug("已释放旧截图资源,准备重新捕获"); + } catch (e) { + log.debug(`释放旧截图失败(可能已释放): ${e.message}`); } - log.debug("已释放旧截图资源,准备重新捕获"); } - + // 重新捕获后增加null校验 currentScreenshot = captureGameRegion(); + isExternalRa = false; // 重新捕获的截图不是外部传入的 if (!currentScreenshot) { log.error("重新捕获截图失败,返回了null值"); } - + log.error(`OCR识别异常(已重新截图,将重试): ${error.message}`); await sleep(5); // 短暂等待,避免高频截图占用CPU/内存 } @@ -117,8 +206,12 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, // 6. 超时未成功:返回「空结果 + 超时前最后一次截图」 log.warn(`OCR识别超时(超过${timeout}ms)`); + log.info(`[OCR] 准备保存失败区域截图, screenshot: ${!!currentScreenshot}, region: ${JSON.stringify(region)}`); + await saveFailedOcrRegion(currentScreenshot, region); + log.info(`[OCR] 保存失败区域截图完成`); return { results: [], - screenshot: currentScreenshot // 超时前最后一次有效截图(可用于排查原因) + screenshot: currentScreenshot, // 超时前最后一次有效截图(可用于排查原因) + shouldDispose: !isExternalRa // 标记是否需要释放 }; } diff --git a/repo/js/背包材料统计/lib/pathProcessor.js b/repo/js/背包材料统计/lib/pathProcessor.js new file mode 100644 index 000000000..78d003e21 --- /dev/null +++ b/repo/js/背包材料统计/lib/pathProcessor.js @@ -0,0 +1,827 @@ +// ============================================== +// 路径处理模块 +// ============================================== + +/** + * 极简封装:用路径和当前目标发通知,然后执行路径 + * @param {string} pathingFilePath - 路径文件路径 + * @param {string} currentMaterialName - 当前材料名 + * @param {number} estimatedTime - 预计耗时(秒) + * @returns {Promise} 执行结果 + */ +async function runPathAndNotify(pathingFilePath, currentMaterialName, estimatedTime = null) { + const pathName = basename(pathingFilePath); + if (notify) { + let notifyMsg = `当前执行路径:${pathName}\n目标:${currentMaterialName || '未知'}`; + if (estimatedTime !== null) { + notifyMsg += `\n预计耗时:${formatTime(estimatedTime)}`; + } + notification.Send(notifyMsg); + } + return await pathingScript.runFile(pathingFilePath); +} + +const MIN_RECORD_TIME = 15; + +function findRefreshCD(resourceKey, CDCategories) { + 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(resourceKey)) { + return JSON.parse(cdKey); + } + } + } + return null; +} + +async function executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime, logModule) { + const startTime = new Date().toLocaleString(); + log.info(`→ 开始执行地图追踪任务: "${pathName}"`); + await runPathAndNotify(pathingFilePath, currentMaterialName, estimatedTime); + const endTime = new Date().toLocaleString(); + const runTime = (new Date(endTime) - new Date(startTime)) / 1000; + log.info(`→ 脚本执行结束: "${pathName}", 耗时: ${formatTime(runTime)}`); + return { startTime, endTime, runTime }; +} + +function generateRecordContent(pathingFilePath, pathName, startTime, endTime, runTime, changes, contentCode = null) { + const code = contentCode || (pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"); + const changeStr = typeof changes === 'string' ? changes : JSON.stringify(changes); + return `路径名: ${pathName}\n内容检测码: ${code}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${changeStr}\n\n`; +} + +async function handlePathError(error, pathingFilePath, pathName, resourceKey, startTime, endTime, runTime, shouldSkipRecord, isCancelled) { + const canRecord = runTime > MIN_RECORD_TIME; + + if (!canRecord) { + return; + } + + if (isCancelled || !shouldSkipRecord) { + const content = generateRecordContent( + pathingFilePath, + pathName, + startTime, + endTime, + runTime, + error.message + ); + writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${resourceKey}.txt`, content); + log.info(`${CONSTANTS.LOG_MODULES.RECORD}异常路径已记录:${pathName}(运行时间${runTime.toFixed(1)}秒)`); + } +} + +function checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, logModule) { + if (!canRunCD) { + log.info(`${logModule}${pathName} 跳过 | CD未刷新 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`); + return true; + } + if (!(pathCheckResult === true || pathCheckResult.valid)) { + log.info(`${logModule}${pathName} 跳过 | ${pathCheckResult.reason || '0记录频率超限'} | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`); + return true; + } + if (!isTimeCostOk) { + log.info(`${logModule}${pathName} 跳过 | 时间成本不合格 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`); + return true; + } + return false; +} + +/** + * 处理狗粮路径条目(完整校验: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, CDCategories, timeCost, pathRecordCache, timeCostStats) { + const { path: pathingFilePath, resourceName } = entry; + const pathName = basename(pathingFilePath); + const { foodExpAccumulator, currentMaterialName: prevMaterialName } = accumulators; + + let startTime = null; + let runTime = 0; + + try { + const refreshCD = findRefreshCD(resourceName, CDCategories); + if (!refreshCD) { + log.debug(`${CONSTANTS.LOG_MODULES.CD}狗粮材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`); + await sleep(1); + return accumulators; + } + + const pathCheckResult = checkPathNameFrequency(resourceName, pathName, recordDir, true, pathingFilePath); + if (pathCheckResult !== true && !pathCheckResult.valid) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 跳过 | ${pathCheckResult.reason}`); + await sleep(1); + return accumulators; + } + + const currentTime = getCurrentTimeInHours(); + const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath); + const perTime = noRecord ? null : calculatePerTime( + resourceName, + pathName, + recordDir, + noRecordDir, + true, + pathRecordCache, + pathingFilePath + ); + + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir); + const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName); + const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, resourceName, timeCost, timeCostStats); + + if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}狗粮路径`)) { + await sleep(1); + return accumulators; + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'}秒/EXP | 预计耗时:${estimatedTime}秒`); + + let currentMaterialName = prevMaterialName; + if (currentMaterialName !== resourceName) { + if (prevMaterialName && foodExpAccumulator[prevMaterialName]) { + const prevMsg = `材料[${prevMaterialName}]收集完成,累计EXP:${foodExpAccumulator[prevMaterialName]}`; + await sendNotificationInChunks(prevMsg, notification.Send); + } + currentMaterialName = resourceName; + foodExpAccumulator[resourceName] = 0; + log.info(`${CONSTANTS.LOG_MODULES.PATH}切换至狗粮材料【${resourceName}】`); + } + + const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime); + startTime = timing.startTime; + runTime = timing.runTime; + + const { success, totalExp } = await executeSalvageWithOCR(); + foodExpAccumulator[resourceName] += totalExp; + + const foodRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, { "EXP": totalExp }); + const recordDirFinal = noRecord ? noRecordDir : recordDir; + + const canRecord = runTime > MIN_RECORD_TIME; + if (canRecord && !state.cancelRequested) { + if (totalExp === 0) { + const zeroExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_ZERO_EXP_SUFFIX}`; + writeContentToFile(zeroExpFilePath, foodRecordContent); + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} EXP=0,写入0记录文件:${zeroExpFilePath}`); + } else { + 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]}`; + await sendNotificationInChunks(foodMsg, notification.Send); + } + } else { + if (state.cancelRequested) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName}手动终止,跳过记录`); + } else { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} 不满足记录条件:运行时间${runTime.toFixed(1)}秒(需>${MIN_RECORD_TIME}秒)`); + } + } + + await sleep(1); + return { ...accumulators, foodExpAccumulator, currentMaterialName }; + } catch (error) { + if (startTime) { + const endTime = new Date().toLocaleString(); + runTime = (new Date(endTime) - new Date(startTime)) / 1000; + await handlePathError(error, pathingFilePath, pathName, resourceName, startTime, endTime, runTime, noRecord, state.cancelRequested); + } + throw error; + } +} + +/** + * 处理怪物路径条目 + * @param {Object} entry - 路径条目 { path, monsterName, resourceName } + * @param {Object} context - 上下文 { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, ... } + * @returns {Object} 更新后的上下文 + */ +async function processMonsterPathEntry(entry, context) { + const { path: pathingFilePath, monsterName } = entry; + const pathName = basename(pathingFilePath); + const { + CDCategories, timeCost, recordDir, noRecordDir, imagesDir, + materialCategoryMap, flattenedLowCountMaterials, + currentMaterialName: prevMaterialName, + materialAccumulatedDifferences, globalAccumulatedDifferences, + pathRecordCache, timeCostStats + } = context; + + let startTime = null; + let runTime = 0; + let shouldRunAsNoRecord = false; + + try { + const monsterMaterials = monsterToMaterials[monsterName] || []; + const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); + if (allExcess) { + log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】所有材料已超量,跳过路径:${pathName}`); + await sleep(1); + return context; + } + + const refreshCD = findRefreshCD(monsterName, CDCategories); + if (!refreshCD) { + log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】未找到CD配置,跳过路径:${pathName}`); + await sleep(1); + return context; + } + + const currentTime = getCurrentTimeInHours(); + const lastEndTime = getLastRunEndTime(monsterName, pathName, recordDir, noRecordDir, pathingFilePath); + const pathCheckResult = checkPathNameFrequency(monsterName, pathName, recordDir, false, pathingFilePath); + const perTime = noRecord ? null : calculatePerTime( + monsterName, + pathName, + recordDir, + noRecordDir, + false, + pathRecordCache, + pathingFilePath + ); + + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, monsterName }, recordDir, noRecordDir); + const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName); + const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, monsterName, timeCost, timeCostStats); + + if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}怪物路径`)) { + await sleep(1); + return context; + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`); + + const resourceCategoryMap = {}; + const materials = monsterToMaterials[monsterName] || []; + + ocrContext.currentPathType = 'monster'; + ocrContext.currentTargetMaterials = materials; + + materials.forEach(mat => { + const category = matchImageAndGetCategory(mat, imagesDir); + if (category) { + if (!resourceCategoryMap[category]) resourceCategoryMap[category] = []; + if (!resourceCategoryMap[category].includes(mat)) { + resourceCategoryMap[category].push(mat); + } + } + }); + log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物${monsterName}的扫描分类:${JSON.stringify(resourceCategoryMap)}`); + + let currentMaterialName = prevMaterialName; + let updatedFlattened = flattenedLowCountMaterials; + + const hasExcessMaterial = materials.some(mat => excessMaterialNames.includes(mat)); + shouldRunAsNoRecord = noRecord || hasExcessMaterial; + + if (shouldRunAsNoRecord) { + if (currentMaterialName !== monsterName) { + currentMaterialName = monsterName; + materialAccumulatedDifferences[monsterName] = {}; + log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至怪物【${monsterName}】`); + } + + const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime); + startTime = timing.startTime; + runTime = timing.runTime; + + if (runTime > MIN_RECORD_TIME) { + const noRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, "noRecord模式忽略"); + writeContentToFile(`${noRecordDir}/${monsterName}.txt`, noRecordContent); + } else { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}怪物路径${pathName}运行时间不足(${runTime.toFixed(1)}秒 < ${MIN_RECORD_TIME}秒),跳过noRecord记录`); + } + } else { + if (currentMaterialName !== monsterName) { + if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) { + const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`; + await sendNotificationInChunks(prevMsg, notification.Send); + } + currentMaterialName = monsterName; + const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); + updatedFlattened = updatedLowCountMaterials + .flat() + .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10)); + materialAccumulatedDifferences[monsterName] = {}; + } + + const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime); + startTime = timing.startTime; + runTime = timing.runTime; + + const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); + const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count); + + const materialCountDifferences = {}; + flattenedUpdated.forEach(updated => { + const original = updatedFlattened.find(m => m.name === updated.name); + if (original) { + const updatedCount = parseInt(updated.count); + const originalCount = parseInt(original.count); + let diff = updatedCount - originalCount; + + if (isNaN(updatedCount) || isNaN(originalCount)) { + diff = NaN; + } else if (diff < 0) { + diff = null; + } + + if ((diff !== 0 && !isNaN(diff)) || materials.includes(updated.name)) { + materialCountDifferences[updated.name] = diff; + if (!isNaN(diff)) { + globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff; + materialAccumulatedDifferences[monsterName][updated.name] = (materialAccumulatedDifferences[monsterName][updated.name] || 0) + diff; + } + } + } + }); + + updatedFlattened = updatedFlattened.map(m => { + const updated = flattenedUpdated.find(u => u.name === m.name); + return updated ? { ...m, count: updated.count } : m; + }); + + log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}怪物路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); + + recordRunTime(monsterName, pathName, timing.startTime, timing.endTime, runTime, recordDir, materialCountDifferences, pathingFilePath); + } + + await sleep(1); + return { + ...context, + currentMaterialName, + flattenedLowCountMaterials: updatedFlattened, + materialAccumulatedDifferences, + globalAccumulatedDifferences + }; + } catch (error) { + if (startTime) { + const endTime = new Date().toLocaleString(); + runTime = (new Date(endTime) - new Date(startTime)) / 1000; + await handlePathError(error, pathingFilePath, pathName, monsterName, startTime, endTime, runTime, shouldRunAsNoRecord, false); + } + throw error; + } +} + +/** + * 处理普通材料路径条目 + * @param {Object} entry - 路径条目 { path, resourceName } + * @param {Object} context - 上下文(同怪物路径) + * @returns {Object} 更新后的上下文 + */ +async function processNormalPathEntry(entry, context) { + const { path: pathingFilePath, resourceName } = entry; + const pathName = basename(pathingFilePath); + const { + CDCategories, timeCost, recordDir, noRecordDir, + materialCategoryMap, flattenedLowCountMaterials, + currentMaterialName: prevMaterialName, + materialAccumulatedDifferences, globalAccumulatedDifferences, + pathRecordCache, timeCostStats + } = context; + + let startTime = null; + let runTime = 0; + + try { + const refreshCD = findRefreshCD(resourceName, CDCategories); + if (!refreshCD) { + log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`); + await sleep(1); + return context; + } + + const currentTime = getCurrentTimeInHours(); + const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath); + const pathCheckResult = checkPathNameFrequency(resourceName, pathName, recordDir, false, pathingFilePath); + const perTime = noRecord ? null : calculatePerTime(resourceName, pathName, recordDir, noRecordDir, false, pathRecordCache, pathingFilePath); + + const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir); + const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName); + const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, resourceName, timeCost, timeCostStats); + + if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}材料路径`)) { + await sleep(1); + return context; + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`); + + const resourceCategoryMap = {}; + for (const [cat, list] of Object.entries(materialCategoryMap)) { + if (list.includes(resourceName)) { + resourceCategoryMap[cat] = [resourceName]; + break; + } + } + + let currentMaterialName = prevMaterialName; + let updatedFlattened = flattenedLowCountMaterials; + + if (noRecord) { + if (currentMaterialName !== resourceName) { + currentMaterialName = resourceName; + materialAccumulatedDifferences[resourceName] = {}; + log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至材料【${resourceName}】`); + } + + const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime); + startTime = timing.startTime; + runTime = timing.runTime; + + if (runTime > MIN_RECORD_TIME) { + const noRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, "noRecord模式忽略"); + writeContentToFile(`${noRecordDir}/${resourceName}.txt`, noRecordContent); + } else { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}材料路径${pathName}运行时间不足(${runTime.toFixed(1)}秒 < ${MIN_RECORD_TIME}秒),跳过noRecord记录`); + } + } else { + if (currentMaterialName !== resourceName) { + if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) { + const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`; + await sendNotificationInChunks(prevMsg, notification.Send); + } + currentMaterialName = resourceName; + const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); + updatedFlattened = updatedLowCountMaterials + .flat() + .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10)); + materialAccumulatedDifferences[resourceName] = {}; + } + + const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime); + startTime = timing.startTime; + runTime = timing.runTime; + + const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); + const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count); + + const materialCountDifferences = {}; + flattenedUpdated.forEach(updated => { + const original = updatedFlattened.find(m => m.name === updated.name); + if (original) { + const updatedCount = parseInt(updated.count); + const originalCount = parseInt(original.count); + let diff = updatedCount - originalCount; + + if (isNaN(updatedCount) || isNaN(originalCount)) { + diff = NaN; + } else if (diff < 0) { + diff = null; + } + + if ((diff !== 0 && !isNaN(diff)) || updated.name === resourceName) { + materialCountDifferences[updated.name] = diff; + if (!isNaN(diff)) { + globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff; + materialAccumulatedDifferences[resourceName][updated.name] = (materialAccumulatedDifferences[resourceName][updated.name] || 0) + diff; + } + } + } + }); + + updatedFlattened = updatedFlattened.map(m => { + const updated = flattenedUpdated.find(u => u.name === m.name); + return updated ? { ...m, count: updated.count } : m; + }); + + log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}材料路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); + + recordRunTime(resourceName, pathName, timing.startTime, timing.endTime, runTime, recordDir, materialCountDifferences, pathingFilePath); + } + + await sleep(1); + return { + ...context, + currentMaterialName, + flattenedLowCountMaterials: updatedFlattened, + materialAccumulatedDifferences, + globalAccumulatedDifferences + }; + } catch (error) { + if (startTime) { + const endTime = new Date().toLocaleString(); + runTime = (new Date(endTime) - new Date(startTime)) / 1000; + await handlePathError(error, pathingFilePath, pathName, resourceName, startTime, endTime, runTime, noRecord, state.cancelRequested); + } + throw error; + } +} + +/** + * 批量处理所有路径(核心修改:时间预判+缓存传递) + * @param {Object[]} allPaths - 所有路径条目 + * @param {Object} CDCategories - CD分类配置 + * @param {Object} materialCategoryMap - 材料分类映射 + * @param {number} timeCost - 时间成本阈值 + * @param {Object[]} flattenedLowCountMaterials - 低数量材料列表 + * @param {string|null} currentMaterialName - 当前处理的材料名 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 无记录目录 + * @param {string} imagesDir - 图像目录 + * @param {string} endTimeStr - 指定终止时间 + * @returns {Object} 处理结果 + */ +async function processAllPaths(allPaths, CDCategories, materialCategoryMap, timeCost, flattenedLowCountMaterials, currentMaterialName, recordDir, noRecordDir, imagesDir, endTimeStr) { + try { + let foodExpAccumulator = {}; + const globalAccumulatedDifferences = {}; + const materialAccumulatedDifferences = {}; + const pathRecordCache = {}; + + if (pathingMode.estimateMode) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[测算模式] 开始路径分析...`); + + const allTimeCostData = collectAllTimeCostData(allPaths, recordDir, noRecordDir, pathRecordCache); + + const timeCostStats = {}; + for (const [resourceKey, data] of Object.entries(allTimeCostData)) { + timeCostStats[resourceKey] = calculateTimeCostStats(data); + } + + const resourceAnalysis = {}; + let totalQualifiedPaths = 0; + let totalPaths = 0; + let totalEstimatedTime = 0; + let failedCD = 0; + let failedFrequency = 0; + let passedTimeCost = 0; + let failedTimeCost = 0; + let insufficientRecords = 0; + + for (const entry of allPaths) { + const { path: pathingFilePath, resourceName, monsterName } = entry; + const pathName = basename(pathingFilePath); + const resourceKey = monsterName || resourceName || '未知'; + + 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(resourceKey)) { + refreshCD = JSON.parse(cdKey); + break; + } + } + if (refreshCD) break; + } + + const currentTime = getCurrentTimeInHours(); + const lastEndTime = getLastRunEndTime(resourceKey, pathName, recordDir, noRecordDir, pathingFilePath); + const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName); + + const isFood = resourceName && isFoodResource(resourceName); + const pathCheckResult = checkPathNameFrequency(resourceKey, pathName, recordDir, isFood, pathingFilePath); + + const perTime = calculatePerTime( + resourceKey, + pathName, + recordDir, + noRecordDir, + isFood, + pathRecordCache, + pathingFilePath + ); + + const isTimeCostOk = perTime === null || isTimeCostQualified( + perTime, + resourceKey, + timeCost, + timeCostStats + ); + + if (!resourceAnalysis[resourceKey]) { + resourceAnalysis[resourceKey] = { + total: 0, + qualified: 0, + perTimeSum: 0, + perTimeCount: 0, + qualifiedPerTimeSum: 0, + estimatedTimeSum: 0 + }; + } + + resourceAnalysis[resourceKey].total++; + totalPaths++; + + if (!canRunCD) { + failedCD++; + } + if (!(pathCheckResult === true || pathCheckResult.valid)) { + failedFrequency++; + } + if (perTime === null) { + insufficientRecords++; + } else if (isTimeCostOk) { + passedTimeCost++; + } else { + failedTimeCost++; + } + + if (canRunCD && (pathCheckResult === true || pathCheckResult.valid) && isTimeCostOk) { + resourceAnalysis[resourceKey].qualified++; + totalQualifiedPaths++; + + const estimatedTime = estimatePathTotalTime(entry, recordDir, noRecordDir, pathRecordCache); + resourceAnalysis[resourceKey].estimatedTimeSum += estimatedTime; + totalEstimatedTime += estimatedTime; + + if (perTime !== null) { + resourceAnalysis[resourceKey].qualifiedPerTimeSum += perTime; + } + } + + if (perTime !== null) { + resourceAnalysis[resourceKey].perTimeSum += perTime; + resourceAnalysis[resourceKey].perTimeCount++; + } + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH}\n===== 测算模式分析结果 =====`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}时间成本:${timeCost}%`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}总路径数:${totalPaths}`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}未通过(CD未刷新):${failedCD}条`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}未通过(0记录超限):${failedFrequency}条`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}未达标(时间成本不合格):${failedTimeCost}条`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}直通达标(记录不足):${insufficientRecords}条`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}达标路径数:${totalQualifiedPaths}条(同时通过三个校验)`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}达标占比:${totalPaths > 0 ? ((totalQualifiedPaths / totalPaths) * 100).toFixed(1) : 0}%`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}预计耗时:${formatTime(totalEstimatedTime)}`); + + log.info(`${CONSTANTS.LOG_MODULES.PATH}\n各材料平均时间成本及达标情况:`); + for (const [resourceKey, data] of Object.entries(resourceAnalysis)) { + const avgPerTime = data.perTimeCount > 0 ? (data.perTimeSum / data.perTimeCount).toFixed(4) : '无记录'; + const qualifiedRatio = data.total > 0 ? ((data.qualified / data.total) * 100).toFixed(1) : '0.0'; + const totalEstimatedTime = formatTime(data.estimatedTimeSum); + const avgQualifiedPerTime = data.qualified > 0 ? (data.qualifiedPerTimeSum / data.qualified).toFixed(4) : '无达标'; + const thresholdValue = timeCostStats[resourceKey] ? (timeCostStats[resourceKey].percentiles[timeCost] || timeCostStats[resourceKey].median).toFixed(4) : '无数据'; + + const isFood = resourceKey && isFoodResource(resourceKey); + const isMonster = monsterToMaterials.hasOwnProperty(resourceKey); + let unit = '秒/个'; + if (isFood) { + unit = '秒/EXP'; + } else if (isMonster) { + unit = '秒/中位材料'; + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH} --------------------------------------`); + log.info(`${CONSTANTS.LOG_MODULES.PATH} ${resourceKey}:`); + log.info(`${CONSTANTS.LOG_MODULES.PATH} 达标平均:${avgQualifiedPerTime}${unit}`); + log.info(`${CONSTANTS.LOG_MODULES.PATH} 设置的${timeCost}%分位阈值:${thresholdValue}${unit}`); + log.info(`${CONSTANTS.LOG_MODULES.PATH} 所有有记录路径的平均:${avgPerTime}${unit}`); + log.info(`${CONSTANTS.LOG_MODULES.PATH} 达标:${qualifiedRatio}% (${data.qualified}/${data.total}),总耗时:${totalEstimatedTime}`); + } + log.info(`${CONSTANTS.LOG_MODULES.PATH}=============================\n`); + + if (notify) { + const estimateMsg = `【测算模式】分析完成\n总路径数:${totalPaths}\n达标路径数:${totalQualifiedPaths}\n达标占比:${totalPaths > 0 ? ((totalQualifiedPaths / totalPaths) * 100).toFixed(1) : 0}%\n预计耗时:${formatTime(totalEstimatedTime)}`; + await sendNotificationInChunks(estimateMsg, notification.Send); + } + + state.completed = true; + state.cancelRequested = true; + return { + currentMaterialName: null, + flattenedLowCountMaterials: [], + globalAccumulatedDifferences: {}, + foodExpAccumulator + }; + } + + if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 开始收集时间成本数据...`); + const allTimeCostData = collectAllTimeCostData(allPaths, recordDir, noRecordDir, pathRecordCache); + + const timeCostStats = {}; + for (const [resourceKey, data] of Object.entries(allTimeCostData)) { + timeCostStats[resourceKey] = calculateTimeCostStats(data); + if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] ${resourceKey}统计:样本数=${timeCostStats[resourceKey].count}, 平均值=${timeCostStats[resourceKey].mean}, 中位数=${timeCostStats[resourceKey].median}`); + if (debugLog) { + log.info(` 标准差:${timeCostStats[resourceKey].stdDev}`); + log.info(` 百分位数:${JSON.stringify(timeCostStats[resourceKey].percentiles)}`); + } + } + if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 时间成本阈值:${timeCost}%`); + if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 时间成本数据收集完成,共${Object.keys(timeCostStats).length}个资源`); + + let context = { + CDCategories, timeCost, recordDir, noRecordDir, imagesDir, + materialCategoryMap, flattenedLowCountMaterials, + currentMaterialName, materialAccumulatedDifferences, + globalAccumulatedDifferences, + pathRecordCache, + timeCostStats + }; + + for (const entry of allPaths) { + if (state.cancelRequested) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到手动终止指令,停止路径处理`); + break; + } + + let skipPath = false; + if (endTimeStr) { + const isValidEndTime = /^\d{1,2}[::]\d{1,2}$/.test(endTimeStr); + if (isValidEndTime) { + const remainingMinutes = getRemainingMinutesToEndTime(endTimeStr); + if (remainingMinutes <= 0) { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}已过指定终止时间(${endTimeStr}),停止路径处理`); + state.cancelRequested = true; + break; + } + + const pathTotalTimeSec = estimatePathTotalTime( + entry, + recordDir, + noRecordDir, + pathRecordCache + ); + const pathTotalTimeMin = pathTotalTimeSec / 60; + const requiredMin = pathTotalTimeMin + 2; + + if (remainingMinutes <= requiredMin) { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}时间不足:剩余${remainingMinutes}分钟,需${requiredMin}分钟(含2分钟空闲)`); + state.cancelRequested = true; + skipPath = true; + break; + } else { + log.debug(`${CONSTANTS.LOG_MODULES.MAIN}时间充足:剩余${remainingMinutes}分钟,需${requiredMin}分钟`); + } + } else { + log.warn(`${CONSTANTS.LOG_MODULES.MAIN}终止时间格式无效(${endTimeStr}),跳过定时判断`); + } + } + + if (skipPath) break; + + const { path: pathingFilePath, resourceName, monsterName } = entry; + + try { + if (resourceName && isFoodResource(resourceName)) { + const result = await processFoodPathEntry( + entry, + { + foodExpAccumulator, + currentMaterialName: context.currentMaterialName + }, + recordDir, + noRecordDir, + CDCategories, + timeCost, + context.pathRecordCache, + context.timeCostStats + ); + 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}`); + + await sleep(1); + if (state.cancelRequested) { + log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到终止指令,停止处理`); + break; + } + } + } + + if (context.currentMaterialName) { + if (isFoodResource(context.currentMaterialName) && foodExpAccumulator[context.currentMaterialName]) { + const finalMsg = `狗粮材料[${context.currentMaterialName}]收集完成,累计EXP:${foodExpAccumulator[context.currentMaterialName]}`; + await sendNotificationInChunks(finalMsg, notification.Send); + } else if (materialAccumulatedDifferences[context.currentMaterialName]) { + const finalMsg = `目标[${context.currentMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[context.currentMaterialName])}`; + await sendNotificationInChunks(finalMsg, notification.Send); + } + } + + return { + currentMaterialName: context.currentMaterialName, + flattenedLowCountMaterials: context.flattenedLowCountMaterials, + globalAccumulatedDifferences: context.globalAccumulatedDifferences, + foodExpAccumulator + }; + + } catch (error) { + log.error(`${CONSTANTS.LOG_MODULES.PATH}路径处理整体错误:${error.message}`); + throw error; + } finally { + log.info(`${CONSTANTS.LOG_MODULES.PATH}路径组处理结束`); + state.completed = true; + } +} diff --git a/repo/js/背包材料统计/lib/recordManager.js b/repo/js/背包材料统计/lib/recordManager.js new file mode 100644 index 000000000..628cbc772 --- /dev/null +++ b/repo/js/背包材料统计/lib/recordManager.js @@ -0,0 +1,438 @@ +// ============================================== +// 记录管理模块 +// ============================================== + +// ============================================== +// 内容检测码生成(通用哈希逻辑) +// ============================================== +/** + * 生成内容检测码(基于路径位置数据) + * @param {Array} positions - 路径位置数组 + * @returns {string} 8位十六进制检测码 + */ +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; + } + 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 || []; + + 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"; + } +} + +// ============================================== +// 记录管理函数 +// ============================================== + +/** + * 写入内容到文件(追加模式) + * @param {string} filePath - 目标文件路径 + * @param {string} content - 要写入的内容 + */ +function writeContentToFile(filePath, content) { + try { + let existingContent = ''; + try { + existingContent = safeReadTextSync(filePath); + } catch (readError) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在或读取失败: ${filePath}`); + } + + const updatedContent = content + existingContent; + const result = file.writeTextSync(filePath, updatedContent, false); + if (result) { + log.info(`${CONSTANTS.LOG_MODULES.RECORD}记录成功: ${filePath}`); + } else { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${filePath}`); + } + } catch (error) { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${error}`); + } +} + +/** + * 检查路径名出现频率(避免重复无效路径) + * @param {string} resourceName - 资源名 + * @param {string} pathName - 路径名 + * @param {string} recordDir - 记录目录 + * @param {boolean} isFood - 是否为狗粮路径 + * @returns {boolean|Object} 是否允许运行(true=允许),false时返回详细信息对象 + */ +function checkPathNameFrequency(resourceName, pathName, recordDir, isFood = false, pathingFilePath = null) { + let suffix = CONSTANTS.ZERO_COUNT_SUFFIX; + if (isFood) { + suffix = CONSTANTS.FOOD_ZERO_EXP_SUFFIX; + } + const recordPath = `${recordDir}/${resourceName}${suffix}`; + let totalCount = 0; + + const currentContentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null; + const hasValidContentCode = currentContentCode && currentContentCode !== "00000000"; + + try { + const content = safeReadTextSync(recordPath); + const lines = content.split('\n'); + + let currentPathName = null; + let recordContentCode = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('路径名: ')) { + currentPathName = line.split('路径名: ')[1]; + } else if (line.startsWith('内容检测码: ')) { + recordContentCode = line.split('内容检测码: ')[1]; + } else if (line === '' && currentPathName && recordContentCode) { + if (hasValidContentCode) { + if (recordContentCode === currentContentCode) { + totalCount++; + } + } else { + if (currentPathName === pathName) { + totalCount++; + } + } + currentPathName = null; + recordContentCode = null; + } + } + } catch (error) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${recordDir}中无${resourceName}${suffix}记录,跳过检查`); + } + + if (totalCount >= 3) { + const typeDesc = isFood ? "狗粮" : "普通材料"; + return { + valid: false, + reason: `0记录频率超限(累计${totalCount}次)` + }; + } + + return true; +} + +/** + * 记录路径运行时间与材料变化 + * @param {string} resourceName - 资源名(普通材料名/怪物名) + * @param {string} pathName - 路径名 + * @param {string} startTime - 开始时间 + * @param {string} endTime - 结束时间 + * @param {number} runTime - 运行时间(秒) + * @param {string} recordDir - 记录目录 + * @param {Object} materialCountDifferences - 材料数量变化 + * @param {string} pathingFilePath - 路径文件路径 + */ +function recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences = {}, pathingFilePath) { + const recordPath = `${recordDir}/${resourceName}.txt`; + 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 > 5) { + const isError = typeof materialCountDifferences === 'string' || (typeof materialCountDifferences === 'object' && Object.values(materialCountDifferences).some(v => typeof v === 'string')); + + if (isError) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}检测到错误状态,跳过0记录`); + return; + } + + const isMonsterPath = monsterToMaterials.hasOwnProperty(resourceName); + if (isMonsterPath) { + const monsterTargetMaterials = monsterToMaterials[resourceName] || []; + let monsterMaterialsTotal = 0; + monsterTargetMaterials.forEach(targetMat => { + monsterMaterialsTotal += (materialCountDifferences[targetMat] || 0); + }); + if (monsterMaterialsTotal === 0) { + const zeroMonsterPath = `${recordDir}/${resourceName}${CONSTANTS.ZERO_COUNT_SUFFIX}`; + 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}`); + } + } + + 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内容检测码: ${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); + + if (!hasZeroMaterial) { + writeContentToFile(recordPath, normalContent); + } else { + if (hasZeroMaterial) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}存在材料数目为0的情况: ${JSON.stringify(materialCountDifferences)}`); + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}未写入正常记录: ${recordPath}`); + } + } else { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}运行时间小于5秒,未满足记录条件: ${recordPath}`); + } + } catch (error) { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录运行时间失败: ${error}`); + } +} + +/** + * 获取上次运行结束时间 + * @param {string} resourceName - 资源名 + * @param {string} pathName - 路径名 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 无记录目录 + * @returns {string|null} 上次结束时间字符串(null=无记录) + */ +function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath) { + const checkDirs = [recordDir, noRecordDir]; + let latestEndTime = null; + + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null; + const hasValidContentCode = contentCode && contentCode !== "00000000"; + + const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + + checkDirs.forEach(dir => { + const recordPath = `${dir}/${resourceName}.txt`; + try { + const content = safeReadTextSync(recordPath); + const lines = content.split('\n'); + + const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); + + 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'); + + let isMatch = false; + if (hasValidContentCode) { + isMatch = blockContentCode === contentCode; + } else { + isMatch = cleanBlockPathName === cleanPathName; + } + + 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}记录,跳过检查`); + } + }); + + return latestEndTime; +} + +/** + * 公共函数:读取路径历史记录(支持缓存复用,避免重复读文件) + * @param {string} resourceKey - 记录键(怪物名/材料名) + * @param {string} pathName - 路径名 + * @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 = {}, pathingFilePath) { + const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null; + const hasValidContentCode = contentCode && contentCode !== "00000000"; + + const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json'); + + const isFoodSuffix = isFood ? CONSTANTS.FOOD_EXP_RECORD_SUFFIX : ".txt"; + const recordFile = `${recordDir}/${resourceKey}${isFoodSuffix}`; + const cacheKey = `${recordFile}|${cleanPathName}|${contentCode || "00000000"}|${hasValidContentCode ? "code" : "name"}`; + + if (cache[cacheKey]) { + if (debugLog) log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从缓存复用记录:${cacheKey}`); + return cache[cacheKey]; + } + + const records = []; + let targetFile = recordFile; + let content = ""; + + try { + content = safeReadTextSync(targetFile); + } catch (mainErr) { + targetFile = `${noRecordDir}/${resourceKey}${isFoodSuffix}`; + try { + content = safeReadTextSync(targetFile); + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从备用目录读取记录:${targetFile}`); + } catch (backupErr) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}无${resourceKey}的历史记录:${targetFile}`); + cache[cacheKey] = records; + return records; + } + } + + const lines = content.split('\n'); + const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); + + 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 => { + 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 (line.startsWith('内容检测码: ')) { + recordContentCode = line.split('内容检测码: ')[1] || "00000000"; + } + if (line.startsWith('运行时间: ')) { + runTime = parseInt(line.split('运行时间: ')[1].split('秒')[0], 10) || 0; + } + 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}`); + } + } + }); + + let shouldInclude = false; + if (hasValidContentCode) { + shouldInclude = recordContentCode === contentCode && runTime > 0; + } else { + shouldInclude = isTargetPath && runTime > 0; + } + + if (shouldInclude) { + records.push({ runTime, quantityChange, contentCode: recordContentCode }); + } + }); + + cache[cacheKey] = records; + if (debugLog) log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取记录并缓存:${cacheKey}(${records.length}条)`); + return records; +} + +/** + * 基于历史runTime预估路径总耗时(默认5分钟) + * @param {Object} entry - 路径条目 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 备用目录 + * @param {Object} cache - 缓存对象 + * @returns {number} 预估耗时(秒) + */ +function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) { + const { resourceName, monsterName, path: pathingFilePath } = entry; + const pathName = basename(pathingFilePath); + const isFood = resourceName && isFoodResource(resourceName); + let resourceKey = isFood ? resourceName : (monsterName || resourceName); + + if (!resourceKey) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无资源关联,默认按120秒(2分钟)预估`); + return 120; + } + + const historicalRecords = getHistoricalPathRecords( + resourceKey, + pathName, + recordDir, + noRecordDir, + isFood, + cache, + pathingFilePath + ); + + if (historicalRecords.length === 0) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName} 无正常运行记录,默认耗时120秒`); + return 120; + } + + const recentRecords = [...historicalRecords].reverse().slice(0, 5); + const avgRunTime = Math.round( + recentRecords.reduce((sum, record) => sum + record.runTime, 0) / recentRecords.length + ); + + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史runTime(最近5条):${recentRecords.map(r => r.runTime)}秒,预估耗时:${avgRunTime}秒`); + return avgRunTime; +} diff --git a/repo/js/背包材料统计/lib/region.js b/repo/js/背包材料统计/lib/region.js index 422d7b4a2..c86216365 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 { @@ -23,7 +23,11 @@ async function recognizeImage( if (useNewScreenshot) { // 释放之前的临时资源 if (tempRa) { - tempRa.dispose(); + try { + tempRa.dispose(); + } catch (e) { + log.debug(`释放临时截图失败(可能已释放): ${e.message}`); + } } tempRa = captureGameRegion(); currentRa = tempRa; @@ -58,7 +62,11 @@ async function recognizeImage( } finally { // 释放临时资源但保留全局引用的资源 if (tempRa && tempRa !== globalLatestRa) { - tempRa.dispose(); + try { + tempRa.dispose(); + } catch (e) { + log.debug(`释放临时截图失败(可能已释放): ${e.message}`); + } } } @@ -84,10 +92,10 @@ async function drawAndClearRedBox(searchRegion, ra, delay = 500) { searchRegion.width, searchRegion.height ); drawRegion.DrawSelf("icon"); // 绘制红框 - + // 等待指定时间 await sleep(delay); - + // 清除红框 - 使用更可靠的方式 if (drawRegion && typeof drawRegion.DrawSelf === 'function') { // 可能需要使用透明绘制来清除,或者绘制一个0大小的区域 @@ -102,50 +110,3 @@ async function drawAndClearRedBox(searchRegion, ra, delay = 500) { } } } - -// 截图保存函数 -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/背包材料统计/lib/timeCostSystem.js b/repo/js/背包材料统计/lib/timeCostSystem.js new file mode 100644 index 000000000..eb1549e52 --- /dev/null +++ b/repo/js/背包材料统计/lib/timeCostSystem.js @@ -0,0 +1,317 @@ +// ============================================== +// 时间成本系统模块 +// ============================================== + +/** + * 解析材料CD文件内容,转换为刷新时间与材料的映射 + * @param {string} content - CD文件内容 + * @returns {Object} 刷新时间(JSON字符串)到材料列表的映射 + */ +function parseMaterialContent(content) { + if (!content) { + log.warn(`${CONSTANTS.LOG_MODULES.CD}文件内容为空`); + return {}; + } + + const lines = content.split('\n').map(line => line.trim()); + const materialCDInfo = {}; + + lines.forEach(line => { + if (!line.includes(':')) return; + + const [refreshCD, materials] = line.split(':'); + if (!refreshCD || !materials) return; + + let refreshCDInHours; + if (refreshCD.includes('次0点')) { + const times = parseInt(refreshCD.split('次')[0], 10); + if (isNaN(times)) { + log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); + return; + } + refreshCDInHours = { type: 'midnight', times: times }; + } else if (refreshCD.includes('点')) { + const hours = parseFloat(refreshCD.replace('点', '')); + if (isNaN(hours)) { + log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); + return; + } + refreshCDInHours = { type: 'specific', hour: hours }; + } else if (refreshCD.includes('小时')) { + const hours = parseFloat(refreshCD.replace('小时', '')); + if (isNaN(hours)) { + log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); + return; + } + refreshCDInHours = hours; + } else if (refreshCD === '即时刷新') { + refreshCDInHours = { type: 'instant' }; + } else { + log.error(`${CONSTANTS.LOG_MODULES.CD}未知的刷新时间格式:${refreshCD}`); + return; + } + let materialList = materials + .split(/[,,]\s*/) + .map(material => material.trim()) + .filter(material => material !== ''); + + materialCDInfo[JSON.stringify(refreshCDInHours)] = materialList; + }); + + return materialCDInfo; +} + +/** + * 格式化时间显示,根据时间大小自动选择单位 + * @param {number} seconds - 时间(秒) + * @returns {string} 格式化后的时间字符串 + */ +function formatTime(seconds) { + const formatValue = (value) => { + if (value % 1 === 0) { + return value.toString(); + } + return value.toFixed(4); + }; + + if (seconds >= 86400) { + return `${formatValue(seconds / 86400)}天`; + } else if (seconds >= 3600) { + return `${formatValue(seconds / 3600)}小时`; + } else if (seconds >= 60) { + return `${formatValue(seconds / 60)}分钟`; + } else { + return `${formatValue(seconds)}秒`; + } +} + +const MaterialType = { + NORMAL: 'normal', + MONSTER: 'monster', + FOOD: 'food' +}; + +const timeCostStatistics = { + [MaterialType.NORMAL]: [], + [MaterialType.MONSTER]: [], + [MaterialType.FOOD]: [] +}; + +/** + * 获取材料类型 + * @param {string} resourceName - 资源名 + * @returns {string} 材料类型 + */ +function getMaterialType(resourceName) { + if (isFoodResource(resourceName)) { + return MaterialType.FOOD; + } + if (monsterToMaterials.hasOwnProperty(resourceName)) { + return MaterialType.MONSTER; + } + return MaterialType.NORMAL; +} + +/** + * 收集所有路径的时间成本数据(按具体材料/怪物分组) + * @param {Object[]} allPaths - 所有路径条目 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 无记录目录 + * @param {Object} cache - 缓存对象 + * @returns {Object} 按材料名/怪物名分组的时间成本数据 + */ +function collectAllTimeCostData(allPaths, recordDir, noRecordDir, cache = {}) { + const statistics = {}; + + allPaths.forEach(entry => { + const { path: pathingFilePath, resourceName, monsterName } = entry; + const pathName = basename(pathingFilePath); + + const resourceKey = monsterName || resourceName; + if (!resourceKey) return; + + const perTime = calculatePerTime( + resourceName || monsterName, + pathName, + recordDir, + noRecordDir, + isFoodResource(resourceName || monsterName), + cache, + pathingFilePath + ); + + if (perTime !== null && !isNaN(perTime) && perTime > 0) { + if (!statistics[resourceKey]) { + statistics[resourceKey] = []; + } + statistics[resourceKey].push(perTime); + } + }); + + return statistics; +} + +/** + * 计算百分位数 + * @param {number[]} data - 数据数组 + * @param {number} percentile - 百分位数(0-100) + * @returns {number} 百分位数值 + */ +function calculatePercentile(data, percentile) { + if (data.length === 0) return 0; + + const sorted = [...data].sort((a, b) => a - b); + const index = (percentile / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = Math.ceil(index); + const weight = index - lower; + + if (upper >= sorted.length) return sorted[sorted.length - 1]; + if (lower === upper) return sorted[lower]; + + return sorted[lower] * (1 - weight) + sorted[upper] * weight; +} + +/** + * 计算时间成本统计信息 + * @param {number[]} data - 时间成本数据数组 + * @returns {Object} 统计信息 + */ +function calculateTimeCostStats(data) { + if (data.length === 0) { + return { + count: 0, + mean: 0, + median: 0, + stdDev: 0, + percentiles: {} + }; + } + + const sorted = [...data].sort((a, b) => a - b); + const mean = data.reduce((sum, val) => sum + val, 0) / data.length; + const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length; + const stdDev = Math.sqrt(variance); + + const percentiles = {}; + for (let p = 1; p <= 100; p++) { + percentiles[p] = calculatePercentile(sorted, p); + } + + return { + count: data.length, + mean: parseFloat(mean.toFixed(4)), + median: parseFloat(calculatePercentile(sorted, 50).toFixed(4)), + stdDev: parseFloat(stdDev.toFixed(4)), + percentiles + }; +} + +/** + * 判断路径时间成本是否合格(基于百分位数) + * @param {number} perTime - 路径的时间成本 + * @param {string} resourceKey - 资源键(材料名/怪物名) + * @param {number} thresholdPercentile - 百分位数阈值(1-100) + * @param {Object} statistics - 时间成本统计数据(按资源键分组) + * @returns {boolean} 是否合格 + */ +function isTimeCostQualified(perTime, resourceKey, thresholdPercentile, statistics) { + if (perTime === null || perTime === undefined) return true; + + const stats = statistics[resourceKey]; + if (!stats || stats.count === 0) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}资源${resourceKey}无统计数据,默认合格`); + return true; + } + + const thresholdValue = stats.percentiles[thresholdPercentile] || stats.median; + const isQualified = perTime <= thresholdValue; + + if (debugLog) { + log.info(`${CONSTANTS.LOG_MODULES.RECORD}时间成本校验:${resourceKey} | 路径成本:${perTime.toFixed(4)} | 阈值(${thresholdPercentile}%):${thresholdValue.toFixed(4)} | 结果:${isQualified ? '合格' : '不合格'}`); + } + + return isQualified; +} + +/** + * 计算单次时间成本(秒/单位材料)(复用缓存) + * @param {string} resourceName - 资源名 + * @param {string} pathName - 路径名 + * @param {string} recordDir - 记录目录 + * @param {string} noRecordDir - 备用目录 + * @param {boolean} isFood - 是否为狗粮路径 + * @param {Object} cache - 缓存对象 + * @returns {number|null} 时间成本 + */ +function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) { + const isMonster = monsterToMaterials.hasOwnProperty(resourceName); + + const historicalRecords = getHistoricalPathRecords( + resourceName, + pathName, + recordDir, + noRecordDir, + isFood, + cache, + pathingFilePath + ); + + if (historicalRecords.length < 3) { + if (debugLog) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史记录不足3条,无法计算时间成本`); + return null; + } + + const completeRecords = []; + if (isMonster) { + const monsterMaterials = monsterToMaterials[resourceName]; + const gradeRatios = [3, 1, 1/3]; + + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + let totalMiddleCount = 0; + + monsterMaterials.forEach((mat, index) => { + const count = quantityChange[mat] || 0; + totalMiddleCount += count * (gradeRatios[index] || 1); + }); + + totalMiddleCount = parseFloat(totalMiddleCount.toFixed(2)); + if (totalMiddleCount > 0) { + completeRecords.push(parseFloat((runTime / totalMiddleCount).toFixed(2))); + } + }); + } else if (isFood) { + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + const expValue = quantityChange.exp || 0; + if (expValue > 0) { + completeRecords.push(parseFloat((runTime / expValue).toFixed(2))); + } + }); + } else { + historicalRecords.forEach(record => { + const { runTime, quantityChange } = record; + if (quantityChange[resourceName] !== undefined && quantityChange[resourceName] !== 0) { + completeRecords.push(parseFloat((runTime / quantityChange[resourceName]).toFixed(2))); + } + }); + } + + if (completeRecords.length < 3) { + if (debugLog) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效记录不足3条,无法计算时间成本`); + return null; + } + + const recentRecords = completeRecords.slice(-5).filter(r => !isNaN(r) && r !== Infinity); + const mean = recentRecords.reduce((acc, val) => acc + val, 0) / recentRecords.length; + const stdDev = Math.sqrt(recentRecords.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / recentRecords.length); + const filteredRecords = recentRecords.filter(r => Math.abs(r - mean) <= 1 * stdDev); + + if (filteredRecords.length === 0) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}记录数据差异过大,无法计算有效时间成本`); + return null; + } + + return parseFloat((filteredRecords.reduce((acc, val) => acc + val, 0) / filteredRecords.length).toFixed(2)); +} diff --git a/repo/js/背包材料统计/lib/updateSettings.js b/repo/js/背包材料统计/lib/updateSettings.js index 9b8f4b12a..0a4c20ab2 100644 --- a/repo/js/背包材料统计/lib/updateSettings.js +++ b/repo/js/背包材料统计/lib/updateSettings.js @@ -10,43 +10,66 @@ function updateSettingsOptions() { log.info("settings.json内容长度: " + settingsContent.length); var settings = JSON.parse(settingsContent); log.info("settings.json解析成功,配置项数量: " + settings.length); - + var hasChanges = false; - + var popupDirs = readAllFilePaths("assets/imageClick", 0, 2, [], true) - .filter(function (dirPath) { + .filter(function(dirPath) { var entries = readAllFilePaths(dirPath, 0, 0, [], true); - return entries.some(function (entry) { + return entries.some(function(entry) { return normalizePath(entry).endsWith('/icon'); }); }) - .filter(function (dirPath) { + .filter(function(dirPath) { return !normalizePath(dirPath).includes('/其他/'); }) - .map(function (dirPath) { + .map(function(dirPath) { return basename(dirPath); }) .sort(); log.info("扫描到弹窗目录数量: " + popupDirs.length); - - var popupSetting = settings.find(function (s) { - return s.name === "PopupNames"; - }); + + var cdCategories = readAllFilePaths("materialsCD", 0, 1, ['.txt']) + .map(function(filePath) { + return basename(filePath).replace('.txt', ''); + }) + .sort(); + + var pickCategories = readAllFilePaths("targetText", 0, 1, ['.txt']) + .map(function(filePath) { + return basename(filePath).replace('.txt', ''); + }) + .sort(); + + var popupSetting = null; + var cdSetting = null; + var pickSetting = null; + + for (var i = 0; i < settings.length; i++) { + var setting = settings[i]; + if (setting.name === "PopupNames") { + popupSetting = setting; + } else if (setting.name === "CDCategories") { + cdSetting = setting; + } else if (setting.name === "PickCategories") { + pickSetting = setting; + } + } if (popupSetting) { log.info("找到PopupNames配置项"); var existingOptions = popupSetting.options || []; log.info("现有options数量: " + existingOptions.length); - + var existingSet = {}; for (var k = 0; k < existingOptions.length; k++) { existingSet[existingOptions[k]] = true; } - + var popupSet = {}; for (var p = 0; p < popupDirs.length; p++) { popupSet[popupDirs[p]] = true; } - + var newOptions = []; var removedOptions = []; for (var m = 0; m < popupDirs.length; m++) { @@ -59,10 +82,10 @@ function updateSettingsOptions() { removedOptions.push(existingOptions[n]); } } - + log.info("新增options数量: " + newOptions.length); log.info("删除options数量: " + removedOptions.length); - + if (newOptions.length > 0 || removedOptions.length > 0) { popupSetting.options = popupDirs; hasChanges = true; @@ -78,16 +101,7 @@ function updateSettingsOptions() { } else { log.info("未找到PopupNames配置项"); } - - var cdCategories = readAllFilePaths("materialsCD", 0, 1, ['.txt']) - .map(function (filePath) { - return basename(filePath).replace('.txt', ''); - }) - .sort(); - - var cdSetting = settings.find(function (s) { - return s.name === "CDCategories"; - }); + if (cdSetting) { var existingOptions = cdSetting.options || []; var existingSet = {}; @@ -121,16 +135,7 @@ function updateSettingsOptions() { } } } - - var pickCategories = readAllFilePaths("targetText", 0, 1, ['.txt']) - .map(function (filePath) { - return basename(filePath).replace('.txt', ''); - }) - .sort(); - - var pickSetting = settings.find(function (s) { - return s.name === "PickCategories"; - }); + if (pickSetting) { var existingOptions = pickSetting.options || []; var existingSet = {}; @@ -164,7 +169,7 @@ function updateSettingsOptions() { } } } - + if (hasChanges) { var updatedContent = JSON.stringify(settings, null, 2); file.writeTextSync(SETTINGS_FILE, updatedContent, false); diff --git a/repo/js/背包材料统计/lib/writeImage.js b/repo/js/背包材料统计/lib/writeImage.js new file mode 100644 index 000000000..df4f2040b --- /dev/null +++ b/repo/js/背包材料统计/lib/writeImage.js @@ -0,0 +1,233 @@ +// 带Y坐标间距校验的Rarity定位函数 +async function locateRarityAndCropColumn(ra, scanX0, startY, columnHeight, minYGap) { + // 配置参数:完全沿用你代码中的设定(78x13星级图、±2px扩展、阈值) + const config = { + maxRows: 4, // 目标最多4个区域(你需求中“未必有四个”,按实际识别返回) + columnYMin: 205, // 材料列Y下限(236-85=151;151-121=30;236-30=206;206-1=205) + columnYMax: 844, // 材料列Y上限(最后一页星级Y值843+1) + globalThreshold: 0.85, // 你代码中已设置的全局识别阈值 + localThreshold: 0.8, // 你代码中已设置的局部验证阈值 + expandPx: 2, // 你确认的±2px扩展(适配78x13星级图) + rarityW: 78, // 你代码中定义的星级图宽度 + rarityH: 13, // 你代码中定义的星级图高度 + rarityMatDir: "assets/rarity/" // 你代码中星级素材的固定路径 + }; + + const scanX = Math.round(scanX0); + log.debug(`[处理开始] 列X=${scanX},有效Y范围:${config.columnYMin}-${config.columnYMax}`); + + // ---------------------- 步骤1:找基准Y(复用你提供的recognizeImage函数) ---------------------- + let baseX = null; + let baseY = null; + for (let star = 1; star <= 5; star++) { // 遍历1-5星素材(你代码中星级范围) + const matPath = `${config.rarityMatDir}${star}.png`; + const rarityMat = file.readImageMatSync(matPath); // 你代码中用的素材加载方式 + + if (rarityMat.empty()) { // 你代码中素材加载失败的处理逻辑 + log.warn(`[基准识别] 素材加载失败:${matPath}`); + await sleep(30); // 你代码中常用的短延迟,避免高频报错 + continue; + } + + // 全局识别对象:X宽123(与你代码中一致),覆盖整列 + const globalRo = RecognitionObject.TemplateMatch( + rarityMat, + scanX, // 与scanMaterials传递的scanX一致(列起始X) + config.columnYMin, // 材料列顶部Y + 123, // 你代码中固定的图标宽度(非星级图宽,适配列扫描) + columnHeight // 与scanMaterials传递的columnHeight一致 + ); + globalRo.threshold = config.globalThreshold; + globalRo.Use3Channels = true; // 你代码中启用的3通道识别 + + // 复用你提供的recognizeImage函数(带超时、重试,避免阻塞) + const result = await recognizeImage(globalRo, ra, 200, 50); + if (result.isDetected) { // 识别成功则取Y坐标(你代码中用result.y作为基准) + baseX = result.x; + baseY = result.y; + log.debug(`[基准识别] 找到${star}星基准,Y=${baseY}`); + break; + } + await sleep(30); + } + + // 无基准则返回空(你代码中基准识别失败的处理逻辑) + if (baseY === null) { + log.debug(`[基准识别] 无有效基准,返回空`); + return []; + } + + // ---------------------- 步骤2:先下后上推导候选Y(你需求中的优先顺序) ---------------------- + const candidateYs = [baseY]; // 初始包含基准Y + let currentY; + + // 2.1 优先向下推导(Y递增,与你需求“先向下算”一致) + currentY = baseY + minYGap; // minYGap=176(scanMaterials调用时传递的参数) + while (candidateYs.length < config.maxRows && currentY <= config.columnYMax) { + if (!candidateYs.includes(currentY)) { + candidateYs.push(currentY); + log.debug(`[向下推导] 新增Y=${currentY},当前总数:${candidateYs.length}`); + } + currentY += minYGap; + } + + // 2.2 不足则向上补(Y递减,与你需求“超阈值向上算”一致) + const need = config.maxRows - candidateYs.length; + if (need > 0) { + currentY = baseY - minYGap; + let added = 0; + while (added < need && currentY >= config.columnYMin) { + if (!candidateYs.includes(currentY)) { + candidateYs.push(currentY); + added++; + log.debug(`[向上补全] 新增Y=${currentY},已补${added}/${need}`); + } + currentY -= minYGap; + } + } + + // 整理候选Y:去重→过滤超界→排序(你代码中常用的数组处理逻辑) + const sortedYs = [...new Set(candidateYs)] + .filter(y => y >= config.columnYMin && y <= config.columnYMax) + .sort((a, b) => a - b) // 从上到下排序(符合游戏界面布局) + .slice(0, config.maxRows); // 最多保留4个 + log.debug(`[候选整理] 最终候选Y:${sortedYs.join(', ')}(共${sortedYs.length}个)`); + + // ---------------------- 步骤3:局部验证(避免空裁图,你代码中的验证逻辑) ---------------------- + const validYs = []; + for (const y of sortedYs) { + // 局部验证范围:按星级图尺寸+扩展计算(你确认的78x13+±2px) + const localXStart = baseX - config.expandPx; + const localXWidth = config.rarityW + 2 * config.expandPx; // 78+4=82px + const localYStart = y - config.expandPx; + const localYHeight = config.rarityH + 2 * config.expandPx; // 13+4=17px + let isVerified = false; + + // 遍历1-5星验证(与基准识别逻辑一致) + for (let star = 1; star <= 5; star++) { + const matPath = `${config.rarityMatDir}${star}.png`; + const rarityMat = file.readImageMatSync(matPath); + if (rarityMat.empty()) continue; + const region = { + x:localXStart, + y:localYStart, + width:localXWidth, + height:localYHeight} + await drawAndClearRedBox(region, ra, 20);// 调用异步函数绘制红框并延时清除 + const localRo = RecognitionObject.TemplateMatch( + rarityMat, + localXStart, + localYStart, + localXWidth, + localYHeight + ); + localRo.threshold = config.localThreshold; + localRo.Use3Channels = true; + + // 再次复用recognizeImage验证(确保与基准识别逻辑统一) + const result = await recognizeImage(localRo, ra, 50, 10); // 超时减半,加快验证 + if (result.isDetected) { + isVerified = true; + break; + } + await sleep(10); + } + + if (isVerified) { + validYs.push(y); + log.debug(`[局部验证] Y=${y} 通过`); + } else { + log.debug(`[局部验证] Y=${y} 无匹配星级,排除`); + } + } + + // ---------------------- 步骤4:生成裁图区域(与你代码中固定偏移一致) ---------------------- + const ocrRegions = validYs.map(y => ({ + x: Math.round(scanX0 + 29.5), // 你代码中固定的X偏移(scanX0+29.5) + y: y - 85, // 你代码中固定的Y偏移(y-85) + width: 64, // 你代码中固定的裁图宽度 + height: 64 // 你代码中固定的裁图高度 + })); + + log.debug(`[处理完成] 有效裁图区域:${ocrRegions.length}个(Y:${validYs.join(', ')})`); + return ocrRegions; +} +/** + * 保存OCR裁图区域(扩大2像素识图,用识别到的图片名命名) + * @param {Object} ra - 游戏区域捕获对象(来自captureGameRegion()) + * @param {Array} ocrRegions - 原始裁图区域数组(含x,y,width,height) + * @param {Object} materialImages - 材料图片缓存(key:材料名,value:图片Mat对象,来自scanMaterials) + * @param {string} [saveDir='assets/regions'] - 保存目录 + */ +async function saveAllOcrRegionImages(ra, ocrRegions, materialImages, saveDir = 'assets/regions') { + // Fixed: Added proper parameter validation at function entry + if (!ra || !ocrRegions || !Array.isArray(ocrRegions) || ocrRegions.length === 0) { + log.warn('saveAllOcrRegionImages: Invalid parameters provided'); + return; + } + // 保留你原有函数的初始化检查(仅补充materialImages参数,因需要它匹配图片名) + log.debug(`【保存OCR区域图像】区域数量:${ocrRegions?.length || 0}`); + if (!ra || !ocrRegions || ocrRegions.length === 0 || !materialImages) { + log.error("【保存OCR区域图像】源图像或区域列表为空"); + return; + } + + for (const region of ocrRegions) { + // 你原有函数的变量(仅新增matchedMatName用于存图片名) + let fileName = ""; + let matchedMatName = "unknown"; // 默认名(未匹配到时用) + let croppedRegion = null; + + // 保留你原有函数的红框绘制逻辑 + await drawAndClearRedBox(region, ra, 50); + + try { + // ---------------------- 核心修改1:裁图区域扩大2像素作为识图区域 ---------------------- + // 原有裁图区域:region.x, region.y, region.width, region.height + // 扩大2像素:上下左右各扩2px,总宽高各+4(复用你原代码的坐标逻辑,无陌生函数) + const detectX = region.x - 2; + const detectY = region.y - 2; + const detectW = region.width + 4; + const detectH = region.height + 4; + log.debug(`【识图区域扩大】原区域(${region.x},${region.y}) → 扩大后(${detectX},${detectY},${detectW},${detectH})`); + + // ---------------------- 核心修改2:用扩大区域识别图片名(复用你原代码的模板匹配) ---------------------- + // materialImages是你scanMaterials函数中已有的“材料图片缓存”,不是陌生变量 + // RecognitionObject.TemplateMatch是你原代码识别材料用的方法,无陌生函数 + for (const [matName, mat] of Object.entries(materialImages)) { + if (mat.empty()) continue; // 跳过空图(你原代码的逻辑) + + // 用扩大后的区域做模板匹配(和你scanMaterials识别材料的逻辑完全一致) + const matchRo = RecognitionObject.TemplateMatch(mat, detectX, detectY, detectW, detectH); + matchRo.threshold = 0.85; // 和你原代码识别阈值一致 + matchRo.Use3Channels = true; // 你原代码启用的3通道识别 + + const matchResult = ra.find(matchRo); // 你原代码的识别方法 + if (matchResult.isExist()) { + matchedMatName = matName; // 匹配到后,获取图片名 + break; // 找到匹配项就退出循环 + } + } + + // ---------------------- 核心修改3:用识别到的图片名命名文件(保留你原有保存逻辑) ---------------------- + // 文件名:图片名_时间戳.png(避免重名,复用你原代码的Date.now()) + fileName = `${matchedMatName}_${Date.now()}`; + + // 保留你原有函数的裁图逻辑(裁的是原区域,不是扩大后的区域) + croppedRegion = ra.DeriveCrop(region.x, region.y, region.width, region.height); + if (!croppedRegion || croppedRegion.SrcMat.empty()) throw new Error("裁剪失败"); + + // 保留你原有函数的保存逻辑 + file.WriteImageSync(`${saveDir}/${fileName}.png`, croppedRegion.SrcMat); + log.info(`【保存OCR区域图像】成功:${saveDir}/${fileName}.png`); + } catch (error) { + // 保留你原有函数的错误处理逻辑 + log.error(`【保存OCR区域图像】失败(${fileName || '未知区域'}):${error.message}`); + } finally { + // 保留你原有函数的资源释放逻辑 + if (croppedRegion && typeof croppedRegion.dispose === 'function') { + croppedRegion.dispose(); + } + } + } +} \ No newline at end of file diff --git a/repo/js/背包材料统计/main.js b/repo/js/背包材料统计/main.js index b2a96e602..eeea6af47 100644 --- a/repo/js/背包材料统计/main.js +++ b/repo/js/背包材料统计/main.js @@ -10,12 +10,13 @@ const CONSTANTS = { NO_RECORD_DIR: "pathing_record/noRecord", IMAGES_DIR: "assets/images", MONSTER_MATERIALS_PATH: "assets/Monster-Materials.txt", + EXCESS_MATERIALS_PATH: "user/超量名单.txt", // 解析与处理配置 - MAX_PATH_DEPTH: 3, // 路径解析最大深度 + MAX_PATH_DEPTH: 6, // 路径解析最大深度 NOTIFICATION_CHUNK_SIZE: 500, // 通知拆分长度 - FOOD_EXP_RECORD_SUFFIX: "_狗粮.txt", - FOOD_ZERO_EXP_SUFFIX: "_狗粮-0.txt", // 新增:狗粮0 EXP记录后缀 + FOOD_EXP_RECORD_SUFFIX: ".txt", // 狗粮记录后缀(统一格式) + FOOD_ZERO_EXP_SUFFIX: "-0.txt", // 狗粮0记录后缀 SUMMARY_FILE_NAME: "材料收集汇总.txt", ZERO_COUNT_SUFFIX: "-0.txt", @@ -37,82 +38,20 @@ const CONSTANTS = { eval(file.readTextSync("lib/file.js")); eval(safeReadTextSync("lib/updateSettings.js")); eval(safeReadTextSync("lib/ocr.js")); +eval(safeReadTextSync("lib/writeImage.js")); eval(safeReadTextSync("lib/autoPick.js")); eval(safeReadTextSync("lib/exp.js")); eval(safeReadTextSync("lib/backStats.js")); +eval(safeReadTextSync("lib/colorDetection.js")); eval(safeReadTextSync("lib/imageClick.js")); -eval(safeReadTextSync("lib/displacement.js")); +eval(safeReadTextSync("lib/region.js")); // ============================================== -// 内容检测码生成(通用哈希逻辑) +// 引入拆分后的模块 // ============================================== -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"; - } -} +eval(safeReadTextSync("lib/recordManager.js")); +eval(safeReadTextSync("lib/timeCostSystem.js")); +eval(safeReadTextSync("lib/pathProcessor.js")); // ============================================== // 全局状态(保持不变) @@ -122,32 +61,17 @@ var state = { completed: false, cancelRequested: false, ocrPaused: false }; // ============================================== // 全局图片缓存(避免重复加载) // ============================================== -const globalImageCache = new Map(); - -function getCachedImageMat(filePath) { - if (globalImageCache.has(filePath)) { - return globalImageCache.get(filePath); - } - const mat = file.readImageMatSync(filePath); - if (!mat.empty()) { - globalImageCache.set(filePath, mat); - } - return mat; -} - -// ============================================== -// OCR上下文(用于动态过滤拾取列表) -// ============================================== const ocrContext = { currentPathType: null, currentTargetMaterials: [], pathingMonsterMaterials: new Set() }; // ============================================== // 初始化配置参数 // ============================================== -const timeCost = Math.min(300, Math.max(0, Math.floor(Number(settings.TimeCost) || 30))); +const timeCost = Math.min(100, Math.max(1, Math.floor(Number(settings.TimeCost) || 50))); const notify = settings.notify || false; const noRecord = settings.noRecord || false; +const noMonsterFilter = !settings.noMonsterFilter; const debugLog = settings.debugLog || false; -const targetCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.TargetCount) || 5000))); // 设定的目标数量 +const targetCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.TargetCount) || 1000))); // 设定的目标数量 const exceedCount = Math.min(9999, Math.max(0, Math.floor(Number(settings.ExceedCount) || 9000))); // 设定的超量目标数量 const endTimeStr = settings.CurrentTime ? settings.CurrentTime : null; @@ -221,12 +145,15 @@ function parseMonsterMaterials() { .filter(mat => mat); if (monsterName && materials.length > 0) { - monsterToMaterials[monsterName] = materials; + const monsterNames = monsterName.split('/').map(m => m.trim()).filter(m => m); + monsterNames.forEach(m => { + monsterToMaterials[m] = materials; + }); materials.forEach(mat => { if (!materialToMonsters[mat]) { - materialToMonsters[mat] = new Set(); // 用Set替代Array + materialToMonsters[mat] = new Set(); } - materialToMonsters[mat].add(monsterName); + monsterNames.forEach(m => materialToMonsters[mat].add(m)); }); } }); @@ -236,25 +163,65 @@ function parseMonsterMaterials() { } parseMonsterMaterials(); // 初始化怪物材料映射 +/** + * 将怪物名转换为最高级材料 + * @param {string} name - 输入的名称(可能是怪物名或材料名) + * @returns {string} 返回材料名(如果是怪物名则返回最高级材料,否则返回原名称) + */ +function convertMonsterToHighLevelMaterial(name) { + if (monsterToMaterials[name]) { + const materials = monsterToMaterials[name]; + return materials[materials.length - 1]; + } + return name; +} + +/** + * 处理目标资源名,将怪物名转换为最高级材料 + * @param {string[]} targetResourceNames - 原始目标资源名列表 + * @returns {string[]} 处理后的目标资源名列表(怪物名已转换为最高级材料) + */ +function processTargetResourceNames(targetResourceNames) { + const processedNames = targetResourceNames.map(name => convertMonsterToHighLevelMaterial(name)); + const uniqueNames = [...new Set(processedNames)]; + + if (debugLog) { + const convertedNames = targetResourceNames.filter(name => monsterToMaterials[name]); + if (convertedNames.length > 0) { + log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}[怪物名转换] 以下怪物名已转换为最高级材料:`); + convertedNames.forEach(monsterName => { + const highLevelMat = convertMonsterToHighLevelMaterial(monsterName); + log.info(` ${monsterName} → ${highLevelMat}`); + }); + } + } + + return uniqueNames; +} + // ============================================== // 路径模式配置 // ============================================== const pathingValue = Array.from(settings.Pathing || []); +const estimateMode = pathingValue.includes('【测算模式】'); +const pathingValueWithoutEstimate = pathingValue.filter(item => item !== '【测算模式】'); const pathingMode = { - includeBoth: pathingValue.length === 2, - onlyPathing: pathingValue.length === 1 && pathingValue.includes('📁pathing材料'), - onlyCategory: pathingValue.length === 1 && pathingValue.includes('【扫描额外的分类】') + includeBoth: pathingValueWithoutEstimate.length === 2, + onlyPathing: pathingValueWithoutEstimate.length === 1 && pathingValueWithoutEstimate.includes('📁pathing材料'), + onlyCategory: pathingValueWithoutEstimate.length === 1 && pathingValueWithoutEstimate.includes('【扫描额外的分类】'), + estimateMode: estimateMode }; -if (pathingValue.length === 0) { +if (pathingValueWithoutEstimate.length === 0 && !estimateMode) { log.warn(`${CONSTANTS.LOG_MODULES.PATH}未配置Pathing,默认为仅📁pathing材料`); pathingMode.onlyPathing = true; } -if (pathingMode.includeBoth) log.warn(`${CONSTANTS.LOG_MODULES.PATH}默认模式,📁pathing材料 将覆盖 勾选的分类`); +if (pathingMode.includeBoth) log.warn(`${CONSTANTS.LOG_MODULES.PATH}默认模式,📁pathing材料 将覆盖 勾选的【扫描额外的分类】`); if (pathingMode.onlyCategory) log.warn(`${CONSTANTS.LOG_MODULES.PATH}已开启【背包统计】专注模式,将忽略📁pathing材料`); if (pathingMode.onlyPathing) log.warn(`${CONSTANTS.LOG_MODULES.PATH}已开启【路径材料】专注模式,将忽略勾选的分类`); +if (pathingMode.estimateMode) log.warn(`${CONSTANTS.LOG_MODULES.PATH}已开启【测算模式】,将只测算不执行路径`); // ============================================== // 材料分类处理 @@ -273,31 +240,6 @@ if (pathingMode.onlyPathing) log.warn(`${CONSTANTS.LOG_MODULES.PATH}已开启【 log.error(`${CONSTANTS.LOG_MODULES.MATERIAL}获取分类设置失败: ${e.message}`); } - // 兼容旧的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]); - } - }); - } - // 默认分类 if (!selectedCategories || selectedCategories.length === 0) { selectedCategories = ["一般素材", "烹饪用食材"]; @@ -315,63 +257,6 @@ const selected_materials_array = getSelectedMaterialCategories(); // ============================================== // CD内容解析 // ============================================== -/** - * 解析材料CD文件内容,转换为刷新时间与材料的映射 - * @param {string} content - CD文件内容 - * @returns {Object} 刷新时间(JSON字符串)到材料列表的映射 - */ -function parseMaterialContent(content) { - if (!content) { - log.warn(`${CONSTANTS.LOG_MODULES.CD}文件内容为空`); - return {}; - } - - const lines = content.split('\n').map(line => line.trim()); - const materialCDInfo = {}; - - lines.forEach(line => { - if (!line.includes(':')) return; - - const [refreshCD, materials] = line.split(':'); - if (!refreshCD || !materials) return; - - let refreshCDInHours; - if (refreshCD.includes('次0点')) { - const times = parseInt(refreshCD.split('次')[0], 10); - if (isNaN(times)) { - log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); - return; - } - refreshCDInHours = { type: 'midnight', times: times }; - } else if (refreshCD.includes('点')) { - const hours = parseFloat(refreshCD.replace('点', '')); - if (isNaN(hours)) { - log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); - return; - } - refreshCDInHours = { type: 'specific', hour: hours }; - } else if (refreshCD.includes('小时')) { - const hours = parseFloat(refreshCD.replace('小时', '')); - if (isNaN(hours)) { - log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`); - return; - } - refreshCDInHours = hours; - } else if (refreshCD === '即时刷新') { - refreshCDInHours = { type: 'instant' }; - } else { - log.error(`${CONSTANTS.LOG_MODULES.CD}未知的刷新时间格式:${refreshCD}`); - return; - } - materialCDInfo[JSON.stringify(refreshCDInHours)] = materials - .split(/[,,]\s*/) - .map(material => material.trim()) - .filter(material => material !== ''); - }); - - return materialCDInfo; -} - // ============================================== // 路径资源提取(复用并优化) // ============================================== @@ -483,451 +368,6 @@ function getRemainingMinutesToEndTime(endTimeStr) { return Math.floor(remainingMs / (1000 * 60)); } -// ============================================== -// 记录管理(核心修改:公共函数+缓存复用) -// ============================================== -/** - * 写入内容到文件(追加模式) - * @param {string} filePath - 目标文件路径 - * @param {string} content - 要写入的内容 - */ -function writeContentToFile(filePath, content) { - try { - let existingContent = ''; - try { - existingContent = safeReadTextSync(filePath); - } catch (readError) { - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在或读取失败: ${filePath}`); - } - - const updatedContent = content + existingContent; - const result = file.writeTextSync(filePath, updatedContent, false); - if (result) { - log.info(`${CONSTANTS.LOG_MODULES.RECORD}记录成功: ${filePath}`); - } else { - log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${filePath}`); - } - } catch (error) { - log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${error}`); - } -} - -/** - * 检查路径名出现频率(避免重复无效路径) - * @param {string} resourceName - 资源名 - * @param {string} pathName - 路径名 - * @param {string} recordDir - 记录目录 - * @param {boolean} isFood - 是否为狗粮路径(新增参数) - * @returns {boolean} 是否允许运行(true=允许) - */ -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 = safeReadTextSync(recordPath); - const lines = content.split('\n'); - - lines.forEach(line => { - if (line.startsWith('路径名: ') && line.split('路径名: ')[1] === pathName) { - totalCount++; - } - }); - } catch (error) { - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${recordDir}中无${resourceName}${suffix}记录,跳过检查`); - } - - // 重复次数≥3则禁止运行(仅统计0记录) - if (totalCount >= 3) { - const typeDesc = isFood ? "狗粮" : "普通材料"; - log.info(`${CONSTANTS.LOG_MODULES.RECORD}${typeDesc}路径文件: ${pathName},累计0 EXP/0数量运行${totalCount}次,请清理记录后再执行`); - return false; - } - return true; -} - -/** - * 记录路径运行时间与材料变化 - * @param {string} resourceName - 资源名(普通材料名/怪物名) - * @param {string} pathName - 路径名 - * @param {string} startTime - 开始时间 - * @param {string} endTime - 结束时间 - * @param {number} runTime - 运行时间(秒) - * @param {string} recordDir - 记录目录 - * @param {Object} materialCountDifferences - 材料数量变化 - * @param {number} finalCumulativeDistance - 累计移动距离 - */ -function recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences = {}, finalCumulativeDistance, pathingFilePath) { - const recordPath = `${recordDir}/${resourceName}.txt`; - // 生成内容检测码 - 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 > 5) { // 运行时间>5秒才处理记录 - // 怪物路径专用逻辑(判断对应材料总数量是否为0) - const isMonsterPath = monsterToMaterials.hasOwnProperty(resourceName); // 是否为怪物路径 - if (isMonsterPath) { - // 1. 获取当前怪物对应的所有目标材料(从已有映射中取) - const monsterTargetMaterials = monsterToMaterials[resourceName] || []; - // 2. 计算这些材料的总数量变化(只累加目标材料,忽略其他无关材料) - let monsterMaterialsTotal = 0; - monsterTargetMaterials.forEach(targetMat => { - monsterMaterialsTotal += (materialCountDifferences[targetMat] || 0); - }); - // 3. 若总数量为0,生成怪物专用0记录文件(文件名含“总0”标识,避免混淆) - if (monsterMaterialsTotal === 0) { - const zeroMonsterPath = `${recordDir}/${resourceName}${CONSTANTS.ZERO_COUNT_SUFFIX}`; - 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内容检测码: ${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 isFinalCumulativeDistanceTooSmall = finalCumulativeDistance <= 5; - - if (!(hasZeroMaterial && isFinalCumulativeDistanceTooSmall)) { - writeContentToFile(recordPath, normalContent); - log.info(`${CONSTANTS.LOG_MODULES.RECORD}正常记录已写入: ${recordPath}`); - } else { - if (hasZeroMaterial) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}存在材料数目为0的情况: ${JSON.stringify(materialCountDifferences)}`); - if (isFinalCumulativeDistanceTooSmall) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}累计距离≤5: finalCumulativeDistance=${finalCumulativeDistance}`); - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}未写入正常记录: ${recordPath}`); - } - } else { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}运行时间小于5秒,未满足记录条件: ${recordPath}`); - } - } catch (error) { - log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录运行时间失败: ${error}`); - } -} - -/** - * 获取上次运行结束时间 - * @param {string} resourceName - 资源名 - * @param {string} pathName - 路径名 - * @param {string} recordDir - 记录目录 - * @param {string} noRecordDir - 无记录目录 - * @returns {string|null} 上次结束时间字符串(null=无记录) - */ -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 = safeReadTextSync(recordPath); - const lines = content.split('\n'); - - // 按空行分割成记录块 - const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); - - 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}记录,跳过检查`); - } - }); - - return latestEndTime; -} - -/** - * 公共函数:读取路径历史记录(支持缓存复用,避免重复读文件) - * @param {string} resourceKey - 记录键(怪物名/材料名) - * @param {string} pathName - 路径名 - * @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 = {}, 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}|${cleanPathName}|${contentCode || "00000000"}`; // 键格式:文件路径|清理后的路径名|内容检测码 - - // 2. 优先从缓存获取,命中则直接返回(不读文件) - if (cache[cacheKey]) { - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从缓存复用记录:${cacheKey}`); - return cache[cacheKey]; - } - - // 3. 缓存未命中,才读取文件 - const records = []; - let targetFile = recordFile; - let content = ""; - - // 读主目录→读备用目录 - try { - content = safeReadTextSync(targetFile); - } catch (mainErr) { - targetFile = `${noRecordDir}/${resourceKey}${isFoodSuffix}`; - try { - content = safeReadTextSync(targetFile); - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从备用目录读取记录:${targetFile}`); - } catch (backupErr) { - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}无${resourceKey}的历史记录:${targetFile}`); - // 空记录也写入缓存,避免下次重复尝试读文件 - cache[cacheKey] = records; - return records; - } - } - - // 解析记录(核心修改:遍历找关键字,而非硬编码行数) - const lines = content.split('\n'); - // 先按空行分割成独立的记录块,避免跨记录解析 - const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: ')); - - 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; - } - } - // 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; - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取记录并缓存:${cacheKey}(${records.length}条)`); - return records; -} - -/** - * 基于历史runTime预估路径总耗时(默认5分钟) - * @param {Object} entry - 路径条目 - * @param {string} recordDir - 记录目录 - * @param {string} noRecordDir - 备用目录 - * @param {Object} cache - 缓存对象 - * @returns {number} 预估耗时(秒) - */ -function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) { - const { resourceName, monsterName, path: pathingFilePath } = entry; - const pathName = basename(pathingFilePath); - const isFood = resourceName && isFoodResource(resourceName); - let resourceKey = isFood ? resourceName : (monsterName || resourceName); - - // 无资源关联时,默认5分钟(300秒) - if (!resourceKey) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无资源关联,默认按300秒(5分钟)预估`); - return 300; - } - - // 调用公共函数获取记录(复用缓存) - const historicalRecords = getHistoricalPathRecords( - resourceKey, - pathName, - recordDir, - noRecordDir, - isFood, - cache, - pathingFilePath - ); - - // 无记录时,默认5分钟(300秒) - if (historicalRecords.length === 0) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无有效runTime记录,默认按300秒(5分钟)预估`); - return 300; - } - - // 取最近5条记录计算平均值 - const recentRecords = [...historicalRecords].reverse().slice(0, 5); - const avgRunTime = Math.round( - recentRecords.reduce((sum, record) => sum + record.runTime, 0) / recentRecords.length - ); - - log.debug(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史runTime(最近5条):${recentRecords.map(r => r.runTime)}秒,预估耗时:${avgRunTime}秒`); - return avgRunTime; -} - -/** - * 计算单次时间成本(秒/单位材料)(复用缓存) - * @param {string} resourceName - 资源名 - * @param {string} pathName - 路径名 - * @param {string} recordDir - 记录目录 - * @param {string} noRecordDir - 备用目录 - * @param {boolean} isFood - 是否为狗粮路径 - * @param {Object} cache - 缓存对象 - * @returns {number|null} 时间成本 - */ -function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) { - const isMonster = monsterToMaterials.hasOwnProperty(resourceName); - // 调用公共函数获取记录(复用缓存) - const historicalRecords = getHistoricalPathRecords( - resourceName, - pathName, - recordDir, - noRecordDir, - isFood, - cache, - pathingFilePath - ); - - // 有效记录不足3条,返回null - if (historicalRecords.length < 3) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效记录不足3条,无法计算时间成本`); - return null; - } - - const completeRecords = []; - if (isMonster) { - // 怪物路径:按中级单位计算 - const monsterMaterials = monsterToMaterials[resourceName]; - const gradeRatios = [3, 1, 1/3]; // 最高级×3,中级×1,最低级×1/3 - - historicalRecords.forEach(record => { - const { runTime, quantityChange } = record; - let totalMiddleCount = 0; - - monsterMaterials.forEach((mat, index) => { - const count = quantityChange[mat] || 0; - totalMiddleCount += count * (gradeRatios[index] || 1); - }); - - totalMiddleCount = parseFloat(totalMiddleCount.toFixed(2)); - if (totalMiddleCount > 0) { - completeRecords.push(parseFloat((runTime / totalMiddleCount).toFixed(2))); - } - }); - } else 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 => { - const { runTime, quantityChange } = record; - if (quantityChange[resourceName] !== undefined && quantityChange[resourceName] !== 0) { - completeRecords.push(parseFloat((runTime / quantityChange[resourceName]).toFixed(2))); - } - }); - } - - // 异常值过滤与平均值计算 - if (completeRecords.length < 3) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效效率记录不足3条,无法计算时间成本`); - return null; - } - - const recentRecords = completeRecords.slice(-5).filter(r => !isNaN(r) && r !== Infinity); - const mean = recentRecords.reduce((acc, val) => acc + val, 0) / recentRecords.length; - const stdDev = Math.sqrt(recentRecords.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / recentRecords.length); - const filteredRecords = recentRecords.filter(r => Math.abs(r - mean) <= 1 * stdDev); - - if (filteredRecords.length === 0) { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}记录数据差异过大,无法计算有效时间成本`); - return null; - } - - return parseFloat((filteredRecords.reduce((acc, val) => acc + val, 0) / filteredRecords.length).toFixed(2)); -} - // ============================================== // 路径运行控制 // ============================================== @@ -952,37 +392,36 @@ function canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName) { nextRunTime.setDate(lastEndTimeDate.getDate() + times); nextRunTime.setHours(0, 0, 0, 0); const canRun = currentDate >= nextRunTime; - log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRunTime.toLocaleString()}`); + // 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 lastRefreshAfterRun = new Date(lastEndTimeDate); - lastRefreshAfterRun.setHours(specificHour, 0, 0, 0); - if (lastRefreshAfterRun <= lastEndTimeDate) { - lastRefreshAfterRun.setDate(lastRefreshAfterRun.getDate() + 1); - } + const getRefreshTime = (date) => { + const refreshTime = new Date(date); + if (date.getHours() < specificHour) { + refreshTime.setHours(specificHour, 0, 0, 0); + } else { + refreshTime.setDate(date.getDate() + 1); + refreshTime.setHours(specificHour, 0, 0, 0); + } + return refreshTime; + }; - // 如果当前时间已经过了最近的刷新时间,允许运行 - if (currentDate >= lastRefreshAfterRun) { - log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},最近刷新时间:${lastRefreshAfterRun.toLocaleString()},允许运行`); - return true; - } + const lastRefreshTime = getRefreshTime(lastDate); + const currentRefreshTime = getRefreshTime(currentDate); + const canRun = currentRefreshTime > lastRefreshTime; - // 计算下次刷新时间 - const todayRefresh = new Date(currentDate); - todayRefresh.setHours(specificHour, 0, 0, 0); - 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; + // log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},上次刷新点:${lastRefreshTime.toLocaleString()},当前刷新点:${currentRefreshTime.toLocaleString()},可运行:${canRun}`); + return canRun; } else if (refreshCD.type === 'instant') { return true; } } else { const nextRefreshTime = new Date(lastEndTimeDate.getTime() + refreshCD * 3600 * 1000); - log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRefreshTime.toLocaleString()}`); + // log.info(`${CONSTANTS.LOG_MODULES.CD}路径${pathName}上次运行:${lastEndTimeDate.toLocaleString()},下次运行:${nextRefreshTime.toLocaleString()}`); return currentDate >= nextRefreshTime; } @@ -1000,62 +439,6 @@ const MATERIAL_ALIAS = { }; const imageMapCache = new Map(); // 保持固定,不动态刷新 -/** - * 创建图像分类映射(目录到分类的映射) - * @param {string} imagesDir - 图像目录 - * @returns {Object} 图像名到分类的映射 - */ -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; - - const imageName = pathParts.pop() - .replace(/\.png$/i, '') - .trim() - .toLowerCase(); - - if (!(imageName in map)) { - map[imageName] = pathParts[2]; - } - } - - return map; -}; - -const loggedResources = new Set(); - -/** - * 匹配图像并获取材料分类 - * @param {string} resourceName - 资源名 - * @param {string} imagesDir - 图像目录 - * @returns {string|null} 材料分类(null=未匹配) - */ -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)); - } - - const result = imageMapCache.get(imagesDir)[processedName] ?? null; - if (result) { - // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`); - } else { - // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`); - } - - if (!loggedResources.has(processedName)) { - loggedResources.add(processedName); - } - - return result; -} - // ============================================== // 特殊材料与超量判断(核心新增逻辑) // ============================================== @@ -1067,22 +450,32 @@ const specialMaterials = [ let excessMaterialNames = []; // 超量材料名单 // 筛选低数量材料 + 平行标记超量材料(同源allMaterials) -function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { +function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap, onlyCategoryMode = false) { // 超量阈值(普通材料/矿石处理后统一对比) const EXCESS_THRESHOLD = exceedCount; + log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}[超量判断] 超量阈值:${EXCESS_THRESHOLD}`); + // 临时存储超量材料(从原始数据提取,平行于低数量筛选) const tempExcess = []; // 提取所有需要扫描的材料(超量+低数量共用同一源) const allMaterials = Object.values(materialCategoryMap).flat(); - if (debugLog) log.info(`【材料基准】本次需扫描的全量材料:${allMaterials.join("、")}`); - + + // onlyCategory模式下,检查所有扫描到的材料,而不仅仅是materialCategoryMap中的材料 + const shouldCheckAll = onlyCategoryMode || allMaterials.length === 0; + log.info(`【材料基准】onlyCategoryMode=${onlyCategoryMode}, allMaterials长度=${allMaterials.length}, shouldCheckAll=${shouldCheckAll}, pathingMaterialCounts长度=${pathingMaterialCounts.length}`); + log.info(`【扫描结果】${pathingMaterialCounts.map(item => `${item.name}:${item.count}`).join(', ')}`); + // ========== 第一步:平行判断超量材料(原始数据,不经过低数量过滤) ========== pathingMaterialCounts.forEach(item => { - // 只处理allMaterials内的材料(同源) - if (!allMaterials.includes(item.name)) return; + // onlyCategory模式下,检查所有材料;否则只检查allMaterials内的材料(同源) + if (!shouldCheckAll && !allMaterials.includes(item.name)) { + return; + } // 未知数量(?)不判断超量 - if (item.count === "?") return; + if (item.count === "?") { + return; + } // 矿石数量特殊处理(和低数量筛选的处理逻辑一致) let processedCount = Number(item.count); @@ -1093,7 +486,7 @@ function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { // 超量判断(平行逻辑:只要≥阈值就标记,和低数量无关) if (processedCount >= EXCESS_THRESHOLD) { tempExcess.push(item.name); - log.debug(`【超量标记】${item.name} 原始数量:${item.count} → 处理后:${processedCount} ≥ 阈值${EXCESS_THRESHOLD},标记为超量`); + log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}[超量标记] ${item.name} 原始:${item.count} → ${processedCount} ≥ ${EXCESS_THRESHOLD}`); } }); @@ -1114,693 +507,17 @@ function filterLowCountMaterials(pathingMaterialCounts, materialCategoryMap) { return { ...item, count: processedCount }; }); - tempExcess.push("OCR启动"); // 添加特殊标记,用于终止OCR等待 + tempExcess.push("OCR启动"); // ========== 第三步:更新全局超量名单(去重) ========== excessMaterialNames = [...new Set(tempExcess)]; - log.info(`【超量材料更新】共${excessMaterialNames.length}种:${excessMaterialNames.join("、")}`); + const realExcessCount = excessMaterialNames.filter(name => name !== "OCR启动").length; + log.info(`【超量材料更新】共${realExcessCount}种:${excessMaterialNames.filter(name => name !== "OCR启动").join("、")}`); if (debugLog) log.info(`【低数量材料】筛选后共${filteredLowCountMaterials.length}种:${filteredLowCountMaterials.map(m => m.name).join("、")}`); // 返回低数量材料(超量名单已独立生成) return filteredLowCountMaterials; } -// 极简封装:用路径和当前目标发通知,然后执行路径 -async function runPathAndNotify(pathingFilePath, currentMaterialName) { - const pathName = basename(pathingFilePath); // 取路径名 - if (notify) { // 只在需要通知时执行 - notification.Send(`当前执行路径:${pathName}\n目标:${currentMaterialName || '未知'}`); - } - return await pathingScript.runFile(pathingFilePath); // 执行路径 -} -// ============================================== -// 路径处理(拆分巨型函数) -// ============================================== -/** - * 处理狗粮路径条目(完整校验: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, CDCategories, timeCost, pathRecordCache) { - const { path: pathingFilePath, resourceName } = entry; - const pathName = basename(pathingFilePath); - const { foodExpAccumulator, currentMaterialName: prevMaterialName } = accumulators; - - let startTime = null; - let initialPosition = null; - let finalPosition = null; - let runTime = 0; - let finalCumulativeDistance = 0; - - try { - // ========== 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); - 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}秒`); - - 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) { - if (prevMaterialName && foodExpAccumulator[prevMaterialName]) { - const prevMsg = `材料[${prevMaterialName}]收集完成,累计EXP:${foodExpAccumulator[prevMaterialName]}`; - sendNotificationInChunks(prevMsg, notification.Send); - } - currentMaterialName = resourceName; - foodExpAccumulator[resourceName] = 0; - log.info(`${CONSTANTS.LOG_MODULES.PATH}切换至狗粮材料【${resourceName}】`); - } - - startTime = new Date().toLocaleString(); - initialPosition = genshin.getPositionFromMap(); - await runPathAndNotify(pathingFilePath, currentMaterialName); - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const { success, totalExp } = await executeSalvageWithOCR(); - foodExpAccumulator[resourceName] += totalExp; - - 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`; - - const canRecord = runTime > 5 && finalCumulativeDistance > 5; - if (canRecord) { - if (totalExp === 0) { - const zeroExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_ZERO_EXP_SUFFIX}`; - writeContentToFile(zeroExpFilePath, foodRecordContent); - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} EXP=0,写入0记录文件:${zeroExpFilePath}`); - } else { - 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)}秒(需>5秒)| 移动距离${finalCumulativeDistance.toFixed(2)}(需>5)`); - } - - await sleep(1); - return { ...accumulators, foodExpAccumulator, currentMaterialName }; - } catch (error) { - if (startTime && initialPosition) { - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const canRecord = runTime > 5 && finalCumulativeDistance > 5; - if (canRecord) { - const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; - const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${error.message}\n\n`; - writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${resourceName}.txt`, noRecordContent); - log.info(`${CONSTANTS.LOG_MODULES.RECORD}已将错误路径记录为noRecord模式:${pathName}(实际执行:${runTime.toFixed(1)}秒,${finalCumulativeDistance.toFixed(2)}米)`); - } else { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}执行异常但不满足记录条件(运行时间${runTime.toFixed(1)}秒,移动距离${finalCumulativeDistance.toFixed(2)}米),跳过noRecord记录`); - } - } - throw error; - } -} - -/** - * 处理怪物路径条目 - * @param {Object} entry - 路径条目 { path, monsterName, resourceName } - * @param {Object} context - 上下文 { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, ... } - * @returns {Object} 更新后的上下文 - */ -async function processMonsterPathEntry(entry, context) { - const { path: pathingFilePath, monsterName } = entry; - const pathName = basename(pathingFilePath); - const { - CDCategories, timeCost, recordDir, noRecordDir, imagesDir, - materialCategoryMap, flattenedLowCountMaterials, - currentMaterialName: prevMaterialName, - materialAccumulatedDifferences, globalAccumulatedDifferences, - pathRecordCache - } = context; - - let startTime = null; - let initialPosition = null; - let finalPosition = null; - let runTime = 0; - let finalCumulativeDistance = 0; - - try { - const monsterMaterials = monsterToMaterials[monsterName] || []; - const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat)); - if (allExcess) { - log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】所有材料已超量,跳过路径:${pathName}`); - await sleep(1); - return context; - } - - 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(monsterName)) { - refreshCD = JSON.parse(cdKey); - break; - } - } - if (refreshCD) break; - } - - if (!refreshCD) { - log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】未找到CD配置,跳过路径:${pathName}`); - await sleep(1); - return context; - } - - const currentTime = getCurrentTimeInHours(); - 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, - 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); - return context; - } - - const resourceCategoryMap = {}; - const materials = monsterToMaterials[monsterName] || []; - - ocrContext.currentPathType = 'monster'; - ocrContext.currentTargetMaterials = materials; - - materials.forEach(mat => { - const category = matchImageAndGetCategory(mat, imagesDir); - if (category) { - if (!resourceCategoryMap[category]) resourceCategoryMap[category] = []; - if (!resourceCategoryMap[category].includes(mat)) { - resourceCategoryMap[category].push(mat); - } - } - }); - log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物${monsterName}的扫描分类:${JSON.stringify(resourceCategoryMap)}`); - - let currentMaterialName = prevMaterialName; - let updatedFlattened = flattenedLowCountMaterials; - - if (noRecord) { - if (currentMaterialName !== monsterName) { - currentMaterialName = monsterName; - materialAccumulatedDifferences[monsterName] = {}; - log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至怪物【${monsterName}】`); - } - - startTime = new Date().toLocaleString(); - initialPosition = genshin.getPositionFromMap(); - await runPathAndNotify(pathingFilePath, currentMaterialName); - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - 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 { - if (currentMaterialName !== monsterName) { - if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) { - const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`; - sendNotificationInChunks(prevMsg, notification.Send); - } - currentMaterialName = monsterName; - const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); - updatedFlattened = updatedLowCountMaterials - .flat() - .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10)); - materialAccumulatedDifferences[monsterName] = {}; - } - - startTime = new Date().toLocaleString(); - initialPosition = genshin.getPositionFromMap(); - await runPathAndNotify(pathingFilePath, currentMaterialName); - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); - const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count); - - const materialCountDifferences = {}; - flattenedUpdated.forEach(updated => { - const original = updatedFlattened.find(m => m.name === updated.name); - if (original) { - const diff = parseInt(updated.count) - parseInt(original.count); - if (diff !== 0 || materials.includes(updated.name)) { - materialCountDifferences[updated.name] = diff; - globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff; - materialAccumulatedDifferences[monsterName][updated.name] = (materialAccumulatedDifferences[monsterName][updated.name] || 0) + diff; - } - } - }); - - updatedFlattened = updatedFlattened.map(m => { - const updated = flattenedUpdated.find(u => u.name === m.name); - return updated ? { ...m, count: updated.count } : m; - }); - - log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}怪物路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); - // 检查怪物对应的材料是否有超量,如果有,记录到noRecord目录 - let isExcess = false; - const monsterMaterials = monsterToMaterials[monsterName] || []; - for (const material of monsterMaterials) { - if (excessMaterialNames.includes(material)) { - isExcess = true; - break; - } - } - const targetRecordDir = isExcess ? noRecordDir : recordDir; - recordRunTime(monsterName, pathName, startTime, endTime, runTime, targetRecordDir, materialCountDifferences, finalCumulativeDistance, pathingFilePath); - } - - await sleep(1); - return { - ...context, - currentMaterialName, - flattenedLowCountMaterials: updatedFlattened, - materialAccumulatedDifferences, - globalAccumulatedDifferences - }; - } catch (error) { - if (startTime && initialPosition) { - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const canRecord = runTime > 5 && finalCumulativeDistance > 5; - if (canRecord) { - const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; - const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${error.message}\n\n`; - writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${monsterName}.txt`, noRecordContent); - log.info(`${CONSTANTS.LOG_MODULES.RECORD}已将错误路径记录为noRecord模式:${pathName}(实际执行:${runTime.toFixed(1)}秒,${finalCumulativeDistance.toFixed(2)}米)`); - } else { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}执行异常但不满足记录条件(运行时间${runTime.toFixed(1)}秒,移动距离${finalCumulativeDistance.toFixed(2)}米),跳过noRecord记录`); - } - } - throw error; - } -} - -/** - * 处理普通材料路径条目 - * @param {Object} entry - 路径条目 { path, resourceName } - * @param {Object} context - 上下文(同怪物路径) - * @returns {Object} 更新后的上下文 - */ -async function processNormalPathEntry(entry, context) { - const { path: pathingFilePath, resourceName } = entry; - const pathName = basename(pathingFilePath); - const { - CDCategories, timeCost, recordDir, noRecordDir, - materialCategoryMap, flattenedLowCountMaterials, - currentMaterialName: prevMaterialName, - materialAccumulatedDifferences, globalAccumulatedDifferences, - pathRecordCache - } = context; - - let startTime = null; - let initialPosition = null; - let finalPosition = null; - let runTime = 0; - let finalCumulativeDistance = 0; - - try { - 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.MATERIAL}材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`); - await sleep(1); - return context; - } - - const currentTime = getCurrentTimeInHours(); - 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, - 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); - return context; - } - - const resourceCategoryMap = {}; - for (const [cat, list] of Object.entries(materialCategoryMap)) { - if (list.includes(resourceName)) { - resourceCategoryMap[cat] = [resourceName]; - break; - } - } - - let currentMaterialName = prevMaterialName; - let updatedFlattened = flattenedLowCountMaterials; - - if (noRecord) { - if (currentMaterialName !== resourceName) { - currentMaterialName = resourceName; - materialAccumulatedDifferences[resourceName] = {}; - log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至材料【${resourceName}】`); - } - - startTime = new Date().toLocaleString(); - initialPosition = genshin.getPositionFromMap(); - await runPathAndNotify(pathingFilePath, currentMaterialName); - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - 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) { - if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) { - const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`; - sendNotificationInChunks(prevMsg, notification.Send); - } - currentMaterialName = resourceName; - const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); - updatedFlattened = updatedLowCountMaterials - .flat() - .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10)); - materialAccumulatedDifferences[resourceName] = {}; - } - - startTime = new Date().toLocaleString(); - initialPosition = genshin.getPositionFromMap(); - await runPathAndNotify(pathingFilePath, currentMaterialName); - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap); - const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count); - - const materialCountDifferences = {}; - flattenedUpdated.forEach(updated => { - const original = updatedFlattened.find(m => m.name === updated.name); - if (original) { - const diff = parseInt(updated.count) - parseInt(original.count); - if (diff !== 0 || updated.name === resourceName) { - materialCountDifferences[updated.name] = diff; - globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff; - materialAccumulatedDifferences[resourceName][updated.name] = (materialAccumulatedDifferences[resourceName][updated.name] || 0) + diff; - } - } - }); - - updatedFlattened = updatedFlattened.map(m => { - const updated = flattenedUpdated.find(u => u.name === m.name); - return updated ? { ...m, count: updated.count } : m; - }); - - log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}材料路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`); - // 检查材料是否在超量名单中,如果是,记录到noRecord目录 - let isExcess = false; - // 检查当前材料是否超量 - if (excessMaterialNames.includes(resourceName)) { - isExcess = true; - } else if (monsterToMaterials.hasOwnProperty(resourceName)) { - // 对于怪物路径,检查其对应的材料是否有超量 - const monsterMaterials = monsterToMaterials[resourceName]; - for (const material of monsterMaterials) { - if (excessMaterialNames.includes(material)) { - isExcess = true; - break; - } - } - } - const targetRecordDir = isExcess ? noRecordDir : recordDir; - recordRunTime(resourceName, pathName, startTime, endTime, runTime, targetRecordDir, materialCountDifferences, finalCumulativeDistance, pathingFilePath); - } - - await sleep(1); - return { - ...context, - currentMaterialName, - flattenedLowCountMaterials: updatedFlattened, - materialAccumulatedDifferences, - globalAccumulatedDifferences - }; - } catch (error) { - if (startTime && initialPosition) { - finalPosition = genshin.getPositionFromMap(); - finalCumulativeDistance = calculateDistance(initialPosition, finalPosition); - const endTime = new Date().toLocaleString(); - runTime = (new Date(endTime) - new Date(startTime)) / 1000; - - const canRecord = runTime > 5 && finalCumulativeDistance > 5; - if (canRecord) { - const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000"; - const noRecordContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${error.message}\n\n`; - writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${resourceName}.txt`, noRecordContent); - log.info(`${CONSTANTS.LOG_MODULES.RECORD}已将错误路径记录为noRecord模式:${pathName}(实际执行:${runTime.toFixed(1)}秒,${finalCumulativeDistance.toFixed(2)}米)`); - } else { - log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}执行异常但不满足记录条件(运行时间${runTime.toFixed(1)}秒,移动距离${finalCumulativeDistance.toFixed(2)}米),跳过noRecord记录`); - } - } - throw error; - } -} - -/** - * 批量处理所有路径(核心修改:时间预判+缓存传递) - * @param {Object[]} allPaths - 所有路径条目 - * @param {Object} CDCategories - CD分类配置 - * @param {Object} materialCategoryMap - 材料分类映射 - * @param {number} timeCost - 时间成本阈值 - * @param {Object[]} flattenedLowCountMaterials - 低数量材料列表 - * @param {string|null} currentMaterialName - 当前处理的材料名 - * @param {string} recordDir - 记录目录 - * @param {string} noRecordDir - 无记录目录 - * @param {string} imagesDir - 图像目录 - * @param {string} endTimeStr - 指定终止时间 - * @returns {Object} 处理结果 - */ -async function processAllPaths(allPaths, CDCategories, materialCategoryMap, timeCost, flattenedLowCountMaterials, currentMaterialName, recordDir, noRecordDir, imagesDir, endTimeStr) { - try { - // 初始化累加器 - let foodExpAccumulator = {}; - const globalAccumulatedDifferences = {}; - const materialAccumulatedDifferences = {}; - // 单路径处理周期内的记录缓存 - const pathRecordCache = {}; - let context = { - CDCategories, timeCost, recordDir, noRecordDir, imagesDir, - materialCategoryMap, flattenedLowCountMaterials, - currentMaterialName, materialAccumulatedDifferences, - globalAccumulatedDifferences, - pathRecordCache - }; - - for (const entry of allPaths) { - // 优先响应手动终止指令 - if (state.cancelRequested) { - log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到手动终止指令,停止路径处理`); - break; - } - - // 定时终止判断 - let skipPath = false; - if (endTimeStr) { - const isValidEndTime = /^\d{1,2}[::]\d{1,2}$/.test(endTimeStr); - if (isValidEndTime) { - const remainingMinutes = getRemainingMinutesToEndTime(endTimeStr); - if (remainingMinutes <= 0) { - log.warn(`${CONSTANTS.LOG_MODULES.MAIN}已过指定终止时间(${endTimeStr}),停止路径处理`); - state.cancelRequested = true; - break; - } - - const pathTotalTimeSec = estimatePathTotalTime( - entry, - recordDir, - noRecordDir, - pathRecordCache - ); - const pathTotalTimeMin = pathTotalTimeSec / 60; - const requiredMin = pathTotalTimeMin + 2; - - if (remainingMinutes <= requiredMin) { - log.warn(`${CONSTANTS.LOG_MODULES.MAIN}时间不足:剩余${remainingMinutes}分钟,需${requiredMin}分钟(含2分钟空闲)`); - state.cancelRequested = true; - skipPath = true; - break; - } else { - log.debug(`${CONSTANTS.LOG_MODULES.MAIN}时间充足:剩余${remainingMinutes}分钟,需${requiredMin}分钟`); - } - } else { - log.warn(`${CONSTANTS.LOG_MODULES.MAIN}终止时间格式无效(${endTimeStr}),跳过定时判断`); - } - } - - 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 - }, - 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}`); - - await sleep(1); - if (state.cancelRequested) { - log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到终止指令,停止处理`); - break; - } - } - } - - // 最后一个目标收尾通知 - if (context.currentMaterialName) { - if (isFoodResource(context.currentMaterialName) && foodExpAccumulator[context.currentMaterialName]) { - const finalMsg = `狗粮材料[${context.currentMaterialName}]收集完成,累计EXP:${foodExpAccumulator[context.currentMaterialName]}`; - sendNotificationInChunks(finalMsg, notification.Send); - } else if (materialAccumulatedDifferences[context.currentMaterialName]) { - const finalMsg = `目标[${context.currentMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[context.currentMaterialName])}`; - sendNotificationInChunks(finalMsg, notification.Send); - } - } - - return { - currentMaterialName: context.currentMaterialName, - flattenedLowCountMaterials: context.flattenedLowCountMaterials, - globalAccumulatedDifferences: context.globalAccumulatedDifferences, - foodExpAccumulator - }; - - } catch (error) { - log.error(`${CONSTANTS.LOG_MODULES.PATH}路径处理整体错误:${error.message}`); - throw error; - } finally { - log.info(`${CONSTANTS.LOG_MODULES.PATH}路径组处理结束`); - state.completed = true; - } -} // ============================================== // 路径分类与优先级(简化逻辑,补全所有缺失代码) @@ -1814,7 +531,7 @@ async function processAllPaths(allPaths, CDCategories, materialCategoryMap, time * @returns {Object[]} 分类后的路径条目 */ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMaterialNames, cdMaterialNames) { - const pathingFilePaths = readAllFilePaths(pathingDir, 0, 3, ['.json']); + const pathingFilePaths = readAllFilePaths(pathingDir, 0, CONSTANTS.MAX_PATH_DEPTH, ['.json']); const pathEntries = pathingFilePaths.map(path => { const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); return { path, resourceName: materialName, monsterName }; @@ -1886,7 +603,8 @@ function classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMateri */ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames, materialCategoryMap, pathingMode, imagesDir) { // 缓存路径文件列表(减少IO) - const pathingFilePaths = readAllFilePaths(pathingDir, 0, 3, ['.json']); + const pathingFilePaths = readAllFilePaths(pathingDir, 0, CONSTANTS.MAX_PATH_DEPTH, ['.json']); + const pathEntries = pathingFilePaths.map(path => { const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); return { path, resourceName: materialName, monsterName }; @@ -1894,13 +612,24 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames log.info(`${CONSTANTS.LOG_MODULES.PATH}[路径初始化] 共读取有效路径 ${pathEntries.length} 条`); + // 测算模式:不执行背包扫描,直接返回所有路径 + if (pathingMode.estimateMode) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[测算模式] 跳过背包扫描,直接进行路径分析`); + } + // 分类路径(狗粮 > 怪物 > 普通材料) const foodPaths = pathEntries.filter(entry => entry.resourceName && isFoodResource(entry.resourceName)); - const monsterPaths = pathEntries.filter(entry => entry.monsterName && !isFoodResource(entry.resourceName)); - const normalPaths = pathEntries.filter(entry => entry.resourceName && !isFoodResource(entry.resourceName) && !entry.monsterName); + const monsterPaths = pathEntries.filter(entry => entry.monsterName && entry.monsterName !== '地脉花'); + const normalPaths = pathEntries.filter(entry => entry.resourceName && !entry.monsterName && entry.resourceName !== '锄地'); log.info(`${CONSTANTS.LOG_MODULES.PATH}[路径分类] 狗粮:${foodPaths.length} 怪物:${monsterPaths.length} 普通:${normalPaths.length}`); + // 测算模式:直接返回所有路径 + if (pathingMode.estimateMode) { + const allPaths = [...monsterPaths, ...normalPaths, ...foodPaths]; + return { allPaths, pathingMaterialCounts: [] }; + } + // 怪物路径关联材料到分类(扫描用)- 仅includeBoth和onlyPathing模式 if (pathingMode.includeBoth || pathingMode.onlyPathing) { log.info(`${CONSTANTS.LOG_MODULES.MONSTER}开始处理${monsterPaths.length}条怪物路径的材料分类关联...`); @@ -1931,22 +660,73 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames let processedMonsterPaths = monsterPaths; let pathingMaterialCounts = []; - if (normalPaths.length > 0 || monsterPaths.length > 0) { + if (normalPaths.length > 0 || monsterPaths.length > 0 || pathingMode.onlyCategory) { // 优化:一次扫描获取全量材料数量,同时服务于怪物和普通材料 log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] 执行一次全量背包扫描(服务于怪物+普通路径)`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] materialCategoryMap内容:${JSON.stringify(materialCategoryMap)}`); const allMaterialCounts = await MaterialPath(materialCategoryMap); pathingMaterialCounts = allMaterialCounts; + // log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] 扫描返回数据:${JSON.stringify(allMaterialCounts)}`); + // log.info(`${CONSTANTS.LOG_MODULES.PATH}[材料扫描] flat后数据:${JSON.stringify(allMaterialCounts.flat())}`); // 筛选低数量材料(同时生成超量名单) if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 基于全量扫描结果筛选有效材料`); - const filteredMaterials = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap); + const filteredMaterials = filterLowCountMaterials(allMaterialCounts.flat(), materialCategoryMap, pathingMode.onlyCategory); const validMonsterMaterialNames = filteredMaterials.map(m => m.name); if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.MONSTER}[怪物材料] 筛选后有效材料:${validMonsterMaterialNames.join('、')}`); - // 普通材料筛选 + // onlyCategory模式:只扫描,不处理路径 if (pathingMode.onlyCategory) { + // 先读取txt超量名单 + const txtExcessMaterials = loadExcessMaterialsList(); + + // 用本次扫描结果更新超量名单 + const scannedMaterials = allMaterialCounts.flat(); + const updatedExcessMaterials = new Set(txtExcessMaterials); + + log.info(`${CONSTANTS.LOG_MODULES.PATH}[onlyCategory模式] txt超量名单长度:${txtExcessMaterials.length}, 内容:${txtExcessMaterials.join('、')}`); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[onlyCategory模式] updatedExcessMaterials初始长度:${updatedExcessMaterials.size}`); + + // 逐一检查txt超量名单中的每个材料 + txtExcessMaterials.forEach(name => { + const found = scannedMaterials.find(m => m.name === name); + if (found) { + // 扫描到了,检查是否超量 + let rawCount = Number(found.count); + const originalCount = rawCount; + // 矿石类材料需要除以10 + if (specialMaterials.includes(name)) { + rawCount = Math.floor(rawCount / 10); + } + // 用处理后的数量判断是否超量 + if (rawCount >= exceedCount) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[比对] ${name} 原始:${originalCount} 处理后:${rawCount} ≥ ${exceedCount} → 保留在超量名单`); + } else { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[比对] ${name} 原始:${originalCount} 处理后:${rawCount} < ${exceedCount} → 从超量名单剔除`); + updatedExcessMaterials.delete(name); + } + } else { + // 没扫描到,保留在超量名单 + log.info(`${CONSTANTS.LOG_MODULES.PATH}[比对] ${name} 未扫描到 → 保留在超量名单`); + } + }); + + const finalExcessList = Array.from(updatedExcessMaterials); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[onlyCategory模式] 更新后超量名单,共${finalExcessList.length}种:${finalExcessList.join('、')}`); + + // 保存更新后的超量名单(直接覆盖,不合并) + const fullContent = finalExcessList.length > 0 ? `超量名单:${finalExcessList.join(',')}\n` : ""; + file.writeTextSync(CONSTANTS.EXCESS_MATERIALS_PATH, fullContent, false); + log.info(`${CONSTANTS.LOG_MODULES.RECORD}超量名单已保存至 ${CONSTANTS.EXCESS_MATERIALS_PATH},共${finalExcessList.length}种`); + + // 过滤只保留超量名单中的材料 + const filteredByExcess = scannedMaterials.filter(item => finalExcessList.includes(item.name)); + pathingMaterialCounts = [filteredByExcess]; + log.info(`${CONSTANTS.LOG_MODULES.PATH}[onlyCategory模式] 过滤后保留超量材料:${filteredByExcess.map(m => m.name).join('、') || '无'}`); + return { allPaths: [], pathingMaterialCounts }; } + // log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 基于全量扫描结果筛选低数量材料`); const lowCountMaterialsFiltered = filteredMaterials; const flattenedLowCountMaterials = lowCountMaterialsFiltered.flat().sort((a, b) => a.count - b.count); @@ -1955,76 +735,208 @@ async function generateAllPaths(pathingDir, targetResourceNames, cdMaterialNames processedNormalPaths = classifyNormalPathFiles(pathingDir, targetResourceNames, lowCountMaterialNames, cdMaterialNames) .filter(entry => normalPaths.some(n => n.path.replace(/\\/g, '/') === entry.path.replace(/\\/g, '/'))); if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.PATH}[普通材料] 筛选后保留路径 ${processedNormalPaths.length} 条`); + } else if (foodPaths.length > 0) { + // 只有狗粮路径时,也需要初始化超量名单(OCR启动需要) + log.info(`${CONSTANTS.LOG_MODULES.PATH}[狗粮模式] 初始化超量名单`); + excessMaterialNames = ["OCR启动"]; + log.info(`【超量材料更新】共${excessMaterialNames.length}种:${excessMaterialNames.join("、")}`); } - // 路径优先级规则数组 - const PATH_PRIORITIES = [ - // 1. 目标狗粮 - { - source: processedFoodPaths, - filter: e => targetResourceNames.includes(e.resourceName) - }, - // 2. 目标怪物(掉落材料含目标) - { - 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) - }, - // 4. 剩余狗粮 - { - source: processedFoodPaths, - filter: e => !targetResourceNames.includes(e.resourceName) - }, - // 5. 剩余怪物(掉落材料未超量且低数量) - { - source: processedMonsterPaths, - filter: e => { - const materials = monsterToMaterials[e.monsterName] || []; - return !materials.some(mat => targetResourceNames.includes(mat)) && - materials.some(mat => !excessMaterialNames.includes(mat)); - } - }, - // 6. 剩余普通材料 - { - source: processedNormalPaths, - filter: e => !targetResourceNames.includes(e.resourceName) - } - ]; - - // 按优先级合并路径 + // 按TargetresourceName顺序处理路径(优先级1-3按目标顺序,同类型内也按目标顺序) const allPaths = []; - PATH_PRIORITIES.forEach(({ source, filter }, index) => { - const filtered = source.filter(filter); - allPaths.push(...filtered); - log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级${index+1}] 路径 ${filtered.length} 条`); + + // 先处理TargetresourceName中的每个目标,按设置顺序 + targetResourceNames.forEach((targetName, targetIndex) => { + // 确定目标属于哪种类型 + const isFood = isFoodResource(targetName); + const targetMonsterMaterials = monsterToMaterials[targetName] ? [targetName] : []; + + // 1. 目标狗粮(按TargetresourceName顺序) + if (isFood) { + const matchedFoodPaths = processedFoodPaths.filter(e => e.resourceName === targetName); + allPaths.push(...matchedFoodPaths); + if (matchedFoodPaths.length > 0) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[目标${targetIndex + 1}] ${targetName}(狗粮)路径 ${matchedFoodPaths.length} 条`); + } + } + // 2. 目标怪物(按TargetresourceName顺序) + else if (monsterToMaterials[targetName] || materialToMonsters[targetName]) { + const matchedMonsterPaths = processedMonsterPaths.filter(e => { + const materials = monsterToMaterials[e.monsterName] || []; + return materials.includes(targetName); + }); + allPaths.push(...matchedMonsterPaths); + if (matchedMonsterPaths.length > 0) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[目标${targetIndex + 1}] ${targetName}(怪物掉落)路径 ${matchedMonsterPaths.length} 条`); + } + } + // 3. 目标普通材料(按TargetresourceName顺序) + else { + const matchedNormalPaths = processedNormalPaths.filter(e => e.resourceName === targetName); + allPaths.push(...matchedNormalPaths); + if (matchedNormalPaths.length > 0) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[目标${targetIndex + 1}] ${targetName}(普通材料)路径 ${matchedNormalPaths.length} 条`); + } + } }); + + // 剩余非目标路径(优先级4-6,按原有逻辑) + const remainingFoodPaths = processedFoodPaths.filter(e => !targetResourceNames.includes(e.resourceName)); + const remainingMonsterPaths = processedMonsterPaths.filter(e => { + const materials = monsterToMaterials[e.monsterName] || []; + return !materials.some(mat => targetResourceNames.includes(mat)) && + materials.some(mat => !excessMaterialNames.includes(mat)); + }); + const remainingNormalPaths = processedNormalPaths.filter(e => !targetResourceNames.includes(e.resourceName)); + + // 4. 剩余狗粮 + allPaths.push(...remainingFoodPaths); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级4] 剩余狗粮 路径 ${remainingFoodPaths.length} 条`); + + // 5. 剩余怪物 + allPaths.push(...remainingMonsterPaths); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级5] 剩余怪物 路径 ${remainingMonsterPaths.length} 条`); + + // 6. 剩余普通材料 + allPaths.push(...remainingNormalPaths); + log.info(`${CONSTANTS.LOG_MODULES.PATH}[优先级6] 剩余普通材料 路径 ${remainingNormalPaths.length} 条`); + + // 按时间成本排序同一材料的路径(1%到100%) + log.info(`${CONSTANTS.LOG_MODULES.PATH}[时间成本排序] 开始按时间成本排序同一材料的路径...`); + + // 收集时间成本数据 + const pathRecordCache = {}; + const allTimeCostData = collectAllTimeCostData(allPaths, CONSTANTS.RECORD_DIR, CONSTANTS.NO_RECORD_DIR, pathRecordCache); + + // 按材料分组 + const pathsByResource = {}; + allPaths.forEach(entry => { + const resourceKey = entry.monsterName || entry.resourceName; + if (!resourceKey) return; + + if (!pathsByResource[resourceKey]) { + pathsByResource[resourceKey] = []; + } + pathsByResource[resourceKey].push(entry); + }); + + // 对每个材料的路径按时间成本排序 + const sortedPaths = []; + for (const [resourceKey, paths] of Object.entries(pathsByResource)) { + // 为每个路径附加时间成本 + const pathsWithCost = paths.map(entry => { + const pathName = basename(entry.path); + const perTime = calculatePerTime( + entry.resourceName || entry.monsterName, + pathName, + CONSTANTS.RECORD_DIR, + CONSTANTS.NO_RECORD_DIR, + isFoodResource(entry.resourceName || entry.monsterName), + pathRecordCache, + entry.path + ); + return { ...entry, perTime }; + }); + + // 辅助函数:检查路径是否有历史记录(利用已有的pathRecordCache) + const entryHasHistory = (entry) => { + const pathName = basename(entry.path); + const resourceName = entry.resourceName || entry.monsterName; + const isFood = isFoodResource(resourceName); + + // 利用已有的pathRecordCache,避免重复读取文件 + const cacheKey = `${resourceName}_${pathName}`; + const cachedRecords = pathRecordCache[cacheKey]; + + return cachedRecords && cachedRecords.length > 0; + }; + + // 按时间成本排序(三层优先级) + // 优先级1:有具体时间成本的,按数值从小到大排序 + // 优先级2:记录不足但知道时间的,放在有具体时间的后面 + // 优先级3:不知道时间的,默认2分钟(120秒),放在最后 + const DEFAULT_TIME_COST = 120; // 默认2分钟(120秒) + + pathsWithCost.sort((a, b) => { + // 获取排序优先级(1=有具体时间,2=记录不足,3=不知道时间) + const getPriority = (perTime, hasHistory) => { + if (perTime !== null) return 1; // 有具体时间成本 + if (hasHistory) return 2; // 记录不足但知道时间 + return 3; // 不知道时间 + }; + + // 检查是否有历史记录 + const hasHistoryA = entryHasHistory(a); + const hasHistoryB = entryHasHistory(b); + + const priorityA = getPriority(a.perTime, hasHistoryA); + const priorityB = getPriority(b.perTime, hasHistoryB); + + // 优先级不同,按优先级排序 + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + // 优先级相同,按时间成本排序 + if (a.perTime !== null && b.perTime !== null) { + return a.perTime - b.perTime; + } + + // 都是null,保持原顺序 + return 0; + }); + + // 输出排序结果 + if (debugLog) { + log.info(`${CONSTANTS.LOG_MODULES.PATH}[时间成本排序] ${resourceKey} 的路径按时间成本排序:`); + pathsWithCost.forEach((p, idx) => { + const hasHistory = entryHasHistory(p); + let costStr; + let priorityStr; + + if (p.perTime !== null) { + costStr = `${p.perTime.toFixed(2)}秒/单位`; + priorityStr = '优先级1(有具体时间)'; + } else if (hasHistory) { + costStr = '记录不足'; + priorityStr = '优先级2(有历史但不足)'; + } else { + costStr = '无记录(默认120秒)'; + priorityStr = '优先级3(无历史)'; + } + + log.info(` ${idx + 1}. ${basename(p.path)} - ${costStr} - ${priorityStr}`); + }); + } + + sortedPaths.push(...pathsWithCost); + } + + log.info(`${CONSTANTS.LOG_MODULES.PATH}[时间成本排序] 完成,共${sortedPaths.length}条路径`); // log.info(`${CONSTANTS.LOG_MODULES.PATH}[最终路径] 共${allPaths.length}条:${allPaths.map(p => basename(p.path))}`); - return { allPaths, pathingMaterialCounts }; + return { allPaths: sortedPaths, pathingMaterialCounts }; } // ============================================== // 通知工具 // ============================================== /** - * 分块发送通知(保持原有逻辑) + * 分块发送通知(修复为异步函数,确保通知发送完成后再退出) * @param {string} msg - 通知消息 * @param {Function} sendFn - 发送函数 */ -function sendNotificationInChunks(msg, sendFn) { +async function sendNotificationInChunks(msg, sendFn) { if (!notify) return; if (typeof msg !== 'string' || msg.length === 0) return; const chunkSize = CONSTANTS.NOTIFICATION_CHUNK_SIZE; if (msg.length <= chunkSize) { - sendFn(msg); + try { + sendFn(msg); + } catch (e) { + log.debug(`发送通知失败(可能已退出): ${e.message}`); + } return; } @@ -2035,15 +947,82 @@ function sendNotificationInChunks(msg, sendFn) { 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)}`; - sendFn(chunkMsg); - log.info(`${CONSTANTS.LOG_MODULES.MAIN}已发送第${i+1}段通知(${chunkMsg.length}字符)`); + try { + sendFn(chunkMsg); + log.info(`${CONSTANTS.LOG_MODULES.MAIN}已发送第${i+1}段通知(${chunkMsg.length}字符)`); + } catch (e) { + log.debug(`发送第${i+1}段通知失败(可能已退出): ${e.message}`); + } start = end; + await sleep(100); // 每段之间增加短暂延迟,确保发送完成 } } // ============================================== // 结果汇总 // ============================================== +/** + * 保存超量名单到文件 + * @param {string[]} excessList - 超量材料名单 + */ +function saveExcessMaterialsList(excessList) { + try { + const newMaterials = excessList.filter(name => name !== "OCR启动"); + + let existingMaterials = []; + try { + const existingContent = safeReadTextSync(CONSTANTS.EXCESS_MATERIALS_PATH); + const match = existingContent.match(/超量名单:(.*)/); + if (match) { + existingMaterials = match[1].split(',').map(name => name.trim()).filter(name => name); + } + } catch (readError) { + log.debug(`${CONSTANTS.LOG_MODULES.RECORD}超量名单文件不存在或读取失败: ${CONSTANTS.EXCESS_MATERIALS_PATH}`); + } + + const allMaterials = [...new Set([...existingMaterials, ...newMaterials])]; + const fullContent = allMaterials.length > 0 ? `超量名单:${allMaterials.join(',')}\n` : ""; + + const result = file.writeTextSync(CONSTANTS.EXCESS_MATERIALS_PATH, fullContent, false); + if (result) { + const addedCount = newMaterials.filter(m => !existingMaterials.includes(m)).length; + log.info(`${CONSTANTS.LOG_MODULES.RECORD}超量名单已保存至 ${CONSTANTS.EXCESS_MATERIALS_PATH},共${allMaterials.length}种(新增${addedCount}种)`); + } else { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}超量名单保存失败: ${CONSTANTS.EXCESS_MATERIALS_PATH}`); + } + } catch (error) { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}保存超量名单失败:${error.message}`); + log.error(`${CONSTANTS.LOG_MODULES.RECORD}错误堆栈:${error.stack}`); + } +} + +/** + * 读取超量名单文件 + * @returns {string[]} 超量材料名单 + */ +function loadExcessMaterialsList() { + try { + const content = safeReadTextSync(CONSTANTS.EXCESS_MATERIALS_PATH); + if (!content) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}超量名单文件不存在或为空:${CONSTANTS.EXCESS_MATERIALS_PATH}`); + return []; + } + + const match = content.match(/超量名单:(.*)/); + if (!match) { + log.warn(`${CONSTANTS.LOG_MODULES.RECORD}超量名单文件格式错误,未找到"超量名单:"前缀`); + return []; + } + + const materials = match[1].split(',').map(name => name.trim()).filter(name => name); + log.info(`${CONSTANTS.LOG_MODULES.RECORD}读取超量名单成功,共${materials.length}种:${materials.join('、')}`); + return materials; + } catch (error) { + log.error(`${CONSTANTS.LOG_MODULES.RECORD}读取超量名单失败:${error.message}`); + return []; + } +} + /** * 记录最终汇总结果 * @param {string} recordDir - 记录目录 @@ -2057,7 +1036,7 @@ function recordFinalSummary(recordDir, firstScanTime, endTime, totalRunTime, tot const content = `===== 材料收集汇总 ===== 首次扫描时间:${firstScanTime} 末次扫描时间:${endTime} -总耗时:${totalRunTime.toFixed(1)}秒 +总耗时:${formatTime(totalRunTime)} 累计获取材料: ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个`).join('\n')} =========================================\n\n`; @@ -2077,19 +1056,22 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 // 初始化固定资源 const fDialogueRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/F_Dialogue.png"), 1102, 335, 34, 400); const textxRange = { min: 1210, max: 1412 }; - const texttolerance = 2; + const texttolerance = 3; // 目标资源处理 const targetResourceNamesStr = settings.TargetresourceName || ""; - const targetResourceNames = targetResourceNamesStr + const rawTargetResourceNames = targetResourceNamesStr .split(/[,,、 \s]+/) .map(name => name.trim()) .filter(name => name !== ""); + + // 将怪物名转换为最高级材料 + const targetResourceNames = processTargetResourceNames(rawTargetResourceNames); const targetTextCategories = readtargetTextCategories(CONSTANTS.TARGET_TEXT_DIR); // 并行任务:OCR交互(修正版) - const ocrTask = (async () => { + const ocrTask = pathingMode.estimateMode ? null : (async () => { let allTargetTexts = []; for (const categoryName in targetTextCategories) { const targetTexts = targetTextCategories[categoryName]; @@ -2108,39 +1090,48 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 } const getFilteredTargetTexts = () => { + // 首先过滤超量材料 let filtered = allTargetTexts.filter(name => !excessMaterialNames.includes(name)); - if (ocrContext.currentPathType === 'monster') { - // 怪物路径执行时的过滤逻辑: - // 1. 对于怪物材料,只保留: - // - 当前怪物的材料 - // - pathing文件夹中存在且未超量的其他怪物材料 - // 2. 非怪物材料保持不变 - - filtered = filtered.filter(name => { - // 如果不是怪物材料,保留 - if (!materialToMonsters[name]) return true; - - // 如果是怪物材料,检查是否在允许的列表中 - const currentMonsterMaterials = ocrContext.currentTargetMaterials || []; - const pathingMonsterMaterials = Array.from(ocrContext.pathingMonsterMaterials || new Set()); - - // 保留当前怪物的材料或pathing中的怪物材料 - return currentMonsterMaterials.includes(name) || pathingMonsterMaterials.includes(name); - }); - + // 如果不是怪物路径,直接返回过滤结果 + if (ocrContext.currentPathType !== 'monster') { + return filtered; + } + + // 如果取消怪物材料过滤,直接返回过滤结果 + if (noMonsterFilter) { if (debugLog) { - const currentMonsterMaterials = ocrContext.currentTargetMaterials || []; - const pathingMonsterMaterials = Array.from(ocrContext.pathingMonsterMaterials || new Set()); - const additionalMonsterMaterials = pathingMonsterMaterials.filter(mat => - !currentMonsterMaterials.includes(mat) && !excessMaterialNames.includes(mat) - ); - - log.info(`OCR拾取列表(怪物路径):`); - log.info(` - 当前怪物材料:${currentMonsterMaterials.join('、') || '无'}`); - log.info(` - pathing其他怪物材料(未超量):${additionalMonsterMaterials.join('、') || '无'}`); - log.info(` - 非怪物材料:${filtered.filter(name => !materialToMonsters[name]).join('、') || '无'}`); + log.info(`OCR拾取列表(怪物路径,取消过滤):`); + log.info(` - 所有未超量材料:${filtered.join('、') || '无'}`); } + return filtered; + } + + // 怪物路径的过滤逻辑 + filtered = filtered.filter(name => { + // 如果不是怪物材料,保留 + if (!materialToMonsters[name]) return true; + + // 如果是怪物材料,检查是否在允许的列表中 + const currentMonsterMaterials = ocrContext.currentTargetMaterials || []; + const pathingMonsterMaterials = Array.from(ocrContext.pathingMonsterMaterials || new Set()); + + // 保留当前怪物的材料或pathing中的怪物材料 + return currentMonsterMaterials.includes(name) || pathingMonsterMaterials.includes(name); + }); + + // 输出调试信息 + if (debugLog) { + const currentMonsterMaterials = ocrContext.currentTargetMaterials || []; + const pathingMonsterMaterials = Array.from(ocrContext.pathingMonsterMaterials || new Set()); + const additionalMonsterMaterials = pathingMonsterMaterials.filter(mat => + !currentMonsterMaterials.includes(mat) && !excessMaterialNames.includes(mat) + ); + + log.info(`OCR拾取列表(怪物路径):`); + log.info(` - 当前怪物材料:${currentMonsterMaterials.join('、') || '无'}`); + log.info(` - pathing其他怪物材料(未超量):${additionalMonsterMaterials.join('、') || '无'}`); + log.info(` - 非怪物材料:${filtered.filter(name => !materialToMonsters[name]).join('、') || '无'}`); } return filtered; @@ -2186,7 +1177,7 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 // 2. 处理路径相关材料(仅includeBoth和onlyPathing模式) if ((pathingMode.includeBoth || pathingMode.onlyPathing) && (Object.keys(materialCategoryMap).length > 0 || pathingMode.onlyPathing)) { - const pathingFilePaths = readAllFilePaths(CONSTANTS.PATHING_DIR, 0, 3, ['.json']); + const pathingFilePaths = readAllFilePaths(CONSTANTS.PATHING_DIR, 0, CONSTANTS.MAX_PATH_DEPTH, ['.json']); const pathEntries = pathingFilePaths.map(path => { const { materialName, monsterName } = extractResourceNameFromPath(path, cdMaterialNames); return { materialName, monsterName }; @@ -2252,8 +1243,11 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 ); // onlyCategory模式:只扫描,不处理路径和末次扫描,直接结束 - if (pathingMode.onlyCategory) { - log.info(`${CONSTANTS.LOG_MODULES.MAIN}[onlyCategory模式] 扫描完成,直接结束`); + // 但如果同时勾选了测算模式,则跳过此逻辑 + if (pathingMode.onlyCategory && !pathingMode.estimateMode) { + // log.info(`${CONSTANTS.LOG_MODULES.MAIN}[onlyCategory模式] 扫描完成,直接结束`); + // log.info(`${CONSTANTS.LOG_MODULES.MAIN}[onlyCategory模式] 超量名单数据:${JSON.stringify(excessMaterialNames)}`); + saveExcessMaterialsList(excessMaterialNames); state.completed = true; state.cancelRequested = true; return; @@ -2282,6 +1276,14 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 endTimeStr ); + // 测算模式下不执行末次扫描和汇总 + if (pathingMode.estimateMode) { + log.info(`${CONSTANTS.LOG_MODULES.MAIN}[测算模式] 分析完成,直接结束`); + state.completed = true; + state.cancelRequested = true; + return; + } + // 汇总结果 const globalAccumulatedDifferences = processResult.globalAccumulatedDifferences; const foodExpAccumulator = processResult.foodExpAccumulator || {}; @@ -2315,12 +1317,12 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 log.info(`${CONSTANTS.LOG_MODULES.MAIN}\n所有目标收集完成`); log.info(`${CONSTANTS.LOG_MODULES.MAIN}首次扫描时间:${firstScanTime}`); log.info(`${CONSTANTS.LOG_MODULES.MAIN}末次扫描时间:${endTime}`); - log.info(`${CONSTANTS.LOG_MODULES.MAIN}总耗时:${totalRunTime.toFixed(1)}秒`); + log.info(`${CONSTANTS.LOG_MODULES.MAIN}总耗时:${formatTime(totalRunTime)}`); log.info(`${CONSTANTS.LOG_MODULES.MAIN}总累积获取:${JSON.stringify(totalDifferences, null, 2)}`); recordFinalSummary(CONSTANTS.RECORD_DIR, firstScanTime, endTime, totalRunTime, totalDifferences); - let finalMsg = `收集完成\n总耗时:${totalRunTime.toFixed(1)}秒\n累计获取:\n`; + let finalMsg = `收集完成\n总耗时:${formatTime(totalRunTime)}\n累计获取:\n`; Object.entries(totalDifferences).forEach(([n, d]) => { if (n.includes('_EXP')) { finalMsg += ` ${n.replace('_EXP', '')}(EXP): ${d}\n`; @@ -2328,16 +1330,18 @@ ${Object.entries(totalDifferences).map(([name, diff]) => ` ${name}: +${diff}个 finalMsg += ` ${n}: ${d}个\n`; } }); - sendNotificationInChunks(finalMsg, notification.Send); - + await sendNotificationInChunks(finalMsg, notification.Send); })(); - // 并行任务:图像点击 - const imageTask = imageClickBackgroundTask(); - // 执行所有任务 + // 测算模式下,只执行路径任务,不执行 OCR 和图像点击任务 + const imageTask = pathingMode.estimateMode ? null : imageClickBackgroundTask(); + + const tasks = pathingMode.estimateMode ? [pathTask] : [ocrTask, pathTask, imageTask].filter(t => t !== null); + log.info(`${CONSTANTS.LOG_MODULES.MAIN}任务列表已创建,共${tasks.length}个任务`); + try { - await Promise.allSettled([ocrTask, pathTask, imageTask]); + await Promise.allSettled(tasks); } catch (error) { log.error(`${CONSTANTS.LOG_MODULES.MAIN}执行任务时发生错误:${error.message}`); state.cancelRequested = true; diff --git a/repo/js/背包材料统计/manifest.json b/repo/js/背包材料统计/manifest.json index c1d62489a..1e48b97c6 100644 --- a/repo/js/背包材料统计/manifest.json +++ b/repo/js/背包材料统计/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "背包统计采集系统", - "version": "2.62", + "version": "2.67", "bgi_version": "0.55", "description": "可统计背包养成道具、部分食物、素材的数量;根据设定数量、根据材料刷新CD执行挖矿、采集、刷怪等的路径。优势:\n+ 1. 自动判断材料CD,不需要管材料CD有没有好;\n+ 2. 可以随意添加路径,能自动排除低效、无效路径;\n+ 3. 有独立名单识别,不会交互路边的npc或是神像;可自定义识别名单,具体方法看【问题解答】增减识别名单\n+ 4. 有实时的弹窗模块,提供了常见的几种:路边信件、过期物品、月卡、调查;\n+ 5. 可识别爆满的路径材料,自动屏蔽;更多详细内容查看readme.md;可在我的主页下载 路径重命名 工具JS,给路径名批量添加检测码,方便识别。", "saved_files": [ @@ -9,6 +9,7 @@ "history_record/", "overwrite_record/", "latest_record.txt", + "user/", "pathing_record/" ], "authors": [ diff --git a/repo/js/背包材料统计/settings.json b/repo/js/背包材料统计/settings.json index 6318ac746..1ed28f0bf 100644 --- a/repo/js/背包材料统计/settings.json +++ b/repo/js/背包材料统计/settings.json @@ -2,7 +2,7 @@ { "name": "TargetCount", "type": "input-text", - "label": "js目录下默认扫描的文件结构:\n./📁BetterGI/📁User/📁JsScript/📁背包材料统计/\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目标数量,默认1000\n给📁pathing下材料设定的目标数" }, { "name": "TargetresourceName", @@ -12,23 +12,7 @@ { "name": "TimeCost", "type": "input-text", - "label": "----------------------------------\n时间成本:秒\n一单位材料的平均耗时,默认30" - }, - { - "name": "PickCategories", - "type": "multi-checkbox", - "label": "====================\n\n采用的识别名单(默认:全部)\n不勾选,则默认", - "options": [ - "交互", - "宝箱", - "掉落", - "采集" - ] - }, - { - "name": "ExceedCount", - "type": "input-text", - "label": "----------------------------------\n超量阈值(默认:9000),超量的路径材料将也不拾取\n普通材料超量,或者怪物掉落的三个材料都超量,则跳过其路径" + "label": "----------------------------------\n时间成本:百分位数(1-100)\n50代表该材料类型的平均时间成本,值越小越严格\n默认50(只接受前50%的中高效路径)" }, { "name": "notify", @@ -38,15 +22,26 @@ { "name": "noRecord", "type": "checkbox", - "label": "----------------------------------\n取消扫描。默认:否\n勾选将不进行单路径的扫描,但保留运行时间记录\n(推荐路径记录炼成后启用)" + "label": "----------------------------------\n取消扫描。默认:否\n勾选将不进行单路径的背包/摩拉扫描,但保留运行时间记录\n(推荐路径记录炼成后启用)" + }, + { + "name": "noMonsterFilter", + "type": "checkbox", + "label": "----------------------------------\n怪物材料过滤。默认:否\n默认只过滤掉📁pathing中已有的超量材料\n若勾选,只拾取📁pathing已有的未超量材料,不拾取其他材料" + }, + { + "name": "ExceedCount", + "type": "input-text", + "label": "----------------------------------\n超量阈值(默认:9000)\n超量的路径材料将不拾取\n普通材料或者怪物掉落的三个材料都超量,不加载其路径" }, { "name": "Pathing", "type": "multi-checkbox", - "label": "====================\n统计选择:📁pathing下的材料,或【扫描额外的分类】的材料。默认:仅📁pathing材料", + "label": "====================\n统计选择:默认:仅📁pathing材料\n\n📁pathing材料:一般材料和怪物材料路径\n【扫描额外的分类】:背包材料统计,联动生成超量材料\n【测算模式】:可预估执行时间,无记录默认2分钟", "options": [ "📁pathing材料", - "【扫描额外的分类】" + "【扫描额外的分类】", + "【测算模式】" ], "default": [ "📁pathing材料" @@ -75,6 +70,17 @@ "烹饪用食材" ] }, + { + "name": "PickCategories", + "type": "multi-checkbox", + "label": "====================\n\nOCR采用的识别名单(默认:全部)\n不勾选,则默认", + "options": [ + "交互", + "宝箱", + "掉落", + "采集" + ] + }, { "name": "PopupNames", "type": "multi-checkbox", @@ -115,21 +121,21 @@ { "name": "HoldX", "type": "input-text", - "label": "------------------------\n(0,0)———> X 增加\n |\n |\n V Y 增加\n\n翻页拖动点X坐标:0~1920(默认1050)" + "label": "----------------------------------\n(0,0)———> X 增加\n |\n |\n V Y 增加\n\n翻页拖动点X坐标:0~1920(默认1050)" }, { "name": "HoldY", "type": "input-text", - "label": "------------------------\n翻页拖动点Y坐标:0~1080(默认750)" + "label": "----------------------------------\n翻页拖动点Y坐标:0~1080(默认750)" }, { "name": "PageScrollDistance", "type": "input-text", - "label": "------------------------\n拖动距离:(默认711像素点)推荐一次划页稍小于4行材料的距离" + "label": "----------------------------------\n拖动距离:(默认711像素点)推荐一次划页稍小于4行材料的距离" }, { "name": "debugLog", "type": "checkbox", - "label": "------------------------\n调试日志。默认:否\n输出详细的调试信息" + "label": "----------------------------------\n调试日志。默认:否\n输出详细的调试信息" } ] \ No newline at end of file