Files
bettergi-scripts-list/repo/js/CD-Aware-AutoGather/main.js
Patrick-Ze c437b65dde js: CD-Aware-AutoGather: 修复两个corner case (#2863)
Fixes #2855, Fixes #2861: 新地图无法获取坐标时改用时间差判断是否更新CD
Fixes #2860: 修复背包物品与API物品名不匹配时脚本异常终止的问题
2026-02-11 21:04:58 +08:00

1152 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
eval(file.readTextSync("lib/lib.js"));
eval(file.readTextSync("lib/ocr.js"));
eval(file.readTextSync("lib/inventory.js"));
const settingFile = "settings.json";
const defaultTime = getDefaultTime();
const countryList = ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱", "至冬"];
const collectAbility = {
hydro_collect: "水",
electro_collect: "雷",
anemo_collect: "风",
pyro_collect: "火",
nahida_collect: "纳西妲",
};
let stopAtTime = null;
let currentParty = null;
let currentMap = "Teyvat";
let worldInfo = null;
let partyAbility = {};
let runMode = settings.runMode;
class ReachStopTime extends Error {
constructor(message) {
super(message);
this.name = "ReachStopTime";
}
}
class UserCancelled extends Error {
constructor(message) {
super(message);
this.name = "UserCancelled";
}
}
(async function () {
setGameMetrics(1920, 1080, 1.25);
if (!file.IsFolder("pathing")) {
let batFile = "SymLink.bat";
log.error("{0}文件夹不存在请在BetterGI中右键点击本脚本选择{1}。然后双击脚本目录下的{2}文件以创建文件夹链接", "pathing", "打开所在目录", batFile);
return;
}
if (!runMode) {
const defaultRunMode = "扫描文件夹更新可选材料列表";
log.warn("运行模式 未选择或无效: {0},默认为{1}", runMode, defaultRunMode);
runMode = defaultRunMode;
await sleep(3000);
}
log.info("当前运行模式:{0}", runMode);
if (runMode === "扫描文件夹更新可选材料列表") {
await runScanMode();
settings.runMode = "采集选中的材料";
log.info("扫描完成,自动更新设置:下次脚本将以{0}模式运行", settings.runMode);
} else if (runMode === "采集选中的材料") {
let startTime = logFakeScriptStart();
await runGatherMode();
logFakeScriptEnd({ startTime: startTime });
}
})();
// 扫描文件夹更新可选材料列表
async function runScanMode() {
// 读取配置模板
const templateText = readTextSync("assets/settings.template.json");
let config = JSON.parse(templateText);
const configMap = {};
// 扫描地方特产
const { countryToSpecialties, specialtyToFiles } = scanLocalSpecialty();
const localSpecialtyByCountry = {};
for (const [country, specialties] of Object.entries(countryToSpecialties)) {
localSpecialtyByCountry[country] = {};
specialties.forEach((specialty) => {
localSpecialtyByCountry[country][specialty] = specialtyToFiles[specialty] || [];
});
}
configMap["selectLocalSpecialtyByCountry"] = localSpecialtyByCountry;
const cfgLocalSpecialtyByCountry = Object.keys(localSpecialtyByCountry).map((country, index) => {
const name = "selectLocalSpecialty_" + country;
configMap[name] = localSpecialtyByCountry[country];
return {
label: (index === 0 ? "\n【单独选择地方特产】\n\n" : "") + `${country}`,
type: "multi-checkbox",
name: name,
options: Object.keys(localSpecialtyByCountry[country]).sort((a, b) => a.localeCompare(b, "zh")),
};
});
config = config.concat(cfgLocalSpecialtyByCountry);
// 扫描食材与炼金材料
const otherJsonFiles = scanAndFilterJsonFiles("pathing/食材与炼金");
const otherMaterialByName = await groupByMaterialName(otherJsonFiles);
const cfgOtherMaterial = {
label: "\n【食材与炼金】",
type: "multi-checkbox",
name: "selectFoodAndAlchemy",
options: Object.keys(otherMaterialByName).sort((a, b) => a.localeCompare(b, "zh")),
};
const miscEntry = config.find((entry) => entry.name === "selectMiscellaneous");
const miscOptions = miscEntry && miscEntry.options ? miscEntry.options : [];
configMap["selectMiscellaneous"] = miscOptions.reduce((acc, k) => {
if (k in otherMaterialByName) {
acc[k] = otherMaterialByName[k];
}
return acc;
}, {});
configMap["selectFoodAndAlchemy"] = otherMaterialByName;
if (Object.keys(otherMaterialByName).length > 0) {
config.push({ type: "separator" });
config.push(cfgOtherMaterial);
}
const forgingOreJsonFiles = scanAndFilterJsonFiles("pathing/矿物");
const forgingOreByname = await groupByMaterialName(forgingOreJsonFiles);
configMap["selectForgingOre"] = forgingOreByname;
const flattenedSpecialties = Object.assign({}, ...Object.values(localSpecialtyByCountry));
const allMaterials = {
...flattenedSpecialties,
...forgingOreByname,
...(otherMaterialByName["晶蝶"] ? { 晶蝶: otherMaterialByName["晶蝶"] } : {}),
...otherMaterialByName,
};
// 生成按大类选择的配置数据
configMap["selectByCategory"] = {
地方特产: specialtyToFiles,
矿物: forgingOreByname,
食材与炼金: otherMaterialByName,
};
const multiRouteMaterials = {};
for (const [material, paths] of Object.entries(allMaterials)) {
const dirs = [...new Set(paths.map((p) => p.substring(0, p.lastIndexOf("\\"))))];
if (dirs.length <= 1) continue;
// 1. 计算公共前缀长度
const sorted = [...dirs].sort();
let pLen = 0;
while (pLen < sorted[0].length && sorted[0][pLen] === sorted[sorted.length - 1][pLen]) pLen++;
const groupMap = {};
paths.forEach((path) => {
const dir = path.substring(0, path.lastIndexOf("\\"));
// 2. 仅去除公共前缀、开头的材料名及反斜杠
let name = dir.slice(pLen);
if (name.startsWith(material)) name = name.slice(material.length);
name = name.replace(/^\\+/, "") || "(根目录)";
(groupMap[name] ??= []).push(path);
});
multiRouteMaterials[material] = groupMap;
}
const cfgMultiRouteMaterials = Object.entries(multiRouteMaterials).map(([material, groupMap], idx) => {
const firstPaths = Object.values(groupMap)[0][0]; // 假设 groupMap 的值是路径数组
const items = firstPaths.split("\\");
const mIndex = items.indexOf(material);
const tip_items = items.slice(1, mIndex + 1);
const name = "selectRoute_" + material;
configMap[name] = groupMap;
return {
label: (idx === 0 ? "\n【对于具有多版本路线的物品选择要使用的路线】\n\n" : "") + tip_items.join("\\"),
type: "multi-checkbox",
name: name,
options: Object.keys(groupMap).sort((a, b) => a.localeCompare(b, "zh")),
};
});
if (Object.keys(multiRouteMaterials).length > 0) {
config.push({ type: "separator" });
config = config.concat(cfgMultiRouteMaterials);
}
// 写入新的配置(格式化输出)
file.writeTextSync(settingFile, JSON.stringify(config, null, 2));
return configMap;
}
// 采集选中的材料
async function runGatherMode() {
if (settings.excludeTimeRange) {
const { duringRange, nearestStopTime } = checkExecutionExcludeTime(settings.excludeTimeRange);
if (duringRange) {
log.info("当前处于设定的不运行时间段: {0}", duringRange);
return;
}
stopAtTime = nearestStopTime;
log.info("脚本已被配置为达到{0}后停止运行", stopAtTime);
}
const configMap = await runScanMode();
// file.writeTextSync("configMap.json", JSON.stringify(configMap, null, 2));
const isAllEmpty = Object.values(configMap.selectByCategory).every((value) => Object.keys(value).length === 0);
if (isAllEmpty) {
log.error("尚未订阅任何路线请在BetterGI中订阅需要的路线后再运行");
return;
}
const selectedMaterials = getSelectedMaterials(configMap);
const materialNames = Object.keys(selectedMaterials);
if (materialNames.length === 0) {
log.error("未选择任何材料,请在脚本配置中勾选所需项目");
return;
}
log.info("共选中{0}种材料: {1}", materialNames.length, materialNames.join(", "));
let account = settings.manualSetAccountName || "";
if (!account) {
worldInfo = await getCoOpModeAndHostUid();
// 使用掩码后的UID作为账户名避免浮窗和日志等意外暴露用户UID
account = worldInfo.maskUid;
}
const groupedTasks = groupTasksByMaterialsName(selectedMaterials, account);
const refreshedMaterials = Object.entries(groupedTasks)
.filter(([name, info]) => info.refreshed === true)
.map(([name, info]) => name);
if (refreshedMaterials.length === 0) {
log.info("所有选中的材料都还在冷却中,无需执行");
return;
}
// 按物品数量计算实际待执行的任务,并按照数量差额从大到小的顺序排序
updateTargetCountOfTasks(groupedTasks, configMap, account, settings.targetCountOfSelected);
const groupedTasksToRun = await calculateTodoTasksByCount(groupedTasks);
const sortedTasksToRun = sortTasksByGap(groupedTasksToRun);
// file.writeTextSync("sortedTasksToRun.json", JSON.stringify(sortedTasksToRun, null, 2));
const taskCount = Object.keys(sortedTasksToRun).length;
if (taskCount === 0) {
log.info("所有材料的数量均已达标");
return;
}
log.info("共{0}种材料需要采集,将从缺失数量最多的材料开始", taskCount);
for (const [name, { target, current, tasks }] of Object.entries(sortedTasksToRun)) {
const coolType = tasks[0].coolType;
const targetTxt = target === null ? "∞" : target;
log.info(` - {0} (${coolType}, ${current} -> ${targetTxt})`, name);
// 短暂地休眠以便用户有机会看清日志。相比大世界随便个动作花的时间,这都不算啥
await sleep(50);
}
log.info("在{0}的世界采集材料并管理CD", account);
// 传送到神像,回血,安全切换队伍,确保 currentMap 是提瓦特
log.info("前往神像进行采集前的准备工作");
await genshin.tpToStatueOfTheSeven();
if (worldInfo === null) {
worldInfo = await getCoOpModeAndHostUid();
}
if (worldInfo.coOpMode) {
log.info("当前处于联机模式,不执行队伍切换");
} else {
await switchPartySafely(settings.partyName);
currentParty = settings.partyName;
}
// 开始实际采集
dispatcher.addTimer(new RealtimeTimer("AutoPick"));
try {
for (const [name, taskInfo] of Object.entries(sortedTasksToRun)) {
await runPathTaskIfCooldownExpired(name, taskInfo);
}
} catch (e) {
if (e instanceof ReachStopTime) {
log.info("达到设置的停止时间 {0},终止运行", stopAtTime);
} else if (e instanceof UserCancelled) {
log.info("用户取消,终止运行");
} else {
throw e;
}
}
}
function scanAndFilterJsonFiles(folderPath) {
const jsonFiles = getFilesBySuffix(folderPath, ".json");
jsonFiles.sort((a, b) => a.localeCompare(b, "zh", { numeric: true }));
const filterConfig = settings.filterPathByKeywords;
if (!filterConfig || !filterConfig.trim()) return jsonFiles;
let finalRegex;
// 1. 如果以 regex: 开头,直接解析后面的内容
if (filterConfig.startsWith("regex:")) {
const pattern = filterConfig.replace("regex:", "").trim();
finalRegex = new RegExp(pattern);
}
// 2. 如果以 include: 开头,生成“至少包含其一”的正则
else if (filterConfig.startsWith("include:")) {
const keywords = filterConfig.replace("include:", "").trim().split(/\s+/);
// 逻辑:(词A|词B|词C)
const pattern = `(${keywords.join("|")})`;
finalRegex = new RegExp(pattern);
}
// 3. 否则,生成“不得包含任一”的正则 (排除模式)
else {
const keywords = filterConfig.trim().split(/\s+/);
// 逻辑:使用负向先行断言 ^((?!词A|词B|词C).)*$
// 这表示从头到尾的任何位置都不能匹配到关键词
const pattern = `^((?!(?:${keywords.join("|")})).)*$`;
finalRegex = new RegExp(pattern);
}
const result = jsonFiles.reduce(
(acc, path) => {
if (finalRegex.test(path)) {
acc.passed.push(path);
} else {
acc.excluded.push(path);
}
return acc;
},
{ passed: [], excluded: [] }
);
if (result.excluded.length > 0) {
log.info(`{0}扫描完成:根据配置排除了${result.excluded.length}条路线,详见日志`, basename(folderPath));
log.debug("过滤配置: {0}, 排除的文件:\n" + result.excluded.join("\n"), filterConfig);
}
return result.passed;
}
function getMaterialCD(name, path = null) {
let cdType = getItemCD(name);
if (cdType === null && path !== null) {
if (path.includes("地方特产")) {
cdType = "46小时";
}
}
return cdType;
}
function groupTasksByMaterialsName(selectedMaterials, account) {
const groupedTasks = {};
const materialRefreshStatus = {};
for (const [name, jsonFiles] of Object.entries(selectedMaterials)) {
// 每个材料名下可能有多个不同的目录任务。使用对象暂存,以便按 dirSlug 去重
const tasksMap = {};
const coolType = getMaterialCD(name, jsonFiles[0]);
materialRefreshStatus[name] = false;
for (const jsonPath of jsonFiles) {
const parts = jsonPath.split("\\");
const fileName = parts[parts.length - 1]; // 获取最后一行(文件名)
// 路径处理:去掉首项(pathing)和末项(文件名),提取中间目录
const dirPath = parts.slice(1, parts.length - 1).join("\\");
const dirSlug = dirPath.replace(/[^\u4e00-\u9fa5\w]+/g, "_");
const recordFile = `record/${account}/${dirSlug}.txt`;
// 如果这个分组还没初始化,则初始化
if (!tasksMap.hasOwnProperty(dirSlug)) {
const refreshTime = {};
if (fileExists(recordFile)) {
try {
const text = readTextSync(recordFile);
if (text) {
for (const line of text.split("\n")) {
const pair = line.trim().split("\t");
if (pair.length >= 2) {
const [fName, t] = pair;
refreshTime[fName] = new Date(t);
}
}
}
} catch (error) {
log.debug("解析运行记录文件时出错: {0}", error.toString());
}
} else {
log.debug("记录文件不存在: {0}", recordFile);
}
// 将同一目录下的所有 JSON 归纳为一个 Task
tasksMap[dirSlug] = {
label: dirSlug,
coolType: coolType,
recordFile: recordFile,
jsonFiles: [],
refreshTime: refreshTime,
};
}
// 将当前 JSON 文件加入到对应分组的 jsonFiles 列表中
if (!tasksMap[dirSlug].jsonFiles.includes(jsonPath)) {
tasksMap[dirSlug].jsonFiles.push(jsonPath);
}
const nextRefreshTime = tasksMap[dirSlug]['refreshTime'][fileName] || defaultTime;
if (Date.now() > nextRefreshTime) {
materialRefreshStatus[name] = true;
}
}
// 将该材料下所有的任务组转为数组存入 groupedTasks
groupedTasks[name] = { refreshed: false, target: null, current: 0, tasks: Object.values(tasksMap) };
}
Object.entries(materialRefreshStatus).forEach(([name, status]) => {
groupedTasks[name].refreshed = status;
});
return groupedTasks;
}
function updateTargetCountOfTasks(groupedTasks, configMap, account, targetCount) {
const csvFile = `record/${account}/采集目标.csv`;
if (targetCount) {
const targetCountText = targetCount.trim().toLowerCase();
if (targetCountText === "csv") {
log.info("使用{0}中设置的采集目标", csvFile);
// 1 基于当前的 configMap 得到结构
const { hierarchy, materialPaths } = getInitialHierarchy(configMap);
// 2 与 CSV 文件同步(读取旧值 + 补全缺失 + 写回)
const syncedData = syncWithCsv(csvFile, hierarchy);
// 3 计算最终生效的数量:结果将是 { "绯樱绣球": 20, ... }
const materialsTarget = calculateFinalTargets(syncedData, materialPaths);
for (const [name, target] of Object.entries(materialsTarget)) {
if (groupedTasks.hasOwnProperty(name)) {
groupedTasks[name].target = target;
}
}
} else {
const fixedCount = parseInt(targetCountText, 10);
if (isNaN(fixedCount)) {
log.error("采集目标数量设置无效{0},终止运行", targetCount);
throw new Error("Invalid target count");
}
for (const info of Object.values(groupedTasks)) {
info.target = fixedCount;
}
}
} else {
log.info("未设置采集目标数量");
}
}
async function calculateTodoTasksByCount(groupedTasks) {
// 获取目标不为null的材料的当前数量
const materialsHasTarget = Object.keys(groupedTasks).filter((name) => groupedTasks[name].target !== null);
let currentCounts = {};
if (materialsHasTarget.length === 0) {
log.info("所有选中材料的采集目标均为空");
} else {
currentCounts = await getItemCount(materialsHasTarget);
const unknownCountMaterials = [];
Object.entries(currentCounts).forEach(([key, value]) => {
if (value < 0) {
unknownCountMaterials.push(key);
}
groupedTasks[key].current = value < 0 ? 0 : value;
});
if (unknownCountMaterials.length > 0) {
log.warn("获取以下材料的数量失败默认视为0: {0}", unknownCountMaterials.join(", "));
}
}
const groupedTasksToRun = {};
const skippedSummary = { 未刷新: [], 数量已达标: [] };
for (const [name, info] of Object.entries(groupedTasks)) {
const { refreshed, target, current } = info;
let reason = "";
if (refreshed) {
if (target !== null && target <= current) {
log.debug(`{0}的数量已达标 (${current}/${target})`, name);
reason = "数量已达标";
}
} else {
reason = "未刷新";
}
if (reason) {
skippedSummary[reason].push(name);
} else {
groupedTasksToRun[name] = info;
}
}
for (const [reason, names] of Object.entries(skippedSummary)) {
if (names.length > 0) {
log.info(`跳过{0}种${reason}的材料: {1}`, names.length, names.join(","));
await sleep(100);
}
}
return groupedTasksToRun;
}
function scanSpecialCollectMethod(jsonFiles) {
const actions = jsonFiles.flatMap((filePath) => {
try {
const data = JSON.parse(readTextSync(filePath));
return data.positions
.map((p) => p.action)
.filter((a) => a) // 确保 action 存在
.map((a) => collectAbility[a] ?? a);
} catch (e) {
log.warn(`json文件无效: {0}: ${e.message}`, filePath);
return [];
}
});
return [...new Set(actions)];
}
// 扫描地方特产并按国家排序
function scanLocalSpecialty() {
const countryToSpecialtiesRaw = {}; // 暂存 国家 -> Set(特产名)
const specialtyToFiles = {}; // 映射 特产名 -> [路径列表]
const separator = "\\";
const jsonFiles = scanAndFilterJsonFiles("pathing/地方特产");
// 1. 遍历并归类数据
jsonFiles.forEach((path) => {
const parts = path.split(separator);
const idx = parts.indexOf("地方特产");
if (idx !== -1 && parts[idx + 2]) {
const country = parts[idx + 1];
const specialty = parts[idx + 2];
// 填充 特产名 -> 路径列表
(specialtyToFiles[specialty] ??= []).push(path);
// 填充 国家 -> 特产名集合 (使用 Set 自动去重)
(countryToSpecialtiesRaw[country] ??= new Set()).add(specialty);
}
});
// 2. 按照 countryList 排序国家映射,并将 Set 转换为 Array
const sortedCountries = Object.keys(countryToSpecialtiesRaw).sort((a, b) => {
const indexA = countryList.indexOf(a);
const indexB = countryList.indexOf(b);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
const countryToSpecialties = {};
sortedCountries.forEach((country) => {
// 将 Set 转换为数组,这样结果就是 { "璃月": ["夜泊石", "星螺"] }
countryToSpecialties[country] = Array.from(countryToSpecialtiesRaw[country]);
});
return {
countryToSpecialties, // 格式: { "国家名": ["特产1", "特产2"] }
specialtyToFiles, // 格式: { "特产名": ["路径1.json", "路径2.json"] }
};
}
async function groupByMaterialName(jsonFiles) {
const missingCdInfo = new Set();
const materialPathMap = {};
const separator = "\\";
for (const path of jsonFiles) {
const parts = path.split(separator);
if (parts.length > 2) {
const name = parts[2];
const cdType = getMaterialCD(name, path);
if (cdType === null) {
missingCdInfo.add(name);
} else {
(materialPathMap[name] || (materialPathMap[name] = [])).push(path);
}
}
}
if (missingCdInfo.size > 0) {
log.warn("未获取到以下物品的CD信息: {0}", Array.from(missingCdInfo).join(", "));
await sleep(200);
}
return materialPathMap;
}
/**
* 1. 生成扁平化层级字典 (带名字映射)
*/
function getInitialHierarchy(configMap) {
const hierarchy = {};
const materialPaths = new Set(); // 用于记录哪些路径是叶子节点(材料)
const nameMapping = {
selectLocalSpecialtyByCountry: "地方特产",
selectForgingOre: "矿物",
selectFoodAndAlchemy: "食材与炼金",
};
function traverse(currentObj, currentPath, currentKey) {
hierarchy[currentPath] = null;
// 如果是数组,说明 currentPath 是一个具体的材料路径
if (Array.isArray(currentObj)) {
materialPaths.add(currentPath);
return;
}
if (typeof currentObj !== "object" || currentObj === null) return;
for (const key in currentObj) {
if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
traverse(currentObj[key], currentPath + "\\" + key, key);
}
}
}
for (const [apiKey, chineseName] of Object.entries(nameMapping)) {
if (configMap[apiKey]) traverse(configMap[apiKey], chineseName, chineseName);
}
return { hierarchy, materialPaths };
}
/**
* 2. 同步 CSV 文件并回写
* 逻辑:保留 CSV 已有的值,新增 configMap 里的新路径,删除已废弃路径
* 读取时:非法/空内容 -> null
* 写入时null -> 空字符串
*/
function syncWithCsv(filePath, configHierarchy) {
const csvData = {};
// 1. 读取并解析现有 CSV
if (fileExists(filePath)) {
try {
const content = readTextSync(filePath).replace(/^\ufeff/, "");
// 使用正则切分行,同时兼容 Windows (\r\n) 和 Linux (\n) 换行符
const lines = content.split(/\r?\n/).slice(1); // 跳过标题行
// 匹配 CSV 字段的正则表达式:
// 1. (?:^|,) -> 匹配行首或逗号
// 2. "(?:[^"]|"")*" -> 匹配双引号括起来的内容(允许其中包含连续两个双引号 ""
// 3. [^,]* -> 或者匹配不含逗号的普通文本
const csvRegex = /"(?:[^"]|"")*"|[^,]+/g;
lines.forEach((line) => {
const trimmedLine = line.trim();
if (!trimmedLine) return; // 跳过空行
const parts = [];
let match;
// 使用 exec 循环获取所有匹配的字段
while ((match = csvRegex.exec(trimmedLine)) !== null) {
let field = match[0];
// 如果字段被双引号包裹,进行还原处理
if (field.startsWith('"') && field.endsWith('"')) {
// 去掉首尾引号,并将内部的 "" 还原为 "
field = field.slice(1, -1).replace(/""/g, '"');
}
parts.push(field);
}
if (parts.length >= 2) {
const path = parts[0];
const rawVal = parts[1];
// 尝试解析为整数
const parsedInt = parseInt(rawVal, 10);
// 无法解析为 int 的内容NaN都视为 null
csvData[path] = isNaN(parsedInt) ? null : parsedInt;
}
});
} catch (e) {
log.debug("读取{0}文件时失败,未正确获取到采集目标 ({1})", filePath, e.toString());
}
} else {
log.info("{0}不存在,建立新文件供用户使用", filePath);
}
// 2. 以 configMap 的结构为基准进行合并
const updatedDict = {};
Object.keys(configHierarchy).forEach((path) => {
// 如果 CSV 里有就用 CSV 的解析结果,否则初始化为 null
updatedDict[path] = csvData.hasOwnProperty(path) ? csvData[path] : null;
});
// 3. 排序(按深度从浅到深)
const sortedKeys = Object.keys(updatedDict).sort((a, b) => {
const depthA = (a.match(/\\/g) || []).length;
const depthB = (b.match(/\\/g) || []).length;
return depthA - depthB || 0;
});
// 4. 写回 CSV (UTF-8 BOM)
let csvContent = "\ufeff物品,目标数量\n";
sortedKeys.forEach((key) => {
const val = updatedDict[key];
// 写入时null 表达为空字符串
const displayVal = val === null ? "" : val;
// 处理路径中可能存在的特殊字符
let escapedPath = key;
if (key.includes(",") || key.includes('"')) {
escapedPath = `"${key.replace(/"/g, '""')}"`;
}
csvContent += `${escapedPath},${displayVal}\n`;
});
try {
file.writeTextSync(filePath, csvContent);
log.info("CSV配置同步成功: {0}", filePath);
} catch (e) {
log.info("CSV写入失败: {0} ({1})",filePath, e.toString());
}
return updatedDict;
}
/**
* 计算最终目标数量 (结果只保留材料名称)
* @param {Object} syncedDict - 从 CSV 同步后的带路径字典
* @param {Set} materialPaths - 材料路径集合
*/
function calculateFinalTargets(syncedDict, materialPaths) {
const tempPathResults = {}; // 存储路径到计算值的映射
const finalMaterialResults = {}; // 存储材料名到计算值的映射
const sortedPaths = Object.keys(syncedDict);
sortedPaths.forEach((path) => {
const currentVal = syncedDict[path];
let calculatedVal;
// --- 继承逻辑 ---
if (currentVal !== null) {
calculatedVal = currentVal;
} else {
// 当前节点为 null尝试寻找父级
const lastSlashIndex = path.lastIndexOf("\\");
if (lastSlashIndex !== -1) {
const parentPath = path.substring(0, lastSlashIndex);
// 继承父级的值 (如果父级也是 null则继续保持 null)
calculatedVal = tempPathResults.hasOwnProperty(parentPath) ? tempPathResults[parentPath] : null;
} else {
// 顶层节点且为 null
calculatedVal = null;
}
}
// 存入临时表供后代节点参考
tempPathResults[path] = calculatedVal;
// --- 核心修改:如果是材料节点,则提取名称存入最终结果 ---
if (materialPaths.has(path)) {
const pathParts = path.split("\\");
const materialName = pathParts[pathParts.length - 1];
// 最终结果:可能是 数字,也可能是 null
finalMaterialResults[materialName] = calculatedVal;
}
});
return finalMaterialResults;
}
/**
* 对待运行任务进行排序按缺失数量降序null 排最后
* @param {Object} tasksToRun - 筛选出的待运行任务字典
*/
function sortTasksByGap(tasksToRun) {
// 1. 将对象转换为数组 [ [name, info], [name, info], ... ]
const taskEntries = Object.entries(tasksToRun);
// 2. 执行排序
taskEntries.sort((a, b) => {
const infoA = a[1];
const infoB = b[1];
const isANull = infoA.target === null;
const isBNull = infoB.target === null;
if (isANull && !isBNull) return 1;
if (!isANull && isBNull) return -1;
if (isANull && isBNull) return 0;
// 正常数值情况:按 (target - current) 降序排列
const gapA = infoA.target - infoA.current;
const gapB = infoB.target - infoB.current;
return gapB - gapA; // 降序:差距大的在前
});
// 3. 将排序后的数组重新构建回对象
const sortedTasks = {};
taskEntries.forEach(([name, info]) => {
sortedTasks[name] = info;
});
return sortedTasks;
}
function calculateAvatarsAbility(avatars) {
const elements_map = JSON.parse(readTextSync("assets/avatar_elements.json"));
const avatar2element = {};
for (const key in elements_map) {
if (elements_map.hasOwnProperty(key)) {
const values = elements_map[key];
values.forEach((ele) => {
avatar2element[ele] = key;
});
}
}
const ability_set = new Set();
for (const avatar of avatars) {
if (avatar === "纳西妲") {
ability_set.add(avatar);
}
const element = avatar2element[avatar];
if (element) {
ability_set.add(element);
}
}
return [...ability_set];
}
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 = `各条路线所需角色.txt`;
let text = "";
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, "钟纳水雷", "钟纳火风");
}
async function runPathScriptFile(jsonPath) {
await pathingScript.runFile(jsonPath);
//捕获任务取消的信息并跳出循环
try {
await sleep(10);
} catch (error) {
return error.toString();
}
return false;
}
async function runPathTaskIfCooldownExpired(material, taskInfo) {
let { current } = taskInfo;
const { target, tasks } = taskInfo;
const totalPathCount = tasks.reduce((sum, t) => sum + t.jsonFiles.length, 0);
log.info("{0}有{1}组任务,共{2}条路线", material, tasks.length, totalPathCount);
// 开始执行任务
const knownAbilities = Object.values(collectAbility);
const allJsonFiles = tasks.flatMap(task => task.jsonFiles);
totalLoop: for (const pathTask of tasks) {
const { coolType, recordFile, jsonFiles, refreshTime } = pathTask;
for (const jsonPath of jsonFiles) {
if (stopAtTime && isTargetTimeReached(stopAtTime)) {
throw new ReachStopTime("达到设置的停止时间,终止运行");
}
const fileName = basename(jsonPath);
const pathName = fileName.split(".")[0];
const pathRefreshTime = refreshTime[fileName] || defaultTime;
// 使用indexOf计算进度避免数数的方法在continue时繁琐的处理
const progress = `[${allJsonFiles.indexOf(jsonPath)+1}/${totalPathCount}]`;
if (Date.now() > pathRefreshTime) {
log.info(`${progress}{0}: 开始执行`, pathName);
// 队伍采集能力判定
let avatarAbilities;
if (currentParty in partyAbility) {
avatarAbilities = partyAbility[currentParty];
} else {
avatarAbilities = calculateAvatarsAbility(getAvatars());
partyAbility[currentParty] = avatarAbilities;
}
const specialMethods = scanSpecialCollectMethod([jsonPath]);
const requiredAbilities = specialMethods.filter((method) => knownAbilities.includes(method));
const missingAbilities = requiredAbilities.filter((element) => {
return !avatarAbilities.includes(element);
});
if (requiredAbilities.length > 0) {
log.debug("所需角色: {0}", requiredAbilities.join(", "));
}
if (missingAbilities.length > 0 && (! worldInfo.coOpMode)) {
// 联机模式下无法自动切换队伍同时此时BGI本体的报错信息也足够详细因此也不再打印日志
if (settings.partyName && settings.partyName2nd) {
let newParty = currentParty === settings.partyName ? settings.partyName2nd : settings.partyName;
if (!partyAbility[newParty]) {
log.info("当前队伍{0}缺少该路线所需角色{1},尝试切换到{2}", currentParty, missingAbilities.join(", "), newParty);
const teleported = await switchPartySafely(newParty);
currentParty = newParty;
if (teleported) {
currentMap = "Teyvat";
}
partyAbility[newParty] = calculateAvatarsAbility(getAvatars());
}
const avatarAbilities = partyAbility[newParty];
const missingAbilitiesNew = requiredAbilities.filter((element) => {
return !avatarAbilities.includes(element);
});
if (missingAbilitiesNew.length > 0) {
log.warn("另一队伍{0}也缺少该路线所需角色{1},跳过路线", newParty, missingAbilitiesNew.join(", "));
continue;
}
} else {
log.warn("当前队伍缺少该路线要求的采集角色,且用户未配置两支队伍,跳过路线");
continue;
}
}
let pathStart = logFakePathStart(fileName);
let pathStartPos = await genshin.getPositionFromMap(currentMap);
// 延迟抛出`UserCancelled`,以便正确更新运行记录
const pathStartTime = new Date();
let cancel = await runPathScriptFile(jsonPath);
await genshin.returnMainUi();
let pathEndPos = await genshin.getPositionFromMap(currentMap);
let distance = calculateDistance(pathStartPos, pathEndPos);
if (distance === null) {
const timeDiff = (new Date() - pathStartTime) / 1000;
// TemplateMatch模式暂时无法获取新地图的坐标在API支持前简单做个workaround
if (timeDiff > 10) {
log.warn("无法获取位置坐标,基于路径耗时更新刷新时间");
distance = 20928;
} else {
log.warn("无法获取位置坐标,路径耗时较短,不更新刷新时间");
}
}
if (distance >= 5) {
const jsonData = JSON.parse(readTextSync(jsonPath));
const jsonRegion = jsonData.info?.map_name || "Teyvat";
if (jsonRegion !== currentMap) {
log.info("当前地图区域: {0}", currentMap);
currentMap = jsonRegion;
}
refreshTime[fileName] = calculateNextRefreshTime(new Date(), coolType);
const lines = [];
for (const [p, t] of Object.entries(refreshTime)) {
lines.push(`${p}\t${formatDateTime(t)}`);
}
const content = lines.join("\n");
file.writeTextSync(recordFile, content);
log.info(`${progress}{0}: 已完成,下次刷新: ${formatDateTimeShort(refreshTime[fileName])}`, pathName);
} else {
log.info(`${progress}{0}: 位置几乎未变化,不更新刷新时间`, pathName);
}
logFakePathEnd(fileName, pathStart);
if (cancel) {
throw new UserCancelled(cancel);
}
// 不嵌套到if-distance里以确保fake log和cancel得到正确执行
if (distance >= 5 && target !== null) {
const match = pathName.match(/-(\d+)个/);
const collectByPath = parseInt(match ? match[1] : null, 10);
if (!isNaN(collectByPath) && collectByPath > 0) {
current = current + collectByPath;
if (current > target) {
log.info("{0}可能已达成目标数量,打开背包确认", material);
const result = await getItemCount(material);
current = result[material] || 0;
if (current >= target) {
log.info("{0}已达成目标数量({1}>={2}),停止该材料剩余任务", material, current, target);
break totalLoop;
} else {
log.info("{0}实际数量未达标({1}<{2}),更新材料当前数量", material, current, target);
}
}
}
}
} else {
log.info(`${progress}{0}: 已跳过 (${formatDateTimeShort(refreshTime[fileName])}刷新)`, pathName);
}
}
}
}
function getSelectedMaterials(configMap) {
const configText = readTextSync(settingFile);
const config = JSON.parse(configText);
const selectedMaterials = {};
const knownKeys = ["selectForgingOre", "selectMiscellaneous", "selectFoodAndAlchemy"];
// 辅助函数:合并路径并记录日志
const mergeToResult = (materialName, paths) => {
if (!Array.isArray(paths)) return;
if (!selectedMaterials[materialName]) {
selectedMaterials[materialName] = [];
}
selectedMaterials[materialName].push(...paths);
};
config.forEach((entry) => {
if (!entry.name || entry.type !== "multi-checkbox") return;
const { name, label } = entry;
const options = settings[name] ? Array.from(settings[name]) : [];
// 2. 处理 selectLocalSpecialtyByCountry
if (name === "selectByCategory") {
if (options.length === 0) return;
const categoryMap = configMap[name];
if (!categoryMap) return;
options.forEach((categoryName) => {
const materialsInCategory = categoryMap[categoryName];
if (materialsInCategory) {
log.debug("选择了{0}下的所有材料: {1}", categoryName, Object.keys(materialsInCategory).join(", "));
for (const [materialName, paths] of Object.entries(materialsInCategory)) {
mergeToResult(materialName, paths);
}
}
});
} else if (name === "selectLocalSpecialtyByCountry") {
if (options.length === 0) return;
const countryMap = configMap[name];
if (!countryMap) return;
options.forEach((countryName) => {
const materialsInCountry = countryMap[countryName];
if (materialsInCountry) {
log.debug("选择了{0}的所有地方特产: {1}", countryName, Object.keys(materialsInCountry).join(", "));
for (const [materialName, paths] of Object.entries(materialsInCountry)) {
mergeToResult(materialName, paths);
}
}
});
}
// 3. 处理已知 key 或以 selectLocalSpecialty_ 开头的项
else if (knownKeys.includes(name) || name.startsWith("selectLocalSpecialty_")) {
if (options.length === 0) return;
const categoryMap = configMap[name];
if (!categoryMap) return;
const lines = label.trim().split(/\r?\n/);
const last_line = lines[lines.length - 1];
log.debug("选择了{0}分类下的材料: {1}", last_line, options.join(", "));
options.forEach((materialName) => {
const paths = categoryMap[materialName];
if (paths) {
mergeToResult(materialName, paths, "分类选择:" + name);
}
});
}
// 4. 处理 selectRoute_执行覆盖逻辑并记录日志
else if (name.startsWith("selectRoute_")) {
const targetMaterial = name.replace("selectRoute_", "");
const routeMap = configMap[name];
if (!routeMap) return;
let finalRouteKeys = [];
let logAction = "";
if (options.length > 0) {
if (selectedMaterials.hasOwnProperty(targetMaterial)) {
// 已存在该材料,使用用户勾选的路线
finalRouteKeys = options;
logAction = "使用用户勾选的路线";
} else {
log.debug("未选中材料{0},忽略该材料勾选的路线{1}", targetMaterial, options.join(", "));
return;
}
} else {
// 如果 selectRoute 这一项用户什么都没勾,强制选择 entry.options 中的第一项
if (entry.options && entry.options.length > 0 && selectedMaterials.hasOwnProperty(targetMaterial)) {
finalRouteKeys = [entry.options[0]];
logAction = "用户未指定路线,自动选择第一组";
}
}
if (finalRouteKeys.length > 0) {
// 执行取代:先清空,再添加
selectedMaterials[targetMaterial] = [];
finalRouteKeys.forEach((routeKey) => {
const specificPaths = routeMap[routeKey];
if (specificPaths) {
selectedMaterials[targetMaterial].push(...specificPaths);
}
});
log.debug(`{0}: ${logAction} {1}`, targetMaterial, finalRouteKeys.join(", "));
}
}
});
return selectedMaterials;
}
/**
* 获取世界主人的UID
*/
async function getCoOpModeAndHostUid() {
await genshin.returnMainUi();
keyPress("F2");
await waitForTextAppear("多人游戏", [130, 20, 129, 57]);
let uid = await getGameAccount(true, false);
const coOpMode = !(await isTextExistedInRegion("搜索", [1638, 90, 87, 63]));
if (coOpMode) {
const btnText = await getTextInRegion([1560, 992, 191, 55]);
// 仅在多人模式且非房主时需要
if (btnText === "离开队伍") {
log.info("当前处于联机模式,且玩家不是房主");
click(332, 218);
await recognizeTextAndClick("查看资料", [555, 182, 118, 49]);
await waitForTextAppear("角色展柜", [1082, 204, 107, 49]);
await sleep(100);
uid = await getTextInRegion([623, 192, 118, 37]);
} else {
log.info("当前处于联机模式,玩家是房主");
}
} else {
log.info("当前处于单人模式");
}
await genshin.returnMainUi();
const maskUid = uid.replace(/\d\d(\d{3})\d{4}/, (match, group1) => match.replace(group1, "xxx"));
return { coOpMode: coOpMode, uid: uid, maskUid: maskUid };
}