function forge_pathing_start_log(name) { const t = new Date(); const timestamp = t.toTimeString().slice(0, 8) + "." + String(t.getMilliseconds()).padStart(3, "0"); let c = "Forging start log\n\n"; c += `[${timestamp}] [INF] BetterGenshinImpact.Service.ScriptService\n------------------------------\n\n`; c += `[${timestamp}] [INF] BetterGenshinImpact.Service.ScriptService\n→ 开始执行地图追踪任务: "${name}"`; log.debug(c); } function forge_pathing_end_log(name, elapsed_time) { const elapsed_min = Math.floor(elapsed_time / 1000 / 60); const elapsed_sec = (elapsed_time / 1000 % 60).toFixed(3); const t = new Date(); const timestamp = t.toTimeString().slice(0, 8) + "." + String(t.getMilliseconds()).padStart(3, "0"); let c = "Forging end log\n\n"; c += `[${timestamp}] [INF] BetterGenshinImpact.Service.ScriptService\n→ 脚本执行结束: "${name}", 耗时: ${elapsed_min}分${elapsed_sec}秒\n\n`; c += `[${timestamp}] [INF] BetterGenshinImpact.Service.ScriptService\n------------------------------`; log.debug(c); } const country_name_tag_map = { "蒙德": "mondstadt", "璃月": "liyue", "层岩巨渊地下矿区": "chasm underground", "稻妻": "inazuma", "渊下宫": "enkanomiya", "须弥": "sumeru", "枫丹地面": "fontaine terrestrial", "枫丹水下": "fontaine underwater", "旧日之海水下": "sea of bygone eras underwater", "纳塔": "natlan", "远古圣山": "ancient sacred mountain", "挪德卡莱": "nod-krai", }; function get_exclude_tags() { const ore_name_tag_map = { "水晶块": "crystal chunk", "紫晶块": "amethyst lump", "萃凝晶": "condessence crystal", "虹滴晶": "rainbowdrop crystal", }; let tags = []; if (settings.fight_option === "全跳过") { tags.push("fight"); } else if (settings.fight_option === "只跳过与精英怪战斗的路线") { tags.push("elite enemy"); } for (const [i, j] of Object.entries(country_name_tag_map)) { if (Array.from(settings.exclude_regions).includes(i)) { tags.push(j); } } for (const [i, j] of Object.entries(ore_name_tag_map)) { if (Array.from(settings.exclude_ore_types).includes(i)) { tags.push(j); } } return tags; } function underwater_only() { const all_regions = new Set(Object.keys(country_name_tag_map)); const skipped_regions = new Set(settings.exclude_regions); const not_skipped_regions = all_regions.difference(skipped_regions); not_skipped_regions.delete("枫丹水下"); not_skipped_regions.delete("旧日之海水下"); return not_skipped_regions.size === 0; } function get_profile_name() { if (!settings.profile_id) { return null; } return settings.profile_id; } const filename_to_path_map = {}; function load_filename_to_path_map() { let all_paths = []; const read_dir = (path) => { for (const i of file.readPathSync(path)) { if (file.isFolder(i)) { read_dir(i); } else { all_paths.push(i); } } }; read_dir("assets/矿物"); for (const i of all_paths) { const filename = i.replace(/^.*[\\/]/, ""); filename_to_path_map[filename] = i; } } let persistent_data = {}; const in_memory_skip_tasks = new Set(); function load_persistent_data() { let file_content = ""; try { file_content = file.readTextSync("local/persistent_data.json"); } catch (error) {} if (file_content.length !== 0) { persistent_data = JSON.parse(file_content); } } const disabled_paths = new Set(); function load_disabled_paths() { for (const path of ["assets/disabled_paths.conf", "local/disabled_paths.txt"]) { let file_content = ""; try { file_content = file.readTextSync(path); } catch (error) {} for (let l of file_content.split("\n")) { l = l.trim(); if (l.length === 0) { continue; } if (l.startsWith("//") || l.startsWith("#")) { continue; } disabled_paths.add(l); } } } let statistics = {}; function load_statistics_data() { statistics = JSON.parse(file.readTextSync("assets/statistics.json")).data; } const flaky_end_paths = new Set(); function load_flaky_end_paths() { let file_content = ""; try { file_content = file.readTextSync("assets/flaky_end_paths.conf"); } catch (error) {} for (let l of file_content.split("\n")) { l = l.trim(); if (l.length === 0) { continue; } if (l.startsWith("//") || l.startsWith("#")) { continue; } flaky_end_paths.add(l); } } async function flush_persistent_data() { await file.writeText("local/persistent_data.json", JSON.stringify(persistent_data, null, " ")); } async function mark_task_finished(task_name) { const profile_name = get_profile_name(); const profile_key = !profile_name ? "default-profile" : ("profile-" + profile_name); if (!persistent_data.hasOwnProperty(profile_key)) { persistent_data[profile_key] = {}; } persistent_data[profile_key][task_name] = { "last_run_time": Date.now(), }; await flush_persistent_data(); } function get_task_last_run_time(task_name) { const profile_name = get_profile_name(); const profile_key = !profile_name ? "default-profile" : ("profile-" + profile_name); return persistent_data[profile_key]?.[task_name]?.last_run_time || 0; } function is_ore_respawned(t) { t /= 1000; let t0 = Math.floor(t / 86400) * 86400 + 57600; if (t0 > t) { t0 -= 86400; } const respawn_time = t0 + 86400 * 3; return respawn_time < Date.now() / 1000; } function get_some_tasks(hints) { hints.target_running_seconds = Math.min(7200, hints.target_running_seconds || Number.MAX_VALUE); if (hints.target_yield) { log.debug("Schedule with target yield {a}", hints.target_yield); } if (hints.target_running_seconds) { log.debug("Schedule with target runnning seconds {a}", hints.target_running_seconds); } const exclude_tags = new Set(get_exclude_tags()); let filtered_statistics = []; for (const [key, value] of Object.entries(statistics)) { if (in_memory_skip_tasks.has(key)) { continue; } if (disabled_paths.has(key)) { continue; } if (value.tags.some(i => exclude_tags.has(i))) { continue; } if (value.statistics.avg_num_defeats > 0.5) { continue; } if (value.statistics.avg_abnormal_exits > 0.5) { continue; } if (!filename_to_path_map.hasOwnProperty(key)) { continue; } if (!is_ore_respawned(get_task_last_run_time(key))) { log.debug("{name} not respawned, skip", key); continue; } value.statistics.avg_yield_per_min = value.statistics.avg_yield / value.statistics.avg_time_consumed * 60; filtered_statistics.push([key, value]); } filtered_statistics.sort((a, b) => b[1].statistics.avg_yield_per_min - a[1].statistics.avg_yield_per_min ); let candidates = []; let sum_yield = 0; let sum_running_seconds = 0; for (const [key, value] of filtered_statistics) { candidates.push([key, value]); sum_yield += value.statistics.avg_yield; sum_running_seconds += value.statistics.avg_time_consumed; if (hints.target_yield !== null && sum_yield >= hints.target_yield) { break; } if (hints.target_running_seconds !== null && sum_running_seconds >= hints.target_running_seconds) { break; } } const candidate_groups = {}; for (const [key, value] of candidates) { const group_name = value.group; if (!candidate_groups.hasOwnProperty(group_name)) { candidate_groups[group_name] = { sum_yield: 0, sum_running_seconds: 0, tasks: [], }; } candidate_groups[group_name].tasks.push(key); candidate_groups[group_name].sum_yield += value.statistics.avg_yield; candidate_groups[group_name].sum_running_seconds += value.statistics.avg_time_consumed; } for (const i of Object.values(candidate_groups)) { i.avg_yield_per_min = sum_yield / sum_running_seconds * 60; i.tasks.sort(); } const tasks = Array.from(Object.values(candidate_groups)).sort((a, b) => b.avg_yield_per_min - a.avg_yield_per_min).map(i => i.tasks).flat(); let log_content = ""; sum_yield = 0; sum_running_seconds = 0; for (const i of tasks) { const s = statistics[i] log_content += ` ${s.statistics.avg_yield_per_min.toFixed(2)} ${i}\n`; sum_yield += s.statistics.avg_yield; sum_running_seconds += s.statistics.avg_time_consumed; } log.debug(log_content); log.debug("Expected yield {a}, time {b} min", sum_yield, sum_running_seconds / 60); return tasks.map(i => [i, statistics[i]]); } async function close_expired_stuff_popup_window() { const game_region = captureGameRegion(); const text_x = 850; const text_y = 273; const text_w = 225; const text_h = 51; const ocr_res = game_region.find(RecognitionObject.ocr(text_x, text_y, text_w, text_h)); if (ocr_res) { if (ocr_res.text.includes("物品过期")) { log.info("检测到物品过期"); click(1000, 750); await sleep(1000); } } game_region.dispose(); } async function get_inventory() { const ore_image_map = { amethyst_lumps: "assets/images/amethyst_lump.png", crystal_chunks: "assets/images/crystal_chunk.png", condessence_crystals: "assets/images/condessence_crystal.png", rainbowdrop_crystals: "assets/images/rainbowdrop_crystal.png", }; await genshin.returnMainUi(); keyPress("b") await sleep(1000); await close_expired_stuff_popup_window(); click(964, 53); await sleep(500); const game_region = captureGameRegion(); const inventory_result = { crystal_chunks: 0, condessence_crystals: 0, amethyst_lumps: 0, rainbowdrop_crystals: 0, }; for (const [name, path] of Object.entries(ore_image_map)) { for (const suffix of ["", "_new"]) { const filename_with_suffix = path.replace(".png", suffix + ".png"); let match_obj = RecognitionObject.TemplateMatch(file.ReadImageMatSync(filename_with_suffix)); match_obj.threshold = 0.85; match_obj.Use3Channels = true; const match_res = game_region.Find(match_obj); if (match_res.isExist()) { log.debug(`Found ${name} image at (${match_res.x}, ${match_res.y})`); const text_x = match_res.x - 0; const text_y = match_res.y + 120; const text_w = 120; const text_h = 40; const ocr_res = game_region.find(RecognitionObject.ocr(text_x, text_y, text_w, text_h)); if (ocr_res) { inventory_result[name] = Number(ocr_res.text); } break; } } } game_region.dispose(); if (inventory_result.crystal_chunks + inventory_result.condessence_crystals + inventory_result.amethyst_lumps + inventory_result.rainbowdrop_crystals === 0) { log.error("获取背包矿石数量失败"); } await genshin.returnMainUi(); return inventory_result; } let last_script_end_pos = [null, null]; let last_script_normal_completion = true; async function run_pathing_script(name, path_state_change, current_states) { path_state_change ||= {}; path_state_change.require ||= []; path_state_change.add ||= []; path_state_change.sustain ||= []; const use_global_mining_action = settings.custom_mining_action === "默认" || settings.custom_mining_action === "default"; for (const s of path_state_change.require) { if (!current_states.has(s)) { log.debug("Trying to get {s}", s); for (const [name, data] of Object.entries(statistics)) { const add_states = data.state_change?.add || []; if (add_states.includes(s)) { await run_pathing_script(name, data.state_change, current_states); break; } } } } log.info("运行 {name}", name); let json_content = await file.readText(filename_to_path_map[name]); { const json_obj = JSON.parse(json_content); let modified = false; for (const i of json_obj.positions) { if (use_global_mining_action) { if (settings.custom_mining_action && i.action === "combat_script" && i.action_params.includes("诺艾尔 ")) { i.action = "mining"; i.action_params = ""; modified = true; } } else { if (i.action === "mining") { // set Noelle mining action i.action = "combat_script"; i.action_params = settings.custom_mining_action || "诺艾尔 attack(2.0)"; modified = true; } else if (settings.custom_mining_action && i.action === "combat_script" && i.action_params.includes("诺艾尔 ")) { i.action_params = settings.custom_mining_action; modified = true; } } } if (modified) { log.debug("Patched mining action"); json_content = JSON.stringify(json_obj); } } const cancellation_token = dispatcher.getLinkedCancellationToken(); const t0 = Date.now(); forge_pathing_start_log(name); await pathingScript.run(json_content); const elapsed_time = Date.now() - t0; forge_pathing_end_log(name, elapsed_time); if (!cancellation_token.isCancellationRequested) { const curr_pos = (() => { try { const p = genshin.getPositionFromMap(JSON.parse(json_content).info.map_name); if (p === null) { return [null, null]; } return [p.X, p.Y]; } catch (e) {} return [null, null]; })(); log.debug("Character current pos ({x},{y})", curr_pos[0], curr_pos[1]); let character_moved = false; if (curr_pos[0] === null || last_script_end_pos[0] === null) { character_moved = curr_pos[0] !== last_script_end_pos[0] || curr_pos[1] !== last_script_end_pos[1]; log.debug("Character {action}", character_moved ? "moved" : "not moved"); } else { const dist = Math.sqrt(Math.pow(curr_pos[0] - last_script_end_pos[0], 2) + Math.pow(curr_pos[1] - last_script_end_pos[1], 2)); character_moved = dist > 5; log.debug("Character moved distance of {dist}", dist); } if (!character_moved && flaky_end_paths.has(name) && last_script_normal_completion) { log.debug("Assuming script successfully completed"); character_moved = true; } last_script_end_pos = curr_pos; if (elapsed_time <= 5000) { in_memory_skip_tasks.add(name); log.warn("脚本运行时间小于5秒,可能发生了错误,不写记录"); last_script_normal_completion = false; } else if (!character_moved) { in_memory_skip_tasks.add(name); log.warn("角色未移动,可能发生了错误,不写记录"); last_script_normal_completion = false; } else { await mark_task_finished(name); last_script_normal_completion = true; } } else { throw new Error("Cancelled"); } const new_states = current_states.intersection(new Set(path_state_change.sustain)).union(new Set(path_state_change.add)); current_states.clear(); for (const s of new_states) { current_states.add(s); } } async function main() { await genshin.returnMainUi(); file.writeTextSync("local/disabled_paths.txt", "", true); file.writeTextSync("local/persistent_data.json", "", true); load_filename_to_path_map(); load_persistent_data(); load_disabled_paths(); load_statistics_data(); load_flaky_end_paths(); dispatcher.addTimer(new RealtimeTimer("AutoPick")); // Run an empty pathing script to give BGI a chance to switch team if the user specifies one. await pathingScript.runFile("assets/empty_pathing.json"); if (!Object.keys(settings).includes("exclude_ore_types")) { log.error("首次运行前请编辑JS脚本自定义配置"); return; } log.debug("Fight options: {a}", settings.fight_option); log.debug("Exclude regions: {a}, exclude types: {b}", settings.exclude_regions, settings.exclude_ore_types); log.debug("Exclude tags: {a}", get_exclude_tags()); log.debug("Underwater only: {a}", underwater_only()); if (!underwater_only()) { if (!Array.from(getAvatars()).includes("诺艾尔") && !settings.custom_mining_action) { log.error("地面挖矿请带诺艾尔"); return; } } const get_current_cst_hour = () => (Date.now() / 1000 + 8 * 3600) % 86400 / 3600; let run_until_unix_time = settings.target_running_minutes ? (Date.now() + Number(settings.target_running_minutes) * 60 * 1000) : null; if (settings.time_range) { const time_range = settings.time_range.replace("~", "~").replace(":", ":"); if (time_range.includes("~")) { const [ [start_h, start_m], [end_h, end_m] ] = time_range.split("~").map(i => i.split(":").map(Number)); const start_time = start_h + start_m / 60; const end_time = end_h + end_m / 60; const current_time = get_current_cst_hour(); if (start_time < end_time && !(current_time >= start_time && current_time < end_time)) { // format like 01:30~03:50 log.info("不在允许运行的时间内,退出"); return; } if (start_time > end_time && current_time < start_time && current_time > end_time) { // format like 23:30~4:00 log.info("不在允许运行的时间内,退出"); return; } const run_until_unix_time2 = ((end_time - current_time + 24) % 24) * 3600 * 1000 + Date.now(); run_until_unix_time = Math.min(run_until_unix_time2, run_until_unix_time || Number.MAX_VALUE); } else { // format like 03:50 const [end_h, end_m] = time_range.split(":").map(Number); const end_time = end_h + end_m / 60; const run_until_unix_time2 = (end_time - get_current_cst_hour() + 24) % 24 * 3600 * 1000 + Date.now(); run_until_unix_time = Math.min(run_until_unix_time2, run_until_unix_time || Number.MAX_VALUE); } } const original_inventory = await get_inventory(); log.info("已有水晶块{a}个,紫晶块{b}个,萃凝晶{c}个,虹滴晶{d}个", original_inventory.crystal_chunks, original_inventory.amethyst_lumps, original_inventory.condessence_crystals, original_inventory.rainbowdrop_crystals); const target_yield = settings.target_amount ? Math.ceil(Number(settings.target_amount)) : null; if (target_yield && !run_until_unix_time) { log.info("将挖矿{a}个", target_yield); } else if (!target_yield && run_until_unix_time) { const running_minutes = Math.round((run_until_unix_time - Date.now()) / 60 / 1000); log.info("将挖矿{a}分钟", running_minutes); } else if (target_yield && run_until_unix_time) { const running_minutes = Math.round((run_until_unix_time - Date.now()) / 60 / 1000); log.info("将挖矿{a}个或{b}分钟,任何一个先发生", target_yield, running_minutes); } else { log.info("将持续挖矿"); } const start_time = Date.now(); let last_log_progress_time = 0; let accurate_yield = 0; let estimated_yield = 0; let cached_inventory_data = original_inventory; let finished = false; const current_states = new Set(); while (!finished) { const hints = { target_yield: target_yield ? (target_yield - estimated_yield + 5) : null, target_running_seconds: run_until_unix_time ? (run_until_unix_time - Date.now()) / 1000 : null, }; { const now = Math.floor(Date.now() / 1000); let next_refresh_unix_time = Math.floor(now / 86400) * 86400 + 16 * 3600; next_refresh_unix_time += 180; // to avoid the effect of clock skew if (next_refresh_unix_time < now) { next_refresh_unix_time += 86400; } if (!(hints.target_yield || hints.target_running_seconds)) { hints.target_yield = 500; } const target_running_seconds2 = next_refresh_unix_time - now; if (!hints.target_running_seconds || target_running_seconds2 < hints.target_running_seconds) { hints.target_running_seconds = target_running_seconds2; } } const tasks = get_some_tasks(hints); if (tasks.length === 0) { log.info("没有更多任务可运行,退出"); finished = true; } else { log.debug("Running {num} tasks as a group", tasks.length); } for (const [name, data] of tasks) { cached_inventory_data = null; try { await run_pathing_script(name, data.state_change, current_states); } catch (e) { finished = true; break; } estimated_yield += data.statistics.avg_yield; if (target_yield !== null && estimated_yield >= target_yield + 5) { const current_inventory = await get_inventory(); cached_inventory_data = current_inventory; accurate_yield += current_inventory.crystal_chunks - original_inventory.crystal_chunks; accurate_yield += current_inventory.condessence_crystals - original_inventory.condessence_crystals; accurate_yield += current_inventory.amethyst_lumps - original_inventory.amethyst_lumps; accurate_yield += current_inventory.rainbowdrop_crystals - original_inventory.rainbowdrop_crystals; estimated_yield = accurate_yield; } if (target_yield !== null && accurate_yield >= target_yield) { finished = true; break; } if (run_until_unix_time !== null && Date.now() >= run_until_unix_time) { finished = true; break; } if (Date.now() - last_log_progress_time > 30000) { last_log_progress_time = Date.now(); { const estimated_prompt = estimated_yield === accurate_yield ? "" : "(预计)"; const target_yield_prompt = target_yield === null ? "" : `/${target_yield}`; log.info(`当前产出${estimated_prompt}:${Math.round(estimated_yield)}${target_yield_prompt}个`); // For ABGI only log.debug(`当前进度:${Math.round(estimated_yield)}${target_yield_prompt}个`); } { const running_minutes = ((Date.now() - start_time) / 1000 / 60).toFixed(1); const total_minutes_prompt = run_until_unix_time === null ? "" : `/${Math.round((run_until_unix_time - start_time) / 60 / 1000)}`; log.info(`当前运行时间:${running_minutes}${total_minutes_prompt}分钟`); } } } } const end_time = Date.now(); const running_minutes = (end_time - start_time) / 1000 / 60; let total_yield_str = []; const latest_inventory = cached_inventory_data ? cached_inventory_data : (await get_inventory()); if (latest_inventory.crystal_chunks - original_inventory.crystal_chunks) { total_yield_str.push(`${latest_inventory.crystal_chunks - original_inventory.crystal_chunks}水晶块`); } if (latest_inventory.condessence_crystals - original_inventory.condessence_crystals) { total_yield_str.push(`${latest_inventory.condessence_crystals - original_inventory.condessence_crystals}萃凝晶`); } if (latest_inventory.amethyst_lumps - original_inventory.amethyst_lumps) { total_yield_str.push(`${latest_inventory.amethyst_lumps - original_inventory.amethyst_lumps}紫晶块`); } if (latest_inventory.rainbowdrop_crystals - original_inventory.rainbowdrop_crystals) { total_yield_str.push(`${latest_inventory.rainbowdrop_crystals - original_inventory.rainbowdrop_crystals}虹滴晶`); } if (total_yield_str.length > 0) { total_yield_str = "收获" + total_yield_str.join(","); } else { total_yield_str = "无收获"; } log.info("现有水晶块{a}个,紫晶块{b}个,萃凝晶{c}个,虹滴晶{d}个", latest_inventory.crystal_chunks, latest_inventory.amethyst_lumps, latest_inventory.condessence_crystals, latest_inventory.rainbowdrop_crystals); const summary = `运行${running_minutes.toFixed(2)}分钟,${total_yield_str}`; log.info(summary); notification.send(summary); } (async function() { await main(); })();