diff --git a/repo/js/AutoTrainingPlanFarm/README.md b/repo/js/AutoTrainingPlanFarm/README.md
new file mode 100644
index 0000000000..32831ea563
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/README.md
@@ -0,0 +1,33 @@
+# 培养计划自动刷取
+
+读取原神冒险之证-秘境-提升指南里的培养标注,自动刷角色天赋书和武器突破材料。解决天赋/武器副本开启时间杂乱,游戏外刷本配置麻烦问题。
+
+## 使用前
+
+- BetterGI 0.63.0 及以上
+- 在游戏内先给角色配好培养计划,不添加无法使用。
+- 简体中文客户端,推荐 1920x1080;16:9 分辨率能自适应,其它分辨率没测试过
+
+## 使用
+
+1. 把插件添加进调度器,可选择加入一条龙配置中
+2. 建议一条龙配置中,在插件后放一个固定的自动秘境配置,比如一个固定的圣遗物本。当天无所需天赋书和武器突破材料开放时可以由自动秘境来清体力。也可在前配置一个刷固定次数的自动秘境,控制刷天赋消耗的体力
+3. 在游戏中将想培养的角色加入培养计划中:冒险之证-秘境-提升指南-选角色-右上角加入培养计划-设置角色天赋/角色武器目标等级
+4. 插件运行后会自动执行打开提升指南,逐行读材料、算材料缺口、刷本,会优先刷第一条。目前仅支持刷角色天赋书和武器突破材料
+5. 一行刷满自动切下一行;没有标注、全是周本或树脂耗尽时自动结束
+6. 该插件不会计算角色合成材料时的获取加成,最后升级的天赋书可能会略微溢出
+
+## 设置(不推荐做修改)
+
+- markerThreshold:标注图标匹配阈值,0~1,默认 0.9
+- saveFile:需求清单文件名,默认 plan_needs.json
+- debugShowBoxes:在游戏画面上显示识别框,默认关
+- partyName:队伍名,留空用 BGI 本体的设置
+- sundaySelectedValue:周日/限时全开领奖兜底,1/2/3 对应上/中/下,留空用 BGI 本体的设置(周末会自动识别领哪个奖励,空着就行)
+
+## 固定行为
+
+- 自动打开提升指南;材料齐了自动停止;最大轮数不限制
+- 树脂优先用浓缩,其次原粹 40/次;原粹只剩 20~39 时会自动用一次 20 档
+- 缺口很大或树脂不够 2/3 刷时,会走 BGI 的树脂耗尽模式,刷完树脂就结束
+- 一行读不出秘境名(一般是周本或未开放副本)时按正常结束处理。结束该插件
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png
new file mode 100644
index 0000000000..abab15e77a
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png
new file mode 100644
index 0000000000..1b956465c7
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png
new file mode 100644
index 0000000000..541dd87bff
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png
new file mode 100644
index 0000000000..00b861825b
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png
new file mode 100644
index 0000000000..3895ed6137
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png
new file mode 100644
index 0000000000..160fcaab4b
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png
new file mode 100644
index 0000000000..fb48f36b90
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png
new file mode 100644
index 0000000000..ea666e45db
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png
new file mode 100644
index 0000000000..d1785dabf0
Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png differ
diff --git a/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html b/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html
new file mode 100644
index 0000000000..63c1c13c45
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html
@@ -0,0 +1,79 @@
+
+
+
+
+识别框调试
+
+
+
+
+
+
+
diff --git a/repo/js/AutoTrainingPlanFarm/main.js b/repo/js/AutoTrainingPlanFarm/main.js
new file mode 100644
index 0000000000..ae4643ea30
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/main.js
@@ -0,0 +1,213 @@
+// 培养计划自动刷取 v0.1.10(坐标基准 1920x1080,运行时按分辨率缩放)
+import { getS, setMetrics, sY, touchActivity } from "./src/core/common.js";
+import { debugEnsureMask, debugSetCanvas } from "./src/core/debug-overlay.js";
+import { initMarkerTemplate, disposeMarkerResources, scanMarkersStable, dedupeMarkers, disposeMarkers, clusterRows, ROW_Y_BOUNDS } from "./src/core/markers.js";
+import { ensureGuidePage, isGuidePage, waitGuideMarkers, processRow, disposeResinResources, calibrateFirstRow, FIRST_ROW_ANCHOR_Y } from "./src/plan/guide.js";
+import { runDomainPhase } from "./src/plan/domain-run.js";
+
+(async function () {
+ try {
+ setGameMetrics(1920, 1080, 1.25);
+
+ // 计算截图相对 1920x1080 的缩放比例
+ {
+ let probe = null;
+ try {
+ probe = captureGameRegion();
+ setMetrics(probe.width, probe.height);
+ debugSetCanvas(probe.width, probe.height);
+ log.info("[截图] {w}x{h},缩放 {sx}/{sy}", probe.width, probe.height, (probe.width / 1920).toFixed(3), (probe.height / 1080).toFixed(3));
+ } finally {
+ try { if (probe) probe.dispose(); } catch (e) { }
+ }
+ }
+
+ // 提前打开调试覆盖层
+ debugEnsureMask();
+
+ const matchThreshold = parseFloat(getS("markerThreshold", "0.9"));
+ if (isNaN(matchThreshold) || matchThreshold <= 0 || matchThreshold > 1) {
+ throw new Error("markerThreshold 必须是 0~1 之间的数字");
+ }
+ let saveFile = String(getS("saveFile", "plan_needs.json"));
+ if (!/^[^\\/:*?"<>|]+\.json$/i.test(saveFile)) {
+ log.warn("saveFile 非法(只允许 .json 文件名),已回退为 plan_needs.json");
+ saveFile = "plan_needs.json";
+ }
+
+ // 写需求清单(空计划也写,避免日常流程读到上一次的旧清单)
+ const writeSummary = (items) => {
+ const summary = {
+ generatedAt: new Date().toISOString(),
+ count: items.length,
+ items
+ };
+ const ok = file.writeTextSync(saveFile, JSON.stringify(summary, null, 2));
+ if (!ok) throw new Error("保存需求清单失败: " + saveFile);
+ };
+
+ // 读取模板并按当前分辨率缩放
+ initMarkerTemplate(matchThreshold);
+
+ // 完全重扫,和第一次打开脚本一样;首行没标注就先做一次固定位置校准
+ const rescanRows = async () => {
+ await ensureGuidePage();
+ touchActivity();
+
+ const firstRowY = sY(FIRST_ROW_ANCHOR_Y);
+ let visible = dedupeMarkers(await scanMarkersStable(1000));
+ if (!visible.some(m => Math.abs(m.y - firstRowY) < sY(30))) {
+ log.warn("首行区域(y≈{y})未识别到标注,执行一次首行校准后重试", firstRowY);
+ disposeMarkers(visible);
+ await calibrateFirstRow();
+ touchActivity();
+ visible = dedupeMarkers(await scanMarkersStable(1000));
+ }
+ const visibleCount = visible.length;
+ disposeMarkers(visible);
+ log.info("[扫描] 识别到标注 {count} 个", visibleCount);
+
+ if (visibleCount === 0) return null;
+ return clusterRows(visible).map((r, idx) => ({
+ index: idx + 1,
+ y: Math.max(sY(ROW_Y_BOUNDS.min), Math.min(sY(ROW_Y_BOUNDS.max), Math.round(r.y))),
+ names: new Map(),
+ nextRank: 1
+ }));
+ };
+
+ // 清单只保留“当前处理行”:每次都先清空再扫描当前行
+ const entries = [];
+ let stopByNoDomain = false;
+
+ let rows = await rescanRows();
+ if (!rows) {
+ // 校准后仍无任何标注:确认页面后按无待刷材料正常结束
+ if (isGuidePage()) {
+ log.warn("已确认在提升指南页面且无培养标注:本日无待刷材料,正常结束");
+ writeSummary([]);
+ log.info("清单: {file}", saveFile);
+ return;
+ }
+ throw new Error("扫描不到标注,且未确认在提升指南页面,请检查游戏状态");
+ }
+
+ let ri = 0;
+ while (true) {
+ if (ri >= rows.length) break; // 本快照里的行已全部处理完
+
+ const row = rows[ri];
+
+ // 换行时重新打开提升指南页
+ if (ri > 0) {
+ log.info("[行] 切换到 y={y}", row.y);
+ await ensureGuidePage();
+ touchActivity();
+ if (!(await waitGuideMarkers(15000))) {
+ log.warn("切换行后 15 秒内未检测到标注图标,重试一次");
+ if (!(await waitGuideMarkers(15000))) {
+ throw new Error("切换行后 30 秒内未检测到标注图标,请检查游戏状态");
+ }
+ }
+ }
+
+ // A 方案:文件只保留当前行。处理新行前先清掉上一条记录。
+ entries.length = 0;
+
+ let rowOk = false;
+ let rowResult = null;
+ try {
+ rowResult = await processRow(row, entries);
+ rowOk = true;
+ } catch (e) {
+ log.error("[行] 第 {i} 行识别失败,本行结果已清空,不再刷取: {err}", row.index, e.message);
+ entries.length = 0;
+ }
+ const rowEntries = entries.slice();
+
+ // 周本/未开放副本/秘境名 OCR 彻底失败:正常结束,不抛错
+ if (rowResult && rowResult.status === "noDomain") {
+ log.warn("第 {i} 行无可刷秘境(周本/未开放副本),正常结束", row.index);
+ stopByNoDomain = true;
+ writeSummary([]);
+ break;
+ }
+
+ if (!rowOk) {
+ log.warn("[行] 第 {i} 行未成功完成,跳过自动秘境,继续下一行", row.index);
+ ri++;
+ continue;
+ }
+
+ log.info("[行] 第 {i} 行完成,新增 {n} 个材料", row.index, rowEntries.length);
+
+ // 保存当前行清单
+ writeSummary(entries);
+
+ // 刷副本(固定开启):只刷这一行的材料
+ if (rowEntries.length > 0) {
+ let domainResult = null;
+ try {
+ domainResult = await runDomainPhase(entries, Math.round(row.y));
+ } catch (e) {
+ // 出错时先落盘当前清单,再抛给全局 catch
+ log.error("[自动秘境] 第 {i} 行执行异常,保存当前清单后停止: {err}", row.index, (e && e.message) ? e.message : String(e));
+ try {
+ writeSummary(entries);
+ log.info("清单: {file}", saveFile);
+ } catch (e2) {
+ log.error("[自动秘境] 保存清单失败: {err}", (e2 && e2.message) ? e2.message : String(e2));
+ }
+ throw e;
+ }
+ // 树脂耗尽或刷新失败:整体流程完成,不再处理后续行
+ if (domainResult && domainResult.stopScript) {
+ log.info("[自动秘境] 已停止(树脂耗尽或刷新失败),不再处理后续行");
+ break;
+ }
+ // 本行已消失、下方行上移:完全重扫,和第一次打开脚本一样
+ if (domainResult && domainResult.rowGone) {
+ log.info("当前行已完成并消失,重新扫描剩余行");
+ rows = await rescanRows();
+ if (!rows) {
+ if (isGuidePage()) {
+ log.warn("重新扫描无标注:本日无待刷材料,正常结束");
+ writeSummary([]);
+ log.info("清单: {file}", saveFile);
+ return;
+ }
+ throw new Error("重新扫描不到标注,且未确认在提升指南页面,请检查游戏状态");
+ }
+ ri = 0;
+ continue;
+ }
+ }
+
+ // 本行未消失(材料已够但页面仍显示),按第一次运行逻辑切到本快照下一行
+ ri++;
+ }
+
+ // 最终清单(A 方案:写当前/最后一次的清单;noDomain 已在分支内落盘空清单)
+ if (!stopByNoDomain) {
+ writeSummary(entries);
+ }
+
+ if (stopByNoDomain) {
+ log.info("无可刷秘境,正常结束,共记录 {count} 个材料", entries.length);
+ } else {
+ log.info("扫描完成,共 {count} 个材料", entries.length);
+ }
+ for (const e of entries) {
+ log.info(" {name} [{label}] {needText} {source}", e.material, e.qualityLabel, e.needText, e.source);
+ }
+ log.info("清单: {file}", saveFile);
+
+ } catch (e) {
+ const msg = (e && e.message) ? e.message : String(e);
+ log.error("脚本执行失败,已停止: {err}", msg);
+ throw e;
+ } finally {
+ disposeMarkerResources();
+ disposeResinResources();
+ }
+})();
diff --git a/repo/js/AutoTrainingPlanFarm/manifest.json b/repo/js/AutoTrainingPlanFarm/manifest.json
new file mode 100644
index 0000000000..5c153ac1c1
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifest_version": 1,
+ "name": "培养计划自动刷取",
+ "version": "0.1.10",
+ "bgi_version": "0.63.0",
+ "description": "自动读取原神冒险之证-秘境-提升指南页面中培养计划标注的材料需求,记录后自动规划刷取。仅支持武器突破材料与角色天赋副本,需将角色加入培养计划",
+ "authors": [
+ {
+ "name": "锦瑟",
+ "link": "https://github.com/achenjins"
+ }
+ ],
+ "settings_ui": "settings.json",
+ "main": "main.js",
+ "saved_files": [
+ "plan_needs.json"
+ ],
+ "library": [
+ "."
+ ],
+ "allow_js_notification": true
+}
\ No newline at end of file
diff --git a/repo/js/AutoTrainingPlanFarm/settings.json b/repo/js/AutoTrainingPlanFarm/settings.json
new file mode 100644
index 0000000000..60c384ec48
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/settings.json
@@ -0,0 +1,44 @@
+[
+ {
+ "type": "separator"
+ },
+ {
+ "name": "debugShowBoxes",
+ "type": "checkbox",
+ "label": "调试:在游戏画面上叠加显示识别框(OCR区域/模板匹配/颜色裁剪)",
+ "default": false
+ },
+ {
+ "name": "markerThreshold",
+ "type": "input-text",
+ "label": "标注图标匹配阈值(0~1,默认0.9)",
+ "default": "0.9"
+ },
+ {
+ "name": "saveFile",
+ "type": "input-text",
+ "label": "需求清单保存文件名",
+ "default": "plan_needs.json"
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "name": "partyName",
+ "type": "input-text",
+ "label": "队伍名称(留空用 BGI 本体设置)",
+ "default": ""
+ },
+ {
+ "name": "sundaySelectedValue",
+ "type": "select",
+ "label": "周日/限时全开领哪个材料奖励,可自动识别,不建议改(1/2/3=上/中/下,留空用BGI本体设置)",
+ "options": [
+ "",
+ "1",
+ "2",
+ "3"
+ ],
+ "default": ""
+ }
+]
\ No newline at end of file
diff --git a/repo/js/AutoTrainingPlanFarm/src/core/common.js b/repo/js/AutoTrainingPlanFarm/src/core/common.js
new file mode 100644
index 0000000000..1065bf8a93
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/core/common.js
@@ -0,0 +1,56 @@
+// 通用工具:设置读取、名称清洗、缩放坐标、看门狗、编辑距离
+
+export let scaleX = 1;
+export let scaleY = 1;
+
+// 8 秒无操作看门狗
+let lastActivity = Date.now();
+
+export function setMetrics(w, h) {
+ scaleX = w / 1920;
+ scaleY = h / 1080;
+}
+
+// 所有 1920x1080 参考坐标按实际截图尺寸缩放
+export function sX(v) { return Math.round(v * scaleX); }
+export function sY(v) { return Math.round(v * scaleY); }
+
+export function touchActivity() {
+ lastActivity = Date.now();
+}
+
+export function assertAlive() {
+ if (Date.now() - lastActivity > 8000) {
+ throw new Error("8 秒无操作,超时退出");
+ }
+}
+
+export function parkMouse() {
+ try {
+ const [gw, gh] = getGameMetrics();
+ moveMouseTo(Math.round(gw / 2), Math.round(gh / 6));
+ } catch (e) { }
+}
+
+export function canonicalName(name) {
+ return String(name || "").replace(/[「」『』\[\]()()]/g, "").trim();
+}
+
+export function getS(name, def) {
+ const v = settings ? settings[name] : undefined;
+ return v === undefined || v === null ? def : v;
+}
+
+export function levDistance(a, b) {
+ const m = a.length, n = b.length;
+ const dp = [];
+ for (let i = 0; i <= m; i++) dp.push(new Array(n + 1));
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
+ }
+ }
+ return dp[m][n];
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js b/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js
new file mode 100644
index 0000000000..d1fd69ced3
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js
@@ -0,0 +1,57 @@
+// 调试可视化(htmlMask):仅 debugShowBoxes=true 且存在 assets/debug_boxes.html 时生效,失败不影响主流程
+import { getS } from "./common.js";
+
+let debugMaskId = null;
+const debugBoxes = [];
+let debugCapW = 0;
+let debugCapH = 0;
+
+export function debugSetCanvas(w, h) {
+ debugCapW = w;
+ debugCapH = h;
+}
+
+export function debugEnsureMask() {
+ try {
+ if (getS("debugShowBoxes", false) === false) return false;
+ if (typeof htmlMask === "undefined" || !htmlMask || !htmlMask.show) return false;
+ if (!debugMaskId || !htmlMask.exists || !htmlMask.exists(debugMaskId)) {
+ debugMaskId = htmlMask.show("assets/debug_boxes.html", "plan-debug-boxes");
+ }
+ return !!debugMaskId;
+ } catch (e) {
+ return false;
+ }
+}
+
+export function debugFrameStart() {
+ debugBoxes.length = 0;
+ if (!debugEnsureMask()) return;
+ try {
+ htmlMask.send(debugMaskId, "/boxes/clear", "{}");
+ } catch (e) { }
+}
+
+export function debugBox(label, x, y, w, h, color) {
+ if (!label || !debugEnsureMask()) return;
+ if (!debugCapW || !debugCapH) return;
+ const box = {
+ label: String(label).slice(0, 80),
+ x: Math.max(0, Math.round(x)),
+ y: Math.max(0, Math.round(y)),
+ w: Math.max(1, Math.round(w)),
+ h: Math.max(1, Math.round(h)),
+ color: color || "#00e5ff"
+ };
+ const idx = debugBoxes.findIndex(b => b.label === box.label);
+ if (idx >= 0) debugBoxes[idx] = box;
+ else {
+ if (debugBoxes.length >= 80) debugBoxes.shift();
+ debugBoxes.push(box);
+ }
+ try {
+ htmlMask.send(debugMaskId, "/boxes/update", JSON.stringify({
+ w: debugCapW, h: debugCapH, boxes: debugBoxes
+ }));
+ } catch (e) { }
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/core/markers.js b/repo/js/AutoTrainingPlanFarm/src/core/markers.js
new file mode 100644
index 0000000000..4ea745f43e
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/core/markers.js
@@ -0,0 +1,205 @@
+// 培养计划标注图标:模板加载、扫描、按行聚合
+import { sX, sY, scaleX, scaleY, parkMouse, touchActivity, assertAlive } from "./common.js";
+import { debugFrameStart, debugBox } from "./debug-overlay.js";
+
+const MARKER_PATH = "assets/RecognitionObject/guide_marker_green.png";
+
+// 右半屏搜索区域
+const MARKER_ROI = { x: 900, y: 280, w: 1020, h: 600 };
+
+// 行 y 合法范围(ROI 边界内留出模板高度余量)
+export const ROW_Y_BOUNDS = { min: 288, max: 848 };
+
+let markerMat = null;
+let markerRo = null;
+let matchThreshold = 0.9;
+// 扫描异常日志节流:避免稳定扫描循环反复刷屏
+let lastScanErrorAt = 0;
+
+// 读取模板并按当前分辨率缩放;必须在 setMetrics 之后调用
+export function initMarkerTemplate(threshold) {
+ matchThreshold = threshold;
+ markerMat = file.readImageMatSync(MARKER_PATH);
+ if (!markerMat || markerMat.empty() || markerMat.width <= 0 || markerMat.height <= 0) {
+ throw new Error("标注图标模板读取失败或为空: " + MARKER_PATH);
+ }
+ const tw = Math.max(4, Math.round(markerMat.width * scaleX));
+ const th = Math.max(4, Math.round(markerMat.height * scaleY));
+ if (tw !== markerMat.width || th !== markerMat.height) {
+ const resized = new Mat();
+ OpenCvSharp.OpenCvSharp.Cv2.Resize(markerMat, resized, new OpenCvSharp.OpenCvSharp.Size(tw, th));
+ try { markerMat.dispose(); } catch (e) { }
+ markerMat = resized;
+ log.info("[模板] 缩放为 {w}x{h}", tw, th);
+ }
+}
+
+export function scanMarkers() {
+ let cap = null;
+ let results = null;
+ try {
+ debugFrameStart();
+ parkMouse();
+ cap = captureGameRegion();
+ debugBox("标记ROI", sX(MARKER_ROI.x), sY(MARKER_ROI.y), sX(MARKER_ROI.w), sY(MARKER_ROI.h), "#00e676");
+ // 复用同一个识别规则对象,避免每次截图都重建灰度/遮罩 Mat
+ if (!markerRo) {
+ markerRo = RecognitionObject.TemplateMatch(markerMat, true);
+ }
+ markerRo.Threshold = matchThreshold;
+ results = cap.findMulti(markerRo);
+
+ const arr = [];
+ for (let i = 0; i < results.count; i++) {
+ const r = results[i];
+ const drop = () => { try { r.dispose(); } catch (e) { } };
+
+ // 分数缺失/非有限(含 Infinity/NaN)直接丢弃
+ if (r.matchScore === undefined || r.matchScore === null) { drop(); continue; }
+ const score = Number(r.matchScore);
+ if (!Number.isFinite(score)) { drop(); continue; }
+
+ // 只保留右半屏目标区域(按分辨率缩放)
+ if (r.x < sX(MARKER_ROI.x) || r.y < sY(MARKER_ROI.y)) { drop(); continue; }
+ if (r.x + r.width > sX(MARKER_ROI.x) + sX(MARKER_ROI.w)) { drop(); continue; }
+ if (r.y + r.height > sY(MARKER_ROI.y) + sY(MARKER_ROI.h)) { drop(); continue; }
+
+ // 尺寸过滤:模板 24x32,按分辨率缩放后允许一定容差
+ if (r.width < sX(16) || r.width > sX(40)) { drop(); continue; }
+ if (r.height < sY(20) || r.height > sY(48)) { drop(); continue; }
+ const ratio = r.height / r.width;
+ if (ratio < 0.8 || ratio > 1.6) { drop(); continue; }
+
+ // 二次分数过滤(防止 ClearScript 属性赋值未生效)
+ if (score < matchThreshold) { drop(); continue; }
+
+ debugBox("标记@" + r.x + "," + r.y, r.x, r.y, r.width, r.height, "#ff1744");
+ touchActivity();
+ arr.push({
+ x: r.x, y: r.y,
+ width: r.width, height: r.height,
+ cx: r.x + r.width / 2, cy: r.y + r.height / 2,
+ score,
+ region: r
+ });
+ }
+ // results 集合由调用方通过 marker.region.dispose() 释放
+ return arr;
+ } catch (e) {
+ if (Date.now() - lastScanErrorAt > 10000) {
+ lastScanErrorAt = Date.now();
+ log.warn("[标记扫描] 异常,按无标注处理: {err}", e && e.message ? e.message : String(e));
+ }
+ try {
+ if (results) {
+ for (let i = 0; i < results.count; i++) {
+ try { results[i].dispose(); } catch (e2) { }
+ }
+ }
+ } catch (e2) { }
+ return [];
+ } finally {
+ try { if (cap) cap.dispose(); } catch (e) { }
+ // 注意:不要在这里释放 results,marker.region 还引用着其中的对象
+ }
+}
+
+export function disposeMarkers(markers) {
+ for (const m of markers) {
+ try {
+ if (m.region) {
+ m.region.dispose();
+ m.region = null;
+ }
+ } catch (e) { }
+ }
+}
+
+export function disposeMarkerResources() {
+ try {
+ if (markerRo) {
+ try { if (markerRo.TemplateImageGreyMat) markerRo.TemplateImageGreyMat.dispose(); } catch (e) { }
+ try { if (markerRo.MaskMat) markerRo.MaskMat.dispose(); } catch (e) { }
+ markerRo = null;
+ }
+ } catch (e) { }
+ try { if (markerMat) markerMat.dispose(); } catch (e) { }
+ markerMat = null;
+}
+
+export function dedupeMarkers(markers) {
+ const sorted = markers.slice().sort((a, b) => a.y - b.y || a.x - b.x);
+ const out = [];
+ for (const m of sorted) {
+ const dup = out.some(o => Math.abs(o.x - m.x) < sX(20) && Math.abs(o.y - m.y) < sY(20));
+ if (dup) {
+ // 被去重的结果立即释放
+ try {
+ if (m.region) {
+ m.region.dispose();
+ m.region = null;
+ }
+ } catch (e) { }
+ } else {
+ out.push(m);
+ }
+ }
+ return out;
+}
+
+export function clusterRows(markers) {
+ const rows = [];
+ for (const m of markers) {
+ // 同一行:y 差值小于 10(按分辨率缩放)
+ let row = rows.find(r => Math.abs(r.y - m.y) < sY(10));
+ if (!row) {
+ row = { y: m.y, markers: [] };
+ rows.push(row);
+ }
+ row.markers.push(m);
+ }
+ rows.sort((a, b) => a.y - b.y);
+ for (const r of rows) r.markers.sort((a, b) => a.x - b.x);
+ return rows;
+}
+
+export function guideMarkerCount() {
+ const ms = scanMarkers();
+ const n = ms.length;
+ disposeMarkers(ms);
+ return n;
+}
+
+export async function scanMarkersStable(timeoutMs = 1000) {
+ const start = Date.now();
+ while (Date.now() - start <= timeoutMs) {
+ assertAlive();
+ const ms = scanMarkers();
+ if (ms.length > 0) return ms;
+ disposeMarkers(ms);
+ await sleep(250);
+ }
+ return [];
+}
+
+export async function scanRowMarkers(rowY, timeoutMs = 1000) {
+ const start = Date.now();
+ while (Date.now() - start <= timeoutMs) {
+ assertAlive();
+ const all = scanMarkers();
+ const kept = [];
+ const dropped = [];
+ for (const m of all) {
+ if (Math.abs(m.y - rowY) < sY(10)) {
+ kept.push(m);
+ } else {
+ dropped.push(m);
+ }
+ }
+ disposeMarkers(dropped);
+ if (kept.length > 0) return kept;
+ disposeMarkers(kept);
+ await sleep(250);
+ }
+ return [];
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js b/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js
new file mode 100644
index 0000000000..f3a5259c9b
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js
@@ -0,0 +1,238 @@
+// 自动秘境执行:同一行可能包含多个材料系列,按系列逐个刷。
+// 每批在提升指南页 OCR 树脂,按当前系列缺口选 3/2/1 刷一查;树脂不够时用耗尽模式
+import { getS } from "../core/common.js";
+import { buildPlan, checkPlan } from "./solver.js";
+import { ensureGuidePage, refreshPlanFromGuide, readResin } from "./guide.js";
+
+export async function runDomainPhase(entries, rowY) {
+ let current = entries.filter(e => Number(e.rowY) === rowY);
+ if (current.length === 0) {
+ return { stopScript: false, rowGone: false };
+ }
+
+ let plan = buildPlan(current);
+ let zeroCounts = plan.flat.map(() => 0);
+
+ // 行内可能有多个系列:只有所有系列都满足时才整行跳过
+ const fullStatus = checkPlan(plan, zeroCounts);
+ if (fullStatus.ok) {
+ const firstDomain = current.find(e => e.domainName);
+ log.info("库存已满足需求(剩余 {s}),跳过刷取", fullStatus.surplusLowUnits);
+ try { notification.send("材料已齐,无需刷取:" + (firstDomain ? firstDomain.domainName : "")); } catch (e) { }
+ return { stopScript: false, rowGone: false };
+ }
+
+ const singleStatus = (group) =>
+ checkPlan({ groups: [group], flat: group.items }, zeroCounts);
+ const pickUnsatisfied = () => {
+ for (const g of plan.groups) {
+ const st = singleStatus(g);
+ if (!st.ok) return { group: g, status: st };
+ }
+ return null;
+ };
+ const pickItem = (group) =>
+ group.items.find(i => i.entry && i.entry.domainName) || group.items[0];
+
+ let picked = pickUnsatisfied();
+ if (!picked) {
+ // 正常不会走到这里,按整行完成处理
+ return { stopScript: false, rowGone: false };
+ }
+ let targetGroup = picked.group;
+ let item = pickItem(targetGroup);
+ if (!item || !item.entry.domainName) {
+ log.warn("未记录到秘境名,跳过自动秘境");
+ return { stopScript: false, rowGone: false };
+ }
+
+ // 已从配置移除:固定最大秘境轮数不受限(沿用原默认 9999)
+ const maxRounds = 9999;
+
+ let stopScript = false;
+ // 该行是否已经消失(下方行会上移;main 用这个值触发完全重扫)
+ let rowGone = false;
+
+ const param = new AutoDomainParam();
+ param.DomainName = item.entry.domainName;
+ // 不设置 DomainRoundNum:默认 9999,由指定树脂计数/树脂耗尽判定退出时机
+ param.RewardRecognitionEnabled = false;
+ // AutoDomainParam 会继承 BGI 本体配置:显式清零,只允许本插件显式指定的树脂档
+ param.CondensedResinUseCount = 0;
+ param.OriginalResin20UseCount = 0;
+ param.OriginalResin40UseCount = 0;
+ param.OriginalResinUseCount = 0;
+ param.TransientResinUseCount = 0;
+ param.FragileResinUseCount = 0;
+ // 不执行 BGI 本体的圣遗物分解
+ param.AutoArtifactSalvage = false;
+
+ // 周日/限时全开奖励序号:图标位置优先,其次 sundaySelectedValue,最后保持 BGI 本体配置
+ // 切换系列时重设,避免沿用上一系列的档位
+ const bgiSundayDefault = String(param.SundaySelectedValue || "");
+ const updateRewardSelection = () => {
+ const pos = item.entry.sundayPos;
+ if (pos === 1 || pos === 2 || pos === 3) {
+ param.SundaySelectedValue = String(pos);
+ log.info("[奖励序号] 按图标位置: {v}", pos);
+ } else {
+ const setting = String(getS("sundaySelectedValue", ""));
+ if (setting === "1" || setting === "2" || setting === "3") {
+ param.SundaySelectedValue = setting;
+ log.info("[奖励序号] 使用设置值: {v}", setting);
+ } else {
+ param.SundaySelectedValue = bgiSundayDefault;
+ }
+ }
+ };
+ updateRewardSelection();
+
+ const party = String(getS("partyName", ""));
+ if (party) param.PartyName = party;
+
+ log.info("[自动秘境] {name}", item.entry.domainName);
+
+ let totalRounds = 0;
+ for (let batchIndex = 1; totalRounds < maxRounds; batchIndex++) {
+ // 缺口只按当前系列算
+ await ensureGuidePage();
+ const d = singleStatus(targetGroup).deficitLowUnits;
+ log.info("[缺口] 当前系列 {g},缺口 {d}(低品质单位)", targetGroup.base, d);
+
+ let modeName;
+ let exhaust = false;
+ let roundsThisBatch;
+ let resin = null;
+ let original40Use = 0;
+ let original20Use = 0;
+
+ if (d > 160) {
+ // 当前系列缺口 >160:直接耗尽模式,一把刷完并结束整体流程
+ modeName = "树脂耗尽模式";
+ exhaust = true;
+ roundsThisBatch = maxRounds - totalRounds;
+ param.SpecifyResinUse = false;
+ param.CondensedResinUseCount = 0;
+ param.OriginalResin40UseCount = 0;
+ param.OriginalResin20UseCount = 0;
+ param.OriginalResinUseCount = 0;
+ } else {
+ resin = await readResin();
+ if (resin.rounds <= 0) {
+ log.info("树脂不足,自动停止刷取");
+ try { notification.send("树脂不足,停止刷取:" + item.entry.domainName); } catch (e) { }
+ stopScript = true;
+ break;
+ }
+ const need = d >= 120 ? 3 : (d >= 50 ? 2 : 1);
+ if (need >= 2 && resin.rounds < need) {
+ // 树脂不够 2/3 刷:也走耗尽模式
+ modeName = "树脂耗尽模式";
+ exhaust = true;
+ roundsThisBatch = maxRounds - totalRounds;
+ param.SpecifyResinUse = false;
+ param.CondensedResinUseCount = 0;
+ param.OriginalResin40UseCount = 0;
+ param.OriginalResin20UseCount = 0;
+ param.OriginalResinUseCount = 0;
+ } else {
+ // 浓缩优先:1 个浓缩 = 1 次;剩余轮数用原粹树脂,
+ // 先按 40/次分配,40 不够的轮次用 20/次补(原粹剩余 20-39 时)
+ const condensedUse = Math.min(resin.condensed, need);
+ const originalUse = need - condensedUse;
+ original40Use = Math.min(originalUse, Math.floor(resin.original / 40));
+ original20Use = Math.max(0, originalUse - original40Use);
+ modeName = need >= 3 ? "三刷一查" : (need >= 2 ? "两刷一查" : "一刷一查");
+ roundsThisBatch = need;
+ param.SpecifyResinUse = true;
+ param.CondensedResinUseCount = condensedUse;
+ param.OriginalResin40UseCount = original40Use;
+ param.OriginalResin20UseCount = original20Use;
+ param.OriginalResinUseCount = 0;
+ }
+ }
+
+ totalRounds += roundsThisBatch;
+ if (exhaust) {
+ log.info("[批次] {batch}:{mode}(刷到树脂耗尽),当前系列缺口 {d}",
+ batchIndex, modeName, d);
+ } else {
+ log.info("[批次] {batch}:{mode},当前系列缺口 {d},树脂 浓缩{c}/原粹{o},本批 {n} 轮(原粹40x{f}/20x{t}),累计 {total}/{max}",
+ batchIndex, modeName, d, resin.condensed, resin.original, roundsThisBatch, original40Use, original20Use, totalRounds, maxRounds);
+ }
+
+ // 回主界面交给 BGI(传送、进本、战斗、领奖都由 BGI 负责)
+ await genshin.returnMainUi();
+ await sleep(1000);
+
+ try {
+ await dispatcher.runAutoDomainTask(param);
+
+ // 仅树脂耗尽模式:刷取结束即整体流程完成
+ if (exhaust) {
+ log.info("[树脂耗尽模式] 刷取结束,流程完成");
+ try { notification.send("树脂耗尽模式刷取结束,停止刷取:" + item.entry.domainName); } catch (e) { }
+ stopScript = true;
+ break;
+ }
+
+ // 批后完整重扫该行(与重开脚本第一次运行完全一致)
+ const refreshed = await refreshPlanFromGuide(entries, rowY, item.entry.domainName);
+ if (!refreshed) {
+ log.warn("[刷新] 重扫失败,停止流程");
+ stopScript = true;
+ break;
+ }
+
+ current = entries.filter(e => Number(e.rowY) === rowY);
+ if (current.length === 0) {
+ // 该行已无标注(已消失):通知 main 完全重扫剩余行
+ log.info("该行已无待刷材料");
+ rowGone = true;
+ break;
+ }
+
+ // 重建当前行计划,继续挑“还有缺口”的系列;都满足才算该行完成
+ plan = buildPlan(current);
+ zeroCounts = plan.flat.map(() => 0);
+ const nextPicked = pickUnsatisfied();
+ if (!nextPicked) {
+ const afterStatus = checkPlan(plan, zeroCounts);
+ log.info("材料已集齐,停止刷取(剩余 {s})", afterStatus.surplusLowUnits);
+ try { notification.send("材料已集齐,停止刷取:" + item.entry.domainName); } catch (e) { }
+ break;
+ }
+
+ targetGroup = nextPicked.group;
+ item = pickItem(targetGroup);
+ if (!item.entry.domainName) {
+ // 与首次运行一致:该系列秘境名识别不到就不刷,继续下一行
+ log.warn("重扫后未识别到可刷秘境,停止该行刷取");
+ break;
+ }
+ param.DomainName = item.entry.domainName;
+ updateRewardSelection();
+
+ const missing = [].concat(...nextPicked.status.groups.map(g => g.missing || []));
+ log.info("[缺口] 第 {batch} 批后,下一系列缺口 {d},各档 {missing}",
+ batchIndex, nextPicked.status.deficitLowUnits, JSON.stringify(missing));
+ } catch (e) {
+ if (String(e.message).includes("树脂不足") || String(e.message).includes("未找到可用的树脂")) {
+ log.info("树脂耗尽,自动停止刷取");
+ try { notification.send("树脂耗尽,停止刷取:" + item.entry.domainName); } catch (e2) { }
+ // 无论是否耗尽模式:树脂类错误都结束整体流程,避免逐行空转
+ stopScript = true;
+ } else {
+ log.error("第 {batch} 批自动秘境失败: {err}", batchIndex, e.message);
+ try { notification.error("自动秘境失败:" + e.message); } catch (e2) { }
+ // 树脂耗尽模式下无论以何种方式结束,都不再继续后续流程
+ if (exhaust) {
+ stopScript = true;
+ }
+ }
+ break;
+ }
+ }
+
+ return { stopScript, rowGone };
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/guide.js b/repo/js/AutoTrainingPlanFarm/src/plan/guide.js
new file mode 100644
index 0000000000..64d78ac144
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/plan/guide.js
@@ -0,0 +1,642 @@
+// 提升指南页面:导航、行扫描、秘境名识别、校准滑动、刷新
+import { sX, sY, scaleX, scaleY, parkMouse, canonicalName, levDistance, touchActivity, assertAlive } from "../core/common.js";
+import { debugFrameStart, debugBox } from "../core/debug-overlay.js";
+import { guideMarkerCount, scanRowMarkers, disposeMarkers } from "../core/markers.js";
+import { ocrText, clickTextInRegion, clickGuideButton, processMarker } from "./popup.js";
+
+// 首行固定锚点 y(1920x1080 基准):启动扫描用它判断“第一行区域”是否有标注
+export const FIRST_ROW_ANCHOR_Y = 365;
+
+// BGI 内置可传送秘境名(与 tp.json 一致),用于把行 OCR 到的文字标准化
+const KNOWN_DOMAINS = [
+ "铭记之谷", "忘却之峡", "仲夏庭园", "塞西莉亚苗圃", "无妄引咎密宫", "太山府",
+ "震雷连山密宫", "孤云凌霄之处", "华池岩岫", "芬德尼尔之顶", "山脊守望", "菫色之庭",
+ "椛染之庭", "砂流之庭", "沉眠之庭", "昏识塔", "缘觉塔", "有顶塔", "岩中幽谷",
+ "赤金的城墟", "熔铁的孤塞", "深潮的余响", "苍白的遗荣", "罪祸的终末", "临瀑之城",
+ "褪色的剧场", "蕴火的幽墟", "深古瞭望所", "虹灵的净土", "荒废砌造坞", "无光的深都",
+ "霜凝的机枢", "失落的月庭", "月童的库藏", "山风的荆冕", "荒坠的圣迹", "逆悬的冰河",
+ "妄念的创痕"
+];
+
+// 树脂图标模板:透明背景已转纯绿 (0,255,0),useMask=true 时绿色不参与匹配
+const RESIN_CONDENSED_PATH = "assets/RecognitionObject/resin_condensed.png";
+const RESIN_ORIGINAL_PATH = "assets/RecognitionObject/resin_original.png";
+const RESIN_TEMPLATE_THRESHOLD = 0.9;
+
+let resinCondensedMat = null;
+let resinCondensedRo = null;
+let resinOriginalMat = null;
+let resinOriginalRo = null;
+let resinDigitRos = null;
+
+function ensureResinTemplates() {
+ if (resinCondensedRo && resinOriginalRo && resinDigitRos) return;
+ const loadResized = (path) => {
+ let m = file.readImageMatSync(path);
+ if (!m || m.empty() || m.width <= 0 || m.height <= 0) {
+ throw new Error("树脂图标模板读取失败: " + path);
+ }
+ const tw = Math.max(4, Math.round(m.width * scaleX));
+ const th = Math.max(4, Math.round(m.height * scaleY));
+ if (tw !== m.width || th !== m.height) {
+ const resized = new Mat();
+ OpenCvSharp.OpenCvSharp.Cv2.Resize(m, resized, new OpenCvSharp.OpenCvSharp.Size(tw, th));
+ try { m.dispose(); } catch (e) { }
+ m = resized;
+ }
+ return m;
+ };
+ resinCondensedMat = loadResized(RESIN_CONDENSED_PATH);
+ resinOriginalMat = loadResized(RESIN_ORIGINAL_PATH);
+ resinCondensedRo = RecognitionObject.TemplateMatch(resinCondensedMat, true);
+ resinOriginalRo = RecognitionObject.TemplateMatch(resinOriginalMat, true);
+ resinCondensedRo.Threshold = RESIN_TEMPLATE_THRESHOLD;
+ resinOriginalRo.Threshold = RESIN_TEMPLATE_THRESHOLD;
+
+ // 浓缩数字用白色数字模板匹配(素材与 OCRCountResin 一致)
+ resinDigitRos = [];
+ for (let v = 0; v <= 5; v++) {
+ const mat = loadResized("assets/RecognitionObject/num" + v + "_white.png");
+ const ro = RecognitionObject.TemplateMatch(mat);
+ ro.Threshold = 0.8;
+ resinDigitRos.push({ ro, mat, value: v });
+ }
+}
+
+export function disposeResinResources() {
+ try {
+ if (resinCondensedRo) {
+ try { if (resinCondensedRo.TemplateImageGreyMat) resinCondensedRo.TemplateImageGreyMat.dispose(); } catch (e) { }
+ try { if (resinCondensedRo.MaskMat) resinCondensedRo.MaskMat.dispose(); } catch (e) { }
+ resinCondensedRo = null;
+ }
+ } catch (e) { }
+ try {
+ if (resinOriginalRo) {
+ try { if (resinOriginalRo.TemplateImageGreyMat) resinOriginalRo.TemplateImageGreyMat.dispose(); } catch (e) { }
+ try { if (resinOriginalRo.MaskMat) resinOriginalRo.MaskMat.dispose(); } catch (e) { }
+ resinOriginalRo = null;
+ }
+ } catch (e) { }
+ try { if (resinCondensedMat) resinCondensedMat.dispose(); } catch (e) { }
+ try { if (resinOriginalMat) resinOriginalMat.dispose(); } catch (e) { }
+ resinCondensedMat = null;
+ resinOriginalMat = null;
+ if (resinDigitRos) {
+ for (const d of resinDigitRos) {
+ try { if (d.ro.TemplateImageGreyMat) d.ro.TemplateImageGreyMat.dispose(); } catch (e) { }
+ try { if (d.ro.MaskMat) d.ro.MaskMat.dispose(); } catch (e) { }
+ try { if (d.mat) d.mat.dispose(); } catch (e) { }
+ }
+ resinDigitRos = null;
+ }
+}
+
+export function isGuidePage() {
+ if (guideMarkerCount() > 0) return true;
+ const title = ocrText(sX(400), sY(200), sX(300), sY(100), "页面标题OCR", "#00b0ff");
+ return title.includes("提升指南");
+}
+
+export async function waitGuideMarkers(timeoutMs = 20000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ const n = guideMarkerCount();
+ if (n > 0) {
+ log.info("检测到标注图标 {n} 个", n);
+ return true;
+ }
+ await sleep(1000);
+ }
+ return false;
+}
+
+export async function ensureGuidePage() {
+ if (isGuidePage()) {
+ log.info("已在提升指南页面");
+ return;
+ }
+ log.info("未检测到提升指南页面,自动打开");
+ await genshin.returnMainUi();
+ await sleep(800);
+ keyPress("F1");
+ await sleep(1500);
+
+ if (!(await clickTextInRegion("秘境", sX(200), sY(350), sX(200), sY(150), 15000))) {
+ throw new Error("未能找到冒险之证左侧的「秘境」按钮");
+ }
+ await sleep(1000);
+
+ // 点完「秘境」后,先 OCR 找到并点击「提升指南」按钮(不用固定坐标)
+ if (!isGuidePage()) {
+ log.info("点击「提升指南」按钮");
+ if (!(await clickGuideButton(15000))) {
+ throw new Error("未能找到并点击「提升指南」按钮");
+ }
+ await sleep(800);
+ }
+
+ // 页面可能没有任何标注:确认标题即可,不必等标注出现
+ const start = Date.now();
+ while (Date.now() - start < 10000) {
+ if (isGuidePage()) {
+ log.info("已进入提升指南页面");
+ return;
+ }
+ await sleep(500);
+ }
+ throw new Error("10 秒内未确认提升指南页面,请检查游戏状态");
+}
+
+export function qualityLabelForRank(rank) {
+ return "rank" + rank;
+}
+
+export function sundayPosForX(x) {
+ const rules = [[1, 1421], [2, 1336], [3, 1257]];
+ for (const [pos, center] of rules) {
+ if (Math.abs(x - sX(center)) <= sX(25)) return pos;
+ }
+ return 0;
+}
+
+export async function swipeRowRight(captureY) {
+ const startX = sX(1100);
+ const endX = sX(1400);
+ log.info("[校准滑动] y={y} ({start}->{end})", captureY, startX, endX);
+
+ let cap = null;
+ try {
+ cap = captureGameRegion();
+ cap.moveTo(startX, captureY);
+ await sleep(100);
+ leftButtonDown();
+ const steps = 20;
+ for (let i = 1; i <= steps; i++) {
+ const x = startX + (endX - startX) * i / steps;
+ cap.moveTo(Math.round(x), captureY);
+ await sleep(20);
+ }
+ leftButtonUp();
+ } catch (e) {
+ try { leftButtonUp(); } catch (e2) { }
+ log.error("[校准滑动] 失败: {err}", e.message);
+ } finally {
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ parkMouse();
+ await sleep(1000);
+}
+
+// 启动阶段首行区域无标注时:对固定位置做一次校准(从左到右滑动两次)
+export async function calibrateFirstRow() {
+ const y = sY(FIRST_ROW_ANCHOR_Y);
+ log.info("[首行校准] 首行区域无标注,固定位置校准 y={y}", y);
+ await swipeRowRight(y);
+ await swipeRowRight(y);
+}
+
+export async function swipeRowPressHold(captureY) {
+ const startX = sX(1400);
+ const endX = startX - sX(400);
+ log.info("[主力滑动] y={y} ({start}->{end})", captureY, startX, endX);
+
+ let cap = null;
+ try {
+ cap = captureGameRegion();
+ cap.moveTo(startX, captureY);
+ await sleep(100);
+ leftButtonDown();
+ await sleep(400); // 按住0.4s
+ const steps = 25;
+ for (let i = 1; i <= steps; i++) {
+ const x = startX + (endX - startX) * i / steps;
+ cap.moveTo(Math.round(x), captureY);
+ await sleep(16);
+ }
+ await sleep(400); // 划完不松手,保持0.4s
+ leftButtonUp();
+ } catch (e) {
+ try { leftButtonUp(); } catch (e2) { }
+ log.error("[主力滑动] 失败: {err}", e.message);
+ } finally {
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ // 松开后先等 400ms,让滑动手势完全结束,再移动鼠标去点空处
+ await sleep(400);
+ parkMouse();
+ // 只在主力滑动后:点一下中心偏上的空处消除标注悬停放大,再等 500ms 让动画回落
+ try {
+ const [gw, gh] = getGameMetrics();
+ click(Math.round(gw / 2), Math.round(gh / 6));
+ } catch (e) { }
+ await sleep(500);
+}
+
+export async function readDomainName(rowY) {
+ const text = await ocrText(sX(760), rowY - sY(15), sX(300), sY(45), "行秘境名OCR", "#00b0ff");
+ log.info("[秘境OCR] y={y}: {text}", rowY, text);
+
+ // 1. 直接包含
+ for (const d of KNOWN_DOMAINS) {
+ if (text.includes(d)) return d;
+ }
+
+ // 2. 逐行与标准名做编辑距离匹配(如 OCR 把「菫」认成「董」)
+ const lines = String(text).split(/\n/).map(s => s.trim()).filter(s => s.length >= 2);
+ for (const line of lines) {
+ for (const d of KNOWN_DOMAINS) {
+ if (Math.abs(line.length - d.length) <= 1 && levDistance(line, d) <= 1) {
+ log.info("[秘境OCR] {line} -> {d}", line, d);
+ return d;
+ }
+ }
+ }
+ return "";
+}
+
+// 树脂:图标模板定位 + 数字条 OCR;图标没匹配到时回退固定坐标区域
+// 原粹白字预处理:单独截图,二值化 + 反色后 OCR(不污染主截图)
+function ocrWhiteDigits(x, y, w, h) {
+ let cap = null, crop = null, grey = null, bin = null, inv = null, bgr = null, res = null;
+ try {
+ cap = captureGameRegion();
+ crop = cap.deriveCrop(x, y, w, h);
+ grey = crop.srcMat.cvtColor(OpenCvSharp.OpenCvSharp.ColorConversionCodes.BGR2GRAY);
+ bin = grey.threshold(180, 255, OpenCvSharp.OpenCvSharp.ThresholdTypes.Binary);
+ inv = new Mat();
+ OpenCvSharp.OpenCvSharp.Cv2.BitwiseNot(bin, inv);
+ bgr = inv.cvtColor(OpenCvSharp.OpenCvSharp.ColorConversionCodes.GRAY2BGR);
+ bgr.copyTo(crop.srcMat, null);
+ res = crop.find(RecognitionObject.ocrThis);
+ if (res && !res.isEmpty()) return (res.text || "").trim();
+ } catch (e) {
+ // 预处理失败,回退普通 OCR
+ } finally {
+ try { if (res) res.dispose(); } catch (e) { }
+ try { if (bgr) bgr.dispose(); } catch (e) { }
+ try { if (inv) inv.dispose(); } catch (e) { }
+ try { if (bin) bin.dispose(); } catch (e) { }
+ try { if (grey) grey.dispose(); } catch (e) { }
+ try { if (crop) crop.dispose(); } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ return "";
+}
+
+export async function readResin() {
+ ensureResinTemplates();
+ debugFrameStart();
+
+ let condensed = 0;
+ let digitInfo = "未命中";
+ let originalText = "";
+ let cap = null;
+ let cRegion = null;
+ let oRegion = null;
+ let cScore = 0;
+ let oScore = 0;
+ try {
+ parkMouse();
+ cap = captureGameRegion();
+ // 用 findMulti 取最高有限分:单个 find 的 matchScore 可能缺失/Infinity,导致误判未命中
+ const pickBest = (ro) => {
+ let results = null;
+ let best = null;
+ try {
+ results = cap.findMulti(ro);
+ for (let k = 0; k < results.count; k++) {
+ const r = results[k];
+ const s = Number(r.matchScore);
+ if (!Number.isFinite(s)) {
+ try { r.dispose(); } catch (e) { }
+ continue;
+ }
+ if (!best || s > best.score) {
+ if (best) { try { best.region.dispose(); } catch (e) { } }
+ best = { score: s, region: r, x: r.x, y: r.y, w: r.width, h: r.height };
+ } else {
+ try { r.dispose(); } catch (e) { }
+ }
+ }
+ } catch (e) {
+ // 无候选
+ }
+ return best;
+ };
+ const cInfo = pickBest(resinCondensedRo);
+ const oInfo = pickBest(resinOriginalRo);
+ if (cInfo) {
+ cRegion = cInfo.region;
+ cScore = cInfo.score;
+ }
+ if (oInfo) {
+ oRegion = oInfo.region;
+ oScore = oInfo.score;
+ }
+
+ const readDigitsAt = (x, y, w, h) => {
+ let res = null;
+ try {
+ res = cap.find(RecognitionObject.ocr(x, y, w, h));
+ if (res && !res.isEmpty()) return (res.text || "").trim();
+ } catch (e) {
+ // 失败按空处理,外层回退
+ } finally {
+ try { if (res) res.dispose(); } catch (e) { }
+ }
+ return "";
+ };
+
+ if (cRegion && !cRegion.isEmpty()) {
+ log.info("[树脂定位] 浓缩图标 @({x},{y},{w},{h}) 分={s}",
+ cRegion.x, cRegion.y, cRegion.width, cRegion.height, cScore.toFixed(3));
+ // 浓缩数字:白色数字模板匹配,验证区=图标左上角起 90x40
+ const region = { x: cRegion.x, y: cRegion.y, w: sX(90), h: sY(40) };
+ debugBox("浓缩数字区", region.x, region.y, region.w, region.h, "#448aff");
+ let best = null;
+ for (const d of resinDigitRos) {
+ let r = null;
+ try { r = cap.find(d.ro); } catch (e) { }
+ if (!r || r.isEmpty()) {
+ try { if (r) r.dispose(); } catch (e) { }
+ continue;
+ }
+ const s = Number(r.matchScore);
+ const ok = Number.isFinite(s) &&
+ r.x >= region.x && r.x <= region.x + region.w &&
+ r.y >= region.y && r.y <= region.y + region.h;
+ if (!ok || (best && s <= best.score)) {
+ try { r.dispose(); } catch (e) { }
+ continue;
+ }
+ if (best) { try { best.region.dispose(); } catch (e) { } }
+ best = { score: s, region: r, value: d.value, x: r.x, y: r.y, w: r.width, h: r.height };
+ }
+ if (best) {
+ condensed = best.value;
+ digitInfo = "值=" + best.value + " 分=" + best.score.toFixed(3) +
+ " @(" + best.x + "," + best.y + "," + best.w + "," + best.h + ")";
+ debugBox("浓缩数字" + best.value, best.x, best.y, best.w, best.h, "#ff4081");
+ try { best.region.dispose(); } catch (e) { }
+ }
+ } else {
+ log.warn("[树脂] 浓缩图标未命中,浓缩按 0");
+ }
+
+ if (oRegion && !oRegion.isEmpty()) {
+ log.info("[树脂定位] 原粹图标 @({x},{y},{w},{h}) 分={s}",
+ oRegion.x, oRegion.y, oRegion.width, oRegion.height, oScore.toFixed(3));
+ // 数字起点在图标右缘左侧:左移 5px,130x50
+ const x = oRegion.x + oRegion.width - sX(5);
+ const y = Math.max(0, oRegion.y + Math.round((oRegion.height - sY(50)) / 2));
+ debugBox("原粹数字条", x, y, sX(130), sY(50), "#ffc107");
+ originalText = ocrWhiteDigits(x, y, sX(130), sY(50));
+ if (!originalText) originalText = readDigitsAt(x, y, sX(130), sY(50));
+ // 图标定位后的 OCR 为空时,回退 ResinCalibration 标定的固定区域(仅原粹)
+ if (!originalText) {
+ const oFx = sX(1350), oFy = sY(200), oFw = sX(140), oFh = sY(50);
+ log.info("[树脂] 原粹图标定位 OCR 为空,回退固定区域 ({x},{y},{w},{h})", oFx, oFy, oFw, oFh);
+ debugBox("原粹OCR(固定回退)", oFx, oFy, oFw, oFh, "#ffc107");
+ originalText = readDigitsAt(oFx, oFy, oFw, oFh);
+ }
+ } else {
+ debugBox("原粹树脂OCR(回退)", sX(1350), sY(200), sX(140), sY(50), "#ffc107");
+ originalText = await ocrText(sX(1350), sY(200), sX(140), sY(50), "原粹树脂OCR", "#ffc107");
+ }
+ } finally {
+ try { if (cRegion) cRegion.dispose(); } catch (e) { }
+ try { if (oRegion) oRegion.dispose(); } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+
+ // 抗干扰:只保留数字和斜杠
+ const cleanDigits = (t) => String(t || "").replace(/[^0-9/]/g, "");
+ const firstInt = (t) => {
+ const m = String(t || "").match(/\d+/);
+ return m ? parseInt(m[0], 10) : 0;
+ };
+
+ let original = 0;
+ {
+ const raw = String(originalText || "").replace(/\s+/g, "");
+ const m = raw.match(/(\d+)\/(\d+)/) || cleanDigits(originalText).match(/(\d+)\/(\d+)/);
+ original = m ? parseInt(m[1], 10) : firstInt(cleanDigits(originalText));
+ }
+
+ // 浓缩优先:1 个浓缩 = 1 次;原粹每 40 可刷 1 次,
+ // 剩余 20-39 也算可刷 1 次(20 树脂档),BGI 指定使用时按 40 优先、最后 40 不够才用 20
+ const originalRounds = Math.floor(original / 40) + (original % 40 >= 20 ? 1 : 0);
+ const rounds = condensed + originalRounds;
+ log.info("[树脂] 浓缩 {c}({ci}),原粹 {o}({ot}),可刷 {r} 轮(浓缩优先,原粹40优先,余20-39补1次20)",
+ condensed, digitInfo, original, originalText, rounds);
+ return { condensed, original, originalRounds, rounds };
+}
+
+export async function refreshPlanFromGuide(entries, rowY, expectedDomainName) {
+ log.info("重新扫描培养计划");
+ await ensureGuidePage();
+
+ // 原行身份校验:下方行上移后会占据原 y。
+ // 此处读到“不同的秘境名”说明当前行已完成消失,不要用下一行的材料冒充本行重扫结果。
+ if (expectedDomainName) {
+ const observed = await readDomainName(rowY);
+ if (observed && observed !== expectedDomainName &&
+ levDistance(canonicalName(observed), canonicalName(expectedDomainName)) > 1) {
+ log.info("[刷新] 原行已完成(位置秘境名变为 {obs}),视为本行完成,等待切换下一行", observed);
+ for (let i = entries.length - 1; i >= 0; i--) {
+ if (Number(entries[i].rowY) === rowY) entries.splice(i, 1);
+ }
+ return true;
+ }
+ }
+
+ // 与重开脚本完全一致:按 processRow 完整重扫该行
+ const row = { index: 0, y: rowY, names: new Map(), nextRank: 1, domainName: "" };
+ const before = entries.length;
+ try {
+ const result = await processRow(row, entries);
+ if (result && result.status === "noDomain") {
+ // 读不到域名,再判断原行是真的消失了,还是有标注但无可刷秘境(周本)
+ const check = await scanRowMarkers(rowY, 1000);
+ const stillHasMarkers = check.length > 0;
+ disposeMarkers(check);
+ if (!stillHasMarkers) {
+ // 原行已无标注 = 需求已全部满足,继续走下方删旧条目并返回 true
+ log.info("[刷新] 原行已无标注,视为全部满足");
+ } else {
+ // 有标注但读不到秘境名:周本/不可刷,停止后续流程
+ log.info("[刷新] 该行有标注但无可刷秘境,停止后续处理");
+ return false;
+ }
+ }
+ } catch (e) {
+ if (String(e.message || "").includes("校准后仍识别不到标注")) {
+ // 该行已无标注 = 需求已全部满足
+ log.info("[刷新] 该行已无标注,视为全部满足");
+ } else {
+ log.error("[刷新] 重扫失败: {err}", e.message);
+ return false;
+ }
+ }
+
+ // 该行重扫前的旧条目全部移除,以本次重扫结果为准(等价于重开脚本)
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const e = entries[i];
+ if (Number(e.rowY) !== rowY) continue;
+ if (i < before) {
+ entries.splice(i, 1);
+ }
+ }
+ return true;
+}
+
+export async function clickAndRecord(m, row, entries) {
+ const info = await processMarker(m);
+ if (!info) return;
+
+ const key = canonicalName(info.name);
+ const orderRank = row.nextRank;
+ let rank = orderRank;
+ if (info.colorRankTop && info.colorRankTop >= 1 && info.colorRankTop <= 4) {
+ rank = info.colorRankTop;
+ }
+
+ let dup = false;
+ for (const [k, rk] of row.names) {
+ // 完全同名无条件去重;错 1 字只有品质档位相同才算重复,避免把“一角/一片”这类不同档合并
+ if (k === key || (rk === rank && levDistance(k, key) <= 1)) {
+ dup = true;
+ break;
+ }
+ }
+ if (dup) {
+ log.info("[重复] {name} 已记录(含同档错1字),跳过", info.name);
+ return;
+ }
+
+ row.names.set(key, rank);
+ row.nextRank = orderRank + 1;
+ touchActivity();
+ // 品质按颜色绝对4档:金1/紫2/蓝3/绿4;颜色失败按点击顺序兜底
+ const label = qualityLabelForRank(rank);
+ entries.push({
+ material: info.name,
+ category: info.category,
+ source: info.source,
+ have: info.have,
+ need: info.need,
+ needText: info.needText,
+ synthText: info.synthText,
+ synthCount: info.synthCount || 0,
+ qualityRank: rank,
+ qualityLabel: label,
+ colorRankTop: info.colorRankTop || null,
+ orderRank,
+ domainName: row.domainName || "",
+ sundayPos: sundayPosForX(m.cx),
+ rowY: Math.round(row.y),
+ clickX: Math.round(m.cx),
+ clickY: Math.round(m.cy)
+ });
+ log.info("[品质] {name} {label}", info.name, label);
+}
+
+export async function processRow(row, entries) {
+ log.info("[行] 第 {n} 行 y={y}", row.index, row.y);
+ touchActivity();
+
+ // 读不到秘境名就重试一次;仍失败按周本/不可刷处理,返回 noDomain
+ row.domainName = await readDomainName(row.y);
+ if (!row.domainName) {
+ log.info("未识别秘境名,重试一次");
+ const retryDomain = await readDomainName(row.y);
+ if (!retryDomain) {
+ log.warn("该行有标注但秘境名读取失败,按周本/无可刷秘境处理");
+ return { status: "noDomain" };
+ }
+ row.domainName = retryDomain;
+ }
+ log.info("[秘境] {domain}", row.domainName || "(未识别)");
+
+ // 左→右校准两次
+ await swipeRowRight(row.y);
+ await swipeRowRight(row.y);
+ log.info("校准完成");
+
+ // 识别标注,失败则校准重试
+ let ms0 = await scanRowMarkers(row.y, 3000);
+ if (ms0.length === 0) {
+ log.info("未识别标注,校准后重试");
+ for (let i = 0; i < 3; i++) {
+ await swipeRowRight(row.y);
+ }
+ ms0 = await scanRowMarkers(row.y, 5000);
+ if (ms0.length === 0) {
+ throw new Error("校准后仍识别不到标注(卡死 5 秒),自动结束脚本");
+ }
+ }
+ disposeMarkers(ms0);
+ log.info("[扫描] 识别到该行标注");
+
+ // 点击该行可见标注(第 1 次识别)
+ {
+ assertAlive();
+ let ms = await scanRowMarkers(row.y);
+ try {
+ ms.sort((a, b) => a.x - b.x);
+ log.info("[扫描] 该行可见标注 {k} 个", ms.length);
+ for (const m of ms) {
+ assertAlive();
+ await clickAndRecord(m, row, entries);
+ }
+ } finally {
+ disposeMarkers(ms);
+ }
+ }
+
+ // 滑动结果要和第一次识别比“名称+品质”:数量相同、名称容错 1 字、品质档相同才算一致,
+ // 避免把“无垢之海的银杯/金杯”这种错 1 字但品质不同的材料当成同一批
+ const firstSeen = new Map([...row.names]);
+
+ const samePassFuzzy = (a, b) =>
+ a.size === b.size &&
+ [...a].every(([name, rank]) =>
+ [...b].some(([otherName, otherRank]) =>
+ rank === otherRank && levDistance(name, otherName) <= 1));
+
+ // 第 1 次滑动后与初始一致才跳过第 2 次,然后恢复校准
+ for (let s = 1; s <= 2; s++) {
+ assertAlive();
+ await swipeRowPressHold(row.y);
+
+ let ms = await scanRowMarkers(row.y);
+ try {
+ if (ms.length === 0) {
+ log.info("[滑动] 第 {s} 次后未发现标注", s);
+ continue;
+ }
+ ms.sort((a, b) => a.x - b.x);
+ log.info("[滑动] 第 {s} 次后识别 {k} 个标注", s, ms.length);
+ for (const m of ms) {
+ await clickAndRecord(m, row, entries);
+ }
+ if (s === 1 && firstSeen.size > 0 && samePassFuzzy(firstSeen, row.names)) {
+ log.info("[滑动] 识别结果与初始一致(名称+品质),跳过第 2 次滑动");
+ break;
+ }
+ if (s === 1 && firstSeen.size > 0) {
+ log.info("[滑动] 识别结果包含新品质/新材料,继续第 2 次滑动");
+ }
+ } finally {
+ disposeMarkers(ms);
+ }
+ }
+
+ // 恢复校准
+ log.info("滑动识别完成,恢复校准");
+ await swipeRowRight(row.y);
+ await swipeRowRight(row.y);
+
+ return { status: "ok", count: row.names.size };
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/popup.js b/repo/js/AutoTrainingPlanFarm/src/plan/popup.js
new file mode 100644
index 0000000000..be1be720ba
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/plan/popup.js
@@ -0,0 +1,540 @@
+// 材料弹窗:OCR 读取、名称/需求/来源/可合成解析、品质颜色、点击 OCR 文本
+import { sX, sY, scaleX, scaleY, parkMouse, touchActivity, assertAlive, levDistance } from "../core/common.js";
+import { debugFrameStart, debugBox } from "../core/debug-overlay.js";
+
+// 弹窗整块 OCR 区域
+const POPUP_OCR_RECT = { x: 710, y: 55, w: 500, h: 960 };
+
+// 材料弹窗 OCR 时把鼠标停到识别框(y 从约 55 开始)上方,避免遮挡文字/颜色取样
+function parkMouseAbovePopup() {
+ try {
+ const [gw] = getGameMetrics();
+ moveMouseTo(Math.round(gw / 2), 24);
+ } catch (e) { }
+}
+
+export function ocrText(x, y, w, h, label, color) {
+ let cap = null;
+ let res = null;
+ try {
+ debugFrameStart();
+ debugBox(label, x, y, w, h, color);
+ parkMouse();
+ cap = captureGameRegion();
+ res = cap.find(RecognitionObject.ocr(x, y, w, h));
+ if (res && !res.isEmpty()) {
+ return (res.text || "").trim();
+ }
+ return "";
+ } catch (e) {
+ return "";
+ } finally {
+ try { if (res) res.dispose(); } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+}
+
+export function readPopupLines() {
+ let cap = null;
+ let results = null;
+ try {
+ debugFrameStart();
+ debugBox("弹窗整块OCR", sX(POPUP_OCR_RECT.x), sY(POPUP_OCR_RECT.y), sX(POPUP_OCR_RECT.w), sY(POPUP_OCR_RECT.h), "#ffd600");
+ parkMouseAbovePopup();
+ cap = captureGameRegion();
+ results = cap.findMulti(RecognitionObject.ocr(
+ sX(POPUP_OCR_RECT.x), sY(POPUP_OCR_RECT.y), sX(POPUP_OCR_RECT.w), sY(POPUP_OCR_RECT.h)
+ ));
+ const lines = [];
+ for (let i = 0; i < results.count; i++) {
+ const r = results[i];
+ const t = (r.text || "").replace(/\s+/g, "").trim();
+ if (t) {
+ lines.push({ text: t, x: r.x, y: r.y, width: r.width, height: r.height });
+ debugBox("弹窗文本:" + t + "@" + r.x + "," + r.y, r.x, r.y, r.width, r.height, "#4fc3f7");
+ }
+ }
+ lines.sort((a, b) => a.y - b.y || a.x - b.x);
+ return lines;
+ } catch (e) {
+ return [];
+ } finally {
+ try {
+ if (results) {
+ for (let i = 0; i < results.count; i++) {
+ try { results[i].dispose(); } catch (e) { }
+ }
+ }
+ } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+}
+
+export function findAnchorLine(lines, keyword) {
+ for (const l of lines) {
+ if (l.text.includes(keyword)) return l;
+ }
+ return null;
+}
+
+// 可合成数量与数字同在一行:先精确匹配,标签错 1 字也认,最后取该行末尾数字
+function extractSynthCount(text) {
+ const t = String(text || "").replace(/\s+/g, "");
+ if (!t) return 0;
+
+ const exact = t.match(/可合成数量[::]?(\d+)/);
+ if (exact) return parseInt(exact[1], 10) || 0;
+
+ const labels = ["可合成数量:", "可合成数量:", "可合成数量"];
+ const digits = t.match(/\d+/g) || [];
+ for (const d of digits) {
+ const prefix = t.slice(0, t.indexOf(d));
+ if (prefix && labels.some(label => levDistance(prefix, label) <= 1)) {
+ return parseInt(d, 10) || 0;
+ }
+ }
+ return digits.length > 0 ? (parseInt(digits[digits.length - 1], 10) || 0) : 0;
+}
+
+export function collectBelowAnchor(lines, anchor, maxDy, excludeRe) {
+ if (!anchor) return "";
+ const parts = [];
+ for (const l of lines) {
+ if (l.y < anchor.y + anchor.height - sY(5)) continue;
+ if (l.y > anchor.y + sY(maxDy)) continue;
+ if (l.x < anchor.x - sX(60) || l.x > sX(1400)) continue;
+ if (excludeRe && excludeRe.test(l.text)) continue;
+ parts.push(l.text);
+ }
+ return parts.join("");
+}
+
+export function extractPopupName(lines) {
+ const ex = /素材|来源|培养需求|可合成/;
+ const yLimit = sY(360);
+ let first = null;
+ for (const l of lines) {
+ if (l.y > yLimit) break;
+ if (l.text.length >= 2 && !ex.test(l.text)) {
+ first = l;
+ break;
+ }
+ }
+ if (first) {
+ // 同一行的碎片(PaddleOCR 可能把名称拆成多段)按 x 拼接
+ const parts = [];
+ for (const l of lines) {
+ if (Math.abs(l.y - first.y) <= sY(30) && l.x < sX(1400) && !ex.test(l.text)) {
+ parts.push(l);
+ }
+ }
+ parts.sort((a, b) => a.x - b.x);
+ return parts.map(p => p.text).join("");
+ }
+ // 兜底:取顶部第一行并裁掉可能粘在一起的分类后缀
+ for (const l of lines) {
+ if (l.y > yLimit) break;
+ if (l.text.length >= 2) {
+ return l.text.replace(/(角色|武器)?天赋?突破?素材.*$/, "").trim();
+ }
+ }
+ return "";
+}
+
+export function parseNeedFromLines(lines, needAnchor) {
+ const slashRe = /(\d+)\/(\d+)/;
+ const candidates = [];
+
+ if (needAnchor) {
+ const own = needAnchor.text.match(slashRe);
+ if (own) return { have: parseInt(own[1], 10), need: parseInt(own[2], 10) };
+
+ for (const l of lines) {
+ if (Math.abs(l.y - needAnchor.y) > sY(70)) continue;
+ if (l.x < needAnchor.x - sX(80)) continue;
+ const m = l.text.match(slashRe);
+ if (m) candidates.push({ line: l, m });
+ }
+ }
+
+ if (candidates.length === 0) {
+ for (const l of lines) {
+ const m = l.text.match(slashRe);
+ if (m) candidates.push({ line: l, m });
+ }
+ }
+
+ if (candidates.length === 0) {
+ // 没有 x/y 格式时:取靠近「培养需求」的所有数字,最后一个视为需求数
+ const nums = [];
+ const addNums = (text) => {
+ const ms = text.match(/\d+/g);
+ if (ms) ms.forEach(n => nums.push(parseInt(n, 10)));
+ };
+ if (needAnchor) addNums(needAnchor.text);
+ if (needAnchor) {
+ for (const l of lines) {
+ if (Math.abs(l.y - needAnchor.y) <= sY(70) && l.x >= needAnchor.x - sX(80)) addNums(l.text);
+ }
+ }
+ if (nums.length > 0) return { have: nums.length >= 2 ? nums[0] : 0, need: nums[nums.length - 1] };
+ return null;
+ }
+
+ if (needAnchor) {
+ candidates.sort((a, b) =>
+ Math.abs(a.line.y - needAnchor.y) - Math.abs(b.line.y - needAnchor.y));
+ }
+ const c = candidates[0];
+ return { have: parseInt(c.m[1], 10), need: parseInt(c.m[2], 10) };
+}
+
+export async function clickTextInRegion(text, x, y, w, h, timeoutMs = 12000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ let cap = null;
+ let results = null;
+ let clicked = false;
+ try {
+ debugFrameStart();
+ debugBox("点击OCR:" + text, x, y, w, h, "#ff9100");
+ parkMouse();
+ cap = captureGameRegion();
+ results = cap.findMulti(RecognitionObject.ocr(x, y, w, h));
+ for (let i = 0; i < results.count; i++) {
+ const r = results[i];
+ const t = (r.text || "").replace(/\s+/g, "");
+ if (t.includes(text)) {
+ log.info("[点击] {text} @({x},{y},{w},{h})", text, r.x, r.y, r.width, r.height);
+ r.click();
+ clicked = true;
+ break;
+ }
+ }
+ } catch (e) {
+ } finally {
+ try {
+ if (results) {
+ for (let i = 0; i < results.count; i++) {
+ try { results[i].dispose(); } catch (e) { }
+ }
+ }
+ } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ if (clicked) return true;
+ await sleep(400);
+ }
+ return false;
+}
+
+export async function clickGuideButton(timeoutMs = 15000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ let cap = null;
+ let results = null;
+ const found = [];
+ try {
+ debugFrameStart();
+ parkMouse();
+ cap = captureGameRegion();
+ debugBox("全屏OCR:提升指南", 0, 0, cap.width, cap.height, "#ff5252");
+ results = cap.findMulti(RecognitionObject.ocr(0, 0, cap.width, cap.height));
+ for (let i = 0; i < results.count; i++) {
+ const r = results[i];
+ if ((r.text || "").replace(/\s+/g, "").includes("提升指南")) {
+ const cy = r.y + r.height / 2;
+ found.push({ cy, r });
+ }
+ }
+ if (found.length > 0) {
+ // 一律选 y 最小的那个;选定后本次不再点其它候选
+ found.sort((a, b) => a.cy - b.cy);
+ const best = found[0];
+ log.info("[点击] 提升指南 @({x},{y},{w},{h})", best.r.x, best.r.y, best.r.width, best.r.height);
+ best.r.click();
+ return true;
+ }
+ } catch (e) {
+ } finally {
+ try {
+ if (results) {
+ for (let i = 0; i < results.count; i++) {
+ try { results[i].dispose(); } catch (e) { }
+ }
+ }
+ } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ await sleep(500);
+ }
+ return false;
+}
+
+export async function waitPopupName() {
+ // 等弹窗入场动画:第一次识别前固定等 1s
+ await sleep(1000);
+ for (let i = 0; i < 20; i++) {
+ assertAlive();
+ const name = extractPopupName(readPopupLines());
+ if (name) {
+ touchActivity();
+ return name;
+ }
+ await sleep(400);
+ }
+ return "";
+}
+
+export function popupStillOpen(lines) {
+ return !!(findAnchorLine(lines, "培养需求") || findAnchorLine(lines, "来源"));
+}
+
+export async function closePopup(name) {
+ keyPress("ESCAPE");
+ await sleep(500);
+ let closedCount = 0;
+ for (let i = 0; i < 16; i++) {
+ assertAlive();
+ const lines = readPopupLines();
+ if (!popupStillOpen(lines)) {
+ // 连续两次确认锚点消失才认为已关闭,防止单次 OCR 抖动误判
+ closedCount++;
+ if (closedCount >= 2) {
+ touchActivity();
+ return true;
+ }
+ } else {
+ closedCount = 0;
+ if (name) {
+ const current = extractPopupName(lines);
+ if (current && !current.includes(name.substring(0, Math.min(2, name.length)))) {
+ touchActivity();
+ return true;
+ }
+ }
+ }
+ await sleep(250);
+ }
+ keyPress("ESCAPE");
+ await sleep(500);
+ return false;
+}
+
+export async function scrollPopupUp() {
+ let cap = null;
+ try {
+ cap = captureGameRegion();
+ const cx = sX(960);
+ const y1 = sY(700);
+ const y2 = sY(250);
+ cap.moveTo(cx, y1);
+ await sleep(100);
+ leftButtonDown();
+ const steps = 20;
+ for (let i = 1; i <= steps; i++) {
+ const y = y1 + (y2 - y1) * i / steps;
+ cap.moveTo(cx, Math.round(y));
+ await sleep(16);
+ }
+ leftButtonUp();
+ } catch (e) {
+ try { leftButtonUp(); } catch (e2) { }
+ log.error("弹窗向上滑动失败: {err}", e.message);
+ } finally {
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+ parkMouse();
+ await sleep(1000);
+}
+
+export async function readPopup() {
+ const name = await waitPopupName();
+ if (!name) return null;
+
+ const lines = readPopupLines();
+ const sourceAnchor = findAnchorLine(lines, "来源");
+ const needAnchor = findAnchorLine(lines, "培养需求");
+ const synthAnchor = findAnchorLine(lines, "可合成");
+
+ // 「来源」文字在锚点下方;一次没读到就稍等重试
+ let source = collectBelowAnchor(lines, sourceAnchor, 120, /来源|培养需求|可合成/);
+ if (!source) {
+ await sleep(600);
+ const linesRetry = readPopupLines();
+ const sourceAnchorRetry = findAnchorLine(linesRetry, "来源");
+ if (sourceAnchorRetry) {
+ source = collectBelowAnchor(linesRetry, sourceAnchorRetry, 120, /来源|培养需求|可合成/);
+ }
+ }
+
+ const parsed = parseNeedFromLines(lines, needAnchor);
+ // 数字与「可合成数量」标签同在一行:只取锚点行文本解析,不再拼接下方 80px 的其它行
+ let synthText = synthAnchor ? synthAnchor.text : "";
+ let synthCount = extractSynthCount(synthText);
+ log.info("[可合成数量] {text} -> {n}", synthText, synthCount);
+
+ // 类型行:顶部名称下方、y<360 的「XX素材」行
+ let category = "";
+ for (const l of lines) {
+ if (l.y > sY(360)) break;
+ if (/素材/.test(l.text) && l.y > 0) {
+ category = l.text;
+ break;
+ }
+ }
+
+ const needText = parsed
+ ? `${parsed.have}/${parsed.need}`
+ : (needAnchor ? needAnchor.text : "");
+
+ // 按材料名行背景色判品质(金1/紫2/蓝3/绿4),失败为 null
+ const nameLine = popupNameLine(lines);
+ const colorRankTop = qualityFromPopupColor(nameLine);
+
+ // rank1/2/3 且没识别到可合成数量时,向上滑一下补识别;名字等字段要先取完再滑
+ if (colorRankTop >= 1 && colorRankTop <= 3 && synthCount === 0) {
+ log.info("[可合成数量] 未识别,上滑补充 (rank{rank})", colorRankTop);
+ await scrollPopupUp();
+
+ const lines2 = readPopupLines();
+ const synthAnchor2 = findAnchorLine(lines2, "可合成");
+ const synthText2 = synthAnchor2 ? synthAnchor2.text : "";
+ const count2 = extractSynthCount(synthText2);
+ log.info("[可合成数量] 补充识别 {text} -> {n}", synthText2, count2);
+ if (count2 > 0) {
+ synthText = synthText2;
+ synthCount = count2;
+ }
+
+ // 滑动后「来源」可能也重新可见,之前没读到就顺带补一次
+ if (!source) {
+ const sourceAnchor2 = findAnchorLine(lines2, "来源");
+ if (sourceAnchor2) {
+ source = collectBelowAnchor(lines2, sourceAnchor2, 120, /来源|培养需求|可合成/);
+ }
+ }
+ }
+
+ return {
+ name,
+ category,
+ source,
+ needText,
+ have: parsed ? parsed.have : 0,
+ need: parsed ? parsed.need : 0,
+ synthText,
+ synthCount,
+ colorRankTop,
+ foundAt: Date.now()
+ };
+}
+
+export function popupNameLine(lines) {
+ const ex = /素材|来源|培养需求|可合成/;
+ const yLimit = sY(360);
+ for (const l of lines) {
+ if (l.y > yLimit) break;
+ if (l.text.length >= 2 && !ex.test(l.text)) return l;
+ }
+ for (const l of lines) {
+ if (l.y > yLimit) break;
+ if (l.text.length >= 2) return l;
+ }
+ return null;
+}
+
+export function qualityFromPopupColor(nameLine) {
+ if (!nameLine) return null;
+ const x = Math.max(0, nameLine.x - sX(12));
+ const y = Math.max(0, nameLine.y - sY(12));
+ const w = Math.min(sX(1920) - x, nameLine.width + sX(60));
+ const h = Math.min(sY(1080) - y, nameLine.height + sY(24));
+ if (w <= 0 || h <= 0) return null;
+
+ let cap = null, crop = null, hsv = null;
+ try {
+ debugFrameStart();
+ debugBox("品质颜色裁剪", x, y, w, h, "#e040fb");
+ parkMouseAbovePopup();
+ cap = captureGameRegion();
+ crop = cap.deriveCrop(x, y, w, h);
+ const cvt = OpenCvSharp.OpenCvSharp.ColorConversionCodes;
+ hsv = crop.srcMat.cvtColor(cvt.BGR2HSV);
+
+ const ranges = [
+ [1, "金/橙", 0, 15, 60, 255, 80, 255],
+ [2, "紫", 125, 145, 70, 255, 80, 255],
+ [3, "蓝", 100, 120, 70, 255, 80, 255],
+ [4, "绿", 70, 90, 90, 255, 80, 255]
+ ];
+
+ let best = null;
+ for (const [rankTop, name, loH, hiH, loS, hiS, loV, hiV] of ranges) {
+ let mask = null;
+ try {
+ mask = new Mat();
+ OpenCvSharp.OpenCvSharp.Cv2.InRange(
+ hsv,
+ new OpenCvSharp.OpenCvSharp.Scalar(loH, loS, loV),
+ new OpenCvSharp.OpenCvSharp.Scalar(hiH, hiS, hiV),
+ mask
+ );
+ const cnt = OpenCvSharp.OpenCvSharp.Cv2.CountNonZero(mask);
+ if (!best || cnt > best.cnt) best = { rankTop, name, cnt };
+ } catch (e) {
+ // 忽略单档调用失败
+ } finally {
+ try { if (mask) mask.dispose(); } catch (e) { }
+ }
+ }
+
+ if (best && best.cnt >= Math.round(500 * scaleX * scaleY)) {
+ log.info("[品质颜色] {name} {cnt}px", best.name, best.cnt);
+ return best.rankTop;
+ }
+ return null;
+ } catch (e) {
+ return null;
+ } finally {
+ try { if (hsv) hsv.dispose(); } catch (e) { }
+ try { if (crop) crop.dispose(); } catch (e) { }
+ try { if (cap) cap.dispose(); } catch (e) { }
+ }
+}
+
+export async function processMarker(m) {
+ log.info("[点击] 标注 ({x},{y},{w},{h})", m.x, m.y, m.width, m.height);
+ try {
+ if (m.region) {
+ m.region.click();
+ m.region.dispose();
+ m.region = null;
+ } else {
+ return null;
+ }
+ } catch (e) {
+ log.error("点击标注失败: {err}", e.message);
+ try { if (m.region) { m.region.dispose(); m.region = null; } } catch (e2) { }
+ return null;
+ }
+ await sleep(900);
+
+ const info = await readPopup();
+ if (!info) {
+ log.warn("未识别到材料弹窗,ESC 跳过");
+ await closePopup("");
+ return null;
+ }
+
+ // 有效弹窗必须同时有「来源」和「培养需求」;否则是误点/弹窗未弹出
+ if (!info.source || !info.needText || info.need <= 0 || info.name.includes("秘境")) {
+ log.warn("弹窗无效 (name={name}, source={source}, need={need}),跳过", info.name, info.source, info.need);
+ await closePopup(info.name);
+ return null;
+ }
+
+ log.info("[材料] {name} | {source} | {needText}", info.name, info.source, info.needText);
+ await closePopup(info.name);
+ return info;
+}
diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/solver.js b/repo/js/AutoTrainingPlanFarm/src/plan/solver.js
new file mode 100644
index 0000000000..fb290666ad
--- /dev/null
+++ b/repo/js/AutoTrainingPlanFarm/src/plan/solver.js
@@ -0,0 +1,198 @@
+// 材料齐集判断:系列分组、方向贪心、可合成数量约束枚举
+import { canonicalName, levDistance } from "../core/common.js";
+
+export function seriesKey(name) {
+ const n = canonicalName(name);
+ const di = n.indexOf("的");
+ const zi = n.indexOf("之");
+ let idx = -1;
+ if (di > 0 && zi > 0) idx = Math.min(di, zi);
+ else if (di > 0) idx = di;
+ else if (zi > 0) idx = zi;
+ return idx > 0 ? n.slice(0, idx) : n;
+}
+
+export function buildPlan(entries) {
+ const items0 = entries.map(e => {
+ const norm = canonicalName(e.material);
+ return { entry: e, norm, series: seriesKey(norm) };
+ });
+
+ const keyClusters = [];
+ for (const it of items0) {
+ let cluster = keyClusters.find(c => levDistance(c.key, it.series) <= 1);
+ if (!cluster) {
+ cluster = { key: it.series };
+ keyClusters.push(cluster);
+ }
+ it.groupKey = cluster.key;
+ }
+
+ const map = new Map();
+ for (const it of items0) {
+ if (!map.has(it.groupKey)) map.set(it.groupKey, []);
+ map.get(it.groupKey).push(it);
+ }
+
+ const groups = [];
+ const flat = [];
+ let idx = 0;
+ for (const [base, arr] of map) {
+ // 一律按颜色定档:金1/紫2/蓝3/绿4;颜色失败按点击顺序兜底。
+ // 不按“正好3个材料”特判:4档系列可能只缺 3 个,特判会整体错位
+ arr.sort((a, b) =>
+ ((a.entry.qualityRank || 99) - (b.entry.qualityRank || 99)) ||
+ ((a.entry.orderRank || 99) - (b.entry.orderRank || 99))
+ );
+ arr.forEach((item, order) => {
+ // 后缀按每个材料自己的系列名切,避免 OCR 错字导致后缀错位
+ item.suffix = item.norm.slice(item.series.length).replace(/^[的之]/, "");
+ item.localRank = Math.min(4, Math.max(1, Number(item.entry.qualityRank) || order + 1));
+ item.entry.qualityRank = item.localRank;
+ item.globalIdx = idx++;
+ flat.push(item);
+ });
+ // 永远按4档权重链 27/9/3/1 计算
+ groups.push({ base, items: arr, maxRank: 4 });
+ }
+
+ return { groups, flat };
+}
+
+export function directionalGreedy(h, need) {
+ const n = h.length;
+ const w = (i) => Math.pow(3, n - 1 - i);
+ let carry = 0;
+ let deficit = 0;
+ const missing = new Array(n).fill(0);
+ for (let i = n - 1; i >= 0; i--) {
+ const avail = h[i] + carry;
+ const gap = Math.max(0, need[i] - avail);
+ if (gap > 0) {
+ missing[i] = gap;
+ deficit += gap * w(i);
+ carry = 0;
+ } else {
+ carry = Math.floor((avail - need[i]) / 3); // 3低 → 1高
+ }
+ }
+ return { deficit, missing };
+}
+
+export function solveUnknownMinDeficit(n, mask, knownHave, synthKnown, need) {
+ const w = (i) => Math.pow(3, n - 1 - i);
+ let best = null;
+
+ function dfs(j, h, lowerA) {
+ let cands;
+ if (mask[j]) {
+ cands = [knownHave[j]];
+ } else if (j > 0 && mask[j - 1] && synthKnown[j - 1] !== null) {
+ const c = (j === n - 1) ? 0 : Math.floor(lowerA / 3);
+ const base = 3 * synthKnown[j - 1] - c;
+ cands = [base, base + 1, base + 2].filter(v => v >= 0);
+ } else {
+ cands = [0, 1, 2];
+ }
+
+ for (const val of cands) {
+ h[j] = val;
+ const A = (j === n - 1) ? val : val + Math.floor(lowerA / 3);
+ if (j > 0 && mask[j - 1] && synthKnown[j - 1] !== null && Math.floor(A / 3) !== synthKnown[j - 1]) continue;
+
+ if (j === 0) {
+ const r = directionalGreedy(h, need);
+ let surplus = 0;
+ if (r.deficit === 0) {
+ let haveLow = 0;
+ let needLow = 0;
+ for (let i = 0; i < n; i++) {
+ haveLow += h[i] * w(i);
+ needLow += need[i] * w(i);
+ }
+ surplus = Math.max(0, haveLow - needLow);
+ }
+ if (best === null ||
+ r.deficit < best.deficit ||
+ (r.deficit === best.deficit && surplus > best.surplus)) {
+ best = { deficit: r.deficit, missing: r.missing, surplus };
+ }
+ } else {
+ dfs(j - 1, h, A);
+ }
+ }
+ }
+
+ dfs(n - 1, new Array(n).fill(0), 0);
+ return best;
+}
+
+export function checkPlan(plan, counts) {
+ let allOk = true;
+ let totalDeficit = 0;
+ let totalSurplus = 0;
+ const groups = [];
+
+ for (const g of plan.groups) {
+ const n = g.maxRank;
+ const need = new Array(n).fill(0);
+
+ // 权重统一4档:index0(金)=27,index1(紫)=9,index2(蓝)=3,index3(绿)=1
+ const weightOf = (i) => Math.pow(3, n - 1 - i);
+
+ const mask = new Array(n).fill(false);
+ const knownHave = new Array(n).fill(0);
+ const synthKnown = new Array(n - 1).fill(null);
+
+ for (const item of g.items) {
+ const r = (item.localRank || 1) - 1;
+ mask[r] = true;
+ knownHave[r] += (Number(item.entry.have) || 0) + (Number(counts[item.globalIdx]) || 0);
+ need[r] += Number(item.entry.need) || 0;
+ if (r < n - 1) {
+ const sc = Number(item.entry.synthCount) || 0;
+ synthKnown[r] = sc > 0 ? sc : null;
+ }
+ }
+
+ // 用「可合成数量」约束枚举库存组合,取最乐观解(缺口最小,其次剩余最多;宁可少刷)
+ const solved = solveUnknownMinDeficit(n, mask, knownHave, synthKnown, need);
+ let deficit;
+ let surplus;
+ let missing;
+ if (solved === null) {
+ log.warn("[合成补偿] 约束矛盾,放弃补偿,按已扫库存计算");
+ const fb = directionalGreedy(knownHave, need);
+ deficit = fb.deficit;
+ missing = fb.missing;
+ if (deficit === 0) {
+ let haveLow = 0;
+ let needLow = 0;
+ for (let i = 0; i < n; i++) {
+ haveLow += knownHave[i] * weightOf(i);
+ needLow += need[i] * weightOf(i);
+ }
+ surplus = Math.max(0, haveLow - needLow);
+ } else {
+ surplus = 0;
+ }
+ } else {
+ deficit = solved.deficit;
+ surplus = solved.surplus;
+ missing = solved.missing;
+ }
+
+ const ok = deficit === 0;
+ if (!ok) allOk = false;
+ totalDeficit += deficit;
+ totalSurplus += surplus;
+ groups.push({ base: g.base, ok, deficitLowUnits: deficit, surplusLowUnits: surplus, missing });
+ }
+
+ return {
+ ok: allOk,
+ deficitLowUnits: totalDeficit,
+ surplusLowUnits: totalSurplus,
+ groups
+ };
+}