mirror of
https://github.com/babalae/bettergi-scripts-list.git
synced 2026-03-25 04:59:52 +08:00
Fixes #2855, Fixes #2861: 新地图无法获取坐标时改用时间差判断是否更新CD Fixes #2860: 修复背包物品与API物品名不匹配时脚本异常终止的问题
1152 lines
46 KiB
JavaScript
1152 lines
46 KiB
JavaScript
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 };
|
||
}
|