diff --git a/repo/js/CD-Aware-AutoGather/CooldownData.txt b/repo/js/CD-Aware-AutoGather/CooldownData.txt new file mode 100644 index 00000000..ab2964e8 --- /dev/null +++ b/repo/js/CD-Aware-AutoGather/CooldownData.txt @@ -0,0 +1,63 @@ +沉玉仙茗: 24小时 +发光髓: 12小时 +蝴蝶翅膀: 12小时 +晶核: 12小时 +鳗肉: 12小时 +螃蟹: 12小时 +禽肉: 12小时 +青蛙: 12小时 +鳅鳅宝玉: 12小时 +神秘的肉: 12小时 +兽肉: 12小时 +蜥蜴尾巴: 12小时 +鱼肉: 12小时 +白萝卜: 每天0点 +薄荷: 每天0点 +澄晶实: 每天0点 +墩墩桃: 每天0点 +海草: 每天0点 +红果果菇: 每天0点 +胡萝卜: 每天0点 +金鱼草: 每天0点 +堇瓜: 每天0点 +烬芯花: 每天0点 +久雨莲: 每天0点 +颗粒果: 每天0点 +苦种: 每天0点 +莲蓬: 每天0点 +烈焰花花蕊: 每天0点 +马尾: 每天0点 +蘑菇: 每天0点 +茉洁草: 每天0点 +鸟蛋: 每天0点 +泡泡桔: 每天0点 +苹果: 每天0点 +日落果: 每天0点 +树莓: 每天0点 +松果: 每天0点 +松茸: 每天0点 +甜甜花: 每天0点 +汐藻: 每天0点 +香辛果: 每天0点 +星蕈: 每天0点 +须弥蔷薇: 每天0点 +枣椰: 每天0点 +竹笋: 每天0点 +烛伞蘑菇: 每天0点 +沉玉仙茗: 24小时 +晶蝶: 每天4点 + +铁块: 每天0点 +白铁块: 每2天0点 +电气水晶: 每2天0点 +星银矿石: 每2天0点 +萃凝晶: 每3天0点 +水晶块: 每3天0点 +紫晶块: 每3天0点 +奇异的「牙齿」: 46小时 +冰雾花花朵: 46小时 +冰雾花: 46小时 +烈焰花花蕊: 46小时 +烈焰花: 46小时 + +地方特产: 46小时 \ No newline at end of file diff --git a/repo/js/CD-Aware-AutoGather/README.md b/repo/js/CD-Aware-AutoGather/README.md index defedf5b..9dbcfdc4 100644 --- a/repo/js/CD-Aware-AutoGather/README.md +++ b/repo/js/CD-Aware-AutoGather/README.md @@ -28,18 +28,19 @@ | 选项 | 说明 | | ---- | ---- | -| 设置要使用的队伍名称 | 执行采集任务前切换到指定的队伍,未设置则不切换。 | +| 设置首选队伍名称 | 执行采集任务前切换到指定的队伍,未设置则不切换。 | +| 设置备选队伍名称 | 首选队伍缺少对应的采集角色时使用。
两支队伍的名称不要存在包含关系,例如不能一支叫`特产`一支叫`特产备选` | | 停止运行时间 | 超过此时间后,停止后续的任务(会等待正在运行的那条json路线结束)。 | | 我肝的账号不止一个 | 如果你有多个账号,可以选中此选项,选中后将分账号维护对应的材料刷新时间。 | +| 采集扫描到的所有材料 | 选中后将不管后面的每个材料⬇️的选项实际是否勾选,全都视为已勾选 | +| 即使同一种材料有多个版本的路线,也全都执行采集 | 如果某种材料选中了多个版本的路线(常见于不同作者),默认只会执行第一个。勾选此选项后会每个版本都执行,可能造成部分点位重复(空跑)。 | | `↓` 地方特产\稻妻\绯樱绣球 | 根据你订阅的路径追踪任务数量,这里将会显示相应个数的选择框。
勾选后将执行你选中的条目的采集任务。
Tip: `↓`符号是在提示你应该勾选文本下面的选择框 | 运行此模式后,将按照你勾选的条目,执行相应的采集任务。每执行完一条json路线后,将会计算它的下次刷新时间并写入`record`文件夹下的记录文件。下次运行脚本时,未刷新的路线将自动跳过。 可以同时勾选多种材料,会逐个进行采集。 -如果不同的采集任务需要不同队伍,那请在调度器配置组里添加多次本脚本,然后分别设置不同的采集物和采集队伍。 - -> 采集任务可能用到的元素共有`火水雷风`4种,此外还有挖矿类(如钟离)以及纳西妲两个类型,可以考虑建立两支队伍`钟纳火水`和`钟纳雷风`,即可满足所有采集任务的需要。 +采集任务可能用到的元素共有`火水雷风`4种,此外还有挖矿类(如钟离)以及纳西妲两个类型,可以考虑建立两支队伍`钟纳水雷`和`钟纳火风`,即可满足所有采集任务的需要。 支持使用配置组`更多功能`——`日志分析`分析运行记录(参考了[mno](https://github.com/Bedrockx)大佬的写法)。 diff --git a/repo/js/CD-Aware-AutoGather/lib/lib.js b/repo/js/CD-Aware-AutoGather/lib/lib.js new file mode 100644 index 00000000..a1ccb9bc --- /dev/null +++ b/repo/js/CD-Aware-AutoGather/lib/lib.js @@ -0,0 +1,596 @@ +/** + * @author Ayaka-Main + * @link https://github.com/Patrick-Ze + * @description 提供一些通用性的功能函数。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行: + eval(file.readTextSync("lib/lib.js")); + */ + +let scriptContext = { + scriptStartTime: new Date(), + version: "1.0" +}; + +/** + * 将 Date 对象格式化为 ISO 8601 字符串,包含本地时区(如:2020-09-28T20:20:20.999+08:00) + * @param {Date} date - 要格式化的日期对象 + * @returns {string} 格式化后的字符串 + */ +function formatDateTime(date) { + const pad = (n) => n.toString().padStart(2, "0"); + const padMs = (n) => n.toString().padStart(3, "0"); + + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hour = pad(date.getHours()); + const minute = pad(date.getMinutes()); + const second = pad(date.getSeconds()); + const ms = padMs(date.getMilliseconds()); + + // 获取时区偏移(分钟),转换成±HH:MM + const offset = -date.getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + const offsetHour = pad(Math.floor(Math.abs(offset) / 60)); + const offsetMin = pad(Math.abs(offset) % 60); + + return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}${sign}${offsetHour}:${offsetMin}`; +} + +/** + * 将 Date 对象以本地时区格式化为字符串,格式为 "MM-DD HH:mm:ss" + * @param {Date} date - 要格式化的日期对象 + * @returns {string} 格式化后的字符串 + */ +function formatDateTimeShort(date) { + const pad = (n) => n.toString().padStart(2, "0"); + + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hour = pad(date.getHours()); + const minute = pad(date.getMinutes()); + const second = pad(date.getSeconds()); + + return `${month}-${day} ${hour}:${minute}:${second}`; +} + +/** + * 判断当前时间是否已达到目标时间(目标时间基于脚本启动时间,支持跨天)。 + * @param {string} targetTimeStr - 目标时间,格式为 "HH:mm"。 + * @returns {boolean} 如果已达到目标时间,返回 true,否则返回 false。 + */ +function isTargetTimeReached(targetTimeStr) { + const now = new Date(); + const [targetHour, targetMinute] = targetTimeStr.split(":").map(Number); + + const target = new Date(scriptContext.scriptStartTime); + target.setHours(targetHour, targetMinute, 0, 0); + + // 如果目标时间早于脚本启动时间,则认为是第二天 + if (target <= scriptContext.scriptStartTime) { + target.setDate(target.getDate() + 1); + } + + return now >= target; +} + +/** + * 判断当前时间是否在给定时间范围内(支持跨天)。 + * @param {*} startStr 起始时间,格式为"HH:mm" + * @param {*} endStr 结束时间,格式为"HH:mm" + * @returns {boolean} 如果当前时间在范围内,返回 true,否则返回 false。 + */ +function isNowInTimeRange(startStr, endStr) { + const now = new Date(); + const [startHour, startMinute] = startStr.split(":").map(Number); + const [endHour, endMinute] = endStr.split(":").map(Number); + + const start = new Date(now); + start.setHours(startHour, startMinute, 0, 0); + + const end = new Date(now); + end.setHours(endHour, endMinute, 0, 0); + + // 如果结束时间早于开始时间,表示跨天 + if (end <= start) { + end.setDate(end.getDate() + 1); + } + return now >= start && now <= end; +} + +/** + * 根据上期刷新时间字符串和刷新模式计算下一次的刷新时间。 + * + * @param {string} lastRefreshTimeStr 上次刷新时间。如果为空或无效,将使用 getDefaultTime()。 + * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "每24:05" (表示每24小时零5分), "X小时" + * @returns {Date | null} 计算出的下一次刷新时间Date对象,如果模式无法解析则返回null。 + * @example 已进行过的测试用例(用例中 GetDefaultTime() 返回 1970-01-01T00:00:00.000+08:00): + * calculateNextRefreshTime("2025-06-01T10:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每1周"); // 2025-06-02T04:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-02T05:00:00.000+08:00", "每1周"); // 2025-06-09T04:00:00.000+08:00 + * calculateNextRefreshTime(null, "每周"); // 1970-01-05T04:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-02T03:00:00.000+08:00", "每2周"); // 2025-06-09T04:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T22:00:00.000+08:00", "每天8点"); // 2025-06-21T08:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T07:00:00.000+08:00", "每天08点"); // 2025-06-21T08:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T09:00:00.000+08:00", "每天08点"); // 2025-06-22T08:00:00.000+08:00 + * calculateNextRefreshTime(null, "每天12点"); // 1970-01-01T12:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每2天10点"); // 2025-06-22T10:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每3天0点"); // 2025-06-23T00:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T11:00:00.000+08:00", "00:30"); // 2025-06-21T11:30:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "02:00"); // 2025-06-22T01:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T04:00:00.000+08:00", "每24:05"); // 2025-06-21T04:05:00.000+08:00 + * calculateNextRefreshTime(null, "01:00"); // 1970-01-01T01:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "2小时"); // 2025-06-21T12:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T23:00:00.000+08:00", "3小时"); // 2025-06-22T02:00:00.000+08:00 + * calculateNextRefreshTime(null, "5小时"); // 1970-01-01T05:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每1周 每天10点"); // 2025-06-23T04:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "每天10点 02:00 2小时"); // 2025-06-21T10:00:00.000+08:00 + * calculateNextRefreshTime("2025-06-20T10:00:00.000+08:00", "00:30 2小时"); // 2025-06-20T10:30:00.000+08:00 + * calculateNextRefreshTime("2025-06-21T10:00:00.000+08:00", "无效模式"); // null + */ +function calculateNextRefreshTime(lastRefreshTimeStr, refreshMode) { + let lastRunTime = lastRefreshTimeStr ? new Date(lastRefreshTimeStr) : getDefaultTime(); + let nextRunTime = null; + const lowerCaseRefreshMode = refreshMode.toLowerCase(); + + // 1. 匹配 "每(\d*)周" + let match = lowerCaseRefreshMode.match(/每(\d*)周/); + if (match) { + const weeks = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1周 + + nextRunTime = new Date(lastRunTime); + // 找到 lastRunTime 所在周的周一 04:00 + nextRunTime.setDate(lastRunTime.getDate() - ((lastRunTime.getDay() + 6) % 7)); // 调整到上一个或当前周一 + nextRunTime.setHours(4, 0, 0, 0); // 固定到周一 04:00 + + // 确保 nextRunTime 至少晚于 lastRunTime。 + // 如果 lastRunTime 是周一 05:00,而计算出的是周一 04:00,则需要推到下个周期。 + while (nextRunTime <= lastRunTime) { + nextRunTime.setDate(nextRunTime.getDate() + 7); + } + if (weeks > 1) { + // 如果是多周周期,直接加上 weeks 周 + nextRunTime.setDate(nextRunTime.getDate() + 7 * (weeks - 1)); + } + } + + // 2. 匹配 "每(\d*)天(\d{1,2})点" + if (!nextRunTime) { + match = lowerCaseRefreshMode.match(/每(\d*)天(\d{1,2})点/); + if (match) { + const days = parseInt(match[1] || "1", 10); // 如果没有数字,默认为1天 + const hours = parseInt(match[2], 10); + + nextRunTime = new Date(lastRunTime); + nextRunTime.setHours(hours, 0, 0, 0); // 设置固定小时和分钟 + + // 确保 nextRunTime 至少晚于 lastRunTime。 + while (nextRunTime <= lastRunTime) { + nextRunTime.setDate(nextRunTime.getDate() + days); + } + } + } + + // 3. 匹配 "每(\d\d):(\d\d)" (作为间隔) + if (!nextRunTime) { + match = lowerCaseRefreshMode.match(/(\d{1,2}):(\d{2})/); + if (match) { + const intervalHours = parseInt(match[1], 10); + const intervalMinutes = parseInt(match[2], 10); + const intervalMs = (intervalHours * 60 + intervalMinutes) * 60 * 1000; + + if (intervalMs > 0) { + // 确保间隔有效 + nextRunTime = new Date(lastRunTime.getTime() + intervalMs); + } + } + } + + // 4. 匹配 "(\d+)小时" + if (!nextRunTime) { + match = lowerCaseRefreshMode.match(/(\d+)小时/); + if (match) { + const intervalHours = parseInt(match[1], 10); + const intervalMs = intervalHours * 60 * 60 * 1000; + + if (intervalMs > 0) { + // 确保间隔有效 + nextRunTime = new Date(lastRunTime.getTime() + intervalMs); + } + } + } + + return nextRunTime; +} + +/** + * 判断任务是否达到刷新时间 + * + * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分) + * @param {string} taskName 任务名称或采集资源名称 + * @param {string} [accountName] 账户名称,可选 + * @returns {{isRefreshed: boolean, lastRunTime: Date | null, nextRunTime: Date | null}} + * 返回一个对象,包含: + * - isRefreshed: boolean - 任务是否达到刷新时间。 + * - lastRunTime: Date | null - 任务上次运行的时间(如果未找到,则是getDefaultTime()返回的远古时间)。 + * - nextRunTime: Date | null - 计算出的下一次刷新时间。 + */ +function isTaskRefreshed(refreshMode, taskName, accountName = null) { + let record = {}; + const recordPath = `record/${accountName || "默认账号"}.json`; + try { + const content = file.readTextSync(recordPath); + record = JSON.parse(content); + } catch (e) { + log.debug(`无法读取或解析记录文件 ${recordPath},错误: ${e.message}`); + } + + taskName = taskName || "默认任务"; + const lastRunTimeStr = record[taskName]; + const currentTime = new Date(); + const nextRunTime = calculateNextRefreshTime(lastRunTimeStr, refreshMode); + + let isRefreshed = false; + if (!nextRunTime) { + log.error(`无法解析刷新模式 "{0}",请检查格式`, refreshMode); + } else { + isRefreshed = currentTime >= nextRunTime; + } + + const lastRunTime = lastRunTimeStr ? new Date(lastRunTimeStr) : getDefaultTime(); + return { + isRefreshed: isRefreshed, + lastRunTime: lastRunTime, // 返回实际的 Date 对象 + nextRunTime: nextRunTime, + }; +} + +/** + * 判断任务或资源是否仍然未刷新(对`isTaskRefreshed`的易用封装) + * + * @param {string} refreshMode 刷新模式,例如 "每X周", "每X天Y点", "X小时", "每24:05" (表示每24小时零5分) + * @param {string} taskName 任务名称或采集资源名称,可选 + * @param {string} [accountName] 账户名称,可选 + * @example + * // 运行结束时调用 + * updateTaskRunTime(); + * // 在脚本开头检查是否已刷新 + * if (taskIsNotRefresh("每天4点")) { + * return; + * } + */ +function taskIsNotRefresh(refreshMode, taskName = null, accountName = null) { + const { isRefreshed, lastRunTime, nextRunTime } = isTaskRefreshed(refreshMode, taskName, accountName); + + taskName = taskName || "默认任务"; + if (!isRefreshed) { + log.info("{0}未刷新(上次运行: {1}), 刷新时间: {2}", taskName, formatDateTimeShort(lastRunTime), formatDateTimeShort(nextRunTime)); + } + return !isRefreshed; +} + +/** + * 更新指定任务的上次运行时间为当前时间。 + * + * @param {string} taskName 任务名称。 + * @param {string} [accountName=null] 账户名称,可选,默认为null,表示使用默认账户。 + * @returns {boolean} 如果成功更新了任务的上次运行时间则返回true,否则返回false。 + */ +function updateTaskRunTime(taskName = null, accountName = null) { + let record = {}; + taskName = taskName || "默认任务"; + const recordPath = `record/${accountName || "默认账号"}.json`; + + // 1. 读取记录文件 + try { + const content = file.readTextSync(recordPath); + record = JSON.parse(content); + } catch (e) { + log.debug(`未能读取或解析记录文件 ${recordPath},将创建新记录。错误: ${e.message}`); + } + + // 2. 更新指定任务的上次运行时间 + const currentTime = new Date(); + record[taskName] = formatDateTime(currentTime); // 格式化为本地时间字符串,便于人阅读 + + // 3. 将更新后的记录写回文件 + try { + file.writeTextSync(recordPath, JSON.stringify(record, null, 2)); + return true; + } catch (e) { + log.error(`写入文件 ${recordPath} 失败: ${e.message}`); + return false; + } +} + +/** + * 尝试切换队伍,如果失败则传送到七天神像后重试。 + * @param {string} partyName - 要切换的队伍名 + * @returns {Promise} + */ +async function switchPartySafely(partyName) { + if (!partyName) return; + + try { + if (!(await genshin.switchParty(partyName))) { + log.info("切换队伍失败,前往七天神像重试"); + await genshin.tpToStatueOfTheSeven(); + await genshin.returnMainUi(); // 确保传送完成 + await genshin.switchParty(partyName); + await genshin.returnMainUi(); + } + } catch { + log.error("队伍切换失败,可能处于联机模式或其他不可切换状态"); + await genshin.returnMainUi(); + } +} + +/** + * 获取账号名(通常用于区分不同账号的数据) + * + * @async + * @param {*} multiAccount 是否使用OCR区分多个账号(可以传入一个设置项) + * @returns {Promise} 当前账号的UID,如果不区分多账号或OCR失败则返回"默认账号"。 + */ +async function getGameAccount(multiAccount = false) { + let account = "默认账号"; + if (!multiAccount) { + return account; + } + + // 打开背包避免界面背景干扰 + // await genshin.returnMainUi(); + // keyPress("B"); + // await sleep(1000); + + const region = captureGameRegion(); + const ocrResults = RecognitionObject.ocr(region.width * 0.75, region.height * 0.75, region.width * 0.25, region.height * 0.25); + const resList = region.findMulti(ocrResults); + + for (let i = 0; i < resList.count; i++) { + const text = resList[i].text; + if (text.includes("UID")) { + const match = text.match(/\d+/); + if (match) { + account = match[0]; + } + break; + } + } + + if (account === "默认账号") { + log.error("未能提取到UID"); + } + + return account; +} + +/** + * 获取脚本所在文件夹路径 + * @returns {string|null} 脚本所在文件夹路径,若未获取到则返回 null + */ +function getScriptDirPath() { + try { + file.readTextSync(`Ayaka-Main-${Math.random()}.txt`); + } catch (error) { + const err_msg = error.toString(); + const match = err_msg.match(/'([^']+)'/); + const fullPath = match ? match[1] : null; + const folderPath = fullPath ? fullPath.replace(/\\[^\\]+$/, "") : null; + return folderPath; + } + return null; +} + +/** + * 从 manifest.json 获取脚本自身名称 + * @returns {string} 脚本名称 + */ +function getScriptName() { + const content = file.readTextSync("manifest.json"); + const manifest = JSON.parse(content); + return manifest.name; +} + +/** + * 从文件路径中提取文件名。 + * @param {string} filePath - 文件路径。 + * @returns {string} - 文件名。 + */ +function basename(filePath) { + const lastSlashIndex = filePath.lastIndexOf('\\'); // 或者使用 '/' + return filePath.substring(lastSlashIndex + 1); +} + +/** + * 将路径分割为目录和文件名 + * @param {string} path - 文件完整路径 + * @returns {[string, string]} 返回数组,第一个元素是目录路径,第二个是文件名 + * @example + * const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球', '06-绯樱绣球-神里屋敷-10个.json'] + */ +function splitPath(path) { + const normalizedPath = path.replace(/\\/g, "/"); + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return ["", path]; + } + const dir = path.slice(0, lastSlashIndex); + const file = path.slice(lastSlashIndex + 1); + return [dir, file]; +} + +/** + * 将路径分割为主名和扩展名 + * @param {string} filename - 文件名或路径中的文件部分 + * @returns {[string, string]} 返回数组,第一个是主文件名,第二个是扩展名(含点) + * @example + * const [dir, file] = splitPath('稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个.json'); // ['稻妻\\绯樱绣球\\06-绯樱绣球-神里屋敷-10个', '.json'] + */ +function splitExt(filename) { + const baseName = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename; + const lastDotIndex = baseName.lastIndexOf("."); + if (lastDotIndex <= 0) { + return [filename, ""]; + } + return [ + filename.slice(0, filename.length - (baseName.length - lastDotIndex)), + filename.slice(filename.length - (baseName.length - lastDotIndex)), + ]; +} + +/** + * 如果你需要一个很久以前的时间,作为默认时间 + * @returns {Date} 默认时间的Date对象 + */ +function getDefaultTime() { + const now = new Date(); + const year = now.getFullYear() - 18; + return new Date(year, 8, 28, 0, 0, 0); // 9月是month=8(0起始) +} + +/** + * 获取指定目录下所有指定后缀的文件列表(不含子文件夹) + * @param {string} taskDir - 目标目录路径 + * @param {string} [ext=".json"] - 文件后缀名(默认.json) + * @returns {string[]} 返回符合后缀的文件路径数组 + */ +function getFilesByExtension(taskDir, ext = ".json") { + const allFilesRaw = file.ReadPathSync(taskDir); + const extFiles = []; + + for (const filePath of allFilesRaw) { + if (filePath.endsWith(ext)) { + extFiles.push(filePath); + } + } + + return extFiles; +} + +/** + * 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹) + * @param {string} folderPath - 要遍历的根文件夹路径 + * @param {string[]} result - 用于收集最底层文件夹路径的数组 + * @returns {Promise} 所有最底层文件夹的路径 + */ +function getLeafFolders(folderPath, result = []) { + const filesInSubFolder = file.ReadPathSync(folderPath); + let hasSubFolder = false; + + for (const filePath of filesInSubFolder) { + if (file.IsFolder(filePath)) { + hasSubFolder = true; + // 递归查找子文件夹 + getLeafFolders(filePath, result); + } + } + + // 如果没有发现任何子文件夹,则当前为最底层文件夹 + if (!hasSubFolder) { + result.push(folderPath); + } + + return result; +} + +// 参考了 mno 大佬的函数 +function _fakeLogCore(name, isJs = true, dateIn = null) { + const isStart = isJs === (dateIn !== null); + const lastRun = isJs ? new Date() : dateIn; + const task = isJs ? "JS脚本" : "地图追踪任务"; + let logMessage = ""; + let logTime = new Date(); + if (isJs && isStart) { + logTime = dateIn; + } + + // 时间部分从第11位开始,长度是12("20:20:20.999") + const formattedTime = formatDateTime(logTime).slice(11, 23); + + if (isStart) { + logMessage = + `正在伪造开始的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 开始执行${task}: "${name}"`; + } else { + const durationInSeconds = (logTime.getTime() - lastRun.getTime()) / 1000; + const durationMinutes = Math.floor(durationInSeconds / 60); + const durationSeconds = (durationInSeconds % 60).toFixed(3); // 保留三位小数 + logMessage = + `正在伪造结束的日志记录\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + + `------------------------------`; + } + log.debug(logMessage); + return logTime; +} + +/** + * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用 + * @param {string} name 要写入到日志的事项名,例如路径追踪的json文件名 + * @returns {Date} 此函数的调用时间的Date对象 + * @example + * let pathStart = logFakePathStart(fileName); + * // await pathingScript.runFile(jsonPath); + * logFakePathEnd(fileName, pathStart); + */ +function logFakePathStart(name) { + return _fakeLogCore(name, false); +} + +/** + * 在日志文件中创建可供BGI解析耗时的路径追踪记录,Start和End两个函数需配对使用 + * @param {string} name 要写入到日志的事项名,通常传入路径追踪的json文件名 + * @param {Date} startTime 调用`logFakePathStart`时返回的Date对象 + * @example + * let pathStart = logFakePathStart(fileName); + * // await pathingScript.runFile(jsonPath); + * logFakePathEnd(fileName, pathStart); + */ +function logFakePathEnd(name, startTime) { + return _fakeLogCore(name, false, startTime); +} + +/** + * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用 + * @param {string} scriptName 脚本名,留空时将自动获取 + * @returns {Date} 此函数的调用时间的Date对象 + * @example + * let startTime = logFakeScriptStart(); + * // do something; + * logFakeScriptEnd({ startTime: startTime }); + */ +function logFakeScriptStart(scriptName = null) { + if (!scriptName) { + if (!scriptContext.scriptName) { + scriptContext.scriptName = getScriptName(); + } + scriptName = scriptContext.scriptName; + } + return _fakeLogCore(scriptName, true); +} + +/** + * 在日志文件中创建可供BGI解析耗时的脚本运行记录,Start和End两个函数需配对使用 + * @param {Object} params + * @param {string|null} [params.scriptName=null] - 脚本名,留空时将自动获取 + * @param {Date} [params.startTime=new Date()] - 调用`logFakeScriptStart`时返回的Date对象 + * @returns {Date} 此函数的调用时间的Date对象 + * @example + * let startTime = logFakeScriptStart(); + * // do something; + * logFakeScriptEnd({ startTime: startTime }); + */ +function logFakeScriptEnd({ scriptName = null, startTime = new Date() } = {}) { + if (!scriptName) { + if (!scriptContext.scriptName) { + scriptContext.scriptName = getScriptName(); + } + scriptName = scriptContext.scriptName; + } + return _fakeLogCore(scriptName, true, startTime); +} diff --git a/repo/js/CD-Aware-AutoGather/main.js b/repo/js/CD-Aware-AutoGather/main.js index 69a0c60c..5e06da54 100644 --- a/repo/js/CD-Aware-AutoGather/main.js +++ b/repo/js/CD-Aware-AutoGather/main.js @@ -1,107 +1,26 @@ -const CooldownType = { - Unknown: "未配置刷新机制", - Every1DayMidnight: "每1天的0点", - Every2DaysMidnight: "每2天的0点", - Every3DaysMidnight: "每3天的0点", - Daily4AM: "每天凌晨4点", - Every12Hours: "12小时刷新", - Every24Hours: "24小时刷新", - Every46Hours: "46小时刷新", -}; - -const CooldownDataBase = { - 沉玉仙茗: CooldownType.Every24Hours, - 发光髓: CooldownType.Every12Hours, - 蝴蝶翅膀: CooldownType.Every12Hours, - 晶核: CooldownType.Every12Hours, - 鳗肉: CooldownType.Every12Hours, - 螃蟹: CooldownType.Every12Hours, - 禽肉: CooldownType.Every12Hours, - 青蛙: CooldownType.Every12Hours, - 鳅鳅宝玉: CooldownType.Every12Hours, - 神秘的肉: CooldownType.Every12Hours, - 兽肉: CooldownType.Every12Hours, - 蜥蜴尾巴: CooldownType.Every12Hours, - 鱼肉: CooldownType.Every12Hours, - 白萝卜: CooldownType.Every1DayMidnight, - 薄荷: CooldownType.Every1DayMidnight, - 澄晶实: CooldownType.Every1DayMidnight, - 墩墩桃: CooldownType.Every1DayMidnight, - 海草: CooldownType.Every1DayMidnight, - 红果果菇: CooldownType.Every1DayMidnight, - 胡萝卜: CooldownType.Every1DayMidnight, - 金鱼草: CooldownType.Every1DayMidnight, - 堇瓜: CooldownType.Every1DayMidnight, - 烬芯花: CooldownType.Every1DayMidnight, - 久雨莲: CooldownType.Every1DayMidnight, - 颗粒果: CooldownType.Every1DayMidnight, - 苦种: CooldownType.Every1DayMidnight, - 莲蓬: CooldownType.Every1DayMidnight, - 烈焰花花蕊: CooldownType.Every1DayMidnight, - 马尾: CooldownType.Every1DayMidnight, - 蘑菇: CooldownType.Every1DayMidnight, - 茉洁草: CooldownType.Every1DayMidnight, - 鸟蛋: CooldownType.Every1DayMidnight, - 泡泡桔: CooldownType.Every1DayMidnight, - 苹果: CooldownType.Every1DayMidnight, - 日落果: CooldownType.Every1DayMidnight, - 树莓: CooldownType.Every1DayMidnight, - 松果: CooldownType.Every1DayMidnight, - 松茸: CooldownType.Every1DayMidnight, - 甜甜花: CooldownType.Every1DayMidnight, - 汐藻: CooldownType.Every1DayMidnight, - 香辛果: CooldownType.Every1DayMidnight, - 星蕈: CooldownType.Every1DayMidnight, - 须弥蔷薇: CooldownType.Every1DayMidnight, - 枣椰: CooldownType.Every1DayMidnight, - 竹笋: CooldownType.Every1DayMidnight, - 烛伞蘑菇: CooldownType.Every1DayMidnight, - 沉玉仙茗: CooldownType.Every24Hours, - 晶蝶: CooldownType.Daily4AM, - - 铁块: CooldownType.Every1DayMidnight, - 白铁块: CooldownType.Every2DaysMidnight, - 电气水晶: CooldownType.Every2DaysMidnight, - 星银矿石: CooldownType.Every2DaysMidnight, - 萃凝晶: CooldownType.Every3DaysMidnight, - 水晶块: CooldownType.Every3DaysMidnight, - 紫晶块: CooldownType.Every3DaysMidnight, - 奇异的龙牙: CooldownType.Every46Hours, - 冰雾花: CooldownType.Every46Hours, - 烈焰花: CooldownType.Every46Hours, - 地方特产: CooldownType.Every46Hours, -}; +eval(file.readTextSync("lib/lib.js")); const settingFile = "settings.json"; -const baseTime = getBaseTime(); +const defaultTime = getDefaultTime(); +const CooldownDataBase = readRefreshInfo("CooldownData.txt"); -const baseTimeStr = baseTime.toISOString(); -const timeOffset = Date.parse(baseTimeStr) - Date.parse(baseTimeStr.slice(0, -1)); // 计算时区偏移量 -const timeOffsetStr = offsetToTimezone(timeOffset); let stopTime = null; +let currentParty = null; class ReachStopTime extends Error { - constructor(message) { - super(message); - this.name = "ReachStopTime"; - } + constructor(message) { + super(message); + this.name = "ReachStopTime"; + } } (async function () { - if (! file.IsFolder("pathing")) { + if (!file.IsFolder("pathing")) { let batFile = "SymLink.bat"; - try { - file.readTextSync(`Ayaka-Main-${Math.random()}.txt`); - } catch (error) { - const err_msg = error.toString(); - const match = err_msg.match(/'([^']+)'/); - const fullPath = match ? match[1] : null; - const folderPath = fullPath ? fullPath.replace(/\\[^\\]+$/, '') : null; - if (folderPath) { - batFile = `${folderPath}\\${batFile}`; - } + const folderPath = getScriptDirPath(); + if (folderPath) { + batFile = `${folderPath}\\${batFile}`; } - log.error("{0}文件夹不存在,请双击运行下列位置的脚本以创建文件夹链接\n{1}", "pathing", batFile); return; } @@ -111,12 +30,9 @@ class ReachStopTime extends Error { if (runMode === "扫描文件夹更新可选材料列表") { await runScanMode(); } else if (runMode === "采集选中的材料") { - const scriptName = getScriptItselfName(); - // 配对关闭真正由BGI产生的那次开始记录 - startTime = fakeLogCore(scriptName, true); + let startTime = logFakeScriptStart(); await runGatherMode(); - // 重新开始一条记录,与BGI产生的结束记录配对 - fakeLogCore(scriptName, true, startTime); + logFakeScriptEnd({ startTime: startTime }); } else if (runMode === "清除运行记录(重置材料刷新时间)") { await runClearMode(); } else { @@ -130,7 +46,7 @@ class ReachStopTime extends Error { async function runScanMode() { // 1. 扫描所有最底层路径 const focusFolders = ["地方特产", "矿物", "食材与炼金"]; - const pathList = focusFolders.flatMap(fd => getLeafFolders(`pathing/${fd}`)); + const pathList = focusFolders.flatMap((fd) => getLeafFolders(`pathing/${fd}`)); // 2. 读取配置模板 const templateText = file.readTextSync("settings.template.json"); @@ -139,7 +55,7 @@ async function runScanMode() { // 将地方特产按照国家顺序排序 const countryList = ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "至冬"]; const sortedList = pathList.slice().sort((a, b) => { - const getRegion = p => p.split("\\")[2]; + const getRegion = (p) => p.split("\\")[2]; const aIndex = countryList.indexOf(getRegion(a)); const bIndex = countryList.indexOf(getRegion(b)); return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex); @@ -147,29 +63,37 @@ async function runScanMode() { // 3. 处理每个路径 let count = 0; + actions_map = {}; for (const path of sortedList) { const info = getCooldownInfoFromPath(path); const jsonFiles = filterFilesInTaskDir(info.label); + if (jsonFiles.length === 0) { log.info("{0}内无json文件,跳过", path); - } else if (info.coolType === CooldownType.Unknown) { - log.warn("路径{0}未找到对应的刷新机制,跳过", path); + } else if (info.coolType === null) { + log.warn("路径{0}未匹配到对应的刷新机制,跳过", path); } else { config.push({ name: info.name, label: "⬇️ " + info.label, - type: "checkbox" + type: "checkbox", }); count += 1; + + const actions = scanSpecialCollectMethod(jsonFiles); + if (actions.length > 0) { + actions_map[path] = actions; + } } } // 4. 写入新的配置(格式化输出) file.writeTextSync(settingFile, JSON.stringify(config, null, 2)); log.info("共{0}组有效路线,请在脚本配置中勾选需要采集的材料", count); - + // 5. 分析所需角色信息 + analysisCharacterRequirement(actions_map); + await sleep(3000); } - // 采集选中的材料 async function runGatherMode() { const selectedMaterials = getSelectedMaterials(); @@ -178,33 +102,22 @@ async function runGatherMode() { log.error("未选择任何材料,请在脚本配置中勾选所需项目"); return; } - if (settings.stopAtTime) { - stopTime = calcStopTime(settings.stopAtTime); - log.info("脚本已被配置为达到{0}后停止运行", strftime(stopTime, true)); + if (settings.Time) { + stopTime = settings.stopAtTime; + log.info("脚本已被配置为达到{0}后停止运行", stopTime); } log.info("共{0}组材料路线待执行:", selectedMaterials.length); for (const item of selectedMaterials) { const info = getCooldownInfoFromPath(item.label); - log.info(` - {0} (${info.coolType})`, item.label || item.name); + log.info(` - {0} (${info.coolType}刷新)`, item.label || item.name); } - let account = await getCurrentAccount(); + let account = await getGameAccount(settings.iHaveMultipleAccounts); log.info("为{0}采集材料并管理CD", account); - if (settings.partyName) { - try { - if (!(await genshin.switchParty(settings.partyName))) { - log.info("切换队伍失败,前往七天神像重试"); - await genshin.tpToStatueOfTheSeven(); - await sleep(1000); - await genshin.switchParty(settings.partyName); - } - } catch { - log.error("队伍切换失败,可能处于联机模式或其他不可切换状态"); - await genshin.returnMainUi(); - } - } + await switchPartySafely(settings.partyName); + currentParty = settings.partyName; dispatcher.addTimer(new RealtimeTimer("AutoPick")); // 可在此处继续处理 selectedMaterials 列表 @@ -214,14 +127,13 @@ async function runGatherMode() { } } catch (e) { if (e instanceof ReachStopTime) { - log.info("达到设置的停止时间 {0},终止运行", strftime(stopTime, true)); + log.info("达到设置的停止时间 {0},终止运行", stopTime); } else { throw e; } } } - // 清除运行记录(重置材料刷新时间) async function runClearMode() { const selectedMaterials = getSelectedMaterials(); @@ -229,13 +141,13 @@ async function runClearMode() { if (selectedMaterials.length === 0) { log.error("未选择任何材料,请在脚本配置中勾选所需项目"); } - const resetTime = strftime(baseTime); - let account = await getCurrentAccount(); + const resetTimeStr = formatDateTime(getDefaultTime()); + let account = await getGameAccount(settings.iHaveMultipleAccounts); for (const pathTask of selectedMaterials) { const jsonFiles = filterFilesInTaskDir(pathTask.label); const recordFile = getRecordFilePath(account, pathTask); const lines = jsonFiles.map((filePath) => { - return `${basename(filePath)}\t${resetTime}`; + return `${basename(filePath)}\t${resetTimeStr}`; }); const content = lines.join("\n"); file.writeTextSync(recordFile, content); @@ -244,25 +156,94 @@ async function runClearMode() { log.info("已重置{0}组刷新时间。如需重置所有材料刷新时间,请直接删除record目录下对应账号的文件夹", selectedMaterials.length); } +function scanSpecialCollectMethod(jsonFiles) { + const actions = jsonFiles.flatMap((filePath) => { + const data = JSON.parse(file.readTextSync(filePath)); + return data.positions.map((p) => p.action).filter((a) => a); + }); + return [...new Set(actions)]; +} + +function readRefreshInfo(filePath) { + const lines = file.readTextSync(filePath).split(/\r?\n/); + const dict = {}; + for (const line of lines) { + if (!line.trim()) continue; // 跳过空行 + const [key, value] = line.split(":"); + dict[key.trim()] = value ? value.trim() : ""; + } + return dict; +} + +function analysisCharacterRequirement(actions_map) { + const result = {}; + for (const [key, values] of Object.entries(actions_map)) { + const newKey = key.replace(/^pathing\\/, ""); + for (const v of values) { + if (!result[v]) { + result[v] = []; + } + result[v].push(newKey); + } + } + let collect_methods = {}; + for (const [key, value] of Object.entries(result)) { + if (key.endsWith("_collect") || key === "fight" || key === "combat_script") { + collect_methods[key] = value; + } + } + + collect_methods = Object.fromEntries(Object.entries(collect_methods).sort((a, b) => b[1].length - a[1].length)); + + log.info( + "角色需求: {1}条路线需要纳西妲,{2}条路线需要水元素,{3}条路线需要雷元素,{4}条路线需要风元素,{5}条路线需要火元素;{6}条路线需要执行自动战斗;{7}条路线使用了战斗策略脚本(含挖矿等非战斗用途)", + collect_methods["nahida_collect"]?.length || 0, + collect_methods["hydro_collect"]?.length || 0, + collect_methods["electro_collect"]?.length || 0, + collect_methods["anemo_collect"]?.length || 0, + collect_methods["pyro_collect"]?.length || 0, + collect_methods["fight"]?.length || 0, + collect_methods["combat_script"]?.length || 0 + ); + + const nameMap = { + nahida_collect: "纳西妲", + hydro_collect: "水元素", + electro_collect: "雷元素", + anemo_collect: "风元素", + pyro_collect: "火元素", + fight: "自动战斗", + combat_script: "战斗策略脚本", + }; + + let analysisResult = {}; + for (const [key, value] of Object.entries(collect_methods)) { + const name = nameMap[key] || key; + analysisResult[name] = value; + } + + const outFile = `${getScriptDirPath()}\\各条路线所需角色.txt`; + let text = ""; + // text = JSON.stringify(analysisResult, null, 2); + for (const [key, values] of Object.entries(analysisResult)) { + text += `${key}\n`; + for (const v of values) { + text += ` ${v}\n`; + } + } + file.writeTextSync(outFile, text); + log.info("详细路线需求见{x},可考虑组两支队伍{0}和{1}以满足采集需要", outFile, "钟纳水雷", "钟纳火风"); +} + function getRecordFilePath(account, pathTask) { const taskName = pathTask.name.replace(/^OPT_/, ""); return `record/${account}/${taskName}.txt`; } -function filterFilesInTaskDir(taskDir, ext=".json") { - const allFilesRaw = file.ReadPathSync("pathing\\" + taskDir); - const extFiles = []; - - for (const filePath of allFilesRaw) { - if (filePath.endsWith(ext)) { - extFiles.push(filePath); - } - } - - return extFiles; +function filterFilesInTaskDir(taskDir) { + return getFilesByExtension("pathing\\" + taskDir, ".json"); } - async function runPathTaskIfCooldownExpired(account, pathTask) { const recordFile = getRecordFilePath(account, pathTask); const jsonFiles = filterFilesInTaskDir(pathTask.label); @@ -287,44 +268,51 @@ async function runPathTaskIfCooldownExpired(account, pathTask) { for (let i = 0; i < jsonFiles.length; i++) { const jsonPath = jsonFiles[i]; const fileName = basename(jsonPath); - const lastTime = recordMap[fileName] || baseTime; const pathName = fileName.split(".")[0]; + const lastTime = recordMap[fileName] || defaultTime; const progress = `[${i + 1}/${jsonFiles.length}]`; - if (stopTime && Date.now() >= stopTime) { + if (settings.Time && isTargetTimeReached(stopTime)) { throw new ReachStopTime("达到设置的停止时间,终止运行"); } if (Date.now() > lastTime) { - let pathStart = addFakePathLog(fileName); + let pathStart = logFakePathStart(fileName); log.info(`${progress}{0}: 开始执行`, pathName); let pathStartTime = new Date(); try { await pathingScript.runFile(jsonPath); } catch (error) { - log.error(`${progress}{0}: 文件不存在或执行失败: {1}`, pathName, error.toString()); - addFakePathLog(fileName, pathStart); + log.error(`${progress}{0}: 文件不存在或执行失败: {1}`, jsonPath, error.toString()); + logFakePathEnd(fileName, pathStart); continue; // 跳过当前任务 } - // 更新记录 - if (new Date() - pathStartTime > 5000) { + let diffTime = new Date() - pathStartTime; + if (diffTime < 500) { + // "队伍中没有对应元素角色"的错误不会抛出为异常,只能通过路径文件迅速结束来推测 + if (settings.partyName && settings.partyName2nd) { + let newParty = (currentParty === settings.partyName) ? settings.partyName2nd : settings.partyName; + log.info("当前队伍{0}缺少该路线所需角色,尝试切换到{1}", currentParty, newParty); + await switchPartySafely(newParty); + await pathingScript.runFile(jsonPath); + } + } else if (diffTime > 5000) { recordMap[fileName] = calculateNextRunTime(new Date(), jsonPath); const lines = []; - for (const [p, t] of Object.entries(recordMap)) { - lines.push(`${p}\t${strftime(t)}`); + lines.push(`${p}\t${formatDateTime(t)}`); } const content = lines.join("\n"); file.writeTextSync(recordFile, content); - log.info(`${progress}{0}: 已完成,下次刷新: ${strftime(recordMap[fileName], true)}`, pathName); + log.info(`${progress}{0}: 已完成,下次刷新: ${formatDateTimeShort(recordMap[fileName])}`, pathName); } else { log.info(`${progress}{0}: 执行时间过短,不更新记录`, pathName); } - addFakePathLog(fileName, pathStart); + logFakePathEnd(fileName, pathStart); } else { - log.info(`${progress}{0}: 已跳过 (${strftime(recordMap[fileName], true)}刷新)`, pathName); + log.info(`${progress}{0}: 已跳过 (${formatDateTimeShort(recordMap[fileName])}刷新)`, pathName); } } } @@ -336,7 +324,7 @@ async function runPathTaskIfCooldownExpired(account, pathTask) { */ function getCooldownInfoFromPath(fullPath) { const parts = fullPath.split(/[\\/]/); // 支持 \ 或 / 分隔符 - let cooldown = CooldownType.Unknown; + let cooldown = null; let cleanPart = ""; for (const part of parts) { @@ -359,123 +347,11 @@ function getCooldownInfoFromPath(fullPath) { } function calculateNextRunTime(base, fullPath) { - const {coolType} = getCooldownInfoFromPath(fullPath); - let nextTime = baseTime; - - switch (coolType) { - case CooldownType.Every1DayMidnight: { - const next = new Date(base.getTime() + 1 * 24 * 60 * 60 * 1000); - next.setHours(0, 0, 0, 0); - nextTime = next; - break; - } - - case CooldownType.Every2DaysMidnight: { - const next = new Date(base.getTime() + 2 * 24 * 60 * 60 * 1000); - next.setHours(0, 0, 0, 0); - nextTime = next; - break; - } - - case CooldownType.Every3DaysMidnight: { - const next = new Date(base.getTime() + 3 * 24 * 60 * 60 * 1000); - next.setHours(0, 0, 0, 0); - nextTime = next; - break; - } - - case CooldownType.Daily4AM: { - const next = new Date(base); - next.setHours(4, 0, 0, 0); - if (base.getHours() >= 4) { - // 如果已过今天凌晨4点,则设为明天的4点 - next.setDate(next.getDate() + 1); - } - nextTime = next; - break; - } - - case CooldownType.Every12Hours: { - nextTime = new Date(base.getTime() + 12 * 60 * 60 * 1000); - break; - } - - case CooldownType.Every24Hours: { - nextTime = new Date(base.getTime() + 24 * 60 * 60 * 1000); - break; - } - - case CooldownType.Every46Hours: { - nextTime = new Date(base.getTime() + 46 * 60 * 60 * 1000); - break; - } - - default: - throw new Error(`未识别的冷却类型: ${coolType}`); - } - + const { coolType } = getCooldownInfoFromPath(fullPath); + let nextTime = calculateNextRefreshTime(base, coolType); return nextTime; } - -/** - * 获取指定路径下所有最底层的文件夹(即不包含任何子文件夹的文件夹) - * @param {string} folderPath - 要遍历的根文件夹路径 - * @param {string[]} result - 用于收集最底层文件夹路径的数组 - * @returns {Promise} 所有最底层文件夹的路径 - */ -function getLeafFolders(folderPath, result = []) { - const filesInSubFolder = file.ReadPathSync(folderPath); - let hasSubFolder = false; - - for (const filePath of filesInSubFolder) { - if (file.IsFolder(filePath)) { - hasSubFolder = true; - // 递归查找子文件夹 - getLeafFolders(filePath, result); - } - } - - // 如果没有发现任何子文件夹,则当前为最底层文件夹 - if (!hasSubFolder) { - result.push(folderPath); - } - - return result; -} - -async function getCurrentAccount() { - let account = "默认账号"; - - if (settings.iHaveMultipleAccounts) { - // 打开背包避免界面背景干扰 - await genshin.returnMainUi(); - keyPress("B"); - await sleep(1000); - - const region = captureGameRegion(); - const ocrResults = RecognitionObject.ocr(region.width * 0.75, region.height * 0.75, region.width * 0.25, region.height * 0.25); - const resList = region.findMulti(ocrResults); - - for (let i = 0; i < resList.count; i++) { - const text = resList[i].text; - if (text.includes("UID")) { - const match = text.match(/\d+/); - if (match) { - account = match[0]; - } - break; - } - } - - if (account === "默认账号") { - log.error("未能提取到UID"); - } - } - - return account; -} - function getSelectedMaterials() { const configText = file.readTextSync(settingFile); const config = JSON.parse(configText); // 配置数组 @@ -483,120 +359,62 @@ function getSelectedMaterials() { const selectedMaterials = []; for (const entry of config) { - if ( - entry.name && - entry.name.startsWith("OPT_") && - entry.type === "checkbox" - ) { - if (settings[entry.name] === true) { - entry.label = entry.label.split(" ")[1]; // 去除⬇️指示 + if (entry.name && entry.name.startsWith("OPT_") && entry.type === "checkbox") { + if (settings.selectAllMaterials || settings[entry.name] === true) { + let index = entry.label.indexOf(" "); + entry.label = entry.label.slice(index + 1); // 去除⬇️指示 selectedMaterials.push(entry); } } } + const materialDict = {}; + selectedMaterials.forEach((item) => { + const label = item.label; + const match = label.match(/\\(.*?)\\\1/); // \落落莓\落落莓@Author + let materialName; + + if (match) { + materialName = match[1]; + } else { + const parts = label.split("\\"); + materialName = parts[parts.length - 1]; + } + + if (!materialDict[materialName]) { + materialDict[materialName] = []; + } + materialDict[materialName].push(item); + }); + + const firstRoutes = []; + const multiRoutes = {}; + for (const materialName in materialDict) { + const routes = materialDict[materialName]; + if (routes.length > 0) { + firstRoutes.push(routes[0]); + if (materialDict[materialName].length > 1) { + multiRoutes[materialName] = materialDict[materialName]; + } + } + } + const countOfMultiRoutes = Object.keys(multiRoutes).length; + if (countOfMultiRoutes > 0) { + let text = `${countOfMultiRoutes}种材料存在多个版本的路线:\n`; + for (const [key, values] of Object.entries(multiRoutes)) { + text += `${key}\n`; + for (const v of values) { + text += ` ${v.label}\n`; + } + } + log.debug(text); + if (settings.acceptMultiplePathOfSameMaterial) { + log.warn("{0}种材料选中了多个版本的路线(详见日志文件),根据脚本设置,将执行全部版本", countOfMultiRoutes); + } else { + log.warn("{0}种材料选中了多个版本的路线(详见日志文件),默认只执行第一个版本", countOfMultiRoutes); + return firstRoutes; + } + } + return selectedMaterials; } - - -// Happy Birthday -function getBaseTime() { - const now = new Date(); - const year = now.getFullYear() - 18; - return new Date(year, 8, 28, 0, 0, 0); // 9月是month=8(0起始) -} - - -function strftime(dateObj, shortFormat = false) { - const timestamp = dateObj.getTime() + timeOffset; - const newDate = new Date(timestamp); - let s = newDate.toISOString(); - s = s.replace("Z", timeOffsetStr); - - if (shortFormat) { - // 截取出 MM-DD HH:MM:SS - const [datePart, timePart] = s.split("T"); - const [year, month, day] = datePart.split("-"); - const time = timePart.split(".")[0]; // 去掉毫秒部分 - s = `${month}-${day} ${time}`; - } - - return s; -} - -function basename(filePath) { - const lastSlashIndex = filePath.lastIndexOf('\\'); - return filePath.substring(lastSlashIndex + 1); -} - -function offsetToTimezone(offsetMs) { - const totalMinutes = offsetMs / (1000 * 60); - const sign = totalMinutes >= 0 ? "+" : "-"; - const absMinutes = Math.abs(totalMinutes); - const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0"); - const minutes = String(absMinutes % 60).padStart(2, "0"); - return `${sign}${hours}:${minutes}`; -} - -function calcStopTime(timeStr) { - const match = timeStr.match(/\b\d{2}[::]\d{2}\b/); // 匹配 HH:mm - if (!match) { - return new Date(0xFFFFFFFF * 1000); // 不停止 - } - - const [hour, minute] = match[0].split(":").map(Number); - const now = new Date(); - const next = new Date(now); - - next.setHours(hour, minute, 0, 0); - if (next <= now) { - next.setDate(next.getDate() + 1); - } - - return next; -} - -function getScriptItselfName() { - const content = file.readTextSync("manifest.json"); - const manifest = JSON.parse(content); - return manifest.name; -} - -// 参考了 mno 大佬的函数 - -function fakeLogCore(name, isJs = true, dateIn = null) { - const isStart = (isJs === (dateIn !== null)); - const lastRun = isJs ? new Date() : dateIn; - const task = isJs ? "JS脚本" : "地图追踪任务"; - let logMessage = ""; - let logTime = new Date(); - if (isJs && isStart) { - logTime = dateIn; - } - - const logTimeWithOffset = new Date(logTime.getTime() + timeOffset); - const formattedTime = logTimeWithOffset.toISOString().split("T")[1].replace("Z", ""); - - if (isStart) { - logMessage = `正在伪造开始的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 开始执行${task}: "${name}"`; - } else { - const durationInSeconds = (logTime.getTime() - lastRun.getTime()) / 1000; - const durationMinutes = Math.floor(durationInSeconds / 60); - const durationSeconds = (durationInSeconds % 60).toFixed(3); // 保留三位小数 - logMessage = `正在伪造结束的日志记录\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + - `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + - `------------------------------`; - } - log.debug(logMessage); - return logTime; -} - -function addFakePathLog(name, lastRun = null) { - return fakeLogCore(name, false, lastRun); -} diff --git a/repo/js/CD-Aware-AutoGather/manifest.json b/repo/js/CD-Aware-AutoGather/manifest.json index 8f1f9ba6..32f3e54a 100644 --- a/repo/js/CD-Aware-AutoGather/manifest.json +++ b/repo/js/CD-Aware-AutoGather/manifest.json @@ -1,9 +1,9 @@ { "manifest_version": 1, "name": "带CD管理的自动采集", - "version": "1.1", + "version": "1.2", "bgi_version": "0.45.0", - "description": "自动同步你通过BetterGI订阅的地图追踪任务,执行采集任务,并管理材料刷新时间(支持多账号)。\n首次运行前请先简单阅读说明,推荐在线版 https://gitee.com/babalae/bettergi-scripts-list/tree/main/repo/js/CD-Aware-AutoGather \n本地版说明见脚本目录内的 README.md 文件", + "description": "自动同步你通过BetterGI订阅的地图追踪任务,执行采集任务,并管理材料刷新时间(支持多账号)。\n首次使用前请先简单阅读说明(可在`全自动`——`JS脚本`页面,点击本脚本名称查看)", "authors": [ { "name": "Ayaka-Main", diff --git a/repo/js/CD-Aware-AutoGather/settings.json b/repo/js/CD-Aware-AutoGather/settings.json index 5d8185f4..081c1a36 100644 --- a/repo/js/CD-Aware-AutoGather/settings.json +++ b/repo/js/CD-Aware-AutoGather/settings.json @@ -2,7 +2,7 @@ { "name": "runMode", "type": "select", - "label": "首次运行前请先简单阅读说明,推荐在线版\n https://gitee.com/babalae/bettergi-scripts-list\n/tree/main/repo/js/CD-Aware-AutoGather \n本地版说明见脚本目录内的 README.md 文件", + "label": "首次使用前请先简单阅读说明(可在`全自动`——`JS脚本`页面,点击本脚本名称查看)", "options": [ "扫描文件夹更新可选材料列表" ] diff --git a/repo/js/CD-Aware-AutoGather/settings.template.json b/repo/js/CD-Aware-AutoGather/settings.template.json index 5b6012f3..134adeb6 100644 --- a/repo/js/CD-Aware-AutoGather/settings.template.json +++ b/repo/js/CD-Aware-AutoGather/settings.template.json @@ -12,7 +12,12 @@ { "name": "partyName", "type": "input-text", - "label": "设置要使用的队伍名称(留空则不进行切换)" + "label": "设置首选队伍名称(留空则不进行切换)" + }, + { + "name": "partyName2nd", + "type": "input-text", + "label": "设置备选队伍名称(首选队伍缺少对应的采集角色时使用)" }, { "name": "stopAtTime", @@ -23,5 +28,15 @@ "name": "iHaveMultipleAccounts", "type": "checkbox", "label": "我肝的账号不止一个(选中后将分账号维护对应的材料刷新时间)" + }, + { + "name": "selectAllMaterials", + "type": "checkbox", + "label": "采集扫描到的所有材料(选中后将无视后面的每个材料⬇️是否选中)" + }, + { + "name": "acceptMultiplePathOfSameMaterial", + "type": "checkbox", + "label": "即使同一种材料有多个版本的路线,也全都执行采集" } ] \ No newline at end of file