From 623d1d7c5f97f901cb9593e78e3973bdea2ebdf2 Mon Sep 17 00:00:00 2001 From: mno <718135749@qq.com> Date: Sat, 28 Feb 2026 23:29:03 +0800 Subject: [PATCH] =?UTF-8?q?js=EF=BC=9A=E7=8B=97=E7=B2=AE=E6=89=B9=E5=8F=91?= =?UTF-8?q?&&=E9=87=87=E9=9B=86cd=E7=AE=A1=E7=90=86=20(#2941)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * js:狗粮批发 1.修复少量问题 2.优化readme * js:采集cd管理 整体重构代码 * 修点bug --- repo/js/AAA-Artifacts-Bulk-Supply/README.md | 113 +- repo/js/AAA-Artifacts-Bulk-Supply/main.js | 22 +- repo/js/采集cd管理/README.md | 174 +- repo/js/采集cd管理/assets/materialCdMap.json | 140 + repo/js/采集cd管理/main.js | 4384 ++++++++++-------- repo/js/采集cd管理/manifest.json | 2 +- 6 files changed, 2659 insertions(+), 2176 deletions(-) create mode 100644 repo/js/采集cd管理/assets/materialCdMap.json diff --git a/repo/js/AAA-Artifacts-Bulk-Supply/README.md b/repo/js/AAA-Artifacts-Bulk-Supply/README.md index 5c440d944..51cd0429c 100644 --- a/repo/js/AAA-Artifacts-Bulk-Supply/README.md +++ b/repo/js/AAA-Artifacts-Bulk-Supply/README.md @@ -1,3 +1,5 @@ +# AAA 狗粮批发 + ## 琪零、功能及部分原理说明 1. 根据时间自动选择卡时间和路线,方案具体参考下列说明: - 两条普通路线全富点98交替执行 @@ -12,41 +14,47 @@ 1. 将脚本添加至调度器。 2. 右键点击脚本以修改 JS 自定义配置。 -- **请输入狗粮队伍名称**:改成自己捡狗粮的队伍名字。建议使用迪西雅或e启动奶妈作为行走位,如果需要配置生存位,建议与行走位相同。建议携带:迪西雅(耐肘王,提供移速加成),万叶(路线中有配置万叶吸取狗粮,可提高拾取率) -- **请输入清怪队伍名称**:改成自己的战斗队,清理狗粮路线上的小怪,不填则跳过清怪路线,**需要自行匹配战斗策略。** -- **最短时间间隔**:比昨天延后(默认1分钟)开始卡收尾点和额外点。 -- **最大额外等待时间**:98个点拾取完后,可以选择干等N分钟来卡更多次的超限点 -- **强制收尾路线交替执行**:默认不开,用于一些用户想收集其他的调查材料,比如茶叶等。替换掉(替补收尾路线)内的文件,可以一天收尾狗粮,一天收尾茶叶。 -- **狗粮分解模式**:默认不分解,可选分解成(经验瓶/摩拉) -- **账户名称**:**单账号无需更改。** 多账号使用时建多个配置组,一个账号使用一个配置组,填入游戏账号名称(自己取名),就可以按各自名称分别记录运行信息。 -- **芙宁娜** :狗粮队支持自动使用白芙作为生存位(汐姐姐逼的,非必要不要用,会慢很多),当你狗粮队伍中一定要含有芙宁娜时勾选,将确保狗粮路线时芙宁娜为白芙状态,清怪和脚本结束时切回黑芙。 +- **请输入狗粮队伍名称**:改成自己捡狗粮的队伍名字。建议使用迪西雅或e启动奶妈作为行走位,如果需要配置生存位,建议与行走位相同。建议携带:迪西雅(耐肘王,提供移速加成),万叶(路线中有配置万叶吸取狗粮,可提高拾取率)。 +- **请输入清怪队伍名称**:改成自己的战斗队,清理狗粮路线上的小怪,不填则跳过清怪路线,**需要自行匹配战斗策略。** +- **最短时间间隔**:比昨天延后(默认1分钟)开始卡收尾点和额外点。 +- **最大额外等待时间**:98个点拾取完后,可以选择干等N分钟来卡更多次的超限点。 +- **强制收尾路线交替执行**:默认不开,用于一些用户想收集其他的调查材料,比如茶叶等。替换掉(替补收尾路线)内的文件,可以一天收尾狗粮,一天收尾茶叶。 +- **狗粮分解模式**:默认不分解,可选分解成(经验瓶/摩拉)。 +- **账户名称**:**单账号无需更改。** 多账号使用时建多个配置组,一个账号使用一个配置组,填入游戏账号名称(自己取名),就可以按各自名称分别记录运行信息。 +- **芙宁娜**:狗粮队支持自动使用白芙作为生存位(汐姐姐逼的,非必要不要用,会慢很多),当你狗粮队伍中一定要含有芙宁娜时勾选,将确保狗粮路线时芙宁娜为白芙状态,清怪和脚本结束时切回黑芙。 ## 二、调查点类型收益 -| 类型 | 等效1星圣遗物 | 平均经验值 | 平均摩拉 | 计算公式 | -| ---- | ------------- | ---------- | -------- | ---------------------------------- | +| 类型 | 等效1星圣遗物 | 平均经验值 | 平均摩拉 | 计算公式 | +|---|---|---|---|---| | 穷点 | 1.35个 | 567 EXP | 0 | 65%出1星(420EXP)+35%出2星(840EXP) | | 富点 | 1.5个 | 630 EXP | 200 | 50%出1个1星(420EXP)/2个1星(840EXP) | ## 三、调查点机制与刷新CD -### **误区** -❌ *错误认知*:很多人误以为调查点的 **24小时CD** 是从 **按F调查** 时开始计算的。 +### 误区 -✅ **实际机制**: -- **游戏程序只会加载以玩家为圆心,固定半径内地图资源,调查点也在此时被激活,产生发光特效(CD倒计时未结束不会被激活)即数据出现在内存中时CD倒计时自动开始。** -- **每天4点刷新可互动的调查点上限100个,调查满上限后,进入未加载区域,调查点将不再被激活。** 在最后范围内,已经激活的调查点不会消失,可以继续调查。 -- **与是否调查/拾取无关**!即使不调查或下线,CD仍会继续倒计时。 例:今天你10点路过某区域,24小时CD的调查点开始发光随后离开,15点再回来拾取调查点。明天此调查点会在10点CD倒计时结束,可以再次拾取。 -- **单日多次加载无效**:若在CD未结束前重复加载调查点(无论拾取与否),都不会重置计时。 +❌ *错误认知*:很多人误以为调查点的 **24小时CD** 是从 **按F调查** 时开始计算的。 + +### 实际机制 + +✅ **核心原理**: +- **游戏程序只会加载以玩家为圆心,固定半径内地图资源,调查点也在此时被激活,产生发光特效(CD倒计时未结束不会被激活)即数据出现在内存中时CD倒计时自动开始。** +- **每天4点刷新可互动的调查点上限100个,调查满上限后,进入未加载区域,调查点将不再被激活。** 在最后范围内,已经激活的调查点不会消失,可以继续调查。 + +### 重要特性 + +- **与是否调查/拾取无关**!即使不调查或下线,CD仍会继续倒计时。例:今天你10点路过某区域,24小时CD的调查点开始发光随后离开,15点再回来拾取调查点。明天此调查点会在10点CD倒计时结束,可以再次拾取。 +- **单日多次加载无效**:若在CD未结束前重复加载调查点(无论拾取与否),都不会重置计时。 ## 四、路线配置与收益 ### 1. 主要路线参数 -| 路线名称 | 调查点 | 富点数量 | 穷点数量 | 总经验值 | 总摩拉 | 运行时间 | 98上限点 富% | -| -------- | ------ | -------- | -------- | -------- | ------ | -------- | ------------- | +| 路线名称 | 调查点 | 富点数量 | 穷点数量 | 总经验值 | 总摩拉 | 运行时间 | 98上限点富% | +|---|---|---|---|---|---|---|---| | 富A路线 | 162 | 105 | 57 | 99,099 | 22,600 | 69分钟 | 98/98 (100%) | | 富B路线 | 135 | 105 | 30 | 83,790 | 21,000 | 53分钟 | 98/98 (100%) | | ABE A线 | 137 | 33 | 104 | 77,742 | 6,600 | 41分钟 | 27/98 (27.5%) | @@ -57,44 +65,45 @@ `总经验 = (富点数×1.5 + 穷点数×1.35) × 420` -路径详情: -[https://www.kdocs.cn/wo/sl/v13uXscL](https://www.kdocs.cn/wo/sl/v13uXscL) +路径详情:[金山文档 - 路径详情](https://www.kdocs.cn/wo/sl/v13uXscL) ## 五、路径结构 - **激活**:提前加载触发CD倒计时,后续可自由安排时间拾取,避免“现用现触发”的等待问题,无需担心CD同步问题 - **准备**:少数点位需要提前转变为发光点,不拾取调查点!!! 部分优质富点有小怪镇守,清除以后才按路径顺序拾取,此时不拾取调查点!!! - **普通A**:至多98个调查点,这部分每天交叉跑,不再需要卡时间 - **普通B**:至多98个调查点,这部分每天交叉跑,不再需要卡时间 - **收尾**:附近区域超出100以外的调查点 - **额外**:不受满上限影响消失,又叫超限点 +- **激活**:提前加载触发CD倒计时,后续可自由安排时间拾取,避免"现用现触发"的等待问题,无需担心CD同步问题。 +- **准备**:少数点位需要提前转变为发光点,不拾取调查点!部分优质富点有小怪镇守,清除以后才按路径顺序拾取,此时不拾取调查点! +- **普通A**:至多98个调查点,这部分每天交叉跑,不再需要卡时间。 +- **普通B**:至多98个调查点,这部分每天交叉跑,不再需要卡时间。 +- **收尾**:附近区域超出100以外的调查点。 +- **额外**:不受满上限影响消失,又叫超限点。 ## 六、队伍配置建议 + - **根据自己CPU配置选择合适的移速**:电脑性能较差以至于收益远不如及格线时,降低队伍移速(使用较矮小的体型,不使用双风,四风原典,迪西雅天赋等)。 -- **行走位:优先迪希雅;或芭芭拉等 E启动奶** -- **钟剑迪希雅**:耐肘王/白天6-18点移速+10%(js自定义配置可选调时间到白天,全程100%吃满天赋) -- **万叶:建议携带,路径内配有E吸狗粮策略(不带不影响运行)**,琴可作为备选项,不要同时携带琴和万叶 -- **双风BUFF**:移速+10% 体力消耗-15% +- **行走位**:优先迪希雅;或芭芭拉等 E启动奶。 +- **钟剑迪希雅**:耐肘王/白天6-18点移速+10%(js自定义配置可选调时间到白天,全程100%吃满天赋)。 +- **万叶**:建议携带,路径内配有E吸狗粮策略(不带不影响运行),琴可作为备选项,不要同时携带琴和万叶。 +- **双风BUFF**:移速+10%,体力消耗-15%。 ## 七、其他常见问题 - - **为什么不捡东西了** :检查自己的分辨率是否为1080p(更高或更低都不保证能正常运行),检查是否修改过按键 - - **为什么比预期少了很多** : - - 1. 可能运行了其他路线等误触了调查点 - - 2. 首次运行建议比前一天运行任何其他狗粮更晚,否则可能存在部分点位未刷新 - - 3. 电脑性能较差,容易跑偏/跑过头,建议降低画质等,并关闭不必要的其他程序 - - **这个js好慢,不像abe四十分钟就跑完了**:那就用abe - - **想要捡狗粮的时候也能捡其他东西** :去锄地一条龙js中获取相关物品图片放在本js的assets/targetitems中 - - **想要测作者怎么办** :来q群1057307824测测莫酱(有其他问题也行) - - 不看github issue,只接受通过qq反馈 - - **莫酱全家桶(部分)**: - - 日常使用 - - 锄地一条龙:一站式解决自动化锄地,支持只拾取狗粮 - - AAA狗粮批发:自动卡时间拿狗粮,收益最大化 - - AAA狗粮联机团购:联机获取更多狗粮收益 - - 采集cd管理:管理采集路线并自动优化 - - 锁定四星教官:锁定四星教官,支持筛选只要初始3和只要带充能 - - 只要大瓶:尽可能将狗粮分解成大瓶,起个好看的作用 - - 妙妙小工具 - - 性能测试:测测你的电脑性能 - - 食材加工极速版:与采集cd管理中相同的食材加工 - - 更多妙妙小工具可以加群获取 \ No newline at end of file + +- **为什么不捡东西了**:检查自己的分辨率是否为1080p(更高或更低都不保证能正常运行),检查是否修改过按键。 +- **为什么比预期少了很多**: + 1. 可能运行了其他路线等误触了调查点。 + 2. 首次运行建议比前一天运行任何其他狗粮更晚,否则可能存在部分点位未刷新。 + 3. 电脑性能较差,容易跑偏/跑过头,建议降低画质等,并关闭不必要的其他程序。 +- **这个js好慢,不像abe四十分钟就跑完了**:那就用abe。 +- **想要捡狗粮的时候也能捡其他东西**:去锄地一条龙js中获取相关物品图片放在本js的assets/targetItems中。 +- **想要测作者怎么办**:来q群1057307824测测莫酱(有其他问题也行)。 +- 不看github issue,只接受通过qq反馈。 +- **莫酱全家桶(部分)**: + - 日常使用 + - 锄地一条龙:一站式解决自动化锄地,支持只拾取狗粮 + - AAA狗粮批发:自动卡时间拿狗粮,收益最大化 + - AAA狗粮联机团购:联机获取更多狗粮收益 + - 采集cd管理:管理采集路线并自动优化 + - 锁定四星教官:锁定四星教官,支持筛选只要初始3和只要带充能 + - 只要大瓶:尽可能将狗粮分解成大瓶,起个好看的作用 + - 妙妙小工具 + - 性能测试:测测你的电脑性能 + - 食材加工极速版:与采集cd管理中相同的食材加工 + - 更多妙妙小工具可以加群获取 \ No newline at end of file diff --git a/repo/js/AAA-Artifacts-Bulk-Supply/main.js b/repo/js/AAA-Artifacts-Bulk-Supply/main.js index f849c5450..2d6576f3c 100644 --- a/repo/js/AAA-Artifacts-Bulk-Supply/main.js +++ b/repo/js/AAA-Artifacts-Bulk-Supply/main.js @@ -3,7 +3,7 @@ let artifactPartyName = settings.artifactPartyName || "狗粮";//狗粮队伍名 let combatPartyName = settings.combatPartyName;//清怪队伍名称 let minIntervalTime = settings.fastMode ? 10 - : Number(settings.minIntervalTime || 1); + : (parseInt(settings.minIntervalTime) || 1); let maxWaitingTime = settings.maxWaitingTime || 0;//最大额外等待时间(分钟) let forceAlternate = settings.forceAlternate;//强制交替 let onlyActivate = settings.onlyActivate;//只运行激活额外和收尾 @@ -12,14 +12,14 @@ let keep4Star = settings.keep4Star;//保留四星 let autoSalvage = settings.autoSalvage;//启用自动分解 let notify = settings.notify;//启用通知 let accountName = settings.accountName || "默认账户";//账户名 -let TMthreshold = +settings.TMthreshold || 0.9;//拾取阈值 +let tmThreshold = +settings.TMthreshold || 0.9;//拾取阈值 //文件路径 const DeleteButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/DeleteButton.png")); const AutoAddButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/AutoAddButton.png")); const ConfirmButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/ConfirmButton.png")); -const DestoryButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/DestoryButton.png")); -const MidDestoryButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/DestoryButton.png"), 900, 600, 500, 300); +const DestroyButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/DestoryButton.png")); +const MidDestroyButtonRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/DestoryButton.png"), 900, 600, 500, 300); const decomposeRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/decompose.png")); const quickChooseRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/quickChoose.png")); @@ -27,7 +27,7 @@ const confirmRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/ const doDecomposeRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/doDecompose.png")); const doDecompose2Ro = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/doDecompose2.png")); -const outDatedRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/ConfirmButton.png"), 760, 700, 100, 100); +const outdatedRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RecognitionObject/ConfirmButton.png"), 760, 700, 100, 100); const scrollRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/拾取滚轮.png"), 1017, 496, 1093 - 581, 581 - 496); const normalPathA = settings.fastMode ? "" : "assets/ArtifactsPath/普通98点1号线"; @@ -224,7 +224,7 @@ async function readRecord(accountName) { lastRunDate: "1970/01/01", lastActivateTime: new Date("1970-01-01T20:00:00.000Z"), lastRunEndingRoute: "收尾额外A", - records: new Array(33550336).fill(""), + records: new Array(1000).fill(""), version: "" }; @@ -389,7 +389,7 @@ async function processArtifacts(times = 1) { async function decomposeArtifacts() { keyPress("B"); - if (await findAndClick(outDatedRo, true, 1500)) { + if (await findAndClick(outdatedRo, true, 1500)) { log.info("检测到过期物品弹窗,处理"); await sleep(1000); } @@ -537,7 +537,7 @@ async function processArtifacts(times = 1) { await genshin.returnMainUi(); await sleep(250); keyPress("B"); - if (await findAndClick(outDatedRo)) { + if (await findAndClick(outdatedRo)) { log.info("检测到过期物品弹窗,处理"); await sleep(1000); } @@ -573,13 +573,13 @@ async function processArtifacts(times = 1) { } await sleep(600); // 点击摧毁 - if (!await findAndClick(DestoryButtonRo)) { + if (!await findAndClick(DestroyButtonRo)) { await genshin.returnMainUi(); return; } await sleep(600); // 弹出页面点击摧毁 - if (!await findAndClick(MidDestoryButtonRo)) { + if (!await findAndClick(MidDestroyButtonRo)) { await genshin.returnMainUi(); return; } @@ -1265,7 +1265,7 @@ async function recognizeAndInteract() { 30 ); - recognitionObject.Threshold = TMthreshold; + recognitionObject.Threshold = tmThreshold; recognitionObject.InitTemplate(); result = gameRegion.find(recognitionObject); if (result.isExist()) { diff --git a/repo/js/采集cd管理/README.md b/repo/js/采集cd管理/README.md index 0650fadcb..af1dc9afa 100644 --- a/repo/js/采集cd管理/README.md +++ b/repo/js/采集cd管理/README.md @@ -3,99 +3,135 @@ --- ### 一、**📌 脚本定位** -1. 这是一个自动帮你管理“采集路线冷却时间(CD)”的工具。 +1. 这是一个自动帮你管理"采集路线冷却时间(CD)"的工具。 2. 它会按你设定的顺序,自动运行不同采集路线(比如挖矿、采特产),并跳过还没刷新的路线。 3. 使用模板匹配拾取,功耗低,不易误触调查点,自带背包满识别并与采集联动(满背包材料加入黑名单并不计入路线效率计算)。 -### 三、**🚀 快速上手** -1. 准备路线文件 - - 前往脚本仓库订阅需要的地图追踪,打开bgi文件夹,User/AutoPathing文件夹下就是你订阅的地图追踪(食材采集推荐使用食材栏目下的提瓦特食材一条龙,内置部分路线信息)。 +--- -2. 极简模式,最简单的使用方式,将你需要的地图追踪文件全部复制到js文件夹(User/JsScript/采集cd管理)下的pathing中,无需考虑子文件夹层数,塞进去即可,也可尝试使用mklink等自动关联 - - 将js添加至配置组,右键js选择自定义配置,勾选仅刷新自定义配置,路径组数量填0,直接运行一次js - - 找到优先采集材料,按要求填写每天需要采集的材料和数量 - - 根据材料和路径特点,配置合适的队伍并将队伍名称填写在自定义配置中的优先采集材料使用的配队名称 - - 启动js,将自动寻找相关路径,采够需要的材料或所有相关路线都被使用后js将终止 - - 填写的优先采集材料是每日(以四点为界)的数量,长期运行时,js会记录各个路线获取的数量与耗时,并据此优化,先选择效率更高的路线 - - 特别的,锻造矿指代任意的水晶块,紫晶块,萃凝晶,虹滴晶,填写锻造矿*160即可满足每日锻造需求 +### 二、**🚀 快速上手** -3. 更进一步 - - 一个配队不能搞定所有的采集路线怎么办,是时候启用路径组了 - - 路径组的作用是将路线分组运行,分出不同的优先级和配队 - - 在pathing文件夹中,将路线分到不同的文件夹,第一层的每个文件夹都可供路径组选择 - - 勾选仅刷新自定义配置,路径组数量填你需要的数量,直接运行一次js - - 再次打开自定义配置,为每个路径组选择对应的文件夹和使用的配队,并按要求配置其他项目 - - 此外,优先采集执行时也会优先使用路径组指定的配置,因此你完全可以路径组配置后不用,只用来指定配队,临界效率填个100即可在事实上禁用该路径组的正常运行 - - 正常使用路径组时,将会依次执行各个路径组,前一个路径组所有路线都未刷新或低于临界效率等不可用时,将会进入下一个路径组 - - 定时终止:按要求填写定时终止,js将提前结束采集并等待到预定时间,可用于卡4点 - - 不同配置组并不共用配置与路径组信息,只会根据账户名共用cd信息,因此你可以在不同德配置组中使用本js,设置不同的多个路径组与终止时间,实现合理分配采集时间 - - cd类型可参考js文件夹中的表格 +
+1. 准备路线文件 -4. 采集队伍推荐 - - 根据自己想采集的物品(部分路线强制需要某些角色)进行配队。 - - - 当需要采集禽肉/兽肉等时,最好携带:绮良良等天赋不会惊动肉类动物的角色 - - - 当需要采集晶蝶/螃蟹/青蛙等,最好携带:早柚等天赋不会惊动其他动物的角色 - - - (详情请搜索B站/米游社攻略,或自行查阅角色天赋与游戏图鉴) - - - 当采集绯樱绣球/琉鳞石时,必须携带雷系角色,推荐雷法:丽莎/八重神子等(选择一个即可) - - - 采集路线有明确指定的角色也必须携带(例如路线中指定了“草神”) - - - 推荐携带一个血牛迪希雅,路径追踪行走位设置迪希雅,不容易在采集途中死亡。 - - - 更多详细对应采集角色请参考路线的readme,或询问群友。 - - - 示例: - - - 队伍名称:迪西娅+八重神子+草神+琴 - - - 文件夹名称:特产1 - - - 该组可以采集:电气水晶/冰雾花,绯樱绣球/琉鳞石,路线中指定需要草神采集的,蒲公英,以及其他所有无指定的采集。 - - - ⚠️但请注意区分物品CD时间,例如上述冰雾花刷新时间与其他区域特产CD不同,尽量不要放在同一个分组,具体刷新时间请参考本脚本自带的Excel。 +- 前往脚本仓库订阅需要的地图追踪,打开bgi文件夹,User/AutoPathing文件夹下就是你订阅的地图追踪(食材采集推荐使用食材栏目下的提瓦特食材一条龙,内置部分路线信息)。 + +
+ +
+2. 极简模式 + +最简单的使用方式,将你需要的地图追踪文件全部复制到js文件夹(User/JsScript/采集cd管理)下的pathing中,无需考虑子文件夹层数,塞进去即可,也可尝试使用mklink等自动关联。 + +- 将js添加至配置组,右键js选择自定义配置,勾选仅刷新自定义配置,路径组数量填0,直接运行一次js +- 找到优先采集材料,按要求填写每天需要采集的材料和数量 +- 根据材料和路径特点,配置合适的队伍并将队伍名称填写在自定义配置中的优先采集材料使用的配队名称 +- 启动js,将自动寻找相关路径,采够需要的材料或所有相关路线都被使用后js将终止 +- 填写的优先采集材料是每日(以四点为界)的数量,长期运行时,js会记录各个路线获取的数量与耗时,并据此优化,先选择效率更高的路线 +- 特别的,锻造矿指代任意的水晶块,紫晶块,萃凝晶,虹滴晶,填写锻造矿*160即可满足每日锻造需求 + +
+ +
+3. 更进一步 + +- 一个配队不能搞定所有的采集路线怎么办,是时候启用路径组了 +- 路径组的作用是将路线分组运行,分出不同的优先级和配队 +- 在pathing文件夹中,将路线分到不同的文件夹,第一层的每个文件夹都可供路径组选择 +- 勾选仅刷新自定义配置,路径组数量填你需要的数量,直接运行一次js +- 再次打开自定义配置,为每个路径组选择对应的文件夹和使用的配队,并按要求配置其他项目 +- 此外,优先采集执行时也会优先使用路径组指定的配置,因此你完全可以路径组配置后不用,只用来指定配队,临界效率填个100即可在事实上禁用该路径组的正常运行 +- 正常使用路径组时,将会依次执行各个路径组,前一个路径组所有路线都未刷新或低于临界效率等不可用时,将会进入下一个路径组 +- 定时终止:按要求填写定时终止,js将提前结束采集并等待到预定时间,可用于卡4点 +- 不同配置组并不共用配置与路径组信息,只会根据账户名共用cd信息,因此你可以在不同的配置组中使用本js,设置不同的多个路径组与终止时间,实现合理分配采集时间 +- cd类型可参考js文件夹中的表格 + +
+ +
+4. 采集队伍推荐 + +根据自己想采集的物品(部分路线强制需要某些角色)进行配队。 + +- 当需要采集禽肉/兽肉等时,最好携带:绮良良等天赋不会惊动肉类动物的角色 +- 当需要采集晶蝶/螃蟹/青蛙等,最好携带:早柚等天赋不会惊动其他动物的角色 +- (详情请搜索B站/米游社攻略,或自行查阅角色天赋与游戏图鉴) +- 当采集绯樱绣球/琉鳞石时,必须携带雷系角色,推荐雷法:丽莎/八重神子等(选择一个即可) +- 采集路线有明确指定的角色也必须携带(例如路线中指定了"草神") +- 推荐携带一个血牛迪希雅,路径追踪行走位设置迪希雅,不容易在采集途中死亡。 +- 更多详细对应采集角色请参考路线的readme,或询问群友。 + +**示例:** +- 队伍名称:迪希雅+八重神子+草神+琴 +- 文件夹名称:特产1 +- 该组可以采集:电气水晶/冰雾花,绯樱绣球/琉鳞石,路线中指定需要草神采集的,蒲公英,以及其他所有无指定的采集。 + +⚠️ **注意**:请注意区分物品CD时间,例如上述冰雾花刷新时间与其他区域特产CD不同,尽量不要放在同一个分组,具体刷新时间请参考本脚本自带的Excel。 + +
+ +--- ### 三、**📁 文件结构** - - main.js:主程序。 - - settings.json:用于展示配置组的自定义配置面板,不影响使用。 - - pathing/ 文件夹:存放地图追踪文件。 - - blacklists/ 文件夹:保存各个账户名的黑名单物品信息,可以在这里手动编辑,注意需要符合json格式 - - record/ 文件夹:保存各个账户名的运行记录与cd信息 +- main.js:主程序。 +- settings.json:用于展示配置组的自定义配置面板,不影响使用。 +- pathing/ 文件夹:存放地图追踪文件。 +- blacklists/ 文件夹:保存各个账户名的黑名单物品信息,可以在这里手动编辑,注意需要符合json格式 +- record/ 文件夹:保存各个账户名的运行记录与cd信息 + +--- ### 四、**⚙️ 其他说明** -1. 伪造日志(不影响游戏) - - 生成的日志可以被日志分析识别,方便查看路线运行情况。 +1. **伪造日志**(不影响游戏) + - 生成的日志可以被日志分析识别,方便查看路线运行情况。 -2. 跳过路线运行后的坐标校验 +2. **跳过路线运行后的坐标校验** - 推荐勾选,当前0.55版本BGI在6.3地图进行坐标校验存在BUG会导致无法记录CD。 - - 勾选后,部分劣质路线,或因意外导致的导致卡死也将强制记录CD,可避免重跑。 + - 勾选后,部分劣质路线,或因意外导致的卡死也将强制记录CD,可避免重跑。 -3. 支持运行途中定时加工食材如面粉等,参考自定义配置使用 +3. **食材加工** + - 支持运行途中定时加工食材如面粉等,参考自定义配置使用 -4. **想要测作者怎么办** :来q群1057307824测测莫酱(有其他问题也行) - -5. **茶包版小广告**:茶包版bgi具有许多公版bgi没有的功能,想要测测茶包也可以加上面的群聊 +--- -6. **莫酱全家桶(部分)**: - - 日常使用 - - 锄地一条龙:一站式解决自动化锄地,支持只拾取狗粮 - - AAA狗粮批发:自动卡时间拿狗粮,收益最大化 - - AAA狗粮联机团购:联机获取更多狗粮收益 - - 采集cd管理:管理采集路线并自动优化 - - 锁定四星教官:锁定四星教官,支持筛选只要初始3和只要带充能 - - 只要大瓶:尽可能将狗粮分解成大瓶,起个好看的作用 - - 妙妙小工具 - - 性能测试:测测你的电脑性能 - - 食材加工极速版:与采集cd管理中相同的食材加工 - - 更多妙妙小工具可以加群获取 +### 五、**📞 联系方式** +- **QQ群**:1057307824 + - 测测莫酱(有其他问题也行) + - 茶包版bgi具有许多公版bgi没有的功能,想要测测茶包也可以加群 + +--- + +### 六、**📦 莫酱全家桶(部分)** +- 日常使用 + - 锄地一条龙:一站式解决自动化锄地,支持只拾取狗粮 + - AAA狗粮批发:自动卡时间拿狗粮,收益最大化 + - AAA狗粮联机团购:联机获取更多狗粮收益 + - 采集cd管理:管理采集路线并自动优化 + - 锁定四星教官:锁定四星教官,支持筛选只要初始3和只要带充能 + - 只要大瓶:尽可能将狗粮分解成大瓶,起个好看的作用 +- 妙妙小工具 + - 性能测试:测测你的电脑性能 + - 食材加工极速版:与采集cd管理中相同的食材加工 + - 更多妙妙小工具可以加群获取 + +--- + +### 七、**📋 更新日志** -### 五、**⚙️ 更新日志**
-📋 点击查看历史更新 +点击查看历史更新 -### 2026/2/26 -1. 规范变量声明 -2. 修正学习螃蟹技能 +#### 2026/2/28 +1. 重构代码结构 -### 2026/2/26 +#### 2026/2/26 1. 无f时滚轮改为仅在识别到滚轮图标时触发 -### 2026/2/18 +#### 2026/2/18 1. 对食材加工过程中可能出现的道具数量超过上限进行处理 -### 2026/2/17 +#### 2026/2/17 1. 增加通知优先采集阶段和路径组切换 2. 优化部分日志和展示的自定义配置 3. 更新日志回归 diff --git a/repo/js/采集cd管理/assets/materialCdMap.json b/repo/js/采集cd管理/assets/materialCdMap.json new file mode 100644 index 000000000..a7d60a0f3 --- /dev/null +++ b/repo/js/采集cd管理/assets/materialCdMap.json @@ -0,0 +1,140 @@ +{ + "46h特产": { + "小灯草": "46小时刷新", + "嘟嘟莲": "46小时刷新", + "落落莓": "46小时刷新", + "塞西莉亚花": "46小时刷新", + "慕风蘑菇": "46小时刷新", + "蒲公英籽": "46小时刷新", + "钩钩果": "46小时刷新", + "风车菊": "46小时刷新", + "霓裳花": "46小时刷新", + "清心": "46小时刷新", + "琉璃袋": "46小时刷新", + "琉璃百合": "46小时刷新", + "夜泊石": "46小时刷新", + "绝云椒椒": "46小时刷新", + "星螺": "46小时刷新", + "石珀": "46小时刷新", + "清水玉": "46小时刷新", + "海灵芝": "46小时刷新", + "鬼兜虫": "46小时刷新", + "绯樱绣球": "46小时刷新", + "鸣草": "46小时刷新", + "珊瑚真珠": "46小时刷新", + "晶化骨髓": "46小时刷新", + "血斛": "46小时刷新", + "天云草实": "46小时刷新", + "幽灯蕈": "46小时刷新", + "沙脂蛹": "46小时刷新", + "月莲": "46小时刷新", + "帕蒂沙兰": "46小时刷新", + "树王圣体菇": "46小时刷新", + "圣金虫": "46小时刷新", + "万相石": "46小时刷新", + "悼灵花": "46小时刷新", + "劫波莲": "46小时刷新", + "赤念果": "46小时刷新", + "苍晶螺": "46小时刷新", + "海露花": "46小时刷新", + "柔灯铃": "46小时刷新", + "子探测单元": "46小时刷新", + "湖光铃兰": "46小时刷新", + "幽光星星": "46小时刷新", + "虹彩蔷薇": "46小时刷新", + "初露之源": "46小时刷新", + "浪沫羽鳃": "46小时刷新", + "灼灼彩菊": "46小时刷新", + "肉龙掌": "46小时刷新", + "青蜜莓": "46小时刷新", + "枯叶紫英": "46小时刷新", + "微光角菌": "46小时刷新", + "云岩裂叶": "46小时刷新", + "琉鳞石": "46小时刷新", + "冬凌草": "46小时刷新", + "松珀香": "46小时刷新", + "月落银": "46小时刷新", + "便携轴承": "46小时刷新", + "霜盏花": "46小时刷新", + "冰雾花花朵": "46小时刷新", + "奇异的「牙齿」": "46小时刷新" + }, + "12h素材": { + "兽肉": "12小时刷新", + "禽肉": "12小时刷新", + "神秘的肉": "12小时刷新", + "鱼肉": "12小时刷新", + "鳗肉": "12小时刷新", + "螃蟹": "12小时刷新", + "蝴蝶翅膀": "12小时刷新", + "青蛙": "12小时刷新", + "发光髓": "12小时刷新", + "蜥蜴尾巴": "12小时刷新", + "晶核": "12小时刷新", + "鳅鳅宝玉": "12小时刷新" + }, + "4点刷新": { + "盐": "1次4点刷新", + "胡椒": "1次4点刷新", + "洋葱": "1次4点刷新", + "牛奶": "1次4点刷新", + "番茄": "1次4点刷新", + "卷心菜": "1次4点刷新", + "土豆": "1次4点刷新", + "小麦": "1次4点刷新", + "稻米": "1次4点刷新", + "虾仁": "1次4点刷新", + "豆腐": "1次4点刷新", + "杏仁": "1次4点刷新", + "发酵果实汁": "1次4点刷新", + "咖啡豆": "1次4点刷新", + "秃秃豆": "1次4点刷新" + }, + "0点刷新": { + "甜甜花": "1次0点刷新", + "胡萝卜": "1次0点刷新", + "蘑菇": "1次0点刷新", + "松茸": "1次0点刷新", + "松果": "1次0点刷新", + "金鱼草": "1次0点刷新", + "莲蓬": "1次0点刷新", + "薄荷": "1次0点刷新", + "鸟蛋": "1次0点刷新", + "树莓": "1次0点刷新", + "白萝卜": "1次0点刷新", + "苹果": "1次0点刷新", + "日落果": "1次0点刷新", + "竹笋": "1次0点刷新", + "海草": "1次0点刷新", + "堇瓜": "1次0点刷新", + "星蕈": "1次0点刷新", + "墩墩桃": "1次0点刷新", + "须弥蔷薇": "1次0点刷新", + "香辛果": "1次0点刷新", + "枣椰": "1次0点刷新", + "泡泡桔": "1次0点刷新", + "汐藻": "1次0点刷新", + "茉洁草": "1次0点刷新", + "久雨莲": "1次0点刷新", + "颗粒果": "1次0点刷新", + "烛伞蘑菇": "1次0点刷新", + "澄晶实": "1次0点刷新", + "红果果菇": "1次0点刷新", + "白灵果": "1次0点刷新", + "夏槲果": "1次0点刷新", + "宿影花": "1次0点刷新", + "马尾": "1次0点刷新", + "苦种": "1次0点刷新", + "烬芯花": "1次0点刷新", + "烈焰花花蕊": "1次0点刷新", + "铁块": "1次0点刷新", + "白铁块": "2次0点刷新", + "星银矿石": "2次0点刷新", + "电气水晶": "2次0点刷新", + "水晶块": "3次0点刷新", + "紫晶块": "3次0点刷新", + "萃凝晶": "3次0点刷新", + "虹滴晶": "3次0点刷新", + "沉玉仙茗": "24小时刷新" + } +} diff --git a/repo/js/采集cd管理/main.js b/repo/js/采集cd管理/main.js index d01786a64..8ca63d6ba 100644 --- a/repo/js/采集cd管理/main.js +++ b/repo/js/采集cd管理/main.js @@ -1,107 +1,1807 @@ -/* ===== 强制模板匹配拾取(BEGIN) ===== */ +/* ===== 1. 自定义配置 ===== */ +const timeMoveUp = Math.round((settings.timeMove || 1000) * 0.45); +const timeMoveDown = Math.round((settings.timeMove || 1000) * 0.55); +const accountName = settings.infoFileName || "默认账户"; +const operationMode = settings.operationMode || "执行任务(若不存在索引文件则自动创建)"; +const disableJsons = settings.disableJsons || ""; +let processingIngredient = settings.processingIngredient; +let findFInterval = Math.max(16, Math.min(200, parseInt(settings.findFInterval) || 100)); +let checkInterval = +settings.checkInterval || 50; +let groupCount; +/* ===== 2. 使用的模板和识别对象 ===== */ +const mainUiRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/MainUI.png"), 0, 0, 150, 150); +const fullRoi = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/itemFull.png"), 0, 0, 1920, 1080); +const FiconRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/F_Dialogue.png"), 1102, 335, 34, 400); +FiconRo.Threshold = 0.9; +FiconRo.InitTemplate(); +const scrollRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/拾取滚轮.png"), 1017, 496, 1093 - 581, 581 - 496); + +/* ===== 3. 全局通用常量 ===== */ +const targetItemPath = "assets/targetItems"; +const recordFolder = "record"; +const rollingDelay = 32; +const pickupDelay = 100; +const MAX_PICKUP_DAYS = 30; +const cookInterval = 95 * 60 * 1000; +const settimeInterval = 10 * 60 * 1000; + +/* ===== 4. 全局通用变量 ===== */ +let currentParty = ''; let targetItems = []; let blacklist = []; let blacklistSet = new Set(); let gameRegion; let state = { running: true }; state.runPickupLog = []; // 本次路线运行中拾取/交互的物品明细 -const rollingDelay = 32; -const pickupDelay = 100; -const timeMoveUp = Math.round((settings.timeMove || 1000) * 0.45); -const timeMoveDown = Math.round((settings.timeMove || 1000) * 0.55); -const targetItemPath = "assets/targetItems"; -const mainUITemplate = file.ReadImageMatSync("assets/MainUI.png"); -const itemFullTemplate = file.ReadImageMatSync("assets/itemFull.png"); -const fIcontemplate = file.ReadImageMatSync("assets/F_Dialogue.png"); -const accountName = settings.infoFileName || "默认账户"; -let currentParty = ''; - -// 定义目标文件夹路径和记录文件路径 -const recordFolder = "record"; // 存储记录文件的文件夹路径 -const defaultTimeStamp = "2023-10-13T00:00:00.000Z"; // 固定的时间戳 let pickupRecordFile; -const MAX_PICKUP_DAYS = 30; - -// 从 settings 中读取用户配置,并设置默认值 -const userSettings = { - operationMode: settings.operationMode || "执行任务(若不存在索引文件则自动创建)", - pathGroup1CdType: settings.pathGroup1CdType || "", - pathGroup2CdType: settings.pathGroup2CdType || "", - pathGroup3CdType: settings.pathGroup3CdType || "", - otherPathGroupsCdTypes: settings.otherPathGroupsCdTypes || "", - partyNames: settings.partyNames || "", - skipTimeRanges: settings.skipTimeRanges || "", - infoFileName: settings.infoFileName || "默认账户", - disableJsons: settings.disableJsons || "" -}; - -let processingIngredient = settings.processingIngredient; - let firstCook = true; let firstsettime = true; let lastCookTime = new Date(); let lastsettimeTime = new Date(); let lastMapName = ""; - -// 解析禁用名单 let disableArray = []; -if (userSettings.disableJsons) { - let tmp = userSettings.disableJsons.split(';'); +if (disableJsons) { + let tmp = disableJsons.split(';'); for (let k = 0; k < tmp.length; k++) { let s = tmp[k].trim(); if (s) disableArray[disableArray.length] = s; } } - -// 将 partyNames 分割并存储到一个数组中 -const partyNamesArray = userSettings.partyNames.split(";").map(name => name.trim()); - -// 新增一个数组 pathGroupCdType,存储每个路径组的 cdtype 信息 -const pathGroupCdType = [ - userSettings.pathGroup1CdType, - userSettings.pathGroup2CdType, - userSettings.pathGroup3CdType -]; - -// 如果 otherPathGroupsCdTypes 不为空,将其分割为数组并添加到 pathGroupCdType 中 -if (userSettings.otherPathGroupsCdTypes) { - pathGroupCdType.push(...userSettings.otherPathGroupsCdTypes.split(";")); -} - -// 当infoFileName为空时,将其改为由其他自定义配置决定的一个字符串 -if (!userSettings.infoFileName) { - userSettings.infoFileName = [ - userSettings.pathGroup1CdType, - userSettings.pathGroup2CdType, - userSettings.pathGroup3CdType, - userSettings.otherPathGroupsCdTypes, - ].join("."); -} - -let findFInterval = (+settings.findFInterval || 100); -if (findFInterval < 16) { - findFInterval = 16; -} -if (findFInterval > 200) { - findFInterval = 200; -} let lastRoll = new Date(); -let checkDelay = Math.round(findFInterval / 2); - let Foods = []; +let folderNames; +let partyNames; +let subFolderName; +let subFolderPath; +let recordFilePath; +let name2Other; +let alias2Names; -const FiconRo = RecognitionObject.TemplateMatch(fIcontemplate, 1102, 335, 34, 400); -FiconRo.Threshold = 0.95; -FiconRo.InitTemplate(); - -const mainUiRo = RecognitionObject.TemplateMatch(mainUITemplate, 0, 0, 150, 150); -const scrollRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/拾取滚轮.png"), 1017, 496, 1093 - 581, 581 - 496); - -let checkInterval = +settings.checkInterval || 50; +/* ===== 5. 待定分区(后续手动分类) ===== */ +let materialCdMap = {}; (async function () { dispatcher.AddTrigger(new RealtimeTimer("AutoSkip")); - /* ===== 零基构建 settings.json(BEGIN) ===== */ + // ==================== 构建 settings.json ==================== + if (!await buildSettingsJson()) { + return; + } + // ==================== 初始化设置和记录文件 ==================== + await initializeSetup(); + + // ==================== 优先级材料前置采集 ==================== + await processPriorityItems(); + + // ==================== 路径组循环 ==================== + await processPathGroups(); + +})(); + +/** + * 识别并交互函数 + * 该函数会持续运行,识别游戏中的 F 图标并进行交互,同时处理背包满的情况 + * + * @returns {Promise} 无返回值,函数会一直运行直到 state.running 为 false + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - state: 状态对象,包含 running 标志和 runPickupLog 日志数组 + * - blacklistSet: 黑名单集合,用于过滤不需要交互的物品 + * - targetItems: 目标物品数组 + * - lastRoll: 上次滚动时间 + * - timeMoveUp: 向上移动时间 + * - timeMoveDown: 向下移动时间 + * - pickupDelay: 拾取延迟时间 + * - rollingDelay: 滚动延迟时间 + * + * @依赖辅助函数: + * - findFIcon: 寻找 F 图标函数 + * - hasScroll: 检查是否存在拾取滚轮图标函数 + * - performTemplateMatch: 执行模板匹配函数 + * - checkItemFullAndOCR: 检查背包是否满并进行 OCR 识别函数 + * - sleep: 延迟函数 + */ +async function recognizeAndInteract() { + let lastcenterYF = 0, lastItemName = "", thisMoveUpTime = 0, lastMoveDown = 0; + gameRegion = captureGameRegion(); + let lastCheckItemFull = new Date(); + let checkTask = null; + + while (state.running) { + gameRegion.dispose(); + gameRegion = captureGameRegion(); + + if (new Date() - lastCheckItemFull > 2500 && !checkTask) { + lastCheckItemFull = new Date(); + checkTask = checkItemFullAndOCR(); + } + + const centerYF = await findFIcon(); + + if (!centerYF) { + if (new Date() - lastRoll >= 200) { + lastRoll = new Date(); + if (await hasScroll()) { + await keyMouseScript.runFile(`assets/滚轮下翻.json`); + } + } + if (checkTask) { + try { await checkTask; } + catch (e) { log.error('背包满检查异常:', e); } + finally { checkTask = null; } + } + continue; + } + let itemName = null; + itemName = await performTemplateMatch(centerYF); + if (itemName) { + if (Math.abs(lastcenterYF - centerYF) <= 20 && lastItemName === itemName) { + await sleep(160); + lastcenterYF = -20; + lastItemName = null; + if (checkTask) { + try { await checkTask; } + catch (e) { log.error('背包满检查异常:', e); } + finally { checkTask = null; } + } + continue; + } + if (!blacklistSet.has(itemName)) { + keyPress("F"); + log.info(`交互或拾取:"${itemName}"`); + /* >>> 提到最前 begin >>> */ + const idx = targetItems.findIndex(it => it.itemName === itemName); + if (idx > 0) { + const [it] = targetItems.splice(idx, 1); + targetItems.unshift(it); + } + /* <<< 提到最前 end <<< */ + state.runPickupLog.push(itemName); + + lastcenterYF = centerYF; + lastItemName = itemName; + await sleep(pickupDelay); + } + } else { + lastItemName = ""; + } + const currentTime = Date.now(); + if (currentTime - lastMoveDown > timeMoveUp) { + await keyMouseScript.runFile(`assets/滚轮下翻.json`); + if (thisMoveUpTime === 0) thisMoveUpTime = currentTime; + if (currentTime - thisMoveUpTime >= timeMoveDown) { + lastMoveDown = currentTime; + thisMoveUpTime = 0; + } + } else { + await keyMouseScript.runFile(`assets/滚轮上翻.json`); + } + await sleep(rollingDelay); + if (checkTask) { + try { await checkTask; } + catch (e) { log.error('背包满检查异常:', e); } + finally { checkTask = null; } + } + } +} + +/** + * 寻找 F 图标函数 + * 在游戏区域中查找 F 图标,并返回其中心 Y 坐标 + * + * @returns {Promise} 返回 F 图标的中心 Y 坐标,如果未找到则返回 null + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - FiconRo: F 图标的识别对象 + * - findFInterval: 识别间隔时间 + * + * @依赖辅助函数: + * - sleep: 延迟函数 + */ +async function findFIcon() { + try { + const r = gameRegion.find(FiconRo); + if (r.isExist()) return Math.round(r.y + r.height / 2); + } catch (e) { + log.error(`findFIcon:${e.message}`); + } + await sleep(findFInterval); + return null; +} + +/** + * 执行模板匹配函数 + * 在指定的 Y 坐标位置,对不同宽度的区域进行模板匹配,识别物品名称 + * + * @param {number} centerYF - F 图标的中心 Y 坐标 + * @returns {Promise} 返回识别到的物品名称,如果未识别到则返回 null + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - targetItems: 目标物品数组,包含物品的名称和识别对象 + * + * @依赖辅助函数: + * 无 + */ +async function performTemplateMatch(centerYF) { + /* 一次性切 6 种宽度(0-5 汉字) */ + const regions = []; + for (let cn = 0; cn <= 6; cn++) { // 0~5 共 6 档 + const w = 12 + 28 * Math.min(cn, 5) + 2; + regions[cn] = gameRegion.DeriveCrop(1219, centerYF - 15, w, 30); + } + + try { + for (const it of targetItems) { + const cnLen = Math.min( + [...it.itemName].filter(c => c >= '\u4e00' && c <= '\u9fff').length, + 5 + ); // 0-5 + + if (regions[cnLen].find(it.roi).isExist()) { + return it.itemName; + } + } + } catch (e) { + log.error(`performTemplateMatch: ${e.message}`); + } finally { + regions.forEach(r => r.dispose()); + } + return null; +} + +/** + * 检查是否为主界面函数 + * 检查游戏是否处于主界面状态 + * + * @returns {Promise} 返回是否为主界面,true 表示是主界面,false 表示不是 + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - mainUiRo: 主界面的识别对象 + * - state: 状态对象,包含 running 标志 + * - findFInterval: 识别间隔时间 + * + * @依赖辅助函数: + * - sleep: 延迟函数 + */ +async function isMainUI() { + for (let i = 0; i < 1 && state.running; i++) { + if (!gameRegion) gameRegion = captureGameRegion(); + try { + if (gameRegion.find(mainUiRo).isExist()) { + return true; + } + } catch (e) { + log.error(`isMainUI:${e.message}`); + } + await sleep(findFInterval); + } + return false; +} + +/** + * 检查背包是否满并进行 OCR 识别函数 + * 检查游戏背包是否已满,并通过 OCR 识别物品名称,将满的物品加入黑名单 + * + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - fullRoi: 背包满的识别对象 + * - targetItems: 目标物品数组 + * - blacklist: 黑名单数组 + * - blacklistSet: 黑名单集合 + * + * @依赖辅助函数: + * - loadBlacklist: 加载黑名单函数 + */ +async function checkItemFullAndOCR() { + try { + if (!gameRegion.find(fullRoi).isExist()) return; + } catch (e) { return; } + const TEXT_X = 560, TEXT_Y = 450, TEXT_W = 800, TEXT_H = 170; + let ocrText = null; + try { + const list = gameRegion.findMulti(RecognitionObject.ocr(TEXT_X, TEXT_Y, TEXT_W, TEXT_H)); + if (list.count) { + let longest = list[0]; + for (let i = 1; + i < list.count; + i++) if (list[i].text.length > longest.text.length) longest = list[i]; + ocrText = longest.text.replace(/[^\u4e00-\u9fa5]/g, ''); + } + } catch (e) { + log.error(`OCR:${e.message}`); + } if (!ocrText) return; + log.info(`背包满OCR:${ocrText}`); + + function calcMatchRatio(cnPart, txt) { + if (!cnPart || !txt) return 0; + const len = cnPart.length; + let maxMatch = 0; + for (let i = 0; i <= txt.length - len; i++) { + let match = 0; + for (let j = 0; j < len; j++) { + if (txt[i + j] === cnPart[j]) match++; + maxMatch = Math.max(maxMatch, match); + } + } + return maxMatch / len; + } + const ratioMap = new Map(); + for (const it of targetItems) { + const candNames = [it.itemName, ...(it.otherName || [])]; + let maxRatioThisItem = 0; + for (const name of candNames) { + const ratio = calcMatchRatio(name.replace(/[^\u4e00-\u9fa5]/g, ''), ocrText); + if (ratio > maxRatioThisItem) maxRatioThisItem = ratio; + } + if (maxRatioThisItem > 0.75) { + const oldMax = ratioMap.get(it.itemName) || 0; + if (maxRatioThisItem > oldMax) ratioMap.set(it.itemName, maxRatioThisItem); + } + } + if (ratioMap.size === 0) return; + const maxRatio = Math.max(...ratioMap.values()); + const names = [...ratioMap.entries()].filter(([, r]) => r === maxRatio).map(([n]) => n).sort(); + log.warn(`背包满,黑名单加入:${names.join('、')}(${(maxRatio * 100).toFixed(1)}%)`); + for (const n of names) { + blacklistSet.add(n); + blacklist.push(n); + } + await loadBlacklist(true); +} + +/** + * 加载目标物品图片函数 + * 加载指定路径下的目标物品图片,解析图片名称和阈值,并创建识别对象 + * + * @returns {Promise} 返回加载的物品数组,每个物品包含模板、名称、识别对象等信息 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * - readFolder: 读取文件夹函数 + */ +async function loadTargetItems() { + const targetItemPath = "assets/targetItems/"; + + const items = await readFolder(targetItemPath, false); + + for (const it of items) { + try { + it.template = file.ReadImageMatSync(it.fullPath); + it.itemName = it.fileName.replace(/\.png$/i, ''); + it.roi = RecognitionObject.TemplateMatch(it.template); + + /* ---------- 1. 解析小括号阈值 ---------- */ + const match = it.fullPath.match(/[((](.*?)[))]/); + const itsThreshold = (match => { + if (!match) return 0.9; + const v = parseFloat(match[1]); + return !isNaN(v) && v >= 0 && v <= 1 ? v : 0.9; + })(match); + it.roi.Threshold = itsThreshold; + it.roi.InitTemplate(); + + /* ---------- 2. 解析中括号内容 + 纯中文过滤 ---------- */ + const otherNames = new Set(); + + // 一次性扫描完整路径里的所有 [] + for (const m of it.fullPath.matchAll(/\[(.*?)\]/g)) { + const pure = (m[1] || '').replace(/[^\u4e00-\u9fff]/g, '').trim(); + if (pure) otherNames.add(pure); + } + + // 若 itemName 本身含非中文,也生成纯中文别名 + const namePure = it.itemName.replace(/[^\u4e00-\u9fff]/g, '').trim(); + if (namePure && namePure !== it.itemName) otherNames.add(namePure); + + it.otherName = Array.from(otherNames); + + } catch (error) { + log.error(`[loadTargetItems] ${it.fullPath}: ${error.message}`); + } + } + return items; +} + +/** + * 加载黑名单函数 + * 从文件中加载黑名单,并将其合并到内存中的黑名单数组和集合中 + * + * @param {boolean} writeBack - 是否将黑名单写回文件 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - accountName: 账户名称 + * - blacklist: 黑名单数组 + * - blacklistSet: 黑名单集合 + * - settings: 设置对象 + * - disableArray: 禁用关键词数组 + * + * @依赖辅助函数: + * 无 + */ +async function loadBlacklist(writeBack) { + try { + const raw = await file.readText(`blacklists/${accountName}.json`); + blacklist = [...new Set([...blacklist, ...JSON.parse(raw)])]; + } catch { /* 文件不存在就跳过 */ } + blacklistSet = new Set(blacklist); + + // 仅把 blacklist 中的中文部分合并到内存中的 settings.disableJsons + const chineseParts = blacklist + .map(name => name.replace(/[^\u4e00-\u9fa5]/g, '')) + .filter(Boolean); + + const existing = settings.disableJsons + ? settings.disableJsons.split(';').map(s => s.trim()).filter(Boolean) + : []; + + const merged = [...new Set([...existing, ...chineseParts])].sort().join(';'); + settings.disableJsons = merged; + + if (writeBack) { + await file.writeText(`blacklists/${accountName}.json`, JSON.stringify(blacklist, null, 2), false); + } + // 实时同步禁用关键词数组 + disableArray = settings.disableJsons.split(';').map(s => s.trim()).filter(Boolean); +} + +/** + * 伪造日志函数 + * 用于在日志中伪造脚本或地图追踪的开始和结束信息 + * + * @param {string} name - 脚本或地图追踪的名称 + * @param {boolean} isJs - 是否为JS脚本 + * @param {boolean} isStart - 是否为开始信息 + * @param {number} duration - 持续时间(毫秒),仅在伪造结束信息时有效 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * - sleep: 延迟函数 + */ +async function fakeLog(name, isJs, isStart, duration) { + await sleep(1); + const currentTime = Date.now(); + // 参数检查 + if (typeof name !== 'string') { + log.error("参数 'name' 必须是字符串类型!"); + return; + } + if (typeof isJs !== 'boolean') { + log.error("参数 'isJs' 必须是布尔型!"); + return; + } + if (typeof isStart !== 'boolean') { + log.error("参数 'isStart' 必须是布尔型!"); + return; + } + if (typeof currentTime !== 'number' || !Number.isInteger(currentTime)) { + log.error("参数 'currentTime' 必须是整数!"); + return; + } + if (typeof duration !== 'number' || !Number.isInteger(duration)) { + log.error("参数 'duration' 必须是整数!"); + return; + } + + // 将 currentTime 转换为 Date 对象并格式化为 HH:mm:ss.sss + const date = new Date(currentTime); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + const milliseconds = String(date.getMilliseconds()).padStart(3, '0'); + const formattedTime = `${hours}:${minutes}:${seconds}.${milliseconds}`; + + // 将 duration 转换为分钟和秒,并保留三位小数 + const durationInSeconds = duration / 1000; // 转换为秒 + const durationMinutes = Math.floor(durationInSeconds / 60); + const durationSeconds = (durationInSeconds % 60).toFixed(3); // 保留三位小数 + + // 使用四个独立的 if 语句处理四种情况 + if (isJs && isStart) { + // 处理 isJs = true 且 isStart = true 的情况 + const logMessage = `正在伪造js开始的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 开始执行JS脚本: "${name}"`; + log.debug(logMessage); + } + if (isJs && !isStart) { + // 处理 isJs = true 且 isStart = false 的情况 + const logMessage = `正在伪造js结束的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------`; + log.debug(logMessage); + } + if (!isJs && isStart) { + // 处理 isJs = false 且 isStart = true 的情况 + const logMessage = `正在伪造地图追踪开始的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 开始执行地图追踪任务: "${name}"`; + log.debug(logMessage); + } + if (!isJs && !isStart) { + // 处理 isJs = false 且 isStart = false 的情况 + const logMessage = `正在伪造地图追踪结束的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------`; + log.debug(logMessage); + } +} + +/** + * 获取文件名函数 + * 从文件路径中提取文件名 + * + * @param {string} filePath - 文件路径 + * @returns {string} 返回文件名 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * 无 + */ +function basename(filePath) { + const lastSlashIndex = filePath.lastIndexOf('\\'); // 或者使用 '/',取决于你的路径分隔符 + return filePath.substring(lastSlashIndex + 1); +} + +/** + * 读取文件夹函数 + * 递归读取文件夹及其子文件夹中的文件 + * + * @param {string} folderPath - 文件夹路径 + * @param {boolean} onlyJson - 是否只读取 JSON 文件 + * @returns {Promise} 返回文件信息数组,每个元素包含文件路径、文件名等信息 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * 无 + */ +async function readFolder(folderPath, onlyJson) { + const folderStack = [folderPath]; + const files = []; + + while (folderStack.length > 0) { + const currentPath = folderStack.pop(); + const filesInSubFolder = file.ReadPathSync(currentPath); // 同步读取 + const subFolders = []; + + for (const filePath of filesInSubFolder) { + if (file.IsFolder(filePath)) { + subFolders.push(filePath); + continue; + } + + if (filePath.endsWith('.js')) continue; // 跳过 js + + // 仅 json 模式 + if (onlyJson) { + if (!filePath.endsWith('.json')) continue; + + let description = ''; + try { + // 同步读文本,避免 async 传染 + const txt = file.readTextSync(filePath); + const parsed = JSON.parse(txt); + description = parsed?.info?.description ?? ''; + } catch { + /* 读盘或解析失败就留空串 */ + } + + const fileName = filePath.split('\\').pop(); + const folderPathArray = filePath.split('\\').slice(0, -1); + + files.push({ + fullPath: filePath, + fileName, + folderPathArray, + description + }); + continue; + } + + const fileName = filePath.split('\\').pop(); + const folderPathArray = filePath.split('\\').slice(0, -1); + files.push({ fullPath: filePath, fileName, folderPathArray }); + } + + // 子文件夹按原顺序入栈(深度优先) + folderStack.push(...subFolders.reverse()); + } + + return files; +} + +/** + * 带缓存的配队切换函数 + * 如果目标配队与 currentParty 一致则跳过;否则真正切换并更新 currentParty。 + * + * @param {string} partyName - 期望切换到的配队名称 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - currentParty: 当前配队名称 + * + * @依赖辅助函数: + * - genshin.returnMainUi: 返回主界面函数 + * - genshin.switchParty: 切换配队函数 + * - genshin.tpToStatueOfTheSeven: 传送到七天神像函数 + */ +async function switchPartyIfNeeded(partyName) { + if (!partyName) { // 空名直接回主界面 + await genshin.returnMainUi(); + return; + } + + if (partyName === currentParty) { // 缓存命中,跳过切换 + await genshin.returnMainUi(); + return; + } + + /* 真正切换 */ + try { + log.info(`正在尝试切换至配队「${partyName}」`); + let success = await genshin.switchParty(partyName); + if (!success) { // 第一次失败,去神像再试一次 + log.info('切换失败,前往七天神像重试'); + await genshin.tpToStatueOfTheSeven(); + success = await genshin.switchParty(partyName); + } + + if (success) { // 切换成功,更新缓存 + currentParty = partyName; + log.info(`已切换至配队「${partyName}」并更新缓存`); + } else { + throw new Error('两次切换均失败'); + } + } catch (e) { + log.error('队伍切换失败,可能处于联机模式或其他不可切换状态'); + notification.error('队伍切换失败,可能处于联机模式或其他不可切换状态'); + await genshin.returnMainUi(); + } +} + +/** + * 检查当前时间是否处于限制时间内或即将进入限制时间 + * + * @param {string} timeRule - 时间规则字符串,格式如 "8, 8-11, 23:11-23:55" + * @param {number} [threshold=5] - 接近限制时间的阈值(分钟) + * @returns {Promise} - 如果处于限制时间内或即将进入限制时间,则返回 true,否则返回 false + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * - genshin.tpToStatueOfTheSeven: 传送到七天神像函数 + * - sleep: 延迟函数 + */ +async function isTimeRestricted(timeRule, threshold = 5) { + if (!timeRule) return false; + + // 兼容中英文逗号、冒号 + const ruleClean = timeRule + .replace(/,/g, ',') + .replace(/:/g, ':'); + + const now = new Date(); + const currentHour = now.getHours(); + const currentMinute = now.getMinutes(); + const currentTotal = currentHour * 60 + currentMinute; + + for (const seg of ruleClean.split(',').map(s => s.trim())) { + if (!seg) continue; + + let startStr, endStr; + if (seg.includes('-')) { + [startStr, endStr] = seg.split('-').map(s => s.trim()); + } else { + startStr = endStr = seg.trim(); + } + + const parseTime = (str, isEnd) => { + if (str.includes(':')) { + const [h, m] = str.split(':').map(Number); + return { h, m }; + } + // 单独小时:start 8→8:00,end 8→8:59 + const h = Number(str); + return { h, m: isEnd ? 59 : 0 }; + }; + + const start = parseTime(startStr, false); + const end = parseTime(endStr, true); + + const startTotal = start.h * 60 + start.m; + const endTotal = end.h * 60 + end.m; + + const effectiveEnd = endTotal >= startTotal ? endTotal : endTotal + 24 * 60; + + if ( + (currentTotal >= startTotal && currentTotal < effectiveEnd) || + (currentTotal + 24 * 60 >= startTotal && currentTotal + 24 * 60 < effectiveEnd) + ) { + log.warn("处于限制时间内"); + return true; + } + + let nextStartTotal = startTotal; + if (nextStartTotal <= currentTotal) nextStartTotal += 24 * 60; + const waitMin = nextStartTotal - currentTotal; + if (waitMin > 0 && waitMin <= threshold) { + log.warn(`接近限制时间,等待 ${waitMin} 分钟`); + await genshin.tpToStatueOfTheSeven(); + await sleep(waitMin * 60 * 1000); + return true; + } + } + return false; +} + +/** + * 食材加工主函数,用于自动前往指定地点进行食材的加工 + * + * 该函数会根据 Foods 数组中的食材名称,依次查找并制作对应的料食材 + * 支持调味品类食材(直接在“食材加工”界面查找) + * + * @returns {Promise} 无返回值,执行完所有加工流程后退出 + * + * @依赖全局变量: + * - Foods: 要加工的食材数组 + * - checkInterval: 食材加工中的识别间隔(毫秒) + * + * @依赖辅助函数: + * - findPNG: 查找图片函数 + * - clickPNG: 点击图片函数 + * - collectCraftedItems: 领取加工产物函数 + * - handleCraftingError: 处理食材加工错误函数 + * - doCraft: 执行食材加工流程函数 + * - genshin.returnMainUi: 返回主界面函数 + */ +async function ingredientProcessing() { + const targetFoods = [ + "面粉", "兽肉", "鱼肉", "神秘的肉", "黑麦粉", "奶油", "熏禽肉", + "黄油", "火腿", "糖", "香辛料", "酸奶油", "蟹黄", "果酱", + "奶酪", "培根", "香肠" + ]; + if (Foods.length == 0) { log.error("未选择要加工的食材"); return; } + const taskList = Foods.map((name) => `${name}`).join(","); + const tasks = Foods.map((name) => ({ + name, + done: false + })); + log.info(`本次加工食材:${taskList}`); + const stove = "蒙德炉子"; + log.info(`正在前往${stove}进行食材加工`); + + try { + let filePath = `assets/${stove}.json`; + await pathingScript.runFile(filePath); + } catch (error) { + log.error(`执行 ${stove} 路径时发生错误`); + return; + } + + const res1 = await findPNG("交互烹饪锅"); + if (res1) { + keyPress("F"); + } else { + log.warn("烹饪按钮未找到,正在寻找……"); + let attempts = 0; + const maxAttempts = 3; + let foundInRetry = false; + while (attempts < maxAttempts) { + log.info(`第${attempts + 1}次尝试寻找烹饪按钮`); + keyPress("W"); + const res2 = await findPNG("交互烹饪锅"); + if (res2) { + keyPress("F"); + foundInRetry = true; + break; + } else { + attempts++; + await sleep(500); + } + } + if (!foundInRetry) { + log.error("多次未找到烹饪按钮,放弃"); + return; + } + } + await clickPNG("食材加工"); + + + + /* ===== 2. 两轮扫描 ===== */ + // 进入界面先领取一次 + await collectCraftedItems(); + + let lastSuccess = true; + for (let i = 0; i < tasks.length; i++) { + if (!targetFoods.includes(tasks[i].name)) continue; + + const retry = lastSuccess ? 5 : 1; + if (await clickPNG(`${tasks[i].name}1`, retry)) { + log.info(`${tasks[i].name}已找到`); + await doCraft(i, tasks); + tasks[i].done = true; + lastSuccess = true; // 记录成功 + } else { + lastSuccess = false; // 记录失败 + } + } + + const remain1 = tasks.filter(t => !t.done).map(t => `${t.name}`).join(",") || "无"; + log.info(`剩余待加工食材:${remain1}`); + + if (remain1 === "无") { + log.info("所有食材均已加工完毕,跳过第二轮扫描"); + await genshin.returnMainUi(); + return; + } + + const rg = captureGameRegion(); + const foodItems = []; + try { + for (const flag of ['已加工0个', '已加工1个']) { + const mat = file.ReadImageMatSync(`assets/RecognitionObject/${flag}.png`); + const res = rg.findMulti(RecognitionObject.TemplateMatch(mat)); + for (let k = 0; k < res.count; ++k) { + foodItems.push({ x: res[k].x, y: res[k].y }); + } + mat.dispose(); + } + } finally { rg.dispose(); } + + log.info(`识别到${foodItems.length}个加工中食材`); + + for (const item of foodItems) { + click(item.x, item.y); await sleep(1 * checkInterval); + click(item.x, item.y); await sleep(3 * checkInterval); + + for (let round = 0; round < 5; round++) { + const rg = captureGameRegion(); + try { + let hit = false; + + /* 直接扫 tasks,模板已挂在 task.ro */ + for (const task of tasks) { + if (task.done) continue; + if (!targetFoods.includes(task.name)) continue; + + /* 首次使用再加载,避免重复 IO */ + if (!task.ro) { + task.ro = RecognitionObject.TemplateMatch( + file.ReadImageMatSync(`assets/RecognitionObject/${task.name}2.png`) + ); + task.ro.Threshold = 0.9; + task.ro.InitTemplate(); + } + + if (!task.ro) { + log.warn(`${task.name}2.png 不存在,跳过识别`); + continue; + } + const res = rg.find(task.ro); + if (res.isExist()) { + log.info(`${task.name}已找到`); + await doCraft(tasks.indexOf(task), tasks); + task.done = true; + hit = true; + break; // 一轮只处理一个 + } + } + + if (hit) break; // 本轮已命中,跳出 round + } finally { + rg.dispose(); + } + } + } + + const remain = tasks.filter(t => !t.done).map(t => `${t.name}`).join(",") || "无"; + log.info(`剩余待加工食材:${remain}`); + + + + await genshin.returnMainUi(); +} + +/** + * 领取加工产物并处理相关提示 + * + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - checkInterval: 食材加工中的识别间隔(毫秒) + * + * @依赖辅助函数: + * - clickPNG: 点击图片函数 + * - findPNG: 查找图片函数 + * - sleep: 延迟函数 + */ +async function collectCraftedItems() { + if (await clickPNG("全部领取", 3)) { + let dowait = false; + await sleep(4 * checkInterval); + while (await findPNG("道具数量超过上限")) { + await sleep(checkInterval * 4); + log.info("识别到道具数量超过上限,等待消失"); + dowait = true; + } + if (dowait) { + await sleep(10 * checkInterval) + } + await clickPNG("点击空白区域继续"); + await findPNG("食材加工2"); + await sleep(100); + } +} + +/** + * 处理食材加工中的错误情况 + * + * @param {string} errorType - 错误类型:队列已满、材料不足、已不能持有更多 + * @param {string} itemName - 食材名称 + * @param {boolean} removeFromList - 是否从Foods列表中移除该食材 + * @returns {boolean} - 是否发生错误 + * + * @依赖全局变量: + * - Foods: 要加工的食材数组 + * + * @依赖辅助函数: + * - findPNG: 查找图片函数 + * - clickPNG: 点击图片函数 + * - sleep: 延迟函数 + */ +async function handleCraftingError(errorType, itemName, removeFromList) { + if (await findPNG(errorType, 1)) { + log.warn(`检测到${itemName}${errorType},等待图标消失`); + while (await findPNG(errorType, 1)) { + log.warn(`检测到${itemName}${errorType},等待图标消失`); + await sleep(300); + } + if (await clickPNG("全部领取", 3)) { + await clickPNG("点击空白区域继续"); + await findPNG("食材加工2"); + await sleep(100); + } + if (removeFromList) { + const index = Foods.findIndex(f => f === itemName); + if (index !== -1) { + Foods.splice(index, 1); + } + } + return true; + } + return false; +} + +/** + * 执行食材加工流程 + * + * @param {number} index - 食材在tasks数组中的索引 + * @param {Array} tasks - 任务数组 + * @returns {boolean} - 是否加工成功 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * - clickPNG: 点击图片函数 + * - findPNG: 查找图片函数 + * - handleCraftingError: 处理食材加工错误函数 + * - collectCraftedItems: 领取加工产物函数 + * - sleep: 延迟函数 + * - inputText: 输入文本函数 + */ +async function doCraft(index, tasks) { + await clickPNG("制作"); + await sleep(300); + + /* ---------- 1. 队列已满 ---------- */ + if (await handleCraftingError("队列已满", tasks[index].name, false)) { + return false; + } + + /* ---------- 2. 材料不足 ---------- */ + if (await handleCraftingError("材料不足", tasks[index].name, true)) { + return false; + } + + /* ---------- 3. 正常加工流程 ---------- */ + await findPNG("选择加工数量"); + click(960, 460); + await sleep(800); + inputText(String(99)); + + log.info(`尝试制作${tasks[index].name} 99个`); + await clickPNG("确认加工"); + await sleep(500); + + /* ---------- 4. 已不能持有更多 ---------- */ + if (await handleCraftingError("已不能持有更多", tasks[index].name, true)) { + return false; + } + + await sleep(200); + /* 正常完成:仅领取,不移除 */ + await collectCraftedItems(); + return true; +} + +/** + * 计算默认效率值 + * + * @param {Array} knownEff - 已知效率数组 + * @param {string|number} percentile - 分位值设置 + * @param {number} defaultThreshold - 默认阈值 + * @returns {number} - 计算出的默认效率值 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * 无 + */ +function calculateDefaultEfficiency(knownEff, percentile, defaultThreshold) { + if (knownEff.length === 0) { + return defaultThreshold; + } else { + const pct = Math.max(0, Math.min(1, percentile === "" ? 0.5 : Number(percentile))); + const idx = Math.ceil(pct * knownEff.length) - 1; + const percentileEff = knownEff[Math.max(0, idx)]; + return Math.max(percentileEff, defaultThreshold); + } +} + +/** + * 处理水下路线的螃蟹技能检查 + * + * @param {string} mapName - 地图名称 + * @param {string} filePath - 路径文件路径 + * @param {string} lastMapName - 上一个地图名称 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * - findAndClick: 查找并点击函数 + * - pathingScript.runFile: 运行路径脚本函数 + */ +async function handleUnderwaterRoute(mapName, filePath, lastMapName) { + if (filePath.includes('枫丹水下')) { + log.info("当前路线为水下路线,检查螃蟹技能"); + let skillRes = await findAndClick("assets/螃蟹技能图标.png", false, 1000); + if (!skillRes || lastMapName != mapName) { + log.info("识别到没有螃蟹技能或上一条路线处于其他地图,前往获取螃蟹技能"); + + if (mapName === "SeaOfBygoneEras") { + await pathingScript.runFile("assets/学习螃蟹技能2.json"); + } + else { + await pathingScript.runFile("assets/学习螃蟹技能1.json"); + } + } + } +} + +/** + * 处理时间调节 + * + * @param {Date} timeNow - 当前时间 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - settings: 设置对象 + * - firstsettime: 是否首次调节时间 + * - lastsettimeTime: 上次调节时间 + * - settimeInterval: 时间调节间隔 + * + * @依赖辅助函数: + * - pathingScript.runFile: 运行路径脚本函数 + */ +async function handleTimeAdjustment(timeNow) { + if (settings.setTimeMode && settings.setTimeMode != "不调节时间" && (((timeNow - lastsettimeTime) > settimeInterval) || firstsettime)) { + firstsettime = false; + if (settings.setTimeMode === "尽量调为白天") { + await pathingScript.runFile("assets/调为白天.json"); + } else { + await pathingScript.runFile("assets/调为夜晚.json"); + } + lastsettimeTime = new Date(); + } +} + +/** + * 处理食材加工触发 + * + * @param {Date} timeNow - 当前时间 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - Foods: 要加工的食材数组 + * - firstCook: 是否首次加工 + * - lastCookTime: 上次加工时间 + * - cookInterval: 加工间隔 + * - lastMapName: 上一个地图名称 + * + * @依赖辅助函数: + * - ingredientProcessing: 食材加工主函数 + */ +async function handleIngredientProcessing(timeNow) { + if (Foods.length != 0 && (((timeNow - lastCookTime) > cookInterval) || firstCook)) { + firstCook = false; + await ingredientProcessing(); + lastCookTime = new Date(); + lastMapName = "Teyvat"; + } +} + +/** + * 执行采集路线 + * + * @param {string} filePath - 路径文件路径 + * @param {string} fileName - 文件名 + * @param {Object} targetObj - 目标对象 + * @param {Date} startTime - 开始时间 + * @param {string} lastMapName - 上一个地图名称 + * @param {Set} priorityItemSet - 优先材料集合 + * @returns {Object} - 执行结果,包含 success、lastMapName 和 runPickupLog + * + * @依赖全局变量: + * - state: 状态对象,包含 running 标志和 runPickupLog 日志数组 + * - materialCdMap: 材料CD映射表 + * + * @依赖辅助函数: + * - recognizeAndInteract: 识别并交互函数 + * - handleUnderwaterRoute: 处理水下路线函数 + * - fakeLog: 模拟日志函数 + * - isArrivedAtEndPoint: 检查是否到达终点函数 + * - calculateRouteCD: 计算路线CD函数 + */ +async function executeRoute(filePath, fileName, targetObj, startTime, lastMapName, priorityItemSet) { + state.running = true; + + const raw = file.readTextSync(filePath); + const json = JSON.parse(raw); + const mapName = (json.info?.map_name && json.info.map_name.trim()) ? json.info.map_name : 'Teyvat'; + await handleUnderwaterRoute(mapName, filePath, lastMapName); + lastMapName = mapName; + const pickupTask = recognizeAndInteract(); + + try { + await pathingScript.runFile(filePath); + } catch (e) { + log.error(`路线执行失败: ${filePath}`); + state.running = false; + await pickupTask; + return { success: false, lastMapName }; + } + state.running = false; + await pickupTask; + await fakeLog(fileName, false, false, 0); + + /* 4-4 计算CD(掉落材料决定)*/ + const timeDiff = new Date() - startTime; + let pathRes = isArrivedAtEndPoint(filePath); + + // >>> 仅当 >10s 才记录 history;若同时 pathRes === true 再更新 CD <<< + if (timeDiff > 10000) { + /* ---------- 1. 先写 history(无条件) ---------- */ + const durationSec = Math.round(timeDiff / 1000); + const itemCounter = {}; + state.runPickupLog.forEach(n => { itemCounter[n] = (itemCounter[n] || 0) + 1; }); + if (!targetObj.history) targetObj.history = []; + targetObj.history.push({ items: itemCounter, durationSec }); + if (targetObj.history.length > 7) targetObj.history = targetObj.history.slice(-7); + + /* ---------- 2. 仅当 pathRes === true 才计算并更新 CD ---------- */ + if (pathRes) { + /* 2-1 判定本次有没有优先材料 */ + const hasPriority = state.runPickupLog.some(name => priorityItemSet.has(name)); + let hitMaterials; + if (hasPriority) { + hitMaterials = [...new Set(state.runPickupLog.filter(n => priorityItemSet.has(n)))]; + } else { + hitMaterials = [...new Set(state.runPickupLog)]; + } + + /* 2-2 按材料表取最晚 CD */ + let latestCD = new Date(0); + hitMaterials.forEach(name => { + const cdType = materialCdMap[name] || "1次0点刷新"; + const tmpDate = calculateRouteCD(cdType, startTime); + if (tmpDate > latestCD) latestCD = tmpDate; + }); + + /* 兜底:没有任何材料被识别到,按1次0点刷新 */ + if (hitMaterials.length === 0) { + latestCD = calculateRouteCD("1次0点刷新", startTime); + } + + targetObj.cdTime = latestCD.toISOString(); + } + } + + return { success: true, lastMapName, runPickupLog: state.runPickupLog }; +} + +/** + * 优先历史拾取物排序 + * + * @param {Array} targetItems - 目标物品数组 + * @param {Map} cdMap - CD时间映射 + * @param {string} fullName - 文件名 + * @returns {Array} - 排序后的物品数组,历史上出现过的物品放在前面 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * 无 + */ +function prioritizeHistoricalItems(targetItems, cdMap, fullName) { + // 0) 只有 history 里出现过的物品才需要前置 + const historyItemSet = new Set(); + const routeRec = cdMap.get(fullName); + if (routeRec?.history) { + routeRec.history.forEach(log => { + Object.keys(log.items).forEach(name => historyItemSet.add(name)); + }); + } + + // 1) 把 targetItems 拆成「历史出现」+「未出现」两部分 + const frontPart = []; + const backPart = []; + for (const it of targetItems) { + (historyItemSet.has(it.itemName) ? frontPart : backPart).push(it); + } + + // 2) 合并后重新赋值,完成前置 + return [...frontPart, ...backPart]; +} + +/** + * 保存记录并清空日志 + * + * @param {Map} cdMap - CD时间映射 + * @param {string} recordFilePath - 记录文件路径 + * @param {Array} runPickupLog - 运行拾取日志 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - state: 状态对象,包含 runPickupLog 日志数组 + * + * @依赖辅助函数: + * - appendDailyPickup: 追加每日拾取量函数 + */ +async function saveRecordAndClearLog(cdMap, recordFilePath, runPickupLog) { + await file.writeText( + recordFilePath, + JSON.stringify(Array.from(cdMap.values()), null, 2) + ); + await appendDailyPickup(runPickupLog); + state.runPickupLog = []; +} + +/** + * 根据路线路径选择合适的队伍 + * + * @param {string} routePath - 路线路径 + * @param {string} stage - 阶段名称 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - settings: 设置对象,包含路径组和队伍配置 + * - groupCount: 路径组数量 + * + * @依赖辅助函数: + * - switchPartyIfNeeded: 切换队伍函数 + */ +async function selectPartyByRoutePath(routePath, stage) { + const fullPath = routePath; // 例:pathing/须弥/xxx.json + const folderName = fullPath.split(/\\|\//)[1]; // 索引 1 就是第二层 + let targetParty = ''; // 最终要用的队伍名 + + for (let g = 1; g <= groupCount; g++) { // 遍历路径组 + if (settings[`pathGroup${g}FolderName`] === folderName) { // 找到归属组 + targetParty = settings[`pathGroup${g}PartyName`] || ''; + break; // 命中即停 + } + } + if (!targetParty) targetParty = settings.priorityItemsPartyName || ''; // 回退 + if (targetParty) { + await switchPartyIfNeeded(targetParty); + log.info(`${stage}选用配队:${targetParty}(文件夹:${folderName})`); + } +} + +/** + * 计算路线CD时间 + * + * @param {string} cdType - CD类型 + * @param {Date} startTime - 开始时间 + * @returns {Date} - 计算后的CD时间 + * + * @依赖全局变量: + * 无 + * + * @依赖辅助函数: + * 无 + */ +function calculateRouteCD(cdType, startTime) { + let newTimestamp = new Date(startTime); + switch (cdType) { + case "1次0点刷新": + newTimestamp.setDate(newTimestamp.getDate() + 1); + newTimestamp.setHours(0, 0, 0, 0); + break; + case "2次0点刷新": + newTimestamp.setDate(newTimestamp.getDate() + 2); + newTimestamp.setHours(0, 0, 0, 0); + break; + case "3次0点刷新": + newTimestamp.setDate(newTimestamp.getDate() + 3); + newTimestamp.setHours(0, 0, 0, 0); + break; + case "4点刷新": + newTimestamp.setHours(4, 0, 0, 0); + if (newTimestamp <= startTime) newTimestamp.setDate(newTimestamp.getDate() + 1); + break; + case "12小时刷新": + newTimestamp = new Date(startTime.getTime() + 12 * 60 * 60 * 1000); + break; + case "24小时刷新": + newTimestamp = new Date(startTime.getTime() + 24 * 60 * 60 * 1000); + break; + case "46小时刷新": + newTimestamp = new Date(startTime.getTime() + 46 * 60 * 60 * 1000); + break; + default: + newTimestamp = startTime; + break; + } + return newTimestamp; +} + +/** + * 计算路线效率 + * + * @param {Array} files - 路线文件数组 + * @param {Map} cdMap - CD时间映射 + * @param {Object} options - 配置选项 + * @param {number} options.groupIndex - 路径组索引(路径组模式使用) + * @param {Set} options.priorityItemSet - 优先材料集合(优先采集模式使用) + * @param {Array} options.disableArray - 禁用关键词数组(优先采集模式使用) + * @param {boolean} options.isPriorityMode - 是否为优先采集模式 + * @returns {void} 无返回值,直接修改 files 数组中的对象 + * + * @依赖全局变量: + * - settings: 设置对象,包含效率计算相关配置 + * - routeEfficiency: 路线效率对象 + * + * @依赖辅助函数: + * - calculateDefaultEfficiency: 计算默认效率值函数 + */ +function calculateRouteEfficiency(files, cdMap, options = {}) { + const { groupIndex, priorityItemSet, disableArray, isPriorityMode = false } = options; + + if (isPriorityMode) { + // 优先采集模式:只计算优先材料的效率 + for (const file of files) { + const fullName = file.fileName; + const rec = cdMap.get(fullName); + + // 禁用关键词 + let skip = false; + for (const kw of disableArray) { + if (file.fullPath.includes(kw)) { skip = true; break; } + } + if (skip) { file._priorityEff = -1; continue; } + + // 材料相关检查 + const pathHit = [...priorityItemSet].some(n => file.fullPath.includes(n)); + const histHit = rec?.history?.some(log => + Object.keys(log.items).some(name => priorityItemSet.has(name)) + ) ?? false; + let descHit = false; + if (file.description) { + descHit = [...priorityItemSet].some(kw => file.description.includes(kw)); + } + if (!pathHit && !histHit && !descHit) { + file._priorityEff = -1; + continue; + } + + // 计算仅看优先材料的分均效率 + let eff = -2; // 未知标记 + if (rec?.history && rec.history.length >= 3) { + const effList = rec.history.map(log => { + const total = Object.entries(log.items) + .filter(([name]) => priorityItemSet.has(name)) + .reduce((sum, [, cnt]) => sum + cnt, 0); + return (total / log.durationSec) * 60; + }); + eff = effList.reduce((a, b) => a + b, 0) / effList.length; + } + file._priorityEff = eff; + } + } else { + // 路径组模式:计算所有材料的效率,使用加权规则 + // 0) 解析优先关键词 + const priorityKeywords = settings.priorityTags + ? settings.priorityTags.split(',').map(s => s.trim()).filter(Boolean) + : []; + + // 1) 解析加权规则 + const weightMap = new Map(); + if (settings.weightedRule) { + settings.weightedRule + .split(',') + .map(s => s.trim()) + .forEach(rule => { + const [item, wStr] = rule.split('*'); + if (item && wStr) { + const w = Number(wStr); + weightMap.set(item, isNaN(w) ? 1 : w); + } + }); + } + + // 2) 先计算一次基础效率(未知路线先标 -1) + files.forEach(p => { + const fullName = basename(p.fullPath); + const obj = cdMap.get(fullName); + let avgEff = -1; // 先标记为"未知" + + if (obj && obj.history && obj.history.length >= 3) { + const effList = obj.history.map(log => { + const total = Object.entries(log.items).reduce((sum, [name, cnt]) => { + const w = blacklistSet.has(name) ? 0 : (weightMap.get(name) ?? 1); + return sum + cnt * w; + }, 0); + return (total / log.durationSec) * 60; + }); + avgEff = effList.reduce((a, b) => a + b, 0) / effList.length; + } + p._efficiency = avgEff; // 已知路线存真实效率,未知路线存 -1 + }); + + // 3) 计算默认效率(分位值 & 临界值 取最大) + const knownEff = files + .map(p => p._efficiency) + .filter(e => e >= 0) // 只保留已知路线 + .sort((a, b) => a - b); + + const userThreshold = Number(settings[`pathGroup${groupIndex}thresholdEfficiency`]) || 0; + const defaultEff = calculateDefaultEfficiency(knownEff, settings.defaultEffPercentile, userThreshold); + + // 4) 把 -1 的未知路线替换成默认效率 + files.forEach(p => { + if (p._efficiency === -1) p._efficiency = defaultEff; + }); + + // 5) 计算全局最大效率值(已含默认效率) + const maxEff = Math.max(...files.map(p => p._efficiency), 0); + + // 6) 优先关键词加分 + files.forEach(p => { + const fullName = basename(p.fullPath); + const obj = cdMap.get(fullName); + + const itemHit = obj?.history?.some(log => + Object.keys(log.items).some(item => + priorityKeywords.some(key => item.includes(key)) + ) + ); + const pathHit = priorityKeywords.some(key => p.fullPath.includes(key)); + const descHit = priorityKeywords.some(key => (p.description || '').includes(key)); + + if (itemHit || pathHit || descHit) { + p._efficiency += maxEff + 1; + } + }); + } +} + +/** + * 把本次路线的掉落合并到“拾取记录.json”中同一天条目(不含 durationSec) + * @param {string[]} pickupLog 本次路线的 state.runPickupLog + */ +/** + * 追加每日拾取记录到文件 + * 将本次路线运行的拾取物品记录追加到每日统计文件中,按 UTC+8 的 4 点划分日期 + * + * @param {string[]} pickupLog - 本次路线运行拾取的物品名称数组 + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - pickupRecordFile: 拾取记录文件路径 + * - MAX_PICKUP_DAYS: 最大保留天数常量 + * + * @依赖辅助函数:无 + */ +async function appendDailyPickup(pickupLog) { + if (!pickupLog || !pickupLog.length) return; + + let oldArr = []; + try { + const txt = await file.readText(pickupRecordFile); + if (txt) oldArr = JSON.parse(txt); + } catch (_) { /* 文件不存在或解析失败 */ } + + // 统一按 UTC+8 的 4 点划分日期 + const utc8_4am = new Date(Date.now() + 8 * 3600_000 - 4 * 3600_000); + const today = utc8_4am.toISOString().slice(0, 10); // "YYYY-MM-DD" + + let todayItem = oldArr.find(e => e.date === today); + if (!todayItem) { + todayItem = { date: today, items: {} }; + oldArr.push(todayItem); + } + + const todayItems = todayItem.items; + pickupLog.forEach(name => { + todayItems[name] = (todayItems[name] || 0) + 1; + }); + + // 滑动窗口:只保留最新 MAX_PICKUP_DAYS 条 + oldArr.sort((a, b) => b.date.localeCompare(a.date)); // 先排序 + if (oldArr.length > MAX_PICKUP_DAYS) oldArr = oldArr.slice(0, MAX_PICKUP_DAYS); // 再截断 + + // 写盘 + 异常捕获 + try { + await file.writeText(pickupRecordFile, JSON.stringify(oldArr, null, 2), false); + } catch (error) { + log.error(`appendDailyPickup 写盘失败: ${error.message}`); + } +} + +/** + * 点击指定名称的 PNG 模板图片 + * 从 assets/RecognitionObject/ 目录加载指定名称的图片模板,识别并点击 + * + * @param {string} png - 图片文件名(不含扩展名) + * @param {number} [maxAttempts=20] - 最大重试次数 + * @returns {Promise} 返回是否成功点击或 Region 结果 + * + * @依赖全局变量: + * - checkInterval: 识别间隔时间 + * + * @依赖辅助函数: + * - findAndClick: 通用找图并点击函数 + */ +async function clickPNG(png, maxAttempts = 20) { + const pngRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync(`assets/RecognitionObject/${png}.png`)); + pngRo.Threshold = 0.95; + pngRo.InitTemplate(); + return await findAndClick(pngRo, true, maxAttempts * checkInterval, checkInterval); +} + +/** + * 查找指定名称的 PNG 模板图片(不点击) + * 从 assets/RecognitionObject/ 目录加载指定名称的图片模板,识别但不点击 + * + * @param {string} png - 图片文件名(不含扩展名) + * @param {number} [maxAttempts=20] - 最大重试次数 + * @returns {Promise} 返回是否找到或 Region 结果 + * + * @依赖全局变量: + * - checkInterval: 识别间隔时间 + * + * @依赖辅助函数: + * - findAndClick: 通用找图并点击函数 + */ +async function findPNG(png, maxAttempts = 20) { + const pngRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync(`assets/RecognitionObject/${png}.png`)); + pngRo.Threshold = 0.95; + pngRo.InitTemplate(); + return await findAndClick(pngRo, false, maxAttempts * checkInterval, checkInterval); +} + +/** + * 通用找图/找RO并可选点击 + * 支持单图片文件路径、单RO、图片文件路径数组、RO数组 + * + * @param {string|string[]|RecognitionObject|RecognitionObject[]} target - 目标图片路径或识别对象 + * @param {boolean} [doClick=true] - 是否点击 + * @param {number} [timeout=3000] - 识别时间上限(毫秒) + * @param {number} [interval=50] - 识别间隔(毫秒) + * @param {number} [retType=0] - 返回类型:0-返回布尔;1-返回 Region 结果 + * @param {number} [preClickDelay=50] - 点击前等待时间(毫秒) + * @param {number} [postClickDelay=50] - 点击后等待时间(毫秒) + * @returns {Promise} 根据 retType 返回是否成功或最终 Region + * + * @依赖全局变量:无 + * + * @依赖辅助函数:无 + */ +async function findAndClick(target, + doClick = true, + timeout = 3000, + interval = 50, + retType = 0, + preClickDelay = 50, + postClickDelay = 50) { + try { + // 1. 统一转成 RecognitionObject 数组 + let ros = []; + if (Array.isArray(target)) { + ros = target.map(t => + (typeof t === 'string') + ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(t)) + : t + ); + } else { + ros = [(typeof target === 'string') + ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(target)) + : target]; + } + + const start = Date.now(); + let found = null; + + while (Date.now() - start <= timeout) { + const gameRegion = captureGameRegion(); + try { + // 依次尝试每一个 ro + for (const ro of ros) { + const res = gameRegion.find(ro); + if (!res.isEmpty()) { // 找到 + found = res; + if (doClick) { + await sleep(preClickDelay); + res.click(); + await sleep(postClickDelay); + } + break; // 成功即跳出 for + } + } + if (found) break; // 成功即跳出 while + } finally { + gameRegion.dispose(); + } + await sleep(interval); // 没找到时等待 + } + + // 3. 按需返回 + return retType === 0 ? !!found : (found || null); + + } catch (error) { + log.error(`执行通用识图时出现错误:${error.message}`); + return retType === 0 ? false : null; + } +} + +/** + * 判断当前人物是否已到达指定路线的终点 + * 通过读取路线文件获取终点坐标,并与当前人物坐标进行曼哈顿距离比较 + * + * @param {string} fullPath - 路线文件完整路径(.json) + * @returns {boolean} true = 已到达;false = 未到达/读文件失败/取坐标失败 + * + * @依赖全局变量:无 + * + * @依赖辅助函数:无 + */ +function isArrivedAtEndPoint(fullPath) { + try { + if (settings.disableXYCheck) { + log.info("当前禁用了坐标校验,跳过坐标检查") + return true; + } + /* 1. 读路线文件,取终点坐标 */ + const raw = file.readTextSync(fullPath); + const json = JSON.parse(raw); + if (!Array.isArray(json.positions)) return false; + + let endX = 0, endY = 0; + for (let i = json.positions.length - 1; i >= 0; i--) { + const p = json.positions[i]; + if (p.type !== 'orientation' && + typeof p.x === 'number' && + typeof p.y === 'number') { + endX = p.x; + endY = p.y; + break; + } + } + if (endX === 0 && endY === 0) return false; // 没找到有效点 + + /* 2. 取当前人物坐标 */ + + const mapName = (json.info?.map_name && json.info.map_name.trim()) ? json.info.map_name : 'Teyvat'; + const pos = genshin.getPositionFromMap(mapName, 3000); + const curX = pos.X; + const curY = pos.Y; + + let pathres = Math.abs(endX - curX) + Math.abs(endY - curY) <= 30; + if (!pathres) { + log.warn(`距离预定终点${Math.abs(endX - curX) + Math.abs(endY - curY)}`); + log.warn(`距离异常,不记录数据`); + } + /* 3. 曼哈顿距离 ≤30 视为到达 */ + return pathres; + } catch (error) { + /* 任何异常(读盘失败、解析失败、API 异常)都算“未到达” */ + log.warn(`出现异常${error.message},不记录cd`); + return false; + } +} + +/** + * 判断当前是否存在拾取滚轮图标 + * 在指定时间内持续检测游戏画面中是否存在拾取滚轮图标 + * + * @param {number} [maxDuration=10] - 最大允许耗时(毫秒) + * @returns {Promise} 返回是否检测到滚轮图标 + * + * @依赖全局变量: + * - gameRegion: 游戏区域对象 + * - scrollRo: 拾取滚轮识别对象 + * - findFInterval: 识别间隔时间 + * + * @依赖辅助函数:无 + */ +async function hasScroll(maxDuration = 10) { + const start = Date.now(); + let dodispose = false; + while (Date.now() - start < maxDuration) { + if (!gameRegion) { + gameRegion = captureGameRegion(); + dodispose = true; + } + try { + const result = gameRegion.find(scrollRo); + if (result.isExist()) return true; + } catch (error) { + log.error(`识别图像时发生异常: ${error.message}`); + return false; // 一旦出现异常直接退出,不再重试 + } + await sleep(findFInterval); // 识别间隔 + if (dodispose) { + gameRegion.dispose(); + dodispose = false; // 已经释放,标记避免重复 dispose + } + } + /* 超时仍未识别到,返回失败 */ + return false; +} + +/** + * 零基构建 settings.json 配置文件 + * 扫描 pathing 目录下的文件夹,动态生成包含路径组配置的 settings.json 文件 + * + * @returns {Promise} 返回是否继续运行,仅刷新模式返回 false,否则返回 true + * + * @依赖全局变量: + * - settings: 用户设置对象 + * - groupCount: 路径组数量 + * + * @依赖辅助函数:无 + */ +async function buildSettingsJson() { const SETTINGS_FILE = `settings.json`; const PATHINGS_ROOT = `pathing`; @@ -122,11 +1822,8 @@ let checkInterval = +settings.checkInterval || 50; let uniqueDirs = Array.from(new Set(firstLevelDirs)); // 去重 - /* 3. 移除多余的 'pathing' 选项 */ - //uniqueDirs = uniqueDirs.filter(dir => dir !== 'pathing'); - /* 4. 路径组数量 */ - const groupCount = Math.min(99, Math.max(1, parseInt(settings.groupCount || '3'))); + groupCount = Math.min(99, Math.max(1, parseInt(settings.groupCount || '3'))); /* 5. 硬编码构建全新 JSON */ const newSettings = []; @@ -325,1973 +2022,574 @@ let checkInterval = +settings.checkInterval || 50; log.info(`已全新生成 settings.json,共 ${groupCount} 个路径组配置。`); log.info(`扫描到可供选择的文件夹:${uniqueDirs.join(' | ')}`); - /* 仅刷新模式出口 */ + // 仅刷新模式检查 if (settings.onlyRefresh) { settings.onlyRefresh = false; - return; - } - /* ===== 零基构建 settings.json(END) ===== */ - - try { - /* ===== 读取新 settings ===== */ - const groupCount = Math.min(99, Math.max(1, parseInt(settings.groupCount || '3'))); - const folderNames = []; - const partyNames = []; - for (let g = 1; g <= groupCount; g++) { - folderNames.push(settings[`pathGroup${g}FolderName`] || ''); - partyNames.push(settings[`pathGroup${g}PartyName`] || ''); - } - - // 获取子文件夹路径 - const subFolderName = userSettings.infoFileName; - const subFolderPath = `${recordFolder}/${subFolderName}`; - pickupRecordFile = `${recordFolder}/${subFolderName}/拾取记录.json`; - - // 读取子文件夹中的所有文件路径 - const filesInSubFolder = file.ReadPathSync(subFolderPath); - - // 检查优先顺序:record.json > record.txt - let indexDoExist = false; - let useJson = false; - for (const filePath of filesInSubFolder) { - const fileName = basename(filePath); - if (fileName === "record.json") { - indexDoExist = true; - useJson = true; - break; - } - if (fileName === "record.txt") { - indexDoExist = true; - useJson = false; - } - } - - if (userSettings.operationMode === "重新生成索引文件(用于强制刷新CD)") { - log.info("重新生成索引文件模式,将覆盖现有索引文件"); - } - if (!indexDoExist) { - log.info("文件不存在,将尝试生成索引文件"); - } - - /* 禁用BGI原生拾取,强制模板匹配 */ - targetItems = await loadTargetItems(); - /* ===== 别名索引 ===== */ - const name2Other = new Map(); // 本名 → 别名数组 - const alias2Names = new Map(); // 别名 → 本名数组(支持多对一) - for (const it of targetItems) { - const aliases = it.otherName || []; - name2Other.set(it.itemName, aliases); - for (const a of aliases) { - if (!alias2Names.has(a)) alias2Names.set(a, []); - alias2Names.get(a).push(it.itemName); // 一个别名可指向多个本名 - } - } - - await loadBlacklist(true); - state.running = true; - - await fakeLog("采集cd管理", true, false, 1000); - - // 统一的 record.json 文件路径 - const recordFilePath = `${subFolderPath}/record.json`; - - // 读取 pathing 文件夹下的所有 .json 文件 - const pathingFolder = "pathing"; - const files = await readFolder(pathingFolder, true); - const filePaths = files.map(file => file.fullPath); - - // ① 先加载已有记录(整对象) - let recordArray = []; - if (indexDoExist && useJson) { - try { recordArray = JSON.parse(await file.readText(recordFilePath)); } catch (e) { } - } else if (indexDoExist && !useJson) { - try { - const txt = await file.readText(`${subFolderPath}/record.txt`); - txt.trim().split('\n').forEach(line => { - const [n, t] = line.trim().split('::'); - if (n && t) recordArray.push({ fileName: n + '.json', cdTime: t }); - }); - } catch (e) { } - } - - // ② 建 Map 确保 history 存在 - const existMap = new Map(recordArray.map(it => [it.fileName, { - ...it, - history: it.history || [] // 补空数组 - }])); - - // ③ 对 pathing 里存在的路线:只更新 cdTime,其余保留 - const defaultTime = "1970/1/1 08:00:00"; - for (const filePath of filePaths) { - const fileName = basename(filePath); - if (!fileName.endsWith('.json')) continue; - - const old = existMap.get(fileName) || {}; - const newCd = (indexDoExist && - userSettings.operationMode !== "重新生成索引文件(用于强制刷新CD)" && - old.cdTime) - ? old.cdTime - : defaultTime; - - existMap.set(fileName, { - ...old, // 保留所有旧字段 - fileName, - cdTime: newCd, - history: old.history || [] // 确保有 history - }); - } - - // ④ 写回(含已消失的路线) - const writeResult = file.writeTextSync(recordFilePath, - JSON.stringify(Array.from(existMap.values()), null, 2)); - - if (writeResult) { - log.info(`信息已成功写入: ${recordFilePath}`); - } else { - log.error(`写入文件失败: ${recordFilePath}`); - } - - try { - Foods = Array.from(processingIngredient); - } catch (e) { Foods = []; } - - if (typeof foodCounts === 'string' && foodCounts.trim()) { - foodCount = foodCounts - .split(/[,,;;\s]+/) - .map(word => word.trim()) - .filter(word => word.length > 0); - } - - let cookInterval = 95 * 60 * 1000; - let settimeInterval = 10 * 60 * 1000; - // ==================== 优先级材料前置采集 ==================== - - if (settings.priorityItems) { - /* ---------- 1. 解析 ---------- */ - const priorityList = []; - const segments = settings.priorityItems.split('+').map(s => s.trim()); - for (const seg of segments) { - const [itemName, countStr] = seg.split('*').map(s => s.trim()); - if (itemName && countStr && !isNaN(Number(countStr))) { - priorityList.push({ itemName, count: Number(countStr) }); - } - } - log.info(`优先级材料解析完成: ${priorityList.map(e => `${e.itemName}*${e.count}`).join(', ')}`); - /* ===== 追加:扣除今日已拾取(UTC+8 0 点分界) ===== */ - const utc8 = new Date(Date.now() + 8 * 3600_000); // 手动+8小时 - const today = utc8.toISOString().slice(0, 10); // "YYYY-MM-DD" - let todayPicked = {}; // 今日已拾取数量 - try { - const txt = await file.readText(pickupRecordFile); - if (txt) { - const arr = JSON.parse(txt); - const todayItem = arr.find(it => it.date === today); - if (todayItem) todayPicked = todayItem.items || {}; - } - } catch (_) { /* 文件不存在或解析失败 */ } - - /* 扣除今日已拾取:双向扣除 */ - for (let i = priorityList.length - 1; i >= 0; i--) { - const task = priorityList[i]; - let got = 0; - - /* 1. 字面名(可能是别名)直接扣 */ - got += todayPicked[task.itemName] || 0; - - /* 2. 如果字面名是别名,把对应本名也扣一遍 */ - const realNames = alias2Names.get(task.itemName) || []; // 现在是数组 - - for (const n of realNames) got += todayPicked[n] || 0; - - /* 3. 如果字面名是本名,把所有别名再扣一遍 */ - const others = name2Other.get(task.itemName) || []; - for (const a of others) got += todayPicked[a] || 0; - - task.count -= got; - if (task.count <= 0) priorityList.splice(i, 1); - } - - if (priorityList.length === 0) { - log.info("今日优先材料已达标,跳过优先采集阶段"); - notification.send("今日优先材料已达标,跳过优先采集阶段"); - } - /* ================================= */ - - /* ---------- 2. 材料→CD类型 映射表(仅列出现过的,其余默认 1次0点刷新)---------- */ - const materialCdMap = { - // 46h 特产 - "小灯草": "46小时刷新", - "嘟嘟莲": "46小时刷新", - "落落莓": "46小时刷新", - "塞西莉亚花": "46小时刷新", - "慕风蘑菇": "46小时刷新", - "蒲公英籽": "46小时刷新", - "钩钩果": "46小时刷新", - "风车菊": "46小时刷新", - "霓裳花": "46小时刷新", - "清心": "46小时刷新", - "琉璃袋": "46小时刷新", - "琉璃百合": "46小时刷新", - "夜泊石": "46小时刷新", - "绝云椒椒": "46小时刷新", - "星螺": "46小时刷新", - "石珀": "46小时刷新", - "清水玉": "46小时刷新", - "海灵芝": "46小时刷新", - "鬼兜虫": "46小时刷新", - "绯樱绣球": "46小时刷新", - "鸣草": "46小时刷新", - "珊瑚真珠": "46小时刷新", - "晶化骨髓": "46小时刷新", - "血斛": "46小时刷新", - "天云草实": "46小时刷新", - "幽灯蕈": "46小时刷新", - "沙脂蛹": "46小时刷新", - "月莲": "46小时刷新", - "帕蒂沙兰": "46小时刷新", - "树王圣体菇": "46小时刷新", - "圣金虫": "46小时刷新", - "万相石": "46小时刷新", - "悼灵花": "46小时刷新", - "劫波莲": "46小时刷新", - "赤念果": "46小时刷新", - "苍晶螺": "46小时刷新", - "海露花": "46小时刷新", - "柔灯铃": "46小时刷新", - "子探测单元": "46小时刷新", - "湖光铃兰": "46小时刷新", - "幽光星星": "46小时刷新", - "虹彩蔷薇": "46小时刷新", - "初露之源": "46小时刷新", - "浪沫羽鳃": "46小时刷新", - "灼灼彩菊": "46小时刷新", - "肉龙掌": "46小时刷新", - "青蜜莓": "46小时刷新", - "枯叶紫英": "46小时刷新", - "微光角菌": "46小时刷新", - "云岩裂叶": "46小时刷新", - "琉鳞石": "46小时刷新", - "冬凌草": "46小时刷新", - "松珀香": "46小时刷新", - "月落银": "46小时刷新", - "便携轴承": "46小时刷新", - "霜盏花": "46小时刷新", - "冰雾花花朵": "46小时刷新", - "奇异的「牙齿」": "46小时刷新", - - // 12h 素材 - "兽肉": "12小时刷新", - "禽肉": "12小时刷新", - "神秘的肉": "12小时刷新", - "鱼肉": "12小时刷新", - "鳗肉": "12小时刷新", - "螃蟹": "12小时刷新", - "蝴蝶翅膀": "12小时刷新", - "青蛙": "12小时刷新", - "发光髓": "12小时刷新", - "蜥蜴尾巴": "12小时刷新", - "晶核": "12小时刷新", - "鳅鳅宝玉": "12小时刷新", - - // 4点 - "盐": "1次4点刷新", - "胡椒": "1次4点刷新", - "洋葱": "1次4点刷新", - "牛奶": "1次4点刷新", - "番茄": "1次4点刷新", - "卷心菜": "1次4点刷新", - "土豆": "1次4点刷新", - "小麦": "1次4点刷新", - "稻米": "1次4点刷新", - "虾仁": "1次4点刷新", - "豆腐": "1次4点刷新", - "杏仁": "1次4点刷新", - "发酵果实汁": "1次4点刷新", - "咖啡豆": "1次4点刷新", - "秃秃豆": "1次4点刷新", - - // 0点 - "甜甜花": "1次0点刷新", - "胡萝卜": "1次0点刷新", - "蘑菇": "1次0点刷新", - "松茸": "1次0点刷新", - "松果": "1次0点刷新", - "金鱼草": "1次0点刷新", - "莲蓬": "1次0点刷新", - "薄荷": "1次0点刷新", - "鸟蛋": "1次0点刷新", - "树莓": "1次0点刷新", - "白萝卜": "1次0点刷新", - "苹果": "1次0点刷新", - "日落果": "1次0点刷新", - "竹笋": "1次0点刷新", - "海草": "1次0点刷新", - "堇瓜": "1次0点刷新", - "星蕈": "1次0点刷新", - "墩墩桃": "1次0点刷新", - "须弥蔷薇": "1次0点刷新", - "香辛果": "1次0点刷新", - "枣椰": "1次0点刷新", - "泡泡桔": "1次0点刷新", - "汐藻": "1次0点刷新", - "茉洁草": "1次0点刷新", - "久雨莲": "1次0点刷新", - "颗粒果": "1次0点刷新", - "烛伞蘑菇": "1次0点刷新", - "澄晶实": "1次0点刷新", - "红果果菇": "1次0点刷新", - "白灵果": "1次0点刷新", - "夏槲果": "1次0点刷新", - "宿影花": "1次0点刷新", - "马尾": "1次0点刷新", - "苦种": "1次0点刷新", - "烬芯花": "1次0点刷新", - "烈焰花花蕊": "1次0点刷新", - "铁块": "1次0点刷新", - "白铁块": "2次0点刷新", - "星银矿石": "2次0点刷新", - "电气水晶": "2次0点刷新", - "水晶块": "3次0点刷新", - "紫晶块": "3次0点刷新", - "萃凝晶": "3次0点刷新", - "虹滴晶": "3次0点刷新", - "沉玉仙茗": "24小时刷新" - }; - - const runOnce = []; - /* ---------- 3. 主循环 ---------- */ - while (priorityList.length > 0) { - - /* 1. 先把用户填的字面名(可能是别名)全部弄进来 */ - const priorityItemSet = new Set(priorityList.map(p => p.itemName)); - - /* 2. 双向扩:本名↔别名 */ - for (const a of [...priorityItemSet]) { // 复制一份避免遍历过程中增长 - // 2.1 如果 a 是“本名”,把它的所有别名加进来(原来就有的逻辑) - const others = name2Other.get(a) || []; - for (const o of others) priorityItemSet.add(o); - - // 2.2 如果 a 是“别名”,把对应的本名加进来(新增反向) - const realName = alias2Names.get(a) || []; - for (const r of realName) priorityItemSet.add(r); - } - - const pickedCounter = {}; - priorityItemSet.forEach(n => pickedCounter[n] = 0); - /* ===== 剩余物品 ===== */ - let remaining = priorityList.map(t => `${t.itemName}*${t.count}`).join(', '); - /* 4-1 扫描 + 读 record + 前置过滤(禁用/时间/材料相关)+ 计算效率 + CD后置排除 */ - const allFiles = await readFolder('pathing', true); - const rawRecord = await file.readText(`${recordFolder}/${subFolderName}/record.json`); - let recordArray = []; - try { recordArray = JSON.parse(rawRecord); } catch { /* 空记录 */ } - const cdMap = new Map(recordArray.map(it => [it.fileName, it])); - const now = new Date(); - /* 时间管制 */ - if (await isTimeRestricted(settings.timeRule, 10)) { priorityList.length = 0; break; } - - /* ---- 先算效率(不判CD)---- */ - for (const file of allFiles) { - const fullName = file.fileName; - const rec = cdMap.get(fullName); - - /* 禁用关键词 */ - let skip = false; - for (const kw of disableArray) { if (file.fullPath.includes(kw)) { skip = true; break; } } - if (skip) { file._priorityEff = -1; continue; } - - /* 材料相关 */ - const pathHit = [...priorityItemSet].some(n => file.fullPath.includes(n)); - const histHit = rec?.history?.some(log => - Object.keys(log.items).some(name => priorityItemSet.has(name)) - ) ?? false; - let descHit = false; - if (file.description) { - descHit = [...priorityItemSet].some(kw => file.description.includes(kw)); - } - if (!pathHit && !histHit && !descHit) { - file._priorityEff = -1; - continue; - } - - /* 计算仅看优先材料的分均效率 */ - let eff = -2; // 未知标记 - if (rec?.history && rec.history.length >= 3) { - const effList = rec.history.map(log => { - const total = Object.entries(log.items) - .filter(([name]) => priorityItemSet.has(name)) - .reduce((sum, [, cnt]) => sum + cnt, 0); - return (total / log.durationSec) * 60; - }); - eff = effList.reduce((a, b) => a + b, 0) / effList.length; - } - file._priorityEff = eff; - } - - /* ---- 用可运行路线算分位默认值 ---- */ - const knownEff = allFiles - .filter(f => { - const rec = cdMap.get(f.fileName); - const nextCD = rec ? new Date(rec.cdTime) : new Date(0); - return f._priorityEff >= 0 && now > nextCD; - }) - .map(f => f._priorityEff) - .sort((a, b) => a - b); - let defaultEff; - if (knownEff.length === 0) { - defaultEff = 1; - } else { - const rawPct = settings.defaultEffPercentile; - const pct = Math.max(0, Math.min(1, rawPct === "" ? 0.5 : Number(rawPct))); - const idx = Math.ceil(pct * knownEff.length) - 1; - defaultEff = knownEff[Math.max(0, idx)]; - } - /* 回填未知 + 排除CD */ - allFiles.forEach(f => { - if (f._priorityEff === -2) f._priorityEff = defaultEff; - const rec = cdMap.get(f.fileName); - const nextCD = rec ? new Date(rec.cdTime) : new Date(0); - if (now <= nextCD) f._priorityEff = -1; - }); - - if (priorityList.length === 0) break; - - /* 4-2 只跑最高效率路线 */ - const candidateRoutes = allFiles - .filter(f => { - return f._priorityEff >= 0 && - !runOnce.includes(f.fileName); // 本轮没跑过 - }) - .sort((a, b) => b._priorityEff - a._priorityEff); - if (candidateRoutes.length === 0 && priorityList.length > 0) { - log.info('已无可用优先路线(可能全部在CD),退出优先采集阶段'); - notification.send('已无可用优先路线(可能全部在CD),退出优先采集阶段'); - break; - } - const bestRoute = candidateRoutes[0]; - const filePath = bestRoute.fullPath; - const fileName = basename(filePath).replace('.json', ''); - const fullName = fileName + '.json'; - const targetObj = cdMap.get(fullName); - const startTime = new Date(); - - /* ---------- 智能选队:按路线所在文件夹反查路径组 ---------- */ - { - const fullPath = bestRoute.fullPath; // 例:pathing/须弥/xxx.json - const folderName = fullPath.split(/\\|\//)[1]; // 索引 1 就是第二层 - let targetParty = ''; // 最终要用的队伍名 - - const groupCount = Math.min(99, Math.max(1, parseInt(settings.groupCount || '3'))); - for (let g = 1; g <= groupCount; g++) { // 遍历路径组 - if (settings[`pathGroup${g}FolderName`] === folderName) { // 找到归属组 - targetParty = settings[`pathGroup${g}PartyName`] || ''; - break; // 命中即停 - } - } - if (!targetParty) targetParty = settings.priorityItemsPartyName || ''; // 回退 - if (targetParty) { - await switchPartyIfNeeded(targetParty); - log.info(`优先采集阶段选用配队:${targetParty}(文件夹:${folderName})`); - } - } - - log.info(`当前进度:执行路线 ${fileName},剩余优先材料:${remaining}`); - - let timeNow = new Date(); - if (Foods.length != 0 && (((timeNow - lastCookTime) > cookInterval) || firstCook)) { - firstCook = false; - await ingredientProcessing(); - lastCookTime = new Date(); - lastMapName = "Teyvat"; - } - - if (settings.setTimeMode && settings.setTimeMode != "不调节时间" && (((timeNow - lastsettimeTime) > settimeInterval) || firstsettime)) { - firstsettime = false; - if (settings.setTimeMode === "尽量调为白天") { - await pathingScript.runFile("assets/调为白天.json"); - } else { - await pathingScript.runFile("assets/调为夜晚.json"); - } - lastsettimeTime = new Date(); - } - await fakeLog(fileName, false, true, 0); - runOnce.push(fileName); - - /* ================================= */ - state.running = true; - - const raw = file.readTextSync(filePath); - const json = JSON.parse(raw); - const mapName = (json.info?.map_name && json.info.map_name.trim()) ? json.info.map_name : 'Teyvat'; - if (filePath.includes('枫丹水下')) { - log.info("当前路线为水下路线,检查螃蟹技能"); - let skillRes = await findAndClick("assets/螃蟹技能图标.png", false, 1000); - if (!skillRes || lastMapName != mapName) { - log.info("识别到没有螃蟹技能或上一条路线处于其他地图,前往获取螃蟹技能"); - - if (mapName === "SeaOfBygoneEras") { - await pathingScript.runFile("assets/学习螃蟹技能2.json"); - } - else { - await pathingScript.runFile("assets/学习螃蟹技能1.json"); - } - } - } - lastMapName = mapName; - const pickupTask = recognizeAndInteract(); - - try { - await pathingScript.runFile(filePath); - } catch (e) { - log.error(`优先采集路线执行失败: ${filePath}`); - state.running = false; await pickupTask; continue; - } - state.running = false; await pickupTask; - await fakeLog(fileName, false, false, 0); - state.runPickupLog.forEach(name => { - /* 就地展开:别名→本名数组,再把所有相关名称都计数 */ - const realNames = alias2Names.get(name) || [name]; // 可能是多个本名 - for (const rn of realNames) { - if (priorityItemSet.has(name) || priorityItemSet.has(rn)) { - pickedCounter[rn] = (pickedCounter[rn] || 0) + 1; - } - } - }); - - /* ===== 追加:立即把 pickedCounter 回写到 priorityList(双向扣减)===== */ - for (const task of priorityList) { - let picked = 0; - - /* 1. 字面名(可能是别名)直接扣 */ - picked += pickedCounter[task.itemName] || 0; - - /* 2. 别名→本名反向扣(多对一) */ - const realNames = alias2Names.get(task.itemName) || []; - for (const rn of realNames) picked += pickedCounter[rn] || 0; - - /* 3. 本名→别名顺向扣 */ - const others = name2Other.get(task.itemName) || []; - for (const a of others) picked += pickedCounter[a] || 0; - - task.count = Math.max(0, task.count - picked); - } - - /* 倒序删除已达标项 */ - for (let i = priorityList.length - 1; i >= 0; i--) { - if (priorityList[i].count <= 0) { - log.info(`优先材料已达标: ${priorityList[i].itemName}`); - priorityList.splice(i, 1); - } - } - - /* ================================================ */ - - /* 4-4 计算CD(掉落材料决定)*/ - const timeDiff = new Date() - startTime; - let pathRes = isArrivedAtEndPoint(filePath); - - // >>> 仅当 >10s 才记录 history;若同时 pathRes === true 再更新 CD <<< - if (timeDiff > 10000) { - /* ---------- 1. 先写 history(无条件) ---------- */ - const durationSec = Math.round(timeDiff / 1000); - const itemCounter = {}; - state.runPickupLog.forEach(n => { itemCounter[n] = (itemCounter[n] || 0) + 1; }); - if (!targetObj.history) targetObj.history = []; - targetObj.history.push({ items: itemCounter, durationSec }); - if (targetObj.history.length > 7) targetObj.history = targetObj.history.slice(-7); - - /* ---------- 2. 仅当 pathRes === true 才计算并更新 CD ---------- */ - if (pathRes) { - /* 2-1 判定本次有没有优先材料 */ - const hasPriority = state.runPickupLog.some(name => priorityItemSet.has(name)); - let hitMaterials; - if (hasPriority) { - hitMaterials = [...new Set(state.runPickupLog.filter(n => priorityItemSet.has(n)))]; - } else { - hitMaterials = [...new Set(state.runPickupLog)]; - } - - /* 2-2 按材料表取最晚 CD */ - let latestCD = new Date(0); - let foundAny = false; - hitMaterials.forEach(name => { - const cdType = materialCdMap[name] || "1次0点刷新"; - let tmpDate = new Date(startTime); - switch (cdType) { - case "1次0点刷新": - tmpDate.setDate(tmpDate.getDate() + 1); - tmpDate.setHours(0, 0, 0, 0); - break; - case "2次0点刷新": - tmpDate.setDate(tmpDate.getDate() + 2); - tmpDate.setHours(0, 0, 0, 0); - break; - case "3次0点刷新": - tmpDate.setDate(tmpDate.getDate() + 3); - tmpDate.setHours(0, 0, 0, 0); - break; - case "1次4点刷新": - tmpDate.setHours(4, 0, 0, 0); - if (tmpDate <= startTime) tmpDate.setDate(tmpDate.getDate() + 1); - break; - case "12小时刷新": - tmpDate = new Date(startTime.getTime() + 12 * 60 * 60 * 1000); - break; - case "24小时刷新": - tmpDate = new Date(startTime.getTime() + 24 * 60 * 60 * 1000); - break; - case "46小时刷新": - tmpDate = new Date(startTime.getTime() + 46 * 60 * 60 * 1000); - break; - default: - tmpDate.setDate(tmpDate.getDate() + 1); - tmpDate.setHours(0, 0, 0, 0); - } - if (tmpDate > latestCD) latestCD = tmpDate; - foundAny = true; - }); - - /* 兜底:没有任何材料被识别到,按1次0点刷新 */ - if (!foundAny) { - latestCD = new Date(startTime); - latestCD.setDate(latestCD.getDate() + 1); - latestCD.setHours(0, 0, 0, 0); - } - - targetObj.cdTime = latestCD.toISOString(); - } - - /* ---------- 3. 统一写文件 & 清空日志 ---------- */ - await file.writeText( - recordFilePath, - JSON.stringify(Array.from(cdMap.values()), null, 2) - ); - await appendDailyPickup(state.runPickupLog); - state.runPickupLog = []; - } - if (priorityList.length <= 0) { - log.info('每日优先材料已达标,退出优先采集阶段'); - notification.send('每日优先材料已达标,退出优先采集阶段'); - } - } - await sleep(1000); - } - let loopattempts = 0; - // ==================== 路径组循环 ==================== - while (loopattempts < 2) { - loopattempts++; - if (await isTimeRestricted(settings.timeRule, 10)) break; - for (let i = 1; i <= groupCount; i++) { - if (await isTimeRestricted(settings.timeRule, 10)) break; - const currentCdType = settings[`pathGroup${i}CdType`] || ""; - if (!currentCdType) continue; - - const folder = folderNames[i - 1] || `路径组${i}`; - const targetFolder = `pathing/${folder} `; - - log.info(`开始执行路径组${i} 文件夹:${folder}`); - notification.send(`开始执行路径组${i} 文件夹:${folder}`); - - /* 运行期同样用 Map 只改 cdTime */ - const rawRecord = await file.readText(recordFilePath); - let recordArray = JSON.parse(rawRecord); - const cdMap = new Map(recordArray.map(it => [it.fileName, it])); - - const groupFiles = await readFolder(targetFolder, true); - - if (userSettings.operationMode === "执行任务(若不存在索引文件则自动创建)") { - const groupNumber = i; - await genshin.returnMainUi(); - - try { - /* ================== 提前计算分均效率(所有模式通用) ================== */ - // 0) 解析优先关键词 - const priorityKeywords = settings.priorityTags - ? settings.priorityTags.split(',').map(s => s.trim()).filter(Boolean) - : []; - - // 1) 解析加权规则 - const weightMap = new Map(); - if (settings.weightedRule) { - settings.weightedRule - .split(',') - .map(s => s.trim()) - .forEach(rule => { - const [item, wStr] = rule.split('*'); - if (item && wStr) { - const w = Number(wStr); - weightMap.set(item, isNaN(w) ? 1 : w); - } - }); - } - - // 2) 先计算一次基础效率(未知路线先标 -1) - groupFiles.forEach(p => { - const fullName = basename(p.fullPath); - const obj = cdMap.get(fullName); - let avgEff = -1; // 先标记为“未知” - - if (obj && obj.history && obj.history.length >= 3) { - const effList = obj.history.map(log => { - const total = Object.entries(log.items).reduce((sum, [name, cnt]) => { - const w = blacklistSet.has(name) ? 0 : (weightMap.get(name) ?? 1); - return sum + cnt * w; - }, 0); - return (total / log.durationSec) * 60; - }); - avgEff = effList.reduce((a, b) => a + b, 0) / effList.length; - } - p._efficiency = avgEff; // 已知路线存真实效率,未知路线存 -1 - }); - - // 3) 计算默认效率(分位值 & 临界值 取最大) - const knownEff = groupFiles - .map(p => p._efficiency) - .filter(e => e >= 0) // 只保留已知路线 - .sort((a, b) => a - b); - - const userThreshold = Number(settings[`pathGroup${i}thresholdEfficiency`]) || 0; - - let defaultEff; - if (knownEff.length === 0) { - // 一条已知路线都没有 → 直接用临界值 - defaultEff = userThreshold; - } else { - // 按配置的分位取效率 - const rawPct = settings.defaultEffPercentile; - const pct = Math.max(0, Math.min(1, rawPct === "" ? 0.5 : Number(rawPct))); - const idx = Math.ceil(pct * knownEff.length) - 1; - const percentileEff = knownEff[Math.max(0, idx)]; - - // 关键改动:与临界值取最大 - defaultEff = Math.max(percentileEff, userThreshold); - } - - // 4) 把 -1 的未知路线替换成默认效率 - groupFiles.forEach(p => { - if (p._efficiency === -1) p._efficiency = defaultEff; - }); - - // 5) 计算全局最大效率值(已含默认效率) - const maxEff = Math.max(...groupFiles.map(p => p._efficiency), 0); - - // 6) 优先关键词加分(逻辑不变) - groupFiles.forEach(p => { - const fullName = basename(p.fullPath); - const obj = cdMap.get(fullName); - - const itemHit = obj?.history?.some(log => - Object.keys(log.items).some(item => - priorityKeywords.some(key => item.includes(key)) - ) - ); - const pathHit = priorityKeywords.some(key => p.fullPath.includes(key)); - const descHit = priorityKeywords.some(key => (p.description || '').includes(key)); - - if (itemHit || pathHit || descHit) { - p._efficiency += maxEff + 1; - } - }); - - /* ================== 排序分支 ================== */ - switch (settings.sortMode) { - case "优先最早刷新,将优先执行最早刷新的路线": - groupFiles.sort((a, b) => { - const nameA = basename(a.fullPath); - const nameB = basename(b.fullPath); - const timeA = cdMap.has(nameA) ? new Date(cdMap.get(nameA).cdTime) : new Date(0); - const timeB = cdMap.has(nameB) ? new Date(cdMap.get(nameB).cdTime) : new Date(0); - return timeA - timeB; // 越早刷新越靠前 - }); - break; - - case "优先最高效率,将优先执行最高分均拾取物的路线": - // 直接复用提前算好的 _efficiency - groupFiles.sort((a, b) => (b._efficiency || 0) - (a._efficiency || 0)); - break; - - default: - // 保持原有顺序,不做任何排序 - break; - } - - for (const filePath of groupFiles) { - const fileName = basename(filePath.fullPath).replace('.json', ''); - const fullName = fileName + '.json'; - const targetObj = cdMap.get(fullName); - const nextCD = targetObj ? new Date(targetObj.cdTime) : new Date(0); - - const startTime = new Date(); - if (startTime <= nextCD) { - log.info(`当前任务 ${fileName} 未刷新,跳过任务`); - continue; // 跳过,不写回 - } - if (await isTimeRestricted(settings.timeRule, 10)) break; - - let doSkip = false; - for (const kw of disableArray) { - if (filePath.fullPath.includes(kw)) { - log.info(`路径文件 ${filePath.fullPath} 包含禁用关键词 "${kw}",跳过任务 ${fileName}`); - doSkip = true; break; - } - } - if (doSkip) continue; - - // ===== 临界效率过滤 ===== - const routeEff = filePath._efficiency ?? 0; // 提前算好的分均效率 - const threshold = Number(settings[`pathGroup${i}thresholdEfficiency`]) || 0; - if (routeEff < threshold) { - log.info(`路线 ${fileName} 分均效率为 ${routeEff.toFixed(2)},低于设定的临界值 ${threshold},跳过`); - continue; - } - - let timeNow = new Date(); - if (Foods.length != 0 && (((timeNow - lastCookTime) > cookInterval) || firstCook)) { - firstCook = false; - await ingredientProcessing(); - lastCookTime = new Date(); - lastMapName = "Teyvat"; - } - - if (settings.setTimeMode && settings.setTimeMode != "不调节时间" && (((timeNow - lastsettimeTime) > settimeInterval) || firstsettime)) { - firstsettime = false; - if (settings.setTimeMode === "尽量调为白天") { - await pathingScript.runFile("assets/调为白天.json"); - } else { - await pathingScript.runFile("assets/调为夜晚.json"); - } - lastsettimeTime = new Date(); - } - - await switchPartyIfNeeded(partyNames[groupNumber - 1]); - - await fakeLog(fileName, false, true, 0); - - /* ========== 历史拾取物前置排序 ========== */ - // 0) 只有 history 里出现过的物品才需要前置 - const historyItemSet = new Set(); - const routeRec = cdMap.get(fullName); - if (routeRec?.history) { - routeRec.history.forEach(log => { - Object.keys(log.items).forEach(name => historyItemSet.add(name)); - }); - } - - // 1) 把 targetItems 拆成「历史出现」+「未出现」两部分 - const frontPart = []; - const backPart = []; - for (const it of targetItems) { - (historyItemSet.has(it.itemName) ? frontPart : backPart).push(it); - } - - // 2) 合并后重新赋值,完成前置 - targetItems = [...frontPart, ...backPart]; - /* ======================================= */ - - state.running = true; - const raw = file.readTextSync(filePath.fullPath); - const json = JSON.parse(raw); - const mapName = (json.info?.map_name && json.info.map_name.trim()) ? json.info.map_name : 'Teyvat'; - if (filePath.fullPath.includes('枫丹水下')) { - log.info("当前路线为水下路线,检查螃蟹技能"); - let skillRes = await findAndClick("assets/螃蟹技能图标.png", false); - if (!skillRes || lastMapName != mapName) { - log.info("识别到没有螃蟹技能或上一条路线处于其他地图,前往获取螃蟹技能"); - - if (mapName === "SeaOfBygoneEras") { - await pathingScript.runFile("assets/学习螃蟹技能2.json"); - } - else { - "assets/学习螃蟹技能1.json"; - } - } - } - lastMapName = mapName; - const pickupTask = recognizeAndInteract(); - - log.info(`当前进度:执行路线 ${fileName},路径组${i} ${folder} 第 ${groupFiles.indexOf(filePath) + 1}/${groupFiles.length} 个`); - log.info(`当前路线分均效率为 ${(filePath._efficiency ?? 0).toFixed(2)}`); - - try { - state.runPickupLog = []; // 新路线开始前清空 - await pathingScript.runFile(filePath.fullPath); - } catch (error) { - log.error(`路径文件 ${filePath.fullPath} 不存在或执行失败: ${error}`); - continue; - } - - try { await sleep(1); } - catch (error) { log.error(`发生错误: ${error}`); break; } - - const endTime = new Date(); - const timeDiff = endTime.getTime() - startTime.getTime(); - - await fakeLog(fileName, false, false, timeDiff); - state.running = false; - await pickupTask; - - let pathRes = isArrivedAtEndPoint(filePath.fullPath); - - // >>> 仅当 >10s 才记录 history;若同时 pathRes === true 再更新 CD <<< - if (timeDiff > 10000) { - /* ---------- 1. 先写 history(无条件) ---------- */ - const durationSec = Math.round(timeDiff / 1000); - const itemCounter = {}; - for (const name of state.runPickupLog) { - itemCounter[name] = (itemCounter[name] || 0) + 1; - } - const logEntry = { items: itemCounter, durationSec }; - if (!targetObj.history) targetObj.history = []; - targetObj.history.push(logEntry); - if (targetObj.history.length > 7) targetObj.history = targetObj.history.slice(-7); - - /* ---------- 2. 仅当 pathRes === true 才计算并更新 CD ---------- */ - if (pathRes) { - let newTimestamp = new Date(startTime); - switch (currentCdType) { - case "1次0点刷新": - newTimestamp.setDate(newTimestamp.getDate() + 1); - newTimestamp.setHours(0, 0, 0, 0); - break; - case "2次0点刷新": - newTimestamp.setDate(newTimestamp.getDate() + 2); - newTimestamp.setHours(0, 0, 0, 0); - break; - case "3次0点刷新": - newTimestamp.setDate(newTimestamp.getDate() + 3); - newTimestamp.setHours(0, 0, 0, 0); - break; - case "4点刷新": - newTimestamp.setHours(4, 0, 0, 0); - if (newTimestamp <= startTime) newTimestamp.setDate(newTimestamp.getDate() + 1); - break; - case "12小时刷新": - newTimestamp = new Date(startTime.getTime() + 12 * 60 * 60 * 1000); - break; - case "24小时刷新": - newTimestamp = new Date(startTime.getTime() + 24 * 60 * 60 * 1000); - break; - case "46小时刷新": - newTimestamp = new Date(startTime.getTime() + 46 * 60 * 60 * 1000); - break; - default: - newTimestamp = startTime; - break; - } - targetObj.cdTime = newTimestamp.toISOString(); - log.info(`本任务cd信息已更新,下一次可用时间为 ${newTimestamp.toLocaleString()}`); - } - - /* ---------- 3. 统一写文件 & 清空日志 ---------- */ - await file.writeText( - recordFilePath, - JSON.stringify(Array.from(cdMap.values()), null, 2) - ); - await appendDailyPickup(state.runPickupLog); - state.runPickupLog = []; - } - } - log.info(`路径组${groupNumber} 的所有任务运行完成`); - } catch (error) { - log.error(`读取路径组文件时出错: ${error}`); - } - } - } - await sleep(1000); - } - - } catch (error) { - log.error(`操作失败: ${error}`); - } - - //伪造js开始的日志 - await fakeLog("采集cd管理", true, true, 0); -})(); - -async function recognizeAndInteract() { - let lastcenterYF = 0, lastItemName = "", thisMoveUpTime = 0, lastMoveDown = 0, blacklistCounter = 0; - gameRegion = captureGameRegion(); - let lastCheckItemFull = new Date(); - let checkTask = null; - - while (state.running) { - let time1 = new Date(); - gameRegion.dispose(); - gameRegion = captureGameRegion(); - - if (new Date() - lastCheckItemFull > 2500 && !checkTask) { - lastCheckItemFull = new Date(); - checkTask = checkItemFullAndOCR(); - } - - let time2 = new Date(); - const centerYF = await findFIcon(); - - if (!centerYF) { - if (new Date() - lastRoll >= 200) { - lastRoll = new Date(); - if (await hasScroll()) { - await keyMouseScript.runFile(`assets/滚轮下翻.json`); - } - } - if (checkTask) { - try { await checkTask; } - catch (e) { log.error('背包满检查异常:', e); } - finally { checkTask = null; } - } - continue; - } - let time3 = new Date(); - let itemName = null; - itemName = await performTemplateMatch(centerYF); - if (itemName) { - if (Math.abs(lastcenterYF - centerYF) <= 20 && lastItemName === itemName) { - await sleep(160); - lastcenterYF = -20; - lastItemName = null; - if (checkTask) { - try { await checkTask; } - catch (e) { log.error('背包满检查异常:', e); } - finally { checkTask = null; } - } - continue; - } - if (!blacklistSet.has(itemName)) { - keyPress("F"); - log.info(`交互或拾取:"${itemName}"`); - let time4 = new Date(); - /* >>> 提到最前 begin >>> */ - const idx = targetItems.findIndex(it => it.itemName === itemName); - if (idx > 0) { - const [it] = targetItems.splice(idx, 1); - targetItems.unshift(it); - } - /* <<< 提到最前 end <<< */ - state.runPickupLog.push(itemName); - - lastcenterYF = centerYF; - lastItemName = itemName; - let time5 = new Date(); - //log.info(`调试-截图用时${time2 - time1},找f用时${time3 - time2},匹配用时${time4 - time3},后处理用时${time5 - time4}`); - await sleep(pickupDelay); - } - } else { - lastItemName = ""; - } - const currentTime = Date.now(); - if (currentTime - lastMoveDown > timeMoveUp) { - await keyMouseScript.runFile(`assets/滚轮下翻.json`); - if (thisMoveUpTime === 0) thisMoveUpTime = currentTime; - if (currentTime - thisMoveUpTime >= timeMoveDown) { - lastMoveDown = currentTime; - thisMoveUpTime = 0; - } - } else { - await keyMouseScript.runFile(`assets/滚轮上翻.json`); - } - await sleep(rollingDelay); - if (checkTask) { - try { await checkTask; } - catch (e) { log.error('背包满检查异常:', e); } - finally { checkTask = null; } - } - } -} - -async function findFIcon() { - try { - const r = gameRegion.find(FiconRo); - if (r.isExist()) return Math.round(r.y + r.height / 2); - } catch (e) { - log.error(`findFIcon:${e.message}`); - } - await sleep(checkDelay); - return null; -} - -async function performTemplateMatch(centerYF) { - /* 一次性切 6 种宽度(0-5 汉字) */ - const regions = []; - for (let cn = 0; cn <= 6; cn++) { // 0~5 共 6 档 - const w = 12 + 28 * Math.min(cn, 5) + 2; - regions[cn] = gameRegion.DeriveCrop(1219, centerYF - 15, w, 30); - } - - try { - for (const it of targetItems) { - const cnLen = Math.min( - [...it.itemName].filter(c => c >= '\u4e00' && c <= '\u9fff').length, - 5 - ); // 0-5 - - if (regions[cnLen].find(it.roi).isExist()) { - return it.itemName; - } - } - } catch (e) { - log.error(`performTemplateMatch: ${e.message}`); - } finally { - regions.forEach(r => r.dispose()); - } - return null; -} - -async function isMainUI() { - for (let i = 0; i < 1 && state.running; i++) { - if (!gameRegion) gameRegion = captureGameRegion(); - try { - if (gameRegion.find(mainUiRo).isExist()) { - return true; - } - } catch (e) { - log.error(`isMainUI:${e.message}`); - } - await sleep(checkDelay); - } - return false; -} - -async function checkItemFullAndOCR() { - const fullRoi = RecognitionObject.TemplateMatch(itemFullTemplate, 0, 0, 1920, 1080); - try { - if (!gameRegion.find(fullRoi).isExist()) return; - } catch (e) { return; } - const TEXT_X = 560, TEXT_Y = 450, TEXT_W = 800, TEXT_H = 170; - let ocrText = null; - try { - const list = gameRegion.findMulti(RecognitionObject.ocr(TEXT_X, TEXT_Y, TEXT_W, TEXT_H)); - if (list.count) { - let longest = list[0]; - for (let i = 1; - i < list.count; - i++) if (list[i].text.length > longest.text.length) longest = list[i]; - ocrText = longest.text.replace(/[^\u4e00-\u9fa5]/g, ''); - } - } catch (e) { - log.error(`OCR:${e.message}`); - } if (!ocrText) return; - log.info(`背包满OCR:${ocrText}`); - - function calcMatchRatio(cnPart, txt) { - if (!cnPart || !txt) return 0; - const len = cnPart.length; - let maxMatch = 0; - for (let i = 0; i <= txt.length - len; i++) { - let match = 0; - for (let j = 0; j < len; j++) { - if (txt[i + j] === cnPart[j]) match++; - maxMatch = Math.max(maxMatch, match); - } - } - return maxMatch / len; - } - const ratioMap = new Map(); - for (const it of targetItems) { - const candNames = [it.itemName, ...(it.otherName || [])]; - let maxRatioThisItem = 0; - for (const name of candNames) { - const ratio = calcMatchRatio(name.replace(/[^\u4e00-\u9fa5]/g, ''), ocrText); - if (ratio > maxRatioThisItem) maxRatioThisItem = ratio; - } - if (maxRatioThisItem > 0.75) { - const oldMax = ratioMap.get(it.itemName) || 0; - if (maxRatioThisItem > oldMax) ratioMap.set(it.itemName, maxRatioThisItem); - } - } - if (ratioMap.size === 0) return; - const maxRatio = Math.max(...ratioMap.values()); - const names = [...ratioMap.entries()].filter(([, r]) => r === maxRatio).map(([n]) => n).sort(); - log.warn(`背包满,黑名单加入:${names.join('、')}(${(maxRatio * 100).toFixed(1)}%)`); - for (const n of names) { - blacklistSet.add(n); - blacklist.push(n); - } - await loadBlacklist(true); -} - -// 加载拾取物图片 -async function loadTargetItems() { - const targetItemPath = "assets/targetItems/"; - - const items = await readFolder(targetItemPath, false); - - for (const it of items) { - try { - it.template = file.ReadImageMatSync(it.fullPath); - it.itemName = it.fileName.replace(/\.png$/i, ''); - it.roi = RecognitionObject.TemplateMatch(it.template); - - /* ---------- 1. 解析小括号阈值 ---------- */ - const match = it.fullPath.match(/[((](.*?)[))]/); - const itsThreshold = (match => { - if (!match) return 0.9; - const v = parseFloat(match[1]); - return !isNaN(v) && v >= 0 && v <= 1 ? v : 0.9; - })(match); - it.roi.Threshold = itsThreshold; - it.roi.InitTemplate(); - - /* ---------- 2. 解析中括号内容 + 纯中文过滤 ---------- */ - const otherNames = new Set(); - - // 一次性扫描完整路径里的所有 [] - for (const m of it.fullPath.matchAll(/\[(.*?)\]/g)) { - const pure = (m[1] || '').replace(/[^\u4e00-\u9fff]/g, '').trim(); - if (pure) otherNames.add(pure); - } - - // 若 itemName 本身含非中文,也生成纯中文别名 - const namePure = it.itemName.replace(/[^\u4e00-\u9fff]/g, '').trim(); - if (namePure && namePure !== it.itemName) otherNames.add(namePure); - - it.otherName = Array.from(otherNames); - - } catch (error) { - log.error(`[loadTargetItems] ${it.fullPath}: ${error.message}`); - } - } - return items; -} - -async function loadBlacklist(writeBack) { - try { - const raw = await file.readText(`blacklists/${accountName}.json`); - blacklist = [...new Set([...blacklist, ...JSON.parse(raw)])]; - } catch { /* 文件不存在就跳过 */ } - blacklistSet = new Set(blacklist); - - // 仅把 blacklist 中的中文部分合并到内存中的 settings.disableJsons - const chineseParts = blacklist - .map(name => name.replace(/[^\u4e00-\u9fa5]/g, '')) - .filter(Boolean); - - const existing = settings.disableJsons - ? settings.disableJsons.split(';').map(s => s.trim()).filter(Boolean) - : []; - - const merged = [...new Set([...existing, ...chineseParts])].sort().join(';'); - settings.disableJsons = merged; - - if (writeBack) { - await file.writeText(`blacklists/${accountName}.json`, JSON.stringify(blacklist, null, 2), false); - } - // 实时同步禁用关键词数组 - disableArray = settings.disableJsons.split(';').map(s => s.trim()).filter(Boolean); -} - -// fakeLog 函数,使用方法:将本函数放在主函数前,调用时请务必使用await,否则可能出现v8白框报错 -//在js开头处伪造该js结束运行的日志信息,如 await fakeLog("js脚本", true, true, 0); -//在js结尾处伪造该js开始运行的日志信息,如 await fakeLog("js脚本", true, false, 2333); -//duration项目仅在伪造结束信息时有效,且无实际作用,可以任意填写,当你需要在日志中输出特定值时才需要,单位为毫秒 -//在调用地图追踪前伪造该地图追踪开始运行的日志信息,如 await fakeLog(`地图追踪.json`, false, true, 0); -//在调用地图追踪后伪造该地图追踪结束运行的日志信息,如 await fakeLog(`地图追踪.json`, false, false, 0); -//如此便可以在js运行过程中伪造地图追踪的日志信息,可以在日志分析等中查看 - -async function fakeLog(name, isJs, isStart, duration) { - await sleep(1); - const currentTime = Date.now(); - // 参数检查 - if (typeof name !== 'string') { - log.error("参数 'name' 必须是字符串类型!"); - return; - } - if (typeof isJs !== 'boolean') { - log.error("参数 'isJs' 必须是布尔型!"); - return; - } - if (typeof isStart !== 'boolean') { - log.error("参数 'isStart' 必须是布尔型!"); - return; - } - if (typeof currentTime !== 'number' || !Number.isInteger(currentTime)) { - log.error("参数 'currentTime' 必须是整数!"); - return; - } - if (typeof duration !== 'number' || !Number.isInteger(duration)) { - log.error("参数 'duration' 必须是整数!"); - return; - } - - // 将 currentTime 转换为 Date 对象并格式化为 HH:mm:ss.sss - const date = new Date(currentTime); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - const milliseconds = String(date.getMilliseconds()).padStart(3, '0'); - const formattedTime = `${hours}:${minutes}:${seconds}.${milliseconds}`; - - // 将 duration 转换为分钟和秒,并保留三位小数 - const durationInSeconds = duration / 1000; // 转换为秒 - const durationMinutes = Math.floor(durationInSeconds / 60); - const durationSeconds = (durationInSeconds % 60).toFixed(3); // 保留三位小数 - - // 使用四个独立的 if 语句处理四种情况 - if (isJs && isStart) { - // 处理 isJs = true 且 isStart = true 的情况 - const logMessage = `正在伪造js开始的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 开始执行JS脚本: "${name}"`; - log.debug(logMessage); - } - if (isJs && !isStart) { - // 处理 isJs = true 且 isStart = false 的情况 - const logMessage = `正在伪造js结束的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------`; - log.debug(logMessage); - } - if (!isJs && isStart) { - // 处理 isJs = false 且 isStart = true 的情况 - const logMessage = `正在伪造地图追踪开始的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 开始执行地图追踪任务: "${name}"`; - log.debug(logMessage); - } - if (!isJs && !isStart) { - // 处理 isJs = false 且 isStart = false 的情况 - const logMessage = `正在伪造地图追踪结束的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------`; - log.debug(logMessage); - } -} - -// 定义自定义函数 basename,用于获取文件名 -function basename(filePath) { - const lastSlashIndex = filePath.lastIndexOf('\\'); // 或者使用 '/',取决于你的路径分隔符 - return filePath.substring(lastSlashIndex + 1); -} - -// 定义自定义函数 removeJsonSuffix,用于移除文件名中的 .json 后缀 -function removeJsonSuffix(fileName) { - if (fileName.endsWith('.json')) { - return fileName.substring(0, fileName.length - 5); // 移除 .json 后缀 - } - return fileName; -} - -// 定义 readFolder 函数 -async function readFolder(folderPath, onlyJson) { - const folderStack = [folderPath]; - const files = []; - - while (folderStack.length > 0) { - const currentPath = folderStack.pop(); - const filesInSubFolder = file.ReadPathSync(currentPath); // 同步读取 - const subFolders = []; - - for (const filePath of filesInSubFolder) { - if (file.IsFolder(filePath)) { - subFolders.push(filePath); - continue; - } - - if (filePath.endsWith('.js')) continue; // 跳过 js - - // 仅 json 模式 - if (onlyJson) { - if (!filePath.endsWith('.json')) continue; - - let description = ''; - try { - // 同步读文本,避免 async 传染 - const txt = file.readTextSync(filePath); - const parsed = JSON.parse(txt); - description = parsed?.info?.description ?? ''; - } catch { - /* 读盘或解析失败就留空串 */ - } - - const fileName = filePath.split('\\').pop(); - const folderPathArray = filePath.split('\\').slice(0, -1); - - files.push({ - fullPath: filePath, - fileName, - folderPathArray, - description - }); - continue; - } - - const fileName = filePath.split('\\').pop(); - const folderPathArray = filePath.split('\\').slice(0, -1); - files.push({ fullPath: filePath, fileName, folderPathArray }); - } - - // 子文件夹按原顺序入栈(深度优先) - folderStack.push(...subFolders.reverse()); - } - - return files; -} - -/** - * 带缓存的配队切换函数 - * 如果目标配队与 currentParty 一致则跳过;否则真正切换并更新 currentParty。 - * @param {string} partyName 期望切换到的配队名称 - */ -async function switchPartyIfNeeded(partyName) { - if (!partyName) { // 空名直接回主界面 - await genshin.returnMainUi(); - return; - } - - if (partyName === currentParty) { // 缓存命中,跳过切换 - await genshin.returnMainUi(); - return; - } - - /* 真正切换 */ - try { - log.info(`正在尝试切换至配队「${partyName}」`); - let success = await genshin.switchParty(partyName); - if (!success) { // 第一次失败,去神像再试一次 - log.info('切换失败,前往七天神像重试'); - await genshin.tpToStatueOfTheSeven(); - success = await genshin.switchParty(partyName); - } - - if (success) { // 切换成功,更新缓存 - currentParty = partyName; - log.info(`已切换至配队「${partyName}」并更新缓存`); - } else { - throw new Error('两次切换均失败'); - } - } catch (e) { - log.error('队伍切换失败,可能处于联机模式或其他不可切换状态'); - notification.error('队伍切换失败,可能处于联机模式或其他不可切换状态'); - await genshin.returnMainUi(); - } -} - -/** - * 检查当前时间是否处于限制时间内或即将进入限制时间 - * @param {string} timeRule - 时间规则字符串,格式如 "8, 8-11, 23:11-23:55" - * @param {number} [threshold=5] - 接近限制时间的阈值(分钟) - * @returns {Promise} - 如果处于限制时间内或即将进入限制时间,则返回 true,否则返回 false - */ -async function isTimeRestricted(timeRule, threshold = 5) { - if (!timeRule) return false; - - // 兼容中英文逗号、冒号 - const ruleClean = timeRule - .replace(/,/g, ',') - .replace(/:/g, ':'); - - const now = new Date(); - const currentHour = now.getHours(); - const currentMinute = now.getMinutes(); - const currentTotal = currentHour * 60 + currentMinute; - - for (const seg of ruleClean.split(',').map(s => s.trim())) { - if (!seg) continue; - - let startStr, endStr; - if (seg.includes('-')) { - [startStr, endStr] = seg.split('-').map(s => s.trim()); - } else { - startStr = endStr = seg.trim(); - } - - const parseTime = (str, isEnd) => { - if (str.includes(':')) { - const [h, m] = str.split(':').map(Number); - return { h, m }; - } - // 单独小时:start 8→8:00,end 8→8:59 - const h = Number(str); - return { h, m: isEnd ? 59 : 0 }; - }; - - const start = parseTime(startStr, false); - const end = parseTime(endStr, true); - - const startTotal = start.h * 60 + start.m; - const endTotal = end.h * 60 + end.m; - - const effectiveEnd = endTotal >= startTotal ? endTotal : endTotal + 24 * 60; - - if ( - (currentTotal >= startTotal && currentTotal < effectiveEnd) || - (currentTotal + 24 * 60 >= startTotal && currentTotal + 24 * 60 < effectiveEnd) - ) { - log.warn("处于限制时间内"); - return true; - } - - let nextStartTotal = startTotal; - if (nextStartTotal <= currentTotal) nextStartTotal += 24 * 60; - const waitMin = nextStartTotal - currentTotal; - if (waitMin > 0 && waitMin <= threshold) { - log.warn(`接近限制时间,等待 ${waitMin} 分钟`); - await genshin.tpToStatueOfTheSeven(); - await sleep(waitMin * 60 * 1000); - return true; - } - } - return false; -} - -/** -* 食材加工主函数,用于自动前往指定地点进行食材的加工 -* -* 该函数会根据 Foods 数组中的食材名称,依次查找并制作对应的料食材 -* 支持调味品类食材(直接在“食材加工”界面查找) -* -* @returns {Promise} 无返回值,执行完所有加工流程后退出 -*/ -async function ingredientProcessing() { - const targetFoods = [ - "面粉", "兽肉", "鱼肉", "神秘的肉", "黑麦粉", "奶油", "熏禽肉", - "黄油", "火腿", "糖", "香辛料", "酸奶油", "蟹黄", "果酱", - "奶酪", "培根", "香肠" - ]; - if (Foods.length == 0) { log.error("未选择要加工的食材"); return; } - const taskList = Foods.map((name) => `${name}`).join(","); - const tasks = Foods.map((name) => ({ - name, - done: false - })); - log.info(`本次加工食材:${taskList}`); - const stove = "蒙德炉子"; - log.info(`正在前往${stove}进行食材加工`); - - try { - let filePath = `assets/${stove}.json`; - await pathingScript.runFile(filePath); - } catch (error) { - log.error(`执行 ${stove} 路径时发生错误`); - return; - } - - const res1 = await findPNG("交互烹饪锅"); - if (res1) { - keyPress("F"); - } else { - log.warn("烹饪按钮未找到,正在寻找……"); - let attempts = 0; - const maxAttempts = 3; - let foundInRetry = false; - while (attempts < maxAttempts) { - log.info(`第${attempts + 1}次尝试寻找烹饪按钮`); - keyPress("W"); - const res2 = await findPNG("交互烹饪锅"); - if (res2) { - keyPress("F"); - foundInRetry = true; - break; - } else { - attempts++; - await sleep(500); - } - } - if (!foundInRetry) { - log.error("多次未找到烹饪按钮,放弃"); - return; - } - } - await clickPNG("食材加工"); - - /* ===== 1. 公共加工流程 ===== */ - async function doCraft(i) { - await clickPNG("制作"); - await sleep(300); - - /* ---------- 1. 队列已满 ---------- */ - if (await findPNG("队列已满", 1)) { - log.warn(`检测到${tasks[i].name}队列已满,等待图标消失`); - while (await findPNG("队列已满", 1)) { - log.warn(`检测到${tasks[i].name}队列已满,等待图标消失`); - await sleep(300); - } - if (await clickPNG("全部领取", 3)) { - await clickPNG("点击空白区域继续"); - await findPNG("食材加工2"); - await sleep(100); - } - return false; - } - - /* ---------- 2. 材料不足 ---------- */ - if (await findPNG("材料不足", 1)) { - log.warn(`检测到${tasks[i].name}材料不足,等待图标消失`); - while (await findPNG("材料不足", 1)) { - log.warn(`检测到${tasks[i].name}材料不足,等待图标消失`); - await sleep(300); - } - if (await clickPNG("全部领取", 3)) { - await clickPNG("点击空白区域继续"); - await findPNG("食材加工2"); - await sleep(100); - } - Foods.splice(i, 1); - - return false; - } - - /* ---------- 3. 正常加工流程 ---------- */ - await findPNG("选择加工数量"); - click(960, 460); - await sleep(800); - inputText(String(99)); - - log.info(`尝试制作${tasks[i].name} 99个`); - await clickPNG("确认加工"); - await sleep(500); - - /* ---------- 4. 已不能持有更多 ---------- */ - if (await findPNG("已不能持有更多", 1)) { - log.warn(`检测到${tasks[i].name}已满,等待图标消失`); - while (await findPNG("已不能持有更多", 1)) { - log.warn(`检测到${tasks[i].name}已满,等待图标消失`); - await sleep(300); - } - if (await clickPNG("全部领取", 3)) { - await clickPNG("点击空白区域继续"); - await findPNG("食材加工2"); - await sleep(100); - } - Foods.splice(i, 1); - - return false; - } - - await sleep(200); - /* 正常完成:仅领取,不移除 */ - if (await clickPNG("全部领取", 3)) { - let dowait = false; - await sleep(4 * checkInterval); - while (await findPNG("道具数量超过上限")) { - await sleep(checkInterval * 4); - log.info("识别到道具数量超过上限,等待消失"); - dowait = true; - } - if (dowait) { - await sleep(10 * checkInterval) - } - await clickPNG("点击空白区域继续"); - await findPNG("食材加工2"); - await sleep(100); - } - } - - /* ===== 2. 两轮扫描 ===== */ - // 进入界面先领取一次 - if (await clickPNG("全部领取", 3)) { - let dowait = false; - await sleep(4 * checkInterval); - while (await findPNG("道具数量超过上限")) { - await sleep(checkInterval * 4); - log.info("识别到道具数量超过上限,等待消失"); - dowait = true; - } - if (dowait) { - await sleep(10 * checkInterval) - } - await clickPNG("点击空白区域继续"); - await findPNG("食材加工2"); - await sleep(100); - } - - let lastSuccess = true; - for (let i = 0; i < tasks.length; i++) { - if (!targetFoods.includes(tasks[i].name)) continue; - - const retry = lastSuccess ? 5 : 1; - if (await clickPNG(`${tasks[i].name}1`, retry)) { - log.info(`${tasks[i].name}已找到`); - await doCraft(i); - tasks[i].done = true; - lastSuccess = true; // 记录成功 - } else { - lastSuccess = false; // 记录失败 - } - } - - const remain1 = tasks.filter(t => !t.done).map(t => `${t.name}`).join(",") || "无"; - log.info(`剩余待加工食材:${remain1}`); - - if (remain1 === "无") { - log.info("所有食材均已加工完毕,跳过第二轮扫描"); - await genshin.returnMainUi(); - return; - } - - const rg = captureGameRegion(); - const foodItems = []; - try { - for (const flag of ['已加工0个', '已加工1个']) { - const mat = file.ReadImageMatSync(`assets/RecognitionObject/${flag}.png`); - const res = rg.findMulti(RecognitionObject.TemplateMatch(mat)); - for (let k = 0; k < res.count; ++k) { - foodItems.push({ x: res[k].x, y: res[k].y }); - } - mat.dispose(); - } - } finally { rg.dispose(); } - - log.info(`识别到${foodItems.length}个加工中食材`); - - for (const item of foodItems) { - click(item.x, item.y); await sleep(1 * checkInterval); - click(item.x, item.y); await sleep(3 * checkInterval); - - for (let round = 0; round < 5; round++) { - const rg = captureGameRegion(); - try { - let hit = false; - - /* 直接扫 tasks,模板已挂在 task.ro */ - for (const task of tasks) { - if (task.done) continue; - if (!targetFoods.includes(task.name)) continue; - - /* 首次使用再加载,避免重复 IO */ - if (!task.ro) { - task.ro = RecognitionObject.TemplateMatch( - file.ReadImageMatSync(`assets/RecognitionObject/${task.name}2.png`) - ); - task.ro.Threshold = 0.9; - task.ro.InitTemplate(); - } - - if (!task.ro) { - log.warn(`${task.name}2.png 不存在,跳过识别`); - continue; - } - const res = rg.find(task.ro); - if (res.isExist()) { - log.info(`${task.name}已找到`); - await doCraft(tasks.indexOf(task)); - task.done = true; - hit = true; - break; // 一轮只处理一个 - } - } - - if (hit) break; // 本轮已命中,跳出 round - } finally { - rg.dispose(); - } - } - } - - const remain = tasks.filter(t => !t.done).map(t => `${t.name}`).join(",") || "无"; - log.info(`剩余待加工食材:${remain}`); - - - - await genshin.returnMainUi(); -} - -/** - * 把本次路线的掉落合并到“拾取记录.json”中同一天条目(不含 durationSec) - * @param {string[]} pickupLog 本次路线的 state.runPickupLog - */ -async function appendDailyPickup(pickupLog) { - if (!pickupLog || !pickupLog.length) return; - - let oldArr = []; - try { - const txt = await file.readText(pickupRecordFile); - if (txt) oldArr = JSON.parse(txt); - } catch (_) { /* 文件不存在或解析失败 */ } - - // 统一按 UTC+8 的 4 点划分日期 - const utc8_4am = new Date(Date.now() + 8 * 3600_000 - 4 * 3600_000); - const today = utc8_4am.toISOString().slice(0, 10); // "YYYY-MM-DD" - - let todayItem = oldArr.find(e => e.date === today); - if (!todayItem) { - todayItem = { date: today, items: {} }; - oldArr.push(todayItem); - } - - const todayItems = todayItem.items; - pickupLog.forEach(name => { - todayItems[name] = (todayItems[name] || 0) + 1; - }); - - // 滑动窗口:只保留最新 MAX_PICKUP_DAYS 条 - oldArr.sort((a, b) => b.date.localeCompare(a.date)); // 先排序 - if (oldArr.length > MAX_PICKUP_DAYS) oldArr = oldArr.slice(0, MAX_PICKUP_DAYS); // 再截断 - - // 写盘 + 异常捕获 - try { - await file.writeText(pickupRecordFile, JSON.stringify(oldArr, null, 2), false); - } catch (error) { - log.error(`appendDailyPickup 写盘失败: ${error.message}`); - } -} - -async function clickPNG(png, maxAttempts = 20) { - //log.info(`调试-点击目标${png},重试次数${maxAttempts}`); - const pngRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync(`assets/RecognitionObject/${png}.png`)); - pngRo.Threshold = 0.95; - pngRo.InitTemplate(); - return await findAndClick(pngRo, true, maxAttempts * checkInterval, checkInterval); -} - -async function findPNG(png, maxAttempts = 20) { - //log.info(`调试-识别目标${png},重试次数${maxAttempts}`); - const pngRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync(`assets/RecognitionObject/${png}.png`)); - pngRo.Threshold = 0.95; - pngRo.InitTemplate(); - return await findAndClick(pngRo, false, maxAttempts * checkInterval, checkInterval); -} - -/** - * 通用找图/找RO并可选点击(支持单图片文件路径、单RO、图片文件路径数组、RO数组) - * @param {string|string[]|RecognitionObject|RecognitionObject[]} target - * @param {boolean} [doClick=true] 是否点击 - * @param {number} [timeout=3000] 识别时间上限(ms) - * @param {number} [interval=50] 识别间隔(ms) - * @param {number} [retType=0] 0-返回布尔;1-返回 Region 结果 - * @param {number} [preClickDelay=50] 点击前等待 - * @param {number} [postClickDelay=50] 点击后等待 - * @returns {boolean|Region} 根据 retType 返回是否成功或最终 Region - */ -async function findAndClick(target, - doClick = true, - timeout = 3000, - interval = 50, - retType = 0, - preClickDelay = 50, - postClickDelay = 50) { - try { - // 1. 统一转成 RecognitionObject 数组 - let ros = []; - if (Array.isArray(target)) { - ros = target.map(t => - (typeof t === 'string') - ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(t)) - : t - ); - } else { - ros = [(typeof target === 'string') - ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(target)) - : target]; - } - - const start = Date.now(); - let found = null; - - while (Date.now() - start <= timeout) { - const gameRegion = captureGameRegion(); - try { - // 依次尝试每一个 ro - for (const ro of ros) { - const res = gameRegion.find(ro); - if (!res.isEmpty()) { // 找到 - found = res; - if (doClick) { - await sleep(preClickDelay); - res.click(); - await sleep(postClickDelay); - } - break; // 成功即跳出 for - } - } - if (found) break; // 成功即跳出 while - } finally { - gameRegion.dispose(); - } - await sleep(interval); // 没找到时等待 - } - - // 3. 按需返回 - return retType === 0 ? !!found : (found || null); - - } catch (error) { - log.error(`执行通用识图时出现错误:${error.message}`); - return retType === 0 ? false : null; - } -} - -/** - * 判断当前人物是否已到达指定路线的终点 - * @param {string} fullPath 路线文件完整路径(.json) - * @returns {boolean} true = 已到达;false = 未到达/读文件失败/取坐标失败 - */ -function isArrivedAtEndPoint(fullPath) { - try { - if (settings.disableXYCheck) { - log.info("当前禁用了坐标校验,跳过坐标检查") - return true; - } - /* 1. 读路线文件,取终点坐标 */ - const raw = file.readTextSync(fullPath); - const json = JSON.parse(raw); - if (!Array.isArray(json.positions)) return false; - - let endX = 0, endY = 0; - for (let i = json.positions.length - 1; i >= 0; i--) { - const p = json.positions[i]; - if (p.type !== 'orientation' && - typeof p.x === 'number' && - typeof p.y === 'number') { - endX = p.x; - endY = p.y; - break; - } - } - if (endX === 0 && endY === 0) return false; // 没找到有效点 - - /* 2. 取当前人物坐标 */ - - const mapName = (json.info?.map_name && json.info.map_name.trim()) ? json.info.map_name : 'Teyvat'; - const pos = genshin.getPositionFromMap(mapName, 3000); - const curX = pos.X; - const curY = pos.Y; - - let pathres = Math.abs(endX - curX) + Math.abs(endY - curY) <= 30; - if (!pathres) { - log.warn(`距离预定终点${Math.abs(endX - curX) + Math.abs(endY - curY)}`); - log.warn(`距离异常,不记录数据`); - } - /* 3. 曼哈顿距离 ≤30 视为到达 */ - return pathres; - } catch (error) { - /* 任何异常(读盘失败、解析失败、API 异常)都算“未到达” */ - log.warn(`出现异常${error.message},不记录cd`); return false; } + return true; } /** - * 判断当前是否存在拾取滚轮图标 - * @param {number} maxDuration 最大允许耗时(毫秒) + * 初始化设置和记录文件 + * 读取用户设置,初始化全局变量,加载目标物品和黑名单,创建或更新索引文件 + * + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - settings: 用户设置对象 + * - groupCount: 路径组数量 + * - folderNames: 文件夹名称数组 + * - partyNames: 配队名称数组 + * - accountName: 账户名称 + * - recordFolder: 记录文件夹路径 + * - subFolderName: 子文件夹名称 + * - subFolderPath: 子文件夹路径 + * - pickupRecordFile: 拾取记录文件路径 + * - operationMode: 操作模式 + * - targetItems: 目标物品数组 + * - name2Other: 本名到别名数组的映射 + * - alias2Names: 别名到本名数组的映射 + * - state: 状态对象 + * - recordFilePath: 记录文件路径 + * - processingIngredient: 食材加工列表 + * - Foods: 食材数组 + * - materialCdMap: 材料CD映射表 + * + * @依赖辅助函数: + * - loadTargetItems: 加载目标物品函数 + * - loadBlacklist: 加载黑名单函数 + * - fakeLog: 模拟日志函数 + * - readFolder: 读取文件夹函数 + * - basename: 获取文件基本名函数 */ -async function hasScroll(maxDuration = 10) { - const start = Date.now(); - let dodispose = false; - while (Date.now() - start < maxDuration) { - if (!gameRegion) { - gameRegion = captureGameRegion(); - dodispose = true; +async function initializeSetup() { + /* ===== 读取新 settings ===== */ + groupCount = Math.min(99, Math.max(1, parseInt(settings.groupCount || '3'))); + folderNames = []; + partyNames = []; + for (let g = 1; g <= groupCount; g++) { + folderNames.push(settings[`pathGroup${g}FolderName`] || ''); + partyNames.push(settings[`pathGroup${g}PartyName`] || ''); + } + + // 获取子文件夹路径 + subFolderName = accountName; + subFolderPath = `${recordFolder}/${subFolderName}`; + pickupRecordFile = `${recordFolder}/${subFolderName}/拾取记录.json`; + + // 读取子文件夹中的所有文件路径 + const filesInSubFolder = file.ReadPathSync(subFolderPath); + + // 检查优先顺序:record.json > record.txt + let indexDoExist = false; + let useJson = false; + for (const filePath of filesInSubFolder) { + const fileName = basename(filePath); + if (fileName === "record.json") { + indexDoExist = true; + useJson = true; + break; } - try { - const result = gameRegion.find(scrollRo); - if (result.isExist()) return true; - } catch (error) { - log.error(`识别图像时发生异常: ${error.message}`); - return false; // 一旦出现异常直接退出,不再重试 - } - await sleep(checkDelay); // 识别间隔 - if (dodispose) { - gameRegion.dispose(); - dodispose = false; // 已经释放,标记避免重复 dispose + if (fileName === "record.txt") { + indexDoExist = true; + useJson = false; } } - /* 超时仍未识别到,返回失败 */ - return false; -} \ No newline at end of file + + if (operationMode === "重新生成索引文件(用于强制刷新CD)") { + log.info("重新生成索引文件模式,将覆盖现有索引文件"); + } + if (!indexDoExist) { + log.info("文件不存在,将尝试生成索引文件"); + } + + /* 禁用BGI原生拾取,强制模板匹配 */ + targetItems = await loadTargetItems(); + /* ===== 别名索引 ===== */ + name2Other = new Map(); // 本名 → 别名数组 + alias2Names = new Map(); // 别名 → 本名数组(支持多对一) + for (const it of targetItems) { + const aliases = it.otherName || []; + name2Other.set(it.itemName, aliases); + for (const a of aliases) { + if (!alias2Names.has(a)) alias2Names.set(a, []); + alias2Names.get(a).push(it.itemName); // 一个别名可指向多个本名 + } + } + + await loadBlacklist(true); + state.running = true; + + await fakeLog("采集cd管理", true, false, 1000); + + // 统一的 record.json 文件路径 + recordFilePath = `${subFolderPath}/record.json`; + + // 读取 pathing 文件夹下的所有 .json 文件 + const pathingFolder = "pathing"; + const files = await readFolder(pathingFolder, true); + const filePaths = files.map(file => file.fullPath); + + // ① 先加载已有记录(整对象) + let recordArray = []; + if (indexDoExist && useJson) { + try { recordArray = JSON.parse(await file.readText(recordFilePath)); } catch (e) { } + } else if (indexDoExist && !useJson) { + try { + const txt = await file.readText(`${subFolderPath}/record.txt`); + txt.trim().split('\n').forEach(line => { + const [n, t] = line.trim().split('::'); + if (n && t) recordArray.push({ fileName: n + '.json', cdTime: t }); + }); + } catch (e) { } + } + + // ② 建 Map 确保 history 存在 + const existMap = new Map(recordArray.map(it => [it.fileName, { + ...it, + history: it.history || [] // 补空数组 + }])); + + // ③ 对 pathing 里存在的路线:只更新 cdTime,其余保留 + const defaultTime = "1970/1/1 08:00:00"; + for (const filePath of filePaths) { + const fileName = basename(filePath); + if (!fileName.endsWith('.json')) continue; + + const old = existMap.get(fileName) || {}; + const newCd = (indexDoExist && + operationMode !== "重新生成索引文件(用于强制刷新CD)" && + old.cdTime) + ? old.cdTime + : defaultTime; + + existMap.set(fileName, { + ...old, // 保留所有旧字段 + fileName, + cdTime: newCd, + history: old.history || [] // 确保有 history + }); + } + + // ④ 写回(含已消失的路线) + const writeResult = file.writeTextSync(recordFilePath, + JSON.stringify(Array.from(existMap.values()), null, 2)); + + if (writeResult) { + log.info(`信息已成功写入: ${recordFilePath}`); + } else { + log.error(`写入文件失败: ${recordFilePath}`); + } + + // 初始化食材加工列表 + try { + Foods = Array.from(processingIngredient); + } catch (e) { Foods = []; } + + // 加载材料CD映射表 + try { + const cdMapText = await file.readText('assets/materialCdMap.json'); + const cdMapObj = JSON.parse(cdMapText); + materialCdMap = { ...cdMapObj['46h特产'], ...cdMapObj['12h素材'], ...cdMapObj['4点刷新'], ...cdMapObj['0点刷新'] }; + log.info(`材料CD映射表加载完成,共 ${Object.keys(materialCdMap).length} 条记录`); + } catch (e) { + log.error(`加载材料CD映射表失败: ${e.message}`); + materialCdMap = {}; + } +} + +/** + * 处理优先级材料采集 + * 解析用户设置的优先采集材料,计算路线效率,循环执行最高效率路线直到达标 + * + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - settings: 用户设置对象 + * - pickupRecordFile: 拾取记录文件路径 + * - alias2Names: 别名到本名数组的映射 + * - name2Other: 本名到别名数组的映射 + * - subFolderName: 子文件夹名称 + * - lastMapName: 上次地图名称 + * - targetItems: 目标物品数组 + * - disableArray: 禁用关键词数组 + * - materialCdMap: 材料CD映射表 + * + * @依赖辅助函数: + * - readFolder: 读取文件夹函数 + * - isTimeRestricted: 判断时间是否受限函数 + * - calculateRouteEfficiency: 计算路线效率函数 + * - calculateDefaultEfficiency: 计算默认效率函数 + * - selectPartyByRoutePath: 根据路线路径选择配队函数 + * - handleIngredientProcessing: 处理食材加工函数 + * - handleTimeAdjustment: 处理时间调整函数 + * - fakeLog: 模拟日志函数 + * - prioritizeHistoricalItems: 历史拾取物优先排序函数 + * - executeRoute: 执行路线函数 + * - saveRecordAndClearLog: 保存记录并清空日志函数 + * - basename: 获取文件基本名函数 + */ +async function processPriorityItems() { + if (settings.priorityItems) { + /* ---------- 1. 解析 ---------- */ + const priorityList = []; + const segments = settings.priorityItems.split('+').map(s => s.trim()); + for (const seg of segments) { + const [itemName, countStr] = seg.split('*').map(s => s.trim()); + if (itemName && countStr && !isNaN(Number(countStr))) { + priorityList.push({ itemName, count: Number(countStr) }); + } + } + log.info(`优先级材料解析完成: ${priorityList.map(e => `${e.itemName}*${e.count}`).join(', ')}`); + /* ===== 追加:扣除今日已拾取(UTC+8 0 点分界) ===== */ + const utc8 = new Date(Date.now() + 8 * 3600_000); // 手动+8小时 + const today = utc8.toISOString().slice(0, 10); // "YYYY-MM-DD" + let todayPicked = {}; // 今日已拾取数量 + try { + const txt = await file.readText(pickupRecordFile); + if (txt) { + const arr = JSON.parse(txt); + const todayItem = arr.find(it => it.date === today); + if (todayItem) todayPicked = todayItem.items || {}; + } + } catch (_) { /* 文件不存在或解析失败 */ } + + /* 扣除今日已拾取:双向扣除 */ + for (let i = priorityList.length - 1; i >= 0; i--) { + const task = priorityList[i]; + let got = 0; + + /* 1. 字面名(可能是别名)直接扣 */ + got += todayPicked[task.itemName] || 0; + /* 2. 如果字面名是别名,把对应本名也扣一遍 */ + const realNames = alias2Names.get(task.itemName) || []; // 现在是数组 + + for (const n of realNames) got += todayPicked[n] || 0; + + /* 3. 如果字面名是本名,把所有别名再扣一遍 */ + const others = name2Other.get(task.itemName) || []; + for (const a of others) got += todayPicked[a] || 0; + + task.count -= got; + if (task.count <= 0) priorityList.splice(i, 1); + } + + if (priorityList.length === 0) { + log.info("今日优先材料已达标,跳过优先采集阶段"); + notification.send("今日优先材料已达标,跳过优先采集阶段"); + } + /* ================================= */ + + const runOnce = []; + /* ---------- 3. 主循环 ---------- */ + while (priorityList.length > 0) { + + /* 1. 先把用户填的字面名(可能是别名)全部弄进来 */ + const priorityItemSet = new Set(priorityList.map(p => p.itemName)); + + /* 2. 双向扩:本名↔别名 */ + for (const a of [...priorityItemSet]) { // 复制一份避免遍历过程中增长 + // 2.1 如果 a 是“本名”,把它的所有别名加进来(原来就有的逻辑) + const others = name2Other.get(a) || []; + for (const o of others) priorityItemSet.add(o); + + // 2.2 如果 a 是“别名”,把对应的本名加进来(新增反向) + const realName = alias2Names.get(a) || []; + for (const r of realName) priorityItemSet.add(r); + } + + const pickedCounter = {}; + priorityItemSet.forEach(n => pickedCounter[n] = 0); + /* ===== 剩余物品 ===== */ + let remaining = priorityList.map(t => `${t.itemName}*${t.count}`).join(', '); + /* 4-1 扫描 + 读 record + 前置过滤(禁用/时间/材料相关)+ 计算效率 + CD后置排除 */ + const allFiles = await readFolder('pathing', true); + const rawRecord = await file.readText(`record/${subFolderName}/record.json`); + let recordArray = []; + try { recordArray = JSON.parse(rawRecord); } catch { /* 空记录 */ } + const cdMap = new Map(recordArray.map(it => [it.fileName, it])); + const now = new Date(); + /* 时间管制 */ + if (await isTimeRestricted(settings.timeRule, 10)) { priorityList.length = 0; break; } + + /* ---- 先算效率(不判CD)---- */ + calculateRouteEfficiency(allFiles, cdMap, { + priorityItemSet, + disableArray, + isPriorityMode: true + }); + + /* ---- 用可运行路线算分位默认值 ---- */ + const knownEff = allFiles + .filter(f => { + const rec = cdMap.get(f.fileName); + const nextCD = rec ? new Date(rec.cdTime) : new Date(0); + return f._priorityEff >= 0 && now > nextCD; + }) + .map(f => f._priorityEff) + .sort((a, b) => a - b); + const defaultEff = calculateDefaultEfficiency(knownEff, settings.defaultEffPercentile, 1); + /* 回填未知 + 排除CD */ + allFiles.forEach(f => { + if (f._priorityEff === -2) f._priorityEff = defaultEff; + const rec = cdMap.get(f.fileName); + const nextCD = rec ? new Date(rec.cdTime) : new Date(0); + if (now <= nextCD) f._priorityEff = -1; + }); + + if (priorityList.length === 0) break; + + /* 4-2 只跑最高效率路线 */ + const candidateRoutes = allFiles + .filter(f => { + return f._priorityEff >= 0 && + !runOnce.includes(f.fileName); // 本轮没跑过 + }) + .sort((a, b) => b._priorityEff - a._priorityEff); + if (candidateRoutes.length === 0 && priorityList.length > 0) { + log.info('已无可用优先路线(可能全部在CD),退出优先采集阶段'); + notification.send('已无可用优先路线(可能全部在CD),退出优先采集阶段'); + break; + } + const bestRoute = candidateRoutes[0]; + const filePath = bestRoute.fullPath; + const fileName = basename(filePath).replace('.json', ''); + const fullName = fileName + '.json'; + const targetObj = cdMap.get(fullName); + const startTime = new Date(); + + /* ---------- 智能选队:按路线所在文件夹反查路径组 ---------- */ + await selectPartyByRoutePath(bestRoute.fullPath, "优先采集阶段"); + + log.info(`当前进度:执行路线 ${fileName},剩余优先材料:${remaining}`); + + let timeNow = new Date(); + await handleIngredientProcessing(timeNow); + + await handleTimeAdjustment(timeNow); + await fakeLog(fileName, false, true, 0); + runOnce.push(fullName); + + /* ========== 历史拾取物前置排序 ========== */ + targetItems = prioritizeHistoricalItems(targetItems, cdMap, fullName); + /* ================================= */ + + /* ================================= */ + const routeResult = await executeRoute(filePath, fileName, targetObj, startTime, lastMapName, priorityItemSet); + if (!routeResult.success) { + continue; + } + lastMapName = routeResult.lastMapName; + routeResult.runPickupLog.forEach(name => { + /* 就地展开:别名→本名数组,再把所有相关名称都计数 */ + const realNames = alias2Names.get(name) || [name]; // 可能是多个本名 + for (const rn of realNames) { + if (priorityItemSet.has(name) || priorityItemSet.has(rn)) { + pickedCounter[rn] = (pickedCounter[rn] || 0) + 1; + } + } + }); + + /* ===== 追加:立即把 pickedCounter 回写到 priorityList(双向扣减)===== */ + for (const task of priorityList) { + let picked = 0; + + /* 1. 字面名(可能是别名)直接扣 */ + picked += pickedCounter[task.itemName] || 0; + + /* 2. 别名→本名反向扣(多对一) */ + const realNames = alias2Names.get(task.itemName) || []; + for (const rn of realNames) picked += pickedCounter[rn] || 0; + + /* 3. 本名→别名顺向扣 */ + const others = name2Other.get(task.itemName) || []; + for (const a of others) picked += pickedCounter[a] || 0; + + task.count = Math.max(0, task.count - picked); + } + + /* 倒序删除已达标项 */ + for (let i = priorityList.length - 1; i >= 0; i--) { + if (priorityList[i].count <= 0) { + log.info(`优先材料已达标: ${priorityList[i].itemName}`); + priorityList.splice(i, 1); + } + } + + /* ================================================ */ + + /* ---------- 3. 统一写文件 & 清空日志 ---------- */ + await saveRecordAndClearLog(cdMap, recordFilePath, routeResult.runPickupLog); + if (priorityList.length <= 0) { + log.info('每日优先材料已达标,退出优先采集阶段'); + notification.send('每日优先材料已达标,退出优先采集阶段'); + } + } + await sleep(1000); + } +} + +/** + * 处理路径组循环执行 + * 按照用户设置的路径组配置,依次执行各路径组中的路线,支持多种排序模式和CD管理 + * + * @returns {Promise} 无返回值 + * + * @依赖全局变量: + * - settings: 用户设置对象 + * - groupCount: 路径组数量 + * - folderNames: 文件夹名称数组 + * - partyNames: 配队名称数组 + * - recordFilePath: 记录文件路径 + * - operationMode: 操作模式 + * - lastMapName: 上次地图名称 + * - disableArray: 禁用关键词数组 + * + * @依赖辅助函数: + * - isTimeRestricted: 判断时间是否受限函数 + * - readFolder: 读取文件夹函数 + * - calculateRouteEfficiency: 计算路线效率函数 + * - basename: 获取文件基本名函数 + * - handleIngredientProcessing: 处理食材加工函数 + * - handleTimeAdjustment: 处理时间调整函数 + * - switchPartyIfNeeded: 按需切换配队函数 + * - fakeLog: 模拟日志函数 + * - executeRoute: 执行路线函数 + * - isArrivedAtEndPoint: 判断是否到达终点函数 + * - calculateRouteCD: 计算路线CD函数 + * - saveRecordAndClearLog: 保存记录并清空日志函数 + */ +async function processPathGroups() { + let loopattempts = 0; + while (loopattempts < 2) { + loopattempts++; + if (await isTimeRestricted(settings.timeRule, 10)) break; + for (let i = 1; i <= groupCount; i++) { + if (await isTimeRestricted(settings.timeRule, 10)) break; + const currentCdType = settings[`pathGroup${i}CdType`] || ""; + if (!currentCdType) continue; + + const folder = folderNames[i - 1] || `路径组${i}`; + const targetFolder = `pathing/${folder} `; + + log.info(`开始执行路径组${i} 文件夹:${folder}`); + notification.send(`开始执行路径组${i} 文件夹:${folder}`); + + /* 运行期同样用 Map 只改 cdTime */ + const rawRecord = await file.readText(recordFilePath); + let recordArray = JSON.parse(rawRecord); + const cdMap = new Map(recordArray.map(it => [it.fileName, it])); + + const groupFiles = await readFolder(targetFolder, true); + + if (operationMode === "执行任务(若不存在索引文件则自动创建)") { + const groupNumber = i; + await genshin.returnMainUi(); + + try { + /* ================== 提前计算分均效率(所有模式通用) ================== */ + calculateRouteEfficiency(groupFiles, cdMap, { groupIndex: i }); + switch (settings.sortMode) { + case "优先最早刷新,将优先执行最早刷新的路线": + groupFiles.sort((a, b) => { + const nameA = basename(a.fullPath); + const nameB = basename(b.fullPath); + const timeA = cdMap.has(nameA) ? new Date(cdMap.get(nameA).cdTime) : new Date(0); + const timeB = cdMap.has(nameB) ? new Date(cdMap.get(nameB).cdTime) : new Date(0); + return timeA - timeB; // 越早刷新越靠前 + }); + break; + + case "优先最高效率,将优先执行最高分均拾取物的路线": + // 直接复用提前算好的 _efficiency + groupFiles.sort((a, b) => (b._efficiency || 0) - (a._efficiency || 0)); + break; + + default: + // 保持原有顺序,不做任何排序 + break; + } + + for (const filePath of groupFiles) { + const fileName = basename(filePath.fullPath).replace('.json', ''); + const fullName = fileName + '.json'; + const targetObj = cdMap.get(fullName); + const nextCD = targetObj ? new Date(targetObj.cdTime) : new Date(0); + + const startTime = new Date(); + if (startTime <= nextCD) { + log.info(`当前任务 ${fileName} 未刷新,跳过任务`); + continue; // 跳过,不写回 + } + if (await isTimeRestricted(settings.timeRule, 10)) break; + + let doSkip = false; + for (const kw of disableArray) { + if (filePath.fullPath.includes(kw)) { + log.info(`路径文件 ${filePath.fullPath} 包含禁用关键词 "${kw}",跳过任务 ${fileName}`); + doSkip = true; break; + } + } + if (doSkip) continue; + + // ===== 临界效率过滤 ===== + const routeEff = filePath._efficiency ?? 0; // 提前算好的分均效率 + const threshold = Number(settings[`pathGroup${i}thresholdEfficiency`]) || 0; + if (routeEff < threshold) { + log.info(`路线 ${fileName} 分均效率为 ${routeEff.toFixed(2)},低于设定的临界值 ${threshold},跳过`); + continue; + } + + let timeNow = new Date(); + await handleIngredientProcessing(timeNow); + + await handleTimeAdjustment(timeNow); + + await switchPartyIfNeeded(partyNames[groupNumber - 1]); + + await fakeLog(fileName, false, true, 0); + + /* ========== 历史拾取物前置排序 ========== */ + targetItems = prioritizeHistoricalItems(targetItems, cdMap, fullName); + /* ======================================= */ + + log.info(`当前进度:执行路线 ${fileName},路径组${i} ${folder} 第 ${groupFiles.indexOf(filePath) + 1}/${groupFiles.length} 个`); + log.info(`当前路线分均效率为 ${(filePath._efficiency ?? 0).toFixed(2)}`); + + state.runPickupLog = []; // 新路线开始前清空 + // 路径组模式下,传入空的 priorityItemSet + const routeResult = await executeRoute(filePath.fullPath, fileName, targetObj, startTime, lastMapName, new Set()); + if (!routeResult.success) { + continue; + } + lastMapName = routeResult.lastMapName; + + try { await sleep(1); } + catch (error) { log.error(`发生错误: ${error}`); break; } + + // >>> 仅当 >10s 才记录 history;若同时 pathRes === true 再更新 CD <<< + const endTime = new Date(); + const timeDiff = endTime.getTime() - startTime.getTime(); + if (timeDiff > 10000) { + /* ---------- 2. 仅当 pathRes === true 才计算并更新 CD ---------- */ + let pathRes = isArrivedAtEndPoint(filePath.fullPath); + if (pathRes) { + const newTimestamp = calculateRouteCD(currentCdType, startTime); + targetObj.cdTime = newTimestamp.toISOString(); + log.info(`本任务cd信息已更新,下一次可用时间为 ${newTimestamp.toLocaleString()}`); + } + + /* ---------- 3. 统一写文件 & 清空日志 ---------- */ + await saveRecordAndClearLog(cdMap, recordFilePath, routeResult.runPickupLog); + } + } + log.info(`路径组${groupNumber} 的所有任务运行完成`); + } catch (error) { + log.error(`读取路径组文件时出错: ${error}`); + } + } + } + await sleep(1000); + } +} diff --git a/repo/js/采集cd管理/manifest.json b/repo/js/采集cd管理/manifest.json index 4e04edf1d..c65fa5f30 100644 --- a/repo/js/采集cd管理/manifest.json +++ b/repo/js/采集cd管理/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "采集cd管理", - "version": "2.11.1", + "version": "2.12.0", "bgi_version": "0.44.8", "description": "仅面对会操作文件和读readme的用户,基于文件夹操作自动管理采集路线的cd,会按照路径组的顺序依次运行,直到指定的时间,并会按照给定的cd类型,自动跳过未刷新的路线", "saved_files": [