Merge scanner readiness work
This commit is contained in:
+1434
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { confirmedReviewCorpus, ocrEvalCorpus, seedCorpus, validateConfirmedReviewCorpus } from ".";
|
||||
|
||||
describe("confirmed review corpus", () => {
|
||||
it("contains only human-confirmed review labels", () => {
|
||||
expect(validateConfirmedReviewCorpus()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not duplicate eval case ids", () => {
|
||||
const ids = ocrEvalCorpus.map((entry) => entry.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("is included in the full OCR eval corpus", () => {
|
||||
expect(ocrEvalCorpus).toHaveLength(seedCorpus.length + confirmedReviewCorpus.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OcrEvalCase } from "../ocrEvalHarness";
|
||||
|
||||
export interface ConfirmedReviewEvalCase extends OcrEvalCase {
|
||||
confirmed: true;
|
||||
meta: NonNullable<OcrEvalCase["meta"]> & {
|
||||
source: "review-sample";
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Human-confirmed review samples belong here after their `expect` values were
|
||||
// checked against the real artifact. Do not paste unconfirmed exporter output
|
||||
// directly from outputs/review-eval-candidates/.
|
||||
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { confirmedReviewCorpus, type ConfirmedReviewEvalCase } from "./confirmedReviewCorpus";
|
||||
import { seedCorpus } from "./seedCorpus";
|
||||
import type { OcrEvalCase } from "../ocrEvalHarness";
|
||||
|
||||
export { confirmedReviewCorpus, seedCorpus };
|
||||
export type { ConfirmedReviewEvalCase };
|
||||
|
||||
export const ocrEvalCorpus: OcrEvalCase[] = [...seedCorpus, ...confirmedReviewCorpus];
|
||||
|
||||
export function validateConfirmedReviewCorpus(corpus: readonly ConfirmedReviewEvalCase[] = confirmedReviewCorpus) {
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of corpus) {
|
||||
if (seen.has(entry.id)) errors.push(`${entry.id}: duplicate id`);
|
||||
seen.add(entry.id);
|
||||
if (entry.confirmed !== true) errors.push(`${entry.id}: confirmed must be true`);
|
||||
if (entry.meta.source !== "review-sample") errors.push(`${entry.id}: meta.source must be review-sample`);
|
||||
if (!entry.meta.note?.trim()) errors.push(`${entry.id}: meta.note must describe the review source/reason`);
|
||||
if (Object.keys(entry.expect).length === 0) errors.push(`${entry.id}: expect must label at least one field`);
|
||||
if (Object.keys(entry.ocr).length === 0) errors.push(`${entry.id}: ocr must contain at least one field`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { formatSummary, parseWaitSeconds, validatePreflight } = require("../../scripts/live-preflight.cjs") as {
|
||||
formatSummary: (result: unknown) => string;
|
||||
parseWaitSeconds: (value: string) => number;
|
||||
validatePreflight: (input: unknown) => { ok: boolean; errors: string[]; signature: string; isElevated?: boolean; genshinFound?: boolean };
|
||||
};
|
||||
|
||||
function health(signature = "sig-current") {
|
||||
return { ok: true, appBuild: { signature } };
|
||||
}
|
||||
|
||||
function status(runtime = { isElevated: true, genshinFound: true, targetProcess: "GenshinImpact.exe", foregroundProcess: "GenshinImpact.exe" }) {
|
||||
return { ok: true, status: { runtimeInfo: runtime } };
|
||||
}
|
||||
|
||||
describe("live preflight script", () => {
|
||||
it("accepts a matching elevated Genshin runtime", () => {
|
||||
const result = validatePreflight({ health: health(), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.signature).toBe("sig-current");
|
||||
});
|
||||
|
||||
it("rejects stale runtime signatures", () => {
|
||||
const result = validatePreflight({ health: health("old"), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors.join("\n")).toContain("does not match");
|
||||
});
|
||||
|
||||
it("rejects non-elevated or missing Genshin runtime by default", () => {
|
||||
const result = validatePreflight({
|
||||
health: health(),
|
||||
status: status({ isElevated: false, genshinFound: false, targetProcess: "", foregroundProcess: "explorer.exe" }),
|
||||
expected: "sig-current",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors).toContain("Runtime is not elevated.");
|
||||
expect(result.errors).toContain("Genshin process/window was not found.");
|
||||
});
|
||||
|
||||
it("formats a concise human summary", () => {
|
||||
const summary = formatSummary(validatePreflight({ health: health(), status: status(), expected: "sig-current" }));
|
||||
expect(summary).toContain("live preflight: PASS");
|
||||
expect(summary).toContain("elevated: yes");
|
||||
expect(summary).toContain("genshin: yes");
|
||||
});
|
||||
|
||||
it("parses optional wait seconds strictly", () => {
|
||||
expect(parseWaitSeconds("")).toBe(0);
|
||||
expect(parseWaitSeconds("120")).toBe(120);
|
||||
expect(() => parseWaitSeconds("-1")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("1.5")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("soon")).toThrow("--wait");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatReport, runOcrEval } from "./ocrEvalHarness";
|
||||
import { seedCorpus } from "./corpus/seedCorpus";
|
||||
import { ocrEvalCorpus } from "./corpus";
|
||||
|
||||
// Regression gate: the seed corpus is verified ground truth, so the parser must
|
||||
// read every labeled field correctly. A drop here means an OCR/parser change
|
||||
// regressed a previously-correct read - look at the printed failures. If a
|
||||
// change intentionally alters a correct output, update the corpus label in the
|
||||
// same commit (the label is the source of truth, not the code).
|
||||
// Regression gate: every case in the combined corpus is verified ground truth,
|
||||
// so the parser must read every labeled field correctly. A drop here means an
|
||||
// OCR/parser change regressed a previously-correct read - look at the printed
|
||||
// failures. If a change intentionally alters a correct output, update the corpus
|
||||
// label in the same commit (the label is the source of truth, not the code).
|
||||
|
||||
describe("OCR eval harness", () => {
|
||||
const report = runOcrEval(seedCorpus);
|
||||
const report = runOcrEval(ocrEvalCorpus);
|
||||
|
||||
it("prints the accuracy report", () => {
|
||||
// Surfaced in test output for humans; not an assertion.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n" + formatReport(report) + "\n");
|
||||
expect(report.totalCases).toBe(seedCorpus.length);
|
||||
expect(report.totalCases).toBe(ocrEvalCorpus.length);
|
||||
});
|
||||
|
||||
it("reads every labeled field on the seed corpus correctly", () => {
|
||||
it("reads every labeled field on the eval corpus correctly", () => {
|
||||
const failureSummary = report.failures
|
||||
.map((failure) => {
|
||||
const wrong = failure.fields
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { pngBufferToBitmap } from "../../electron/services/pngBitmap";
|
||||
import { lockSignalRatio } from "../lib/lockDetection";
|
||||
|
||||
function chunk(type: string, data: Buffer) {
|
||||
const result = Buffer.alloc(12 + data.length);
|
||||
result.writeUInt32BE(data.length, 0);
|
||||
result.write(type, 4, 4, "ascii");
|
||||
data.copy(result, 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
function rgbPng1x1(r: number, g: number, b: number) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(1, 0);
|
||||
ihdr.writeUInt32BE(1, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
const raw = Buffer.from([0, r, g, b]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("pngBufferToBitmap", () => {
|
||||
it("decodes RGB PNG pixels for lock detection", () => {
|
||||
const bitmap = pngBufferToBitmap(rgbPng1x1(235, 92, 90));
|
||||
expect(bitmap.width).toBe(1);
|
||||
expect(bitmap.height).toBe(1);
|
||||
expect(lockSignalRatio(bitmap)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function writeCandidatePayload(dir: string) {
|
||||
const inputPath = path.join(dir, "review-eval-candidates.json");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify({
|
||||
summary: {},
|
||||
candidates: [
|
||||
{
|
||||
id: "review-2026-07-08T15-02-39-765Z-152",
|
||||
savedAt: "2026-07-08T15:02:39.765Z",
|
||||
reason: "automatic:parser-notes:p1:r2c3",
|
||||
resolution: "1920x1080",
|
||||
ocr: {
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- Energy Recharge+10.4%",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("prepare confirmed review case script", () => {
|
||||
it("creates a confirmed corpus snippet from a candidate and explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
const expectPath = path.join(dir, "expect.json");
|
||||
const outputPath = path.join(dir, "snippet.ts");
|
||||
writeFileSync(
|
||||
expectPath,
|
||||
`\uFEFF${JSON.stringify({
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
setName: "Silken Moon's Serenade",
|
||||
})}`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/prepare-confirmed-review-case.cjs",
|
||||
`--input=${inputPath}`,
|
||||
"--candidate=review-2026-07-08T15-02-39-765Z-152",
|
||||
`--expect-file=${expectPath}`,
|
||||
`--out=${outputPath}`,
|
||||
],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
);
|
||||
|
||||
const snippet = readFileSync(outputPath, "utf8");
|
||||
expect(snippet).toContain("confirmed: true");
|
||||
expect(snippet).toContain("Pristine Plume of the Blessed");
|
||||
expect(snippet).toContain('"artifact-name": "Pristine Plume of the Blessed"');
|
||||
expect(snippet).toContain("sourceCandidate=review-2026-07-08T15-02-39-765Z-152");
|
||||
expect(snippet).not.toContain("confirmed: false");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
"node",
|
||||
["scripts/prepare-confirmed-review-case.cjs", `--input=${inputPath}`, "--candidate=review-2026-07-08T15-02-39-765Z-152"],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function reviewRecord(savedAt: string, reason: string, ocr: Array<{ id: string; text: string }>, locked?: boolean) {
|
||||
return {
|
||||
savedAt,
|
||||
sample: {
|
||||
reason,
|
||||
capture: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
ocr,
|
||||
locked,
|
||||
},
|
||||
parsed: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: ocr.some((entry) => entry.id === "artifact-slot") ? "Plume of Death" : "Unknown Slot",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
setName: "Silken Moon's Serenade",
|
||||
equipped: ocr.some((entry) => entry.id === "artifact-footer") ? "Aino" : "Not detected",
|
||||
substats: ["Energy Recharge+10.4%"],
|
||||
confidence: 0.9,
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const completeOcr = [
|
||||
{ id: "artifact-name", text: "Pristine Plume of the Blessed" },
|
||||
{ id: "artifact-slot", text: "Plume of Death" },
|
||||
{ id: "artifact-main-stat-label", text: "ATK" },
|
||||
{ id: "artifact-level", text: "+20" },
|
||||
{ id: "artifact-substats", text: "- Energy Recharge+10.4%" },
|
||||
{ id: "artifact-footer", text: "Equipped: Aino" },
|
||||
];
|
||||
|
||||
describe("review eval candidate exporter", () => {
|
||||
it("exports deduplicated candidates with stats and stale markers", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-review-candidates-"));
|
||||
try {
|
||||
const inputPath = path.join(dir, "review-samples.jsonl");
|
||||
const outDir = path.join(dir, "out");
|
||||
const staleOcr = completeOcr.filter((entry) => entry.id !== "artifact-slot");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
[
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:01:00.000Z", "automatic:capture-rejected:p1:r0c1", staleOcr, true)),
|
||||
"{not json",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync("node", ["scripts/export-review-eval-candidates.cjs", `--input=${inputPath}`, `--out=${outDir}`, "--limit=10"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const payload = JSON.parse(readFileSync(path.join(outDir, "review-eval-candidates.json"), "utf8"));
|
||||
const markdown = readFileSync(path.join(outDir, "review-eval-candidates.md"), "utf8");
|
||||
|
||||
expect(payload.summary.recordsRead).toBe(4);
|
||||
expect(payload.summary.invalidRecords).toBe(1);
|
||||
expect(payload.summary.uniqueCandidates).toBe(2);
|
||||
expect(payload.summary.exportedCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.completeFastFields).toBe(1);
|
||||
expect(payload.summary.exportStats.likelyStaleCaptures).toBe(1);
|
||||
expect(payload.summary.exportStats.equippedFooterCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.lockedTrueCandidates).toBe(1);
|
||||
expect(payload.candidates[0].missingFastFields).toEqual([]);
|
||||
expect(payload.candidates[0].parsed.equipped).toBe("Aino");
|
||||
expect(payload.candidates[0].reviewPrompt.expectedFields.equipped).toBe("Aino");
|
||||
expect(payload.candidates[1].likelyStaleCapture).toBe(true);
|
||||
expect(payload.candidates[1].locked).toBe(true);
|
||||
expect(markdown).toContain("## Export stats");
|
||||
expect(markdown).toContain("- artifact-slot: 1");
|
||||
expect(markdown).toContain("equipped=Aino");
|
||||
expect(markdown).toContain("Locked=true candidates: 1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type { OcrEvalCase } from "./ocrEvalHarness";
|
||||
// itself). The intended flow is:
|
||||
// 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess.
|
||||
// 2. A human confirms or corrects `expect` in the produced case.
|
||||
// 3. The corrected case is committed into src/eval/corpus/.
|
||||
// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts.
|
||||
// The `confirmed` flag records whether step 2 happened.
|
||||
|
||||
export interface ReviewSampleEvalCase extends OcrEvalCase {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function validAssessment() {
|
||||
return {
|
||||
createdAt: "2026-07-08T12:00:00.000Z",
|
||||
goal100Decision: "qualified-comparison: winner=ik-traineddata",
|
||||
goal100: {
|
||||
limit: 100,
|
||||
comparisonComplete: true,
|
||||
winnerEngine: "ik-traineddata",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 820,
|
||||
winnerActiveProjectedMsFor100: 82000,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0.04,
|
||||
engines: [
|
||||
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04 },
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.06 },
|
||||
],
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
limit: 20,
|
||||
comparisonComplete: true,
|
||||
winnerEngine: "current",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 390,
|
||||
winnerActiveProjectedMsFor100: 39000,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0.05,
|
||||
engines: [
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05 },
|
||||
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function writeAssessment(dir: string, payload: unknown) {
|
||||
const inputPath = path.join(dir, "scan-performance-assessment.json");
|
||||
writeFileSync(inputPath, JSON.stringify(payload), "utf8");
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("scan assessment validator", () => {
|
||||
it("accepts a qualified complete 100-artifact comparison", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a matching expected winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=ik-traineddata"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints an opt-in human summary", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain(`input: ${inputPath}`);
|
||||
expect(output).toContain("createdAt: 2026-07-08T12:00:00.000Z");
|
||||
expect(output).toContain("limit: 100");
|
||||
expect(output).toContain("winner: ik-traineddata");
|
||||
expect(output).toContain("activeAvg: 820ms/artifact");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a qualified complete 20-artifact comparison", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain("limit: 20");
|
||||
expect(output).toContain("winner: current");
|
||||
expect(output).toContain("activeAvg: 390ms/artifact");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid requested limits", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=0"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("--limit must be a positive integer");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a requested limit that is missing from the assessment", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=45"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Missing limit=45 assessment");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => {
|
||||
const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
||||
expect(packageJson.scripts["scan:goal:compare:validated"]).toBe(
|
||||
"npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary",
|
||||
);
|
||||
expect(packageJson.scripts["scan:goal:compare:validated:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate:compare"]).toBe(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate:compare:validated"]).toBe(
|
||||
"npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate:compare:validated:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a mismatched expected winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
expect(() =>
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints errors in summary mode for rejected assessments", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current", "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Expected winner");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a single-engine 100-artifact run", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run";
|
||||
payload.goal100.comparisonComplete = false;
|
||||
payload.goal100.engines = [{ engine: "current", qualified: true, missRate: 0, reviewRate: 0 }];
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
expect(() =>
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unqualified winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
|
||||
payload.goal100.winnerQualified = false;
|
||||
payload.goal100.engines[0].qualified = false;
|
||||
payload.goal100.engines[0].reviewRate = 0.3;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
expect(() =>
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100.engines[0] as { missRate?: number }).missRate;
|
||||
delete (payload.goal100.engines[0] as { reviewRate?: number }).reviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing summary quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerMissRate?: number }).winnerMissRate;
|
||||
delete (payload.goal100 as { winnerReviewRate?: number }).winnerReviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner summary missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner summary reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing speed timing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerActiveAverageMsPerParsed?: number }).winnerActiveAverageMsPerParsed;
|
||||
delete (payload.goal100 as { winnerActiveProjectedMsFor100?: number }).winnerActiveProjectedMsFor100;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner active average timing must be a positive finite number");
|
||||
expect(stdout).toContain("Winner projected100 timing must be a positive finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("validates the latest assessment under a live-soak root", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const older = path.join(dir, "2026-07-08T10-00-00");
|
||||
const newer = path.join(dir, "2026-07-08T11-00-00");
|
||||
mkdirSync(older);
|
||||
mkdirSync(newer);
|
||||
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
|
||||
writeAssessment(newer, validAssessment());
|
||||
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", "--latest", `--root=${dir}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.inputPath).toContain("2026-07-08T11-00-00");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -144,13 +144,16 @@ export async function captureSelectedSourceAction(
|
||||
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
||||
}
|
||||
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame;
|
||||
if (!quietArtifactScanCapture) {
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
}
|
||||
return capture;
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
|
||||
import { AlertTriangle, BadgeCheck, Camera, ClipboardList, Download, Gauge, Play, Target, Upload, Wrench } from "lucide-react";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import type { ScanViewControllerResult } from "../types";
|
||||
import { FieldConfidenceList } from "./ScanResultCards";
|
||||
@@ -14,6 +14,54 @@ interface DiagnosticsViewProps {
|
||||
canDemoScan?: boolean;
|
||||
}
|
||||
|
||||
const appDiagnosisSections = [
|
||||
{
|
||||
title: "Was die App kann",
|
||||
tone: "ok",
|
||||
icon: BadgeCheck,
|
||||
items: [
|
||||
"Genshin-Fenster erkennen, Smart Capture ausfuehren und fokussierte Artifact-Crops erzeugen.",
|
||||
"Artifact-Felder deterministisch gegen das lokale Genshin-Datenpaket parsen.",
|
||||
"Auto-Scan read-only aus der sichtbaren Inventory-Seite starten, inklusive Grid, Verifikation, Dedupe und Store.",
|
||||
"Review-Samples, lokale Lernregeln, GOOD Import/Export, Equipped-Footer und Lock-Status im Store nutzen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was noch fehlt",
|
||||
tone: "warn",
|
||||
icon: ClipboardList,
|
||||
items: [
|
||||
"Paimon-Menue-Einstieg ist gebaut, aber live noch nicht mit 2/20/45 Limits validiert.",
|
||||
"Native/IK-Tesseract ist nur als Benchmark-Pfad vorbereitet, noch nicht Standard.",
|
||||
"Positive locked=true Probe und erneuter Equipped-Footer-Livebeweis an bekannten Artifacts fehlen.",
|
||||
"Empfehlungen bleiben Nebenfunktion, bis Scanner-Vertrauen und Review-Rate stabil genug sind.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Wo es Probleme macht",
|
||||
tone: "risk",
|
||||
icon: AlertTriangle,
|
||||
items: [
|
||||
"OCR ist weiterhin der Haupt-Risikofaktor; einige Felder landen noch in Fallback, Ableitung oder Review.",
|
||||
"Bild-Preprocessing kann nur an echten Captures bewertet werden, nicht allein mit Text-Eval.",
|
||||
"Auto-Scan braucht bei erhoehtem Genshin auch eine erhoehte App-Laufzeit.",
|
||||
"Groessere Runs brauchen weiter Beobachtung auf Scroll-Uebergaenge, Wiederholseiten und Review-Quote.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Naechste Verbesserungen",
|
||||
tone: "next",
|
||||
icon: Target,
|
||||
items: [
|
||||
"Review-Corpus aus echten Samples vergroessern und mit `npm run eval` messbar halten.",
|
||||
"Review-Export fuer Equipped-Footer und locked=true Kandidaten nutzen.",
|
||||
"OCR-Benchmark gegen identische Crops fahren und erst danach Engine-Standard wechseln.",
|
||||
"Paimon-Menue-Pfad live pruefen und bei Blockade sichtbar auf visible-inventory zurueckfallen.",
|
||||
"Diagnose weiter als Operator-Cockpit halten: Live-Status, Evidenz und naechster sicherer Schritt.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// All developer / diagnostic surfaces live here, separated from the Scan
|
||||
// workspace: runtime + rights, grid detection, learning + data-package status,
|
||||
// fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/
|
||||
@@ -39,6 +87,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
playerProgress,
|
||||
reviewStatus,
|
||||
automationLogLines,
|
||||
diagnosticEvents,
|
||||
canSaveReviewSample,
|
||||
handleSaveReviewSample,
|
||||
} = useScanDiagnosticsModalModel({
|
||||
@@ -106,6 +155,37 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card app-diagnosis-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Aktueller App-Stand</p>
|
||||
<h3>Scanner zuerst, Empfehlungen danach</h3>
|
||||
</div>
|
||||
<span className="diagnosis-source">
|
||||
<Gauge size={14} />
|
||||
Quelle: Docs + Live-Status
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-diagnosis-grid">
|
||||
{appDiagnosisSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<article className={`app-diagnosis-section ${section.tone}`} key={section.title}>
|
||||
<div className="app-diagnosis-title">
|
||||
<Icon size={16} />
|
||||
<strong>{section.title}</strong>
|
||||
</div>
|
||||
<ul>
|
||||
{section.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-grid">
|
||||
<div className="diagnose-card">
|
||||
<p className="eyebrow">Status</p>
|
||||
@@ -196,6 +276,67 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Evidence timeline</p>
|
||||
<h3>Scan-Flugschreiber</h3>
|
||||
</div>
|
||||
<span className="diagnosis-source">
|
||||
<Camera size={14} />
|
||||
letzte {diagnosticEvents.length}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticEvents.length > 0 ? (
|
||||
<div className="scan-evidence-timeline">
|
||||
{diagnosticEvents.slice().reverse().map((event) => (
|
||||
<article className={`scan-evidence-event ${event.severity}`} key={event.id}>
|
||||
<div className="scan-evidence-header">
|
||||
<span>{new Date(event.at).toLocaleTimeString()}</span>
|
||||
<strong>{event.phase}</strong>
|
||||
<em>{event.severity}</em>
|
||||
</div>
|
||||
<p>{event.message}</p>
|
||||
{event.details && (
|
||||
<div className="scan-evidence-details">
|
||||
{Object.entries(event.details).map(([key, value]) => (
|
||||
<span key={key}>{key}: {String(value ?? "-")}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{event.capture && (
|
||||
<div className="scan-evidence-capture">
|
||||
<div>
|
||||
<strong>{event.capture.name}</strong>
|
||||
<span>{event.capture.width}x{event.capture.height} · {event.capture.target ?? "capture"} · fp {event.capture.fingerprint}</span>
|
||||
{event.capture.grid && (
|
||||
<span>grid {event.capture.grid.cols}x{event.capture.grid.rows} · {event.capture.grid.targets} targets · {event.capture.grid.confidence}% · {event.capture.grid.source}</span>
|
||||
)}
|
||||
{event.capture.count && (
|
||||
<span>count {event.capture.count.current}/{event.capture.count.total || "?"} · {event.capture.count.confidence}% · {event.capture.count.text || "-"}</span>
|
||||
)}
|
||||
{event.capture.artifactDetail && (
|
||||
<span>detail {event.capture.artifactDetail.present ? "yes" : "no"} · {event.capture.artifactDetail.confidence}% · orange {event.capture.artifactDetail.orangeHits} · text {event.capture.artifactDetail.textHits}</span>
|
||||
)}
|
||||
{event.capture.paimonMenu && (
|
||||
<span>paimon {event.capture.paimonMenu.present ? "yes" : "no"} · {event.capture.paimonMenu.confidence}%</span>
|
||||
)}
|
||||
{event.capture.layoutWarning && <span className="evidence-warning">{event.capture.layoutWarning}</span>}
|
||||
</div>
|
||||
<div className="scan-evidence-images">
|
||||
{event.capture.screenshots?.inventory && <img src={event.capture.screenshots.inventory} alt={`${event.phase} inventory`} />}
|
||||
{event.capture.screenshots?.detail && <img src={event.capture.screenshots.detail} alt={`${event.phase} detail`} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="result-empty">Noch keine Evidence-Events. Starte einen Capture oder Auto-Scan, dann erscheinen hier Schritte mit Screenshots.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<p className="eyebrow">Crops, OCR & Confidence</p>
|
||||
|
||||
@@ -7,14 +7,9 @@ export function ScanMainSection({
|
||||
latestCapture,
|
||||
captureStatus,
|
||||
parsedArtifact,
|
||||
sourceLabel,
|
||||
gridLabel,
|
||||
inventoryLabel,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
autoScanRunning,
|
||||
canOpenReviewQueue,
|
||||
@@ -27,29 +22,25 @@ export function ScanMainSection({
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture,
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
} = useScanMainSectionModel({
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scanner-main-grid">
|
||||
<div className="capture-stage-shell">
|
||||
<div className={`capture-stage-shell ${hasCapture ? "has-capture" : "is-empty"}`}>
|
||||
<div className="capture-stage">
|
||||
{hasCapture ? (
|
||||
<img src={captureImageSrc} alt={captureImageAlt} />
|
||||
@@ -61,10 +52,6 @@ export function ScanMainSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="capture-stage-meta">
|
||||
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
|
||||
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="scanner-result-panel">
|
||||
@@ -81,9 +68,12 @@ export function ScanMainSection({
|
||||
<span>{reviewLabel}</span>
|
||||
</div>
|
||||
<div className="scanner-result-actions">
|
||||
<button className="ghost-button" onClick={handleOpenDetails} disabled={!canOpenDetails}>
|
||||
Details
|
||||
</button>
|
||||
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
|
||||
<AlertTriangle size={15} />
|
||||
Review Queue
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
<p className="scanner-result-caption">{captureStatus}</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ScanTopControlsSection({
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
@@ -46,7 +46,6 @@ export function ScanTopControlsSection({
|
||||
refreshCaptureSourcesTitle,
|
||||
diagnosticsButtonTitle,
|
||||
scanSetupButtonTitle,
|
||||
showPlayerProgress,
|
||||
progressWidth,
|
||||
progressStats,
|
||||
} = useScanTopControlsModel({
|
||||
@@ -64,8 +63,7 @@ export function ScanTopControlsSection({
|
||||
<div className="scanner-header">
|
||||
<div>
|
||||
<p className="eyebrow">Scanner</p>
|
||||
<h2>Artifact capture workspace</h2>
|
||||
<p className="scanner-subcopy">Quelle waehlen, Auto-Scan starten, Ergebnis rechts pruefen. Dev-Details im Diagnose-Tab.</p>
|
||||
<h2>Artifact Scan</h2>
|
||||
</div>
|
||||
<div className="scanner-header-pills">
|
||||
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
|
||||
@@ -119,46 +117,42 @@ export function ScanTopControlsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-scan-actions">
|
||||
<button
|
||||
className="primary-button scan-cta"
|
||||
onClick={runVisibleGridScan}
|
||||
disabled={!canStartAutoScan}
|
||||
title={autoScanButtonTitle}
|
||||
>
|
||||
<Play size={16} />
|
||||
{autoScanButtonLabel}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={runAutoReviewScan}
|
||||
disabled={!canStartManualScan}
|
||||
title={manualScanButtonTitle}
|
||||
>
|
||||
<Radar size={15} />
|
||||
Manueller Scan
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={captureSingleArtifact}
|
||||
disabled={!canCaptureSingle}
|
||||
title={captureSingleButtonTitle}
|
||||
>
|
||||
<Camera size={15} />
|
||||
Einzelnes Artifact lesen
|
||||
</button>
|
||||
{autoScanRunning && (
|
||||
<button className="stop-button" onClick={stopScan}>
|
||||
Stop
|
||||
<div className="player-scan-lower">
|
||||
<div className="player-scan-actions">
|
||||
<button
|
||||
className="primary-button scan-cta"
|
||||
onClick={runGuidedAutoScan}
|
||||
disabled={!canStartAutoScan}
|
||||
title={autoScanButtonTitle}
|
||||
>
|
||||
<Play size={16} />
|
||||
{autoScanButtonLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={runAutoReviewScan}
|
||||
disabled={!canStartManualScan}
|
||||
title={manualScanButtonTitle}
|
||||
>
|
||||
<Radar size={15} />
|
||||
Manueller Scan
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={captureSingleArtifact}
|
||||
disabled={!canCaptureSingle}
|
||||
title={captureSingleButtonTitle}
|
||||
>
|
||||
<Camera size={15} />
|
||||
Einzelnes Artifact
|
||||
</button>
|
||||
{autoScanRunning && (
|
||||
<button className="stop-button" onClick={stopScan}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="player-status">
|
||||
{playerStatusText}
|
||||
</p>
|
||||
|
||||
{showPlayerProgress && (
|
||||
<div className="player-progress">
|
||||
<div className="player-progress-bar">
|
||||
<div style={{ width: `${progressWidth}%` }} />
|
||||
@@ -171,7 +165,11 @@ export function ScanTopControlsSection({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="player-status">
|
||||
{playerStatusText}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,12 @@ export interface ScanMainSectionModel {
|
||||
captureImageSrc: string;
|
||||
captureImageAlt: string;
|
||||
hasCapture: boolean;
|
||||
captureModeText: string;
|
||||
resultHeading: string;
|
||||
noArtifactText: string;
|
||||
noCaptureMessage: string;
|
||||
targetLabel: string;
|
||||
dbLabel: string;
|
||||
reviewLabel: string;
|
||||
rulesLabel: string;
|
||||
}
|
||||
|
||||
type UseScanMainSectionModelProps = Pick<
|
||||
@@ -25,8 +23,6 @@ type UseScanMainSectionModelProps = Pick<
|
||||
| "activeTargetCount"
|
||||
| "storedTotal"
|
||||
| "reviewSampleTotal"
|
||||
| "learningRulesLoaded"
|
||||
| "learningRuleCount"
|
||||
| "setDetailsOpen"
|
||||
| "openReviewQueue"
|
||||
>;
|
||||
@@ -37,8 +33,6 @@ export function useScanMainSectionModel({
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
}: UseScanMainSectionModelProps): ScanMainSectionModel {
|
||||
@@ -53,14 +47,12 @@ export function useScanMainSectionModel({
|
||||
const captureImageAlt = latestCapture
|
||||
? `Latest capture from ${latestCapture.name}`
|
||||
: "Latest capture is not available yet";
|
||||
const captureModeText = latestCapture ? "Erkannt" : "Warte";
|
||||
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
|
||||
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
|
||||
const noCaptureMessage = noArtifactText;
|
||||
const targetLabel = `Ziel ${activeTargetCount}`;
|
||||
const dbLabel = `DB ${storedTotal ?? "-"}`;
|
||||
const reviewLabel = `Review ${reviewSampleTotal}`;
|
||||
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
|
||||
|
||||
return {
|
||||
canOpenDetails,
|
||||
@@ -69,13 +61,11 @@ export function useScanMainSectionModel({
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture: Boolean(latestCapture),
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ScanSummaryFooterProps } from "../types";
|
||||
import type { ScanSummaryFooterProps } from "../types";
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (ms <= 0) return "0s";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
|
||||
}
|
||||
|
||||
export interface ScanSummaryFooterModel {
|
||||
summaryCopy: string;
|
||||
@@ -12,10 +20,10 @@ export function useScanSummaryFooterModel({
|
||||
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
|
||||
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
|
||||
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen in ${formatDuration(scanSummary.elapsedMs)} (${scanSummary.averageMsPerParsed || 0} ms/Artifact). Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
|
||||
const devCopy = devMode
|
||||
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
|
||||
? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | roundtrip ${scanSummary.averageCaptureRoundTripMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ScanTopControlsModel {
|
||||
openDiagnostics: () => void;
|
||||
captureSingleArtifact: () => void;
|
||||
stopScan: () => void;
|
||||
runVisibleGridScan: () => void;
|
||||
runGuidedAutoScan: () => void;
|
||||
runAutoReviewScan: () => void;
|
||||
bridgeStatusText: string;
|
||||
bridgePillClass: string;
|
||||
@@ -47,7 +47,7 @@ export function useScanTopControlsModel({
|
||||
setSettingsOpen,
|
||||
setDiagnosticsOpen,
|
||||
requestScanStop,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
autoScanRunning,
|
||||
canCaptureSource,
|
||||
@@ -77,17 +77,20 @@ export function useScanTopControlsModel({
|
||||
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
|
||||
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
|
||||
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
|
||||
const startGuidedAutoScan = useCallback(() => {
|
||||
void runGuidedAutoScan();
|
||||
}, [runGuidedAutoScan]);
|
||||
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
|
||||
const bridgePillClass = bridgeReady ? "elevated" : "standard";
|
||||
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
|
||||
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
|
||||
const playerStatusText = reviewStatus
|
||||
|| (runtimeInfo?.isElevated
|
||||
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
|
||||
? "App laeuft als Administrator. Auto-Scan prueft den Screen ohne OCR und startet erst, wenn eine Artifact-Detailkarte offen ist."
|
||||
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
|
||||
const autoScanButtonTitle = requiresAdminForAutoScan
|
||||
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
|
||||
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
|
||||
: "Prueft zuerst ohne OCR den Screen, oeffnet bei Bedarf per Inventory-Kamera-Sequenz das Artifact-Inventar und scannt erst mit sichtbarer Detailkarte.";
|
||||
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
|
||||
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
|
||||
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
|
||||
@@ -104,11 +107,20 @@ export function useScanTopControlsModel({
|
||||
{ label: "Klicks", value: autoScanStats.clicked },
|
||||
{ label: "Positionen", value: autoScanStats.attempted },
|
||||
{ label: "Verifiziert", value: autoScanStats.verified },
|
||||
{ label: "ms/Artifact", value: autoScanStats.averageMsPerParsed || "-" },
|
||||
{ label: "Gespeichert", value: autoScanStats.stored },
|
||||
{ label: "Review", value: autoScanStats.review },
|
||||
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
|
||||
],
|
||||
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
|
||||
[
|
||||
autoScanStats.clicked,
|
||||
autoScanStats.attempted,
|
||||
autoScanStats.verified,
|
||||
autoScanStats.averageMsPerParsed,
|
||||
autoScanStats.stored,
|
||||
autoScanStats.review,
|
||||
storedTotal,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -124,7 +136,7 @@ export function useScanTopControlsModel({
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan: startGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ScanSettingsModalProps } from "./types";
|
||||
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
|
||||
import type { StepperControlModel } from "./hooks/useScanSettingsModalModel";
|
||||
|
||||
export function ScanSettingsModal({
|
||||
open,
|
||||
@@ -16,9 +17,9 @@ export function ScanSettingsModal({
|
||||
|
||||
const {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
scanLimitControl,
|
||||
skipRowsControl,
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
@@ -50,33 +51,17 @@ export function ScanSettingsModal({
|
||||
<button className="ghost-button" onClick={closeSettings}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="scan-config-strip">
|
||||
<label>
|
||||
<span>Anzahl Artifacts</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1800}
|
||||
value={scanLimit}
|
||||
onChange={handleScanLimitChange}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zeilen ueberspringen</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={8}
|
||||
value={skipRows}
|
||||
onChange={handleSkipRowsChange}
|
||||
/>
|
||||
</label>
|
||||
<p>
|
||||
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
|
||||
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
|
||||
</p>
|
||||
<div className="scan-settings-layout">
|
||||
<div className="scan-settings-controls">
|
||||
<StepperControl control={scanLimitControl} />
|
||||
<StepperControl control={skipRowsControl} />
|
||||
</div>
|
||||
<div className="scan-settings-note">
|
||||
<strong>Manuelle Werte bleiben erhalten.</strong>
|
||||
<span>Die erkannte Inventar-Anzahl ist nur ein Vorschlag und oberer Deckel. Startzeilen brauchst du nur, wenn du nicht oben beginnst.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scanner-preflight diagnostics-preflight">
|
||||
<div className="scanner-preflight settings-preflight">
|
||||
<div className={inventoryClassName}>
|
||||
<span>Inventarzaehler</span>
|
||||
<strong>{inventoryCountText}</strong>
|
||||
@@ -97,3 +82,42 @@ export function ScanSettingsModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperControl({ control }: { control: StepperControlModel }) {
|
||||
return (
|
||||
<section className="settings-stepper" aria-label={control.label}>
|
||||
<div className="settings-stepper-head">
|
||||
<div>
|
||||
<span>{control.label}</span>
|
||||
<small>{control.helper}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-stepper-row">
|
||||
<button type="button" className="stepper-button" onClick={control.decrement} aria-label={`${control.label} verringern`}>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
value={control.value}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
onKeyDown={control.onKeyDown}
|
||||
aria-label={control.label}
|
||||
/>
|
||||
<button type="button" className="stepper-button" onClick={control.increment} aria-label={`${control.label} erhoehen`}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-presets" aria-label={`${control.label} Presets`}>
|
||||
{control.presets.map((value) => (
|
||||
<button type="button" key={value} onClick={() => control.applyPreset(value)}>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,21 +33,24 @@ export function useScanDetailsModalModel({
|
||||
|
||||
const crops = latestCapture?.crops ?? [];
|
||||
const ocr = latestCapture?.ocr ?? [];
|
||||
const cropRows = crops
|
||||
.filter((crop) => Boolean(crop.dataUrl))
|
||||
.map((crop) => ({
|
||||
id: crop.id,
|
||||
dataUrl: crop.dataUrl ?? "",
|
||||
label: crop.label,
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
}));
|
||||
|
||||
return {
|
||||
closeDetails,
|
||||
stopPropagation,
|
||||
parsedNotes,
|
||||
showParsedNotes: parsedNotes.length > 0,
|
||||
cropRows: crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
dataUrl: crop.dataUrl,
|
||||
label: crop.label,
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
})),
|
||||
cropRows,
|
||||
ocrRows: ocr.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
@@ -55,7 +58,7 @@ export function useScanDetailsModalModel({
|
||||
text: entry.text || "No text detected",
|
||||
})),
|
||||
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
|
||||
showCrops: crops.length > 0,
|
||||
showCrops: cropRows.length > 0,
|
||||
showOcr: ocr.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
|
||||
import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData";
|
||||
import { dataPackageStatus } from "../../../../../lib/dataPackageStatus";
|
||||
import { validateLookupPackage } from "../../../../../lib/genshinLookup";
|
||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||
import type { ScanDiagnosticsModalProps } from "../types";
|
||||
import type { ScanDiagnosticEvent } from "../../../../../lib/scanDiagnosticsLog";
|
||||
|
||||
export interface ScanDiagnosticsModelProgress {
|
||||
width: number;
|
||||
@@ -37,6 +39,7 @@ export interface ScanDiagnosticsModalModel {
|
||||
showDevRows: boolean;
|
||||
reviewStatus: string;
|
||||
automationLogLines: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
canSaveReviewSample: boolean;
|
||||
}
|
||||
|
||||
@@ -98,7 +101,11 @@ export function useScanDiagnosticsModalModel({
|
||||
|
||||
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
|
||||
const dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion);
|
||||
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
|
||||
const lookupStatus = validateLookupPackage();
|
||||
const lookupText = lookupStatus.valid
|
||||
? `Lookup OK: ${lookupStatus.summary.artifactSets} sets / ${lookupStatus.summary.artifactPieces} pieces`
|
||||
: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown error"}`;
|
||||
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. ${lookupText}. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
|
||||
|
||||
const playerProgress = useMemo(() => {
|
||||
const width = Math.min(
|
||||
@@ -128,6 +135,27 @@ export function useScanDiagnosticsModalModel({
|
||||
{ label: "duplicates", value: controller.autoScanStats.duplicates },
|
||||
{ label: "misses", value: controller.autoScanStats.misses },
|
||||
{ label: "pages", value: controller.autoScanStats.pages },
|
||||
{ label: "elapsedMs", value: controller.autoScanStats.elapsedMs },
|
||||
{ label: "activeScanMs", value: controller.autoScanStats.activeScanMs },
|
||||
{ label: "writeFlushMs", value: controller.autoScanStats.writeFlushMs },
|
||||
{ label: "avgMs", value: controller.autoScanStats.averageMsPerParsed },
|
||||
{ label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed },
|
||||
{ label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs },
|
||||
{ label: "avgCaptureRoundTripMs", value: controller.autoScanStats.averageCaptureRoundTripMs },
|
||||
{ label: "avgCaptureRoundTripOverheadMs", value: controller.autoScanStats.averageCaptureRoundTripOverheadMs },
|
||||
{ label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms },
|
||||
{ label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms },
|
||||
{ label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs },
|
||||
{ label: "ocrP50Ms", value: controller.autoScanStats.ocrP50Ms },
|
||||
{ label: "ocrP90Ms", value: controller.autoScanStats.ocrP90Ms },
|
||||
{ label: "cardReadyAvgMs", value: controller.autoScanStats.averageCardReadyMs },
|
||||
{ label: "cardReadyCount", value: controller.autoScanStats.cardReadyCount },
|
||||
{ label: "scrollReadyAvgMs", value: controller.autoScanStats.averageScrollReadyMs },
|
||||
{ label: "scrollReadyCount", value: controller.autoScanStats.scrollReadyCount },
|
||||
{ label: "perMin x10", value: Math.round(controller.autoScanStats.artifactsPerMinute * 10) },
|
||||
{ label: "activePerMin x10", value: Math.round(controller.autoScanStats.activeArtifactsPerMinute * 10) },
|
||||
{ label: "projected100Ms", value: controller.autoScanStats.projectedMsFor100 },
|
||||
{ label: "activeProjected100Ms", value: controller.autoScanStats.activeProjectedMsFor100 },
|
||||
],
|
||||
[
|
||||
controller.autoScanStats.clicked,
|
||||
@@ -139,6 +167,27 @@ export function useScanDiagnosticsModalModel({
|
||||
controller.autoScanStats.duplicates,
|
||||
controller.autoScanStats.misses,
|
||||
controller.autoScanStats.pages,
|
||||
controller.autoScanStats.elapsedMs,
|
||||
controller.autoScanStats.activeScanMs,
|
||||
controller.autoScanStats.writeFlushMs,
|
||||
controller.autoScanStats.averageMsPerParsed,
|
||||
controller.autoScanStats.activeAverageMsPerParsed,
|
||||
controller.autoScanStats.averageCaptureMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripOverheadMs,
|
||||
controller.autoScanStats.captureP50Ms,
|
||||
controller.autoScanStats.captureP90Ms,
|
||||
controller.autoScanStats.averageOcrMs,
|
||||
controller.autoScanStats.ocrP50Ms,
|
||||
controller.autoScanStats.ocrP90Ms,
|
||||
controller.autoScanStats.averageCardReadyMs,
|
||||
controller.autoScanStats.cardReadyCount,
|
||||
controller.autoScanStats.averageScrollReadyMs,
|
||||
controller.autoScanStats.scrollReadyCount,
|
||||
controller.autoScanStats.artifactsPerMinute,
|
||||
controller.autoScanStats.activeArtifactsPerMinute,
|
||||
controller.autoScanStats.projectedMsFor100,
|
||||
controller.autoScanStats.activeProjectedMsFor100,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -186,6 +235,7 @@ export function useScanDiagnosticsModalModel({
|
||||
showDevRows: controller.devMode,
|
||||
reviewStatus: controller.reviewStatus,
|
||||
automationLogLines: controller.automationLog,
|
||||
diagnosticEvents: controller.diagnosticEvents,
|
||||
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
||||
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
|
||||
|
||||
export interface StepperControlModel {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: string;
|
||||
min: number;
|
||||
max: number;
|
||||
presets: number[];
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur: (event: FocusEvent<HTMLInputElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
decrement: () => void;
|
||||
increment: () => void;
|
||||
applyPreset: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface ScanSettingsModalModel {
|
||||
closeSettings: () => void;
|
||||
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
scanLimitControl: StepperControlModel;
|
||||
skipRowsControl: StepperControlModel;
|
||||
inventoryCountText: string;
|
||||
inventoryClassName: string;
|
||||
scanLimitClassName: string;
|
||||
@@ -36,17 +51,71 @@ export function useScanSettingsModalModel({
|
||||
setScanLimitTouched: (touched: boolean) => void;
|
||||
setSkipRows: (rows: number) => void;
|
||||
}): ScanSettingsModalModel {
|
||||
const [scanLimitText, setScanLimitText] = useState(String(scanLimit));
|
||||
const [skipRowsText, setSkipRowsText] = useState(String(skipRows));
|
||||
|
||||
useEffect(() => {
|
||||
setScanLimitText(String(scanLimit));
|
||||
}, [scanLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
setSkipRowsText(String(skipRows));
|
||||
}, [skipRows]);
|
||||
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
||||
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setScanLimitTouched(true);
|
||||
setScanLimit(clampScanLimit(Number(event.target.value)));
|
||||
}, [setScanLimit, setScanLimitTouched]);
|
||||
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSkipRows(clampSkipRows(Number(event.target.value)));
|
||||
}, [setSkipRows]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const applyScanLimit = useCallback((value: number) => {
|
||||
const next = clampScanLimit(value);
|
||||
setScanLimitTouched(true);
|
||||
setScanLimit(next);
|
||||
setScanLimitText(String(next));
|
||||
}, [setScanLimit, setScanLimitTouched]);
|
||||
|
||||
const applySkipRows = useCallback((value: number) => {
|
||||
const next = clampSkipRows(value);
|
||||
setSkipRows(next);
|
||||
setSkipRowsText(String(next));
|
||||
}, [setSkipRows]);
|
||||
|
||||
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setScanLimitText(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
}, []);
|
||||
|
||||
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSkipRowsText(event.target.value.replace(/\D/g, "").slice(0, 2));
|
||||
}, []);
|
||||
|
||||
const commitScanLimit = useCallback((rawValue: string) => {
|
||||
applyScanLimit(rawValue.trim() === "" ? scanLimit : Number(rawValue));
|
||||
}, [applyScanLimit, scanLimit]);
|
||||
|
||||
const commitSkipRows = useCallback((rawValue: string) => {
|
||||
applySkipRows(rawValue.trim() === "" ? skipRows : Number(rawValue));
|
||||
}, [applySkipRows, skipRows]);
|
||||
|
||||
const handleScanLimitBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
|
||||
commitScanLimit(event.target.value);
|
||||
}, [commitScanLimit]);
|
||||
|
||||
const handleSkipRowsBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
|
||||
commitSkipRows(event.target.value);
|
||||
}, [commitSkipRows]);
|
||||
|
||||
const handleScanLimitKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
commitScanLimit(event.currentTarget.value);
|
||||
event.currentTarget.blur();
|
||||
}, [commitScanLimit]);
|
||||
|
||||
const handleSkipRowsKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
commitSkipRows(event.currentTarget.value);
|
||||
event.currentTarget.blur();
|
||||
}, [commitSkipRows]);
|
||||
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
|
||||
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
|
||||
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
|
||||
@@ -56,14 +125,40 @@ export function useScanSettingsModalModel({
|
||||
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
|
||||
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
|
||||
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
|
||||
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
|
||||
const activeTargetText = `${activeTargetCount} Ziele · ${focusModeText}`;
|
||||
const scanSummaryText = "Auto-Scan zaehlt Positionen, verifizierte Ansichten und gespeicherte Artifacts getrennt.";
|
||||
|
||||
return {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
scanLimitControl: {
|
||||
label: "Scan-Ziel",
|
||||
value: scanLimitText,
|
||||
helper: "Wie viele sichtbare Positionen verarbeitet werden.",
|
||||
min: 1,
|
||||
max: 1800,
|
||||
presets: [16, 20, 50, 100],
|
||||
onChange: handleScanLimitChange,
|
||||
onBlur: handleScanLimitBlur,
|
||||
onKeyDown: handleScanLimitKeyDown,
|
||||
decrement: () => applyScanLimit(scanLimit - 1),
|
||||
increment: () => applyScanLimit(scanLimit + 1),
|
||||
applyPreset: applyScanLimit,
|
||||
},
|
||||
skipRowsControl: {
|
||||
label: "Startzeilen ueberspringen",
|
||||
value: skipRowsText,
|
||||
helper: "Nur nutzen, wenn du mitten in der Liste beginnst.",
|
||||
min: 0,
|
||||
max: 8,
|
||||
presets: [0, 1, 2, 3],
|
||||
onChange: handleSkipRowsChange,
|
||||
onBlur: handleSkipRowsBlur,
|
||||
onKeyDown: handleSkipRowsKeyDown,
|
||||
decrement: () => applySkipRows(skipRows - 1),
|
||||
increment: () => applySkipRows(skipRows + 1),
|
||||
applyPreset: applySkipRows,
|
||||
},
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { ScanActionContext } from "./scanViewScanActions";
|
||||
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type {
|
||||
ReviewStateContext,
|
||||
} from "./scanViewReviewHelpers";
|
||||
@@ -42,6 +43,7 @@ export interface ScanActionContextInput {
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (
|
||||
capture: CaptureResult | null,
|
||||
@@ -49,6 +51,14 @@ export interface ScanActionContextInput {
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
) => Promise<number>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -60,7 +70,11 @@ export interface ScanActionContextInput {
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (
|
||||
delayMs?: number,
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult | null>;
|
||||
}
|
||||
|
||||
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
|
||||
@@ -98,8 +112,10 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
|
||||
setReviewStatus: input.setReviewStatus,
|
||||
appendAutomationLog: input.appendAutomationLog,
|
||||
appendClickDiagnostics: input.appendClickDiagnostics,
|
||||
appendDiagnosticEvent: input.appendDiagnosticEvent,
|
||||
parseArtifact: input.parseArtifact,
|
||||
persistParsedArtifact: input.persistParsedArtifact,
|
||||
persistParsedArtifactsBatch: input.persistParsedArtifactsBatch,
|
||||
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
|
||||
saveReviewSample: input.saveReviewSample,
|
||||
focusDashboard: input.focusDashboard,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
artifactTabClickTarget,
|
||||
keyPressBlocked,
|
||||
validateAutoScanEntryPreflight,
|
||||
type ScanEntryMode,
|
||||
} from "../../../lib/autoScanEntry";
|
||||
import { wait } from "../../../lib/scanReviewUtils";
|
||||
import {
|
||||
summarizeClickResult,
|
||||
summarizeKeyPressResult,
|
||||
type createScanDiagnosticEvent,
|
||||
} from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||
|
||||
export interface PrepareAutoScanEntryInput {
|
||||
mode: ScanEntryMode;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}
|
||||
|
||||
export async function prepareAutoScanEntry({
|
||||
mode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: PrepareAutoScanEntryInput) {
|
||||
if (mode === "visible-inventory") {
|
||||
const capture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (candidate) => validateAutoScanEntryPreflight(candidate).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-visible",
|
||||
severity: capture ? "ok" : "error",
|
||||
message: capture && validateAutoScanEntryPreflight(capture).ok
|
||||
? "Visible inventory preflight capture ready."
|
||||
: "Visible inventory preflight capture failed.",
|
||||
capture,
|
||||
});
|
||||
return capture;
|
||||
}
|
||||
|
||||
if (mode === "direct-inventory") {
|
||||
return tryInventoryEntrySequence({
|
||||
label: "direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "auto-entry") {
|
||||
const directCapture = await tryInventoryEntrySequence({
|
||||
label: "auto-direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const directPreflight = validateAutoScanEntryPreflight(directCapture);
|
||||
if (directPreflight.ok) return directCapture;
|
||||
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-fallback",
|
||||
severity: "info",
|
||||
message: `Direct inventory entry did not reach an artifact detail card. Trying IK fallback. ${directPreflight.reason}`,
|
||||
capture: directCapture,
|
||||
});
|
||||
}
|
||||
|
||||
return tryInventoryEntrySequence({
|
||||
label: "paimon",
|
||||
sendEscapeFirst: true,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
async function tryInventoryEntrySequence({
|
||||
label,
|
||||
sendEscapeFirst,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: {
|
||||
label: string;
|
||||
sendEscapeFirst: boolean;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}) {
|
||||
if (sendEscapeFirst) {
|
||||
const escapeResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC`,
|
||||
details: summarizeKeyPressResult(escapeResult),
|
||||
});
|
||||
if (keyPressBlocked(escapeResult)) return null;
|
||||
const menuProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: menuProbe ? "info" : "warn",
|
||||
message: `${label} capture after ESC step.`,
|
||||
capture: menuProbe,
|
||||
});
|
||||
if (menuProbe?.paimonMenu?.present) {
|
||||
const closeMenuResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC close menu`,
|
||||
details: summarizeKeyPressResult(closeMenuResult),
|
||||
});
|
||||
if (keyPressBlocked(closeMenuResult)) return menuProbe;
|
||||
const worldProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: worldProbe ? "info" : "warn",
|
||||
message: `${label} capture after closing Paimon menu.`,
|
||||
capture: worldProbe,
|
||||
});
|
||||
if (worldProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
|
||||
return worldProbe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inventoryResult = await automationRepo.keyPress?.("B");
|
||||
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
|
||||
message: `${label} entry key B`,
|
||||
details: summarizeKeyPressResult(inventoryResult),
|
||||
});
|
||||
if (keyPressBlocked(inventoryResult)) return null;
|
||||
const tabProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 1200,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: tabProbe ? "info" : "warn",
|
||||
message: `${label} capture after Inventory-key step.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (tabProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: "info",
|
||||
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
return tabProbe;
|
||||
}
|
||||
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
|
||||
|
||||
const target = artifactTabClickTarget(tabProbe);
|
||||
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
|
||||
const click = await automationRepo.clickScreen(target.x, target.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
|
||||
message: `${label} artifact tab click at ${target.x},${target.y}`,
|
||||
details: summarizeClickResult(click),
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (click.inputBlocked || click.clicked === false) return null;
|
||||
const gridProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: gridProbe ? "info" : "warn",
|
||||
message: `${label} capture after artifact-tab click before first tile selection.`,
|
||||
capture: gridProbe,
|
||||
});
|
||||
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
|
||||
if (!firstTarget) return gridProbe;
|
||||
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
|
||||
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
|
||||
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
|
||||
details: summarizeClickResult(firstTileClick),
|
||||
capture: gridProbe,
|
||||
});
|
||||
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
|
||||
const finalCapture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: finalCapture ? "info" : "warn",
|
||||
message: `${label} final capture after first artifact selection.`,
|
||||
capture: finalCapture,
|
||||
});
|
||||
return finalCapture;
|
||||
}
|
||||
|
||||
async function waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs,
|
||||
pollMs = 150,
|
||||
predicate,
|
||||
}: {
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
timeoutMs: number;
|
||||
pollMs?: number;
|
||||
predicate: (capture: CaptureResult | null) => boolean;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let latest: CaptureResult | null = null;
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
latest = await captureFastSelectedSource(0, true);
|
||||
if (predicate(latest)) return latest;
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) break;
|
||||
await wait(Math.min(pollMs, remaining));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, inco
|
||||
}
|
||||
|
||||
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
|
||||
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||
return mergeLearningRulePayloads({}, loadedRules?.rules);
|
||||
}
|
||||
|
||||
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
|
||||
@@ -213,17 +213,47 @@ export async function mergeLearningRules(
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
|
||||
const merged = {
|
||||
textReplacements: {
|
||||
...currentRules.textReplacements,
|
||||
...(nextRules.textReplacements ?? {}),
|
||||
},
|
||||
};
|
||||
const merged = mergeLearningRulePayloads(currentRules, nextRules);
|
||||
context.setScannerLearningRules(merged);
|
||||
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||
return { result, merged };
|
||||
}
|
||||
|
||||
function mergeLearningRulePayloads(
|
||||
current: Partial<ScannerLearningRules> | null | undefined,
|
||||
next: Partial<ScannerLearningRules> | null | undefined,
|
||||
): ScannerLearningRules {
|
||||
return {
|
||||
textReplacements: {
|
||||
...(current?.textReplacements ?? {}),
|
||||
...(next?.textReplacements ?? {}),
|
||||
},
|
||||
fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases),
|
||||
constrainedFixes: {
|
||||
...(current?.constrainedFixes ?? {}),
|
||||
...(next?.constrainedFixes ?? {}),
|
||||
},
|
||||
cropAdjustments: {
|
||||
...(current?.cropAdjustments ?? {}),
|
||||
...(next?.cropAdjustments ?? {}),
|
||||
},
|
||||
uiProfileAdjustments: {
|
||||
...(current?.uiProfileAdjustments ?? {}),
|
||||
...(next?.uiProfileAdjustments ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNestedRuleMap(
|
||||
current: Record<string, Record<string, string>> | undefined,
|
||||
next: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) };
|
||||
for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function persistParsedArtifact(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
@@ -256,6 +286,47 @@ export async function persistParsedArtifact(
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistParsedArtifactsBatch(
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||
if (!artifactRepo?.saveMany || items.length === 0) return 0;
|
||||
|
||||
const records: StoredArtifactRecord[] = [];
|
||||
for (const item of items) {
|
||||
const rejection = captureRejectionReason(item.capture, item.parsed);
|
||||
if (rejection) {
|
||||
appendAutomationLog(`persist skip: ${rejection}`);
|
||||
continue;
|
||||
}
|
||||
if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) {
|
||||
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`);
|
||||
continue;
|
||||
}
|
||||
records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked));
|
||||
}
|
||||
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
return records.length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveReviewSample(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -277,29 +348,52 @@ export async function saveReviewSample(
|
||||
}
|
||||
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||
|
||||
const compactAutomaticSample = /^automatic:/i.test(reason);
|
||||
const sampleCapture = compactAutomaticSample
|
||||
? {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
}
|
||||
: {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
dataUrl: capture.dataUrl,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
};
|
||||
|
||||
const result = await reviewSamplesRepo.saveSample({
|
||||
reason,
|
||||
capture: {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
dataUrl: capture.dataUrl,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
},
|
||||
capture: sampleCapture,
|
||||
parsed,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||
import { validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
|
||||
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
|
||||
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import { type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type {
|
||||
AutomationGuard,
|
||||
BooleanResult,
|
||||
@@ -15,8 +17,8 @@ import type {
|
||||
ScrollResult,
|
||||
} from "../../../types/global";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { prepareAutoScanEntry } from "./scanViewEntryActions";
|
||||
|
||||
export interface ScanActionContext {
|
||||
autoScanRunning: boolean;
|
||||
@@ -36,10 +38,12 @@ export interface ScanActionContext {
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>) => Promise<number>;
|
||||
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
|
||||
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
|
||||
focusDashboard: () => Promise<void>;
|
||||
@@ -47,6 +51,9 @@ export interface ScanActionContext {
|
||||
|
||||
export interface VisibleGridScanOptions {
|
||||
scanLimit?: number;
|
||||
scanEntryMode?: ScanEntryMode;
|
||||
processInitialSelection?: boolean;
|
||||
ocrEngine?: CaptureOptions["ocrEngine"];
|
||||
}
|
||||
|
||||
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||
@@ -68,6 +75,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
captureSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
focusDashboard,
|
||||
@@ -83,6 +91,11 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
|
||||
const startedAt = Date.now();
|
||||
const updateManualStats = () => {
|
||||
updateScanTiming(stats, startedAt);
|
||||
setAutoScanStats({ ...stats });
|
||||
};
|
||||
let idleTicks = 0;
|
||||
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
const maxIdleTicks = 90;
|
||||
@@ -96,7 +109,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
if (capture && rejection) {
|
||||
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
|
||||
stats.review++;
|
||||
setAutoScanStats({ ...stats });
|
||||
updateManualStats();
|
||||
}
|
||||
idleTicks++;
|
||||
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
|
||||
@@ -117,6 +130,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
stats.attempted++;
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
|
||||
|
||||
const reason = getAutoReviewReason(capture, parsed);
|
||||
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||
@@ -127,12 +141,13 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
|
||||
stats.stored++;
|
||||
}
|
||||
setAutoScanStats({ ...stats });
|
||||
updateManualStats();
|
||||
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
|
||||
await wait(700);
|
||||
}
|
||||
|
||||
setAutoScanRunning(false);
|
||||
updateScanTiming(stats, startedAt);
|
||||
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
|
||||
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
|
||||
await focusDashboard();
|
||||
@@ -165,10 +180,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
scanLimit: configuredScanLimit,
|
||||
@@ -177,8 +194,14 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
focusDashboard,
|
||||
} = context;
|
||||
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
|
||||
const scanEntryMode = options.scanEntryMode ?? "visible-inventory";
|
||||
const ocrEngine = options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||
if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) {
|
||||
setReviewStatus("Auto-Scan-Einstieg ist nicht verfuegbar: Keypress-Bridge fehlt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||
if (requiresAdminForAutoScan) {
|
||||
@@ -200,6 +223,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setScanSummary(null);
|
||||
setAutoScanStats(emptyAutoScanStats);
|
||||
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
appendDiagnosticEvent({
|
||||
phase: "scan-start",
|
||||
severity: "info",
|
||||
message: `Auto-scan start requested (${scanEntryMode}, ${ocrEngine})`,
|
||||
details: { scanLimit, skipRows, detectedInventoryCount, ocrEngine },
|
||||
});
|
||||
|
||||
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
|
||||
const adminBlockReason = automationBlockReason(freshRuntime);
|
||||
@@ -207,6 +236,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(adminBlockReason);
|
||||
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
|
||||
appendDiagnosticEvent({
|
||||
phase: "preflight",
|
||||
severity: "error",
|
||||
message: adminBlockReason,
|
||||
details: { elevated: freshRuntime?.isElevated, genshinFound: freshRuntime?.genshinFound },
|
||||
});
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
@@ -220,6 +255,17 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
if (freshRuntime) {
|
||||
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
|
||||
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "runtime",
|
||||
severity: freshRuntime.genshinFound ? "ok" : "warn",
|
||||
message: `Runtime ping: ${required}`,
|
||||
details: {
|
||||
elevated: freshRuntime.isElevated,
|
||||
foreground: freshRuntime.foregroundProcess,
|
||||
target: freshRuntime.targetProcess,
|
||||
helperPid: freshRuntime.helperPid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin;
|
||||
@@ -234,6 +280,18 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
appendAutomationLog(
|
||||
`focus attempt ${attempt}/3: ${current.focused ? "ok" : "failed"} found:${current.genshinFound ? "yes" : "no"} setForeground:${current.setForegroundResult ?? "n/a"} target:${current.targetProcess || "?"} fg:${current.foregroundProcess || "?"}`,
|
||||
);
|
||||
appendDiagnosticEvent({
|
||||
phase: "focus",
|
||||
severity: current.focused ? "ok" : "warn",
|
||||
message: `Focus attempt ${attempt}/3 ${current.focused ? "succeeded" : "failed"}`,
|
||||
details: {
|
||||
found: current.genshinFound,
|
||||
setForeground: current.setForegroundResult,
|
||||
target: current.targetProcess,
|
||||
foreground: current.foregroundProcess,
|
||||
alreadyForeground: current.alreadyForeground,
|
||||
},
|
||||
});
|
||||
if (current.focused) break;
|
||||
if (!current.genshinFound) break;
|
||||
}
|
||||
@@ -246,6 +304,16 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
|
||||
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
|
||||
setReviewStatus(reason);
|
||||
appendDiagnosticEvent({
|
||||
phase: "focus",
|
||||
severity: "error",
|
||||
message: reason,
|
||||
details: {
|
||||
found: focusResult?.genshinFound,
|
||||
target: focusResult?.targetProcess,
|
||||
foreground: focusResult?.foregroundProcess,
|
||||
},
|
||||
});
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
@@ -256,7 +324,46 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
setReviewStatus(scanEntryMode !== "visible-inventory"
|
||||
? "Genshin ist im Vordergrund. Oeffne Artifact-Inventar und warte auf Detailkarte..."
|
||||
: "Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
|
||||
const entryCapture = await prepareAutoScanEntry({
|
||||
mode: scanEntryMode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const entryPreflight = validateAutoScanEntryPreflight(entryCapture);
|
||||
if (!entryPreflight.ok) {
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(entryPreflight.reason);
|
||||
appendAutomationLog(`entry blocked: ${entryPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-preflight",
|
||||
severity: "error",
|
||||
message: entryPreflight.reason,
|
||||
capture: entryCapture,
|
||||
});
|
||||
setScanSummary({
|
||||
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||
gridLabel: entryPreflight.reason,
|
||||
});
|
||||
await focusDashboard();
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewStatus("Artifact-Inventar ist bereit. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-preflight",
|
||||
severity: "ok",
|
||||
message: "Artifact inventory preflight passed.",
|
||||
capture: entryCapture,
|
||||
});
|
||||
|
||||
const result = await runAutoScanLoop(
|
||||
{
|
||||
@@ -285,10 +392,11 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
automationRepo?.getAutomationGuard?.() ??
|
||||
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, options),
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
@@ -302,6 +410,9 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
|
||||
skipInitialGridTarget: scanEntryMode !== "visible-inventory",
|
||||
ocrEngine,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -310,7 +421,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
await focusDashboard();
|
||||
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
mode: scanEntryMode === "visible-inventory" ? `Automatischer Scan [${ocrEngine}]` : `Automatischer Scan (${scanEntryMode}) [${ocrEngine}]`,
|
||||
status: result.status,
|
||||
...result.stats,
|
||||
targetCount: result.targetCount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { ScannerCommand } from "../../../types/global";
|
||||
import type { CaptureOptions, ScannerCommand } from "../../../types/global";
|
||||
import type { VisibleGridScanOptions } from "./scanViewScanActions";
|
||||
|
||||
interface ScanCommandListenerInput {
|
||||
@@ -9,6 +9,7 @@ interface ScanCommandListenerInput {
|
||||
isScanning: boolean;
|
||||
selectedSourceId: string;
|
||||
requestScanStop: (reason: string) => void;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export function useScanCommandListener({
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
}: ScanCommandListenerInput) {
|
||||
useEffect(() => {
|
||||
@@ -29,9 +31,12 @@ export function useScanCommandListener({
|
||||
}
|
||||
const commandType = typeof command === "string" ? command : command.type;
|
||||
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||
const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit };
|
||||
void runVisibleGridScan(options);
|
||||
if (typeof command === "string" || !command.scanEntryMode) {
|
||||
void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit, ocrEngine: command.ocrEngine });
|
||||
return;
|
||||
}
|
||||
void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode, ocrEngine: command.ocrEngine });
|
||||
}
|
||||
});
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import type { ArtifactRepositoryPort, ScanExportPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
|
||||
interface UseScanGoodInteropInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
exportRepo?: ScanExportPort;
|
||||
onStoredArtifactsChanged?: () => Promise<void>;
|
||||
setStoredTotal: (value: number | null) => void;
|
||||
bridgeReady: boolean;
|
||||
}
|
||||
|
||||
export function useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
}: UseScanGoodInteropInput) {
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged, setStoredTotal]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
|
||||
return {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSessio
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import { validateLookupPackage } from "../../../lib/genshinLookup";
|
||||
import type { ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
|
||||
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
|
||||
|
||||
@@ -17,6 +19,7 @@ interface ScanSnapshotPublisherInput {
|
||||
snapshot: AppSnapshot;
|
||||
latestInventoryGrid: InventoryGrid | null | undefined;
|
||||
automationLog: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
storedTotal: number | null;
|
||||
learningRuleCount: number;
|
||||
@@ -33,6 +36,7 @@ export function useScanSnapshotPublisher({
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
@@ -52,9 +56,33 @@ export function useScanSnapshotPublisher({
|
||||
snapshotBuilds: snapshot.builds.length,
|
||||
grid: latestInventoryGrid ?? null,
|
||||
automationLog: automationLog.slice(-12),
|
||||
diagnosticEvents: diagnosticEvents.slice(-8).map((event) => ({
|
||||
...event,
|
||||
capture: event.capture
|
||||
? {
|
||||
...event.capture,
|
||||
screenshots: event.capture.screenshots
|
||||
? {
|
||||
detail: event.capture.screenshots.detail ? "[detail screenshot available in Diagnose]" : undefined,
|
||||
inventory: event.capture.screenshots.inventory ? "[inventory screenshot available in Diagnose]" : undefined,
|
||||
full: event.capture.screenshots.full ? "[full screenshot omitted]" : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
lookupStatus: validateLookupPackage(),
|
||||
ocrEngine: scanSummary?.mode.includes("ik-traineddata") ? "ik-traineddata" : "current",
|
||||
entryMode: scanSummary?.mode.includes("auto-entry")
|
||||
? "auto-entry"
|
||||
: scanSummary?.mode.includes("direct-inventory")
|
||||
? "direct-inventory"
|
||||
: scanSummary?.mode.includes("paimon-menu")
|
||||
? "paimon-menu"
|
||||
: "visible-inventory",
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).catch(() => undefined);
|
||||
}, [
|
||||
@@ -67,6 +95,7 @@ export function useScanSnapshotPublisher({
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
|
||||
import {
|
||||
initializeLearningState,
|
||||
loadReviewQueue as loadReviewQueueFromRepo,
|
||||
persistParsedArtifact as persistParsedArtifactHelper,
|
||||
persistParsedArtifactsBatch as persistParsedArtifactsBatchHelper,
|
||||
saveReviewSample as saveReviewSampleHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
|
||||
@@ -21,6 +22,8 @@ import type {
|
||||
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry";
|
||||
|
||||
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
|
||||
type NumberSetter = Dispatch<SetStateAction<number>>;
|
||||
@@ -49,6 +52,7 @@ interface ScanViewActionInput {
|
||||
setReviewStatus: StringSetter;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||
@@ -77,6 +81,7 @@ export interface ScanViewActionResult {
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -99,6 +104,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
@@ -116,6 +122,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
canCaptureSource,
|
||||
setReviewQueueOpen,
|
||||
} = input;
|
||||
const learningInitializedRef = useRef(false);
|
||||
|
||||
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
|
||||
stopVisibleScanRef.current = true;
|
||||
@@ -154,8 +161,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (learningInitializedRef.current) return;
|
||||
if (!learningRepo && !reviewSamplesRepo && !artifactRepo) return;
|
||||
learningInitializedRef.current = true;
|
||||
void initializeLearningState(reviewContext);
|
||||
}, [reviewContext]);
|
||||
}, [artifactRepo, learningRepo, reviewContext, reviewSamplesRepo]);
|
||||
|
||||
const focusDashboard = useCallback(async () => {
|
||||
try {
|
||||
@@ -177,6 +187,15 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const parseArtifactsAndPersistBatch = useCallback(
|
||||
async function parseArtifactsAndPersistBatch(
|
||||
items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>,
|
||||
) {
|
||||
return persistParsedArtifactsBatchHelper(items, reviewContext);
|
||||
},
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const handleSaveReviewSample = useCallback(
|
||||
async function handleSaveReviewSample(
|
||||
capture: CaptureResult | null = latestCapture,
|
||||
@@ -215,12 +234,26 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
persistParsedArtifact: parseArtifactAndPersist,
|
||||
persistParsedArtifactsBatch: parseArtifactsAndPersistBatch,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
|
||||
ocrMode: "artifact",
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
...options,
|
||||
}),
|
||||
captureFastSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
...options,
|
||||
}),
|
||||
}),
|
||||
[
|
||||
autoScanRunning,
|
||||
@@ -240,8 +273,10 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
parseArtifactAndPersist,
|
||||
parseArtifactsAndPersistBatch,
|
||||
handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
@@ -276,12 +311,56 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
scanActionContext,
|
||||
]);
|
||||
|
||||
const runGuidedAutoScan = useCallback(async (options: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] } = {}) => {
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
||||
return;
|
||||
}
|
||||
setReviewStatus("Auto-Scan prueft den Startzustand ohne OCR...");
|
||||
const preflightCapture = await captureSelectedSource(0, true, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
});
|
||||
const visibleInventoryReady = validateAutoScanEntryPreflight(preflightCapture).ok;
|
||||
appendDiagnosticEvent({
|
||||
phase: "guided-start",
|
||||
severity: visibleInventoryReady ? "ok" : "info",
|
||||
message: visibleInventoryReady
|
||||
? "Artifact inventory detail view already visible; starting scan directly."
|
||||
: "Artifact detail view is not ready; guided scan waits for a visible artifact detail card instead of navigating.",
|
||||
capture: preflightCapture,
|
||||
});
|
||||
if (!visibleInventoryReady) {
|
||||
setReviewStatus("Auto-Scan wartet: Bitte Artifact-Inventar mit sichtbarer Detailkarte oeffnen und erneut starten.");
|
||||
return;
|
||||
}
|
||||
await runVisibleGridScanAction(scanActionContext, {
|
||||
scanLimit: options.scanLimit,
|
||||
scanEntryMode: "visible-inventory",
|
||||
processInitialSelection: true,
|
||||
ocrEngine: options.ocrEngine,
|
||||
});
|
||||
}, [
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
selectedSourceId,
|
||||
automationRepo?.clickScreen,
|
||||
automationRepo?.scrollScreen,
|
||||
setReviewStatus,
|
||||
captureSelectedSource,
|
||||
appendDiagnosticEvent,
|
||||
scanActionContext,
|
||||
]);
|
||||
|
||||
useScanCommandListener({
|
||||
automationRepo,
|
||||
autoScanRunning,
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
});
|
||||
|
||||
@@ -291,6 +370,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
parseLearnedArtifact as parseLearnedArtifactHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
|
||||
import { useScanGoodInterop } from "./useScanGoodInterop";
|
||||
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
|
||||
import { useScanViewActions } from "./useScanViewActions";
|
||||
import { useScanViewStateSync } from "./useScanViewStateSync";
|
||||
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
|
||||
export function useScanViewController({
|
||||
@@ -56,6 +56,7 @@ export function useScanViewController({
|
||||
const [scanLimitTouched, setScanLimitTouched] = useState(false);
|
||||
const [skipRows, setSkipRows] = useState(0);
|
||||
const [automationLog, setAutomationLog] = useState<string[]>([]);
|
||||
const [diagnosticEvents, setDiagnosticEvents] = useState<ScanDiagnosticEvent[]>([]);
|
||||
const [storedTotal, setStoredTotal] = useState<number | null>(null);
|
||||
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
|
||||
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
|
||||
@@ -77,7 +78,7 @@ export function useScanViewController({
|
||||
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
|
||||
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
|
||||
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
const activeTargetCount = autoScanRunning
|
||||
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
|
||||
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
@@ -92,12 +93,28 @@ export function useScanViewController({
|
||||
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
|
||||
}, []);
|
||||
|
||||
const appendDiagnosticEvent = useCallback((event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => {
|
||||
setDiagnosticEvents((previous) => [
|
||||
...previous.slice(-23),
|
||||
createScanDiagnosticEvent({
|
||||
...event,
|
||||
includeFullScreenshot: false,
|
||||
}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
|
||||
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
|
||||
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
|
||||
const blocked = result.inputBlocked ? " input:blocked" : "";
|
||||
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
|
||||
}, [appendAutomationLog]);
|
||||
appendDiagnosticEvent({
|
||||
phase: "input",
|
||||
severity: result.inputBlocked || result.clicked === false || result.moved === false ? "warn" : "ok",
|
||||
message: `${prefix}: click ${result.clicked ? "sent" : "not sent"}`,
|
||||
details: summarizeClickResult(result),
|
||||
});
|
||||
}, [appendAutomationLog, appendDiagnosticEvent]);
|
||||
|
||||
const toggleDevMode = useCallback(() => {
|
||||
setDevMode((previous) => {
|
||||
@@ -118,6 +135,7 @@ export function useScanViewController({
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
} = useScanViewActions({
|
||||
autoScanRunning,
|
||||
@@ -137,6 +155,7 @@ export function useScanViewController({
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
@@ -155,41 +174,18 @@ export function useScanViewController({
|
||||
setReviewQueueOpen,
|
||||
});
|
||||
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
const {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
} = useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
});
|
||||
|
||||
useScanViewStateSync({
|
||||
artifactRepo,
|
||||
@@ -209,6 +205,7 @@ export function useScanViewController({
|
||||
snapshot,
|
||||
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
@@ -230,6 +227,7 @@ export function useScanViewController({
|
||||
scanLimitTouched,
|
||||
skipRows,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
storedTotal,
|
||||
devMode,
|
||||
scannerLearningRules,
|
||||
@@ -267,6 +265,7 @@ export function useScanViewController({
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useScanViewStateSync({
|
||||
setStoredTotal,
|
||||
}: ScanViewStateSyncInput) {
|
||||
useEffect(() => {
|
||||
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
if (!scanLimitTouched && detectedCount > 0) {
|
||||
setScanLimit(clampScanLimit(detectedCount));
|
||||
}
|
||||
@@ -32,4 +32,3 @@ export function useScanViewStateSync({
|
||||
}).catch(() => undefined);
|
||||
}, [artifactRepo, setStoredTotal]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, R
|
||||
import type { StoredArtifactRecord } from "../../types/storage";
|
||||
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
|
||||
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
|
||||
import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog";
|
||||
import type { ScannerLearningRules } from "../../lib/scannerLearning";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
|
||||
@@ -36,6 +37,7 @@ export interface ScanViewControllerResult {
|
||||
scanLimitTouched: boolean;
|
||||
skipRows: number;
|
||||
automationLog: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
storedTotal: number | null;
|
||||
devMode: boolean;
|
||||
scannerLearningRules: ScannerLearningRules;
|
||||
@@ -79,6 +81,7 @@ export interface ScanViewControllerResult {
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
canGoodInterop: boolean;
|
||||
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
RuntimeInfo,
|
||||
SaveResultWithPath,
|
||||
ScrollResult,
|
||||
KeyPressResult,
|
||||
AutomationGuard,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
@@ -111,6 +112,10 @@ function emptyScrollResult(): ScrollResult {
|
||||
return { ok: false, notchesSent: 0, inputBlocked: false };
|
||||
}
|
||||
|
||||
function emptyKeyPressResult(key = ""): KeyPressResult {
|
||||
return { ok: false, key, inputBlocked: false, eventsSent: 0 };
|
||||
}
|
||||
|
||||
function emptyBooleanResult(): BooleanResult {
|
||||
return { ok: false };
|
||||
}
|
||||
@@ -203,6 +208,7 @@ export function createRendererRepositories(): RendererRepositories | null {
|
||||
clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()),
|
||||
scrollScreen: (notches, anchorX, anchorY) =>
|
||||
createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()),
|
||||
keyPress: (key) => createBridgeSafeCall(() => bridge.keyPress(key), emptyKeyPressResult(key)),
|
||||
onCommand: bridge.onScannerCommand,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
SaveResultWithPath,
|
||||
ScrollResult,
|
||||
ScannerLearningRulePayload,
|
||||
KeyPressResult,
|
||||
} from "../../types/global";
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
import type { StoredArtifactRecord } from "../../types/storage";
|
||||
@@ -64,6 +65,7 @@ export interface AutomationRepositoryPort {
|
||||
focusMainWindow(): Promise<BooleanResult>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
keyPress(key: string): Promise<KeyPressResult>;
|
||||
onCommand(callback: (command: ScannerCommand) => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,128 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
});
|
||||
|
||||
it("derives main stat value when the fast OCR profile skips the value crop", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Maidens Fading Beauty",
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-main-stat-label": "Cryo DMG Bonus",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT Rate+6.6%\n+ CRIT DMG+12.4%\n+ HP+269\n+ ATK+16.3%",
|
||||
"artifact-footer": "Equipped: Skirk",
|
||||
}));
|
||||
|
||||
expect(parsed?.mainStat).toBe("Cryo DMG Bonus");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("uses split main stat value OCR when level OCR is missing", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Heldenepos's Unspoken Tale",
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-main-stat-label": "Pyro DMG Bonus",
|
||||
"artifact-main-stat-value": "46.6%",
|
||||
"artifact-level": "",
|
||||
"artifact-substats": "+ Energy Recharge+13.0%\n+ ATK+5.8%\n+ Elemental Mastery+58\n+ CRIT DMG+14.0%",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Goblet of Eonothem");
|
||||
expect(parsed?.mainStat).toBe("Pyro DMG Bonus");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainValue.source).toBe("ocr");
|
||||
});
|
||||
|
||||
it("derives fixed flower and plume values without a value crop", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator's Nostalgia",
|
||||
"artifact-slot": "Flower of Life",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.mainStat).toBe("HP");
|
||||
expect(parsed?.mainValue).toBe("4,780");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("derives percent main stat values when slot rules disallow flat HP ATK or DEF", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Moonlit Offering's Final Hour",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ ATK+19\n- Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.mainStat).toBe("HP%");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainStat.source).toBe("derived");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("recovers a known piece from a truncated Viridescent Determination OCR tail", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Determmation oT",
|
||||
"artifact-main-stat-label": "Energy Recharge",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- ATK+5.8%\n- Elemental Mastery+37\nHP+11.7%\n+ ATK+54",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Viridescent Venerer's Determination");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Viridescent Venerer");
|
||||
expect(parsed?.mainStat).toBe("Energy Recharge");
|
||||
expect(parsed?.mainValue).toBe("51.8%");
|
||||
});
|
||||
|
||||
it("recovers long Disenchantment piece names from truncated OCR fragments", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Postintty That Ceased Upon",
|
||||
"artifact-main-stat-label": "DEF",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ DEF+21\n+ Energy Recharge+4.5%\n+ CRIT Rate+3.1%\n- ATK+14",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Moment That Ceased Upon Waking From Grand Dreams");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Disenchantment in Deep Shadow");
|
||||
expect(parsed?.mainStat).toBe("DEF%");
|
||||
expect(parsed?.mainValue).toBe("8.7%");
|
||||
});
|
||||
|
||||
it("derives slot and set from the piece name when fast auto-scan skips slot OCR", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator's Nostalgia",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Flower of Life");
|
||||
expect(parsed?.fields.slot.source).toBe("derived");
|
||||
expect(parsed?.setName).toBe("Gladiator's Finale");
|
||||
expect(parsed?.fields.setName.source).toBe("derived");
|
||||
expect(parsed?.mainStat).toBe("HP");
|
||||
expect(parsed?.mainValue).toBe("4,780");
|
||||
});
|
||||
|
||||
it("caps derived slot and set confidence when the piece name is fuzzy", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator Nostalg",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Gladiator's Nostalgia");
|
||||
expect(parsed?.fields.name.confidence).toBeLessThan(94);
|
||||
expect(parsed?.slot).toBe("Flower of Life");
|
||||
expect(parsed?.fields.slot.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
|
||||
expect(parsed?.setName).toBe("Gladiator's Finale");
|
||||
expect(parsed?.fields.setName.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
|
||||
});
|
||||
|
||||
it("does not let substats override circlet crit main stats", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Holy Crown of the Believer\nCirclet of Logos",
|
||||
@@ -188,6 +310,46 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
});
|
||||
|
||||
it("recognizes equipped characters when OCR splits the footer label and name", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Gladiator's Nostalgia\nFlower of Life",
|
||||
"artifact-main-stat": "HP\n4,780",
|
||||
"artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%",
|
||||
"artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%",
|
||||
"artifact-footer": "Equipped:\nBennett",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("does not persist unknown one-letter equipped fragments as characters", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Vessel of Plenty",
|
||||
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
|
||||
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
|
||||
"artifact-footer": "Equipped: I -",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Not detected");
|
||||
expect(parsed?.fields.equipped.source).toBe("missing");
|
||||
});
|
||||
|
||||
it("normalizes equipped footer trailing fragments to a known character", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ HP+16.9%\n- ATK+8.7%\n+ CRIT DMG+13.2%\n+ Elemental Mastery+21",
|
||||
"artifact-set-effects": "2-Piece Set: Energy Recharge +20%.",
|
||||
"artifact-footer": "Equipped: Linnea l",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Linnea");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => {
|
||||
const sands = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Myths of the Night Realm\nSands of Eon",
|
||||
@@ -342,4 +504,18 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(sands?.mainValue).toBe("46.6%");
|
||||
expect(sands?.mainStat).toBe("Unknown main stat");
|
||||
});
|
||||
|
||||
it("derives DEF percent for goblet max-value reads when the OCR stat label is garbled", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Vessel of Plenty",
|
||||
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
|
||||
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
|
||||
"artifact-footer": "Equipped: I -",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Goblet of Eonothem");
|
||||
expect(parsed?.mainValue).toBe("58.3%");
|
||||
expect(parsed?.mainStat).toBe("DEF%");
|
||||
expect(parsed?.setName).toBe("Night of the Sky's Unveiling");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
textReplacements,
|
||||
} from "./genshinData.js";
|
||||
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
||||
import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js";
|
||||
import { implausibleSubstats } from "./substatRolls.js";
|
||||
|
||||
type MainStatValueReference = { stat: string; base: number; max: number };
|
||||
@@ -62,18 +63,26 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
(capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]),
|
||||
);
|
||||
const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n"));
|
||||
const titleText = byId.get("artifact-title") ?? "";
|
||||
const mainText = byId.get("artifact-main-stat") ?? "";
|
||||
const nameText = byId.get("artifact-name") ?? "";
|
||||
const slotOnlyText = byId.get("artifact-slot") ?? "";
|
||||
const legacyTitleText = byId.get("artifact-title") ?? "";
|
||||
const titleText = [nameText, slotOnlyText].filter(Boolean).join("\n") || legacyTitleText;
|
||||
const mainLabelText = byId.get("artifact-main-stat-label") ?? "";
|
||||
const mainValueText = byId.get("artifact-main-stat-value") ?? "";
|
||||
const legacyMainText = byId.get("artifact-main-stat") ?? "";
|
||||
const mainText = [mainLabelText, mainValueText].filter(Boolean).join("\n") || legacyMainText;
|
||||
const levelText = byId.get("artifact-level") ?? "";
|
||||
const substatText = byId.get("artifact-substats") ?? "";
|
||||
const setText = byId.get("artifact-set-effects") ?? "";
|
||||
const footerText = byId.get("artifact-footer") ?? "";
|
||||
|
||||
const nameField = parseArtifactName(titleText);
|
||||
const slotField = parseSlot(titleText + "\n" + allText, nameField.value);
|
||||
const levelField = parseArtifactLevel(substatText + "\n" + mainText + "\n" + allText);
|
||||
const slotField = parseSlot([slotOnlyText, titleText, allText].filter(Boolean).join("\n"), nameField);
|
||||
const levelField = parseArtifactLevel([levelText, substatText, mainText, allText].filter(Boolean).join("\n"));
|
||||
const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
|
||||
const level = parsedLevel ?? 0;
|
||||
let mainStatField = inferMainStat(slotField.value, mainText);
|
||||
mainStatField = promoteSlotPercentMainStat(slotField.value, mainStatField);
|
||||
let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
||||
if (!mainStatField.value && mainValueField.value) {
|
||||
const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel);
|
||||
@@ -100,7 +109,7 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
}
|
||||
const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"));
|
||||
const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing");
|
||||
const setField = parseSetName(setText, nameField.value);
|
||||
const setField = parseSetName(setText, nameField);
|
||||
const equippedField = parseEquippedCharacter(footerText + "\n" + allText);
|
||||
const notes: string[] = [];
|
||||
|
||||
@@ -161,6 +170,10 @@ function parseArtifactName(titleText: string): ParsedField {
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of titleLines) {
|
||||
const match = matchPiece(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
||||
}
|
||||
const alias = normalizePieceAlias(line);
|
||||
if (alias) return field(alias, 96, "database");
|
||||
}
|
||||
@@ -172,13 +185,17 @@ function parseArtifactName(titleText: string): ParsedField {
|
||||
return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function parseSlot(text: string, artifactName: string): ParsedField {
|
||||
function parseSlot(text: string, artifactName: ParsedField): ParsedField {
|
||||
const slotLines = text
|
||||
.split("\n")
|
||||
.map((line) => cleanupOcrLabel(line))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of slotLines) {
|
||||
const match = matchSlot(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "ocr");
|
||||
}
|
||||
const alias = normalizeSlotAlias(line);
|
||||
if (alias) return field(alias, 96, "ocr");
|
||||
}
|
||||
@@ -186,18 +203,22 @@ function parseSlot(text: string, artifactName: string): ParsedField {
|
||||
const directSlot = fuzzyFindKnown(text, slotNames, 0.68);
|
||||
if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback");
|
||||
|
||||
const derivedSlot = artifactName ? pieceToSlot.get(artifactName) ?? "" : "";
|
||||
return derivedSlot ? field(derivedSlot, 94, "derived") : field("", 0, "missing");
|
||||
const derivedSlot = artifactName.value ? pieceToSlot.get(artifactName.value) ?? "" : "";
|
||||
return derivedSlot ? field(derivedSlot, derivedConfidence(artifactName, 94), "derived") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function parseSetName(setText: string, artifactName: string): ParsedField {
|
||||
const setFromPiece = artifactName ? pieceToSet.get(artifactName) : undefined;
|
||||
function parseSetName(setText: string, artifactName: ParsedField): ParsedField {
|
||||
const setFromPiece = artifactName.value ? pieceToSet.get(artifactName.value) : undefined;
|
||||
const candidateLines = setText
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/:$/, ""))
|
||||
.filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line));
|
||||
|
||||
for (const line of candidateLines) {
|
||||
const match = matchSet(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
||||
}
|
||||
const alias = normalizeSetAlias(line);
|
||||
if (alias) return field(alias, 96, "database");
|
||||
}
|
||||
@@ -206,10 +227,17 @@ function parseSetName(setText: string, artifactName: string): ParsedField {
|
||||
|
||||
const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64);
|
||||
if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback");
|
||||
if (setFromPiece) return field(setFromPiece, 92, "derived");
|
||||
if (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived");
|
||||
const partialSetFromPiece = deriveSetFromPartialPieceName(artifactName.value);
|
||||
if (partialSetFromPiece) return field(partialSetFromPiece, 72, "derived");
|
||||
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function derivedConfidence(sourceField: ParsedField, maxConfidence: number) {
|
||||
if (sourceField.source === "database" || sourceField.confidence >= maxConfidence) return maxConfidence;
|
||||
return Math.max(45, Math.min(maxConfidence, sourceField.confidence));
|
||||
}
|
||||
|
||||
function parseArtifactLevel(text: string): ParsedField {
|
||||
const lines = normalizeText(text)
|
||||
.split("\n")
|
||||
@@ -250,6 +278,22 @@ function firstUsefulLine(text: string, rejectIncludes: string[]) {
|
||||
.find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? "";
|
||||
}
|
||||
|
||||
function deriveSetFromPartialPieceName(text: string) {
|
||||
const words = cleanupOcrLabel(text)
|
||||
.split(/\s+/)
|
||||
.map((word) => simplifyForMatch(word))
|
||||
.filter((word) => word.length >= 5);
|
||||
if (words.length < 2) return "";
|
||||
|
||||
const candidates = knownPieceNames.filter((piece) => {
|
||||
const normalizedPiece = simplifyForMatch(piece);
|
||||
const hits = words.filter((word) => normalizedPiece.includes(word)).length;
|
||||
return hits >= 2;
|
||||
});
|
||||
const sets = [...new Set(candidates.map((piece) => pieceToSet.get(piece)).filter((set): set is string => Boolean(set)))];
|
||||
return sets.length === 1 ? sets[0] : "";
|
||||
}
|
||||
|
||||
function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField {
|
||||
const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0");
|
||||
const percentValue = extractPercentValue(cleaned);
|
||||
@@ -294,6 +338,10 @@ function inferMainStat(slot: string, text: string): ParsedField {
|
||||
if (direct) return field(promotePercentVariant(direct, text), 94, "ocr");
|
||||
|
||||
const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot));
|
||||
const lookup = matchStat(text);
|
||||
if (lookup.value && allowedForSlot.includes(lookup.value)) {
|
||||
return field(promotePercentVariant(lookup.value, text), lookup.confidence, lookup.source === "fuzzy" ? "fallback" : "ocr");
|
||||
}
|
||||
const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68);
|
||||
if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback");
|
||||
|
||||
@@ -332,6 +380,7 @@ function findDirectMainStat(text: string) {
|
||||
if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%";
|
||||
if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%";
|
||||
if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%";
|
||||
if (hasPercentValue && /(^|\s)dlt(\s|$)/i.test(text)) return "DEF%";
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -461,6 +510,10 @@ function parseEquippedCharacter(text: string): ParsedField {
|
||||
if (!equippedLine) return field("Not detected", 45, "missing");
|
||||
|
||||
const afterLabel = cleanupCharacterNoise(equippedLine);
|
||||
const match = afterLabel ? matchCharacter(afterLabel) : null;
|
||||
if (match?.value) {
|
||||
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "database");
|
||||
}
|
||||
const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : "";
|
||||
if (alias) return field(alias, 96, "database");
|
||||
const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null;
|
||||
@@ -470,7 +523,7 @@ function parseEquippedCharacter(text: string): ParsedField {
|
||||
const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null;
|
||||
if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback");
|
||||
|
||||
return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing");
|
||||
return field("Not detected", 45, "missing");
|
||||
}
|
||||
|
||||
function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField {
|
||||
@@ -486,6 +539,20 @@ function promotePercentVariant(stat: string, text: string) {
|
||||
return stat;
|
||||
}
|
||||
|
||||
function promoteSlotPercentMainStat(slot: string, mainStat: ParsedField): ParsedField {
|
||||
if (!["ATK", "HP", "DEF"].includes(mainStat.value)) return mainStat;
|
||||
const references = getSlotMainStatValueReferences(slot);
|
||||
const hasFlat = references.some((candidate) => candidate.stat === mainStat.value);
|
||||
const hasPercent = references.some((candidate) => candidate.stat === `${mainStat.value}%`);
|
||||
if (hasFlat || !hasPercent) return mainStat;
|
||||
return {
|
||||
...mainStat,
|
||||
value: `${mainStat.value}%`,
|
||||
confidence: Math.max(mainStat.confidence, 90),
|
||||
source: "derived",
|
||||
};
|
||||
}
|
||||
|
||||
function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] {
|
||||
const valueReferences = mainStatValueReferences[slot];
|
||||
return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : [];
|
||||
@@ -573,6 +640,7 @@ function cleanupOcrLabel(line: string) {
|
||||
function cleanupCharacterNoise(text: string) {
|
||||
return text
|
||||
.replace(/^.*?equipped\s*:?\s*/i, "")
|
||||
.replace(/^(?:by|to)\s+/i, "")
|
||||
.replace(/[^A-Za-z'\-\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
@@ -585,4 +653,3 @@ function sortLongestFirst(values: string[]) {
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ describe("autoScanController", () => {
|
||||
expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" });
|
||||
});
|
||||
|
||||
it("separates stuck detail views from duplicates", () => {
|
||||
it("treats repeated readable signatures as duplicates", () => {
|
||||
const seen = new Set(["same"]);
|
||||
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "duplicate" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" });
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export function classifyAutoScanCapture({
|
||||
seen: ReadonlySet<string>;
|
||||
}): AutoScanCaptureDecision {
|
||||
if (!signature) return { kind: "unreadable", countAsMiss: true };
|
||||
if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature };
|
||||
if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature };
|
||||
return { kind: "new", countAsParsed: true, signature };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
import { artifactTabClickTarget, buildAutoScanEntryPlan, keyPressBlocked, validateAutoScanEntryPreflight } from "./autoScanEntry";
|
||||
|
||||
function capture(overrides: Partial<CaptureResult> = {}): CaptureResult {
|
||||
return {
|
||||
id: "test",
|
||||
name: "test",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date(0).toISOString(),
|
||||
inventoryGrid: {
|
||||
centers: [{ x: 179, y: 254, row: 0, col: 0 }],
|
||||
rows: 4,
|
||||
cols: 8,
|
||||
confidence: 76,
|
||||
source: "detected",
|
||||
},
|
||||
captureTarget: "genshin-client",
|
||||
artifactDetail: { present: true, confidence: 84, orangeHits: 14, greenHits: 8, textHits: 30 },
|
||||
layout: { aspect: "1.78:1", isSixteenNine: true, warning: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("autoScanEntry", () => {
|
||||
it("plans only read-only keys/clicks for the Paimon entry", () => {
|
||||
expect(buildAutoScanEntryPlan("paimon-menu")).toEqual([
|
||||
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
|
||||
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans a guided auto entry with direct world path before IK fallback", () => {
|
||||
expect(buildAutoScanEntryPlan("auto-entry")).toEqual([
|
||||
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
|
||||
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses Inventory Kamera's artifact-tab coordinate ratio", () => {
|
||||
expect(artifactTabClickTarget(capture())).toEqual({ x: 672, y: 47 });
|
||||
});
|
||||
|
||||
it("blocks invalid lookup and unsupported layouts before auto-scan", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture(), { valid: false, errors: ["bad"], warnings: [], summary: { artifactSets: 0, artifactPieces: 0, characters: 0, stats: 0, generatedAt: "", sourceVersion: "" } }).reason).toContain("Lookup invalid");
|
||||
expect(validateAutoScanEntryPreflight(capture({ layout: { aspect: "2.39:1", isSixteenNine: false, warning: "nicht 16:9" } })).reason).toContain("nicht 16:9");
|
||||
});
|
||||
|
||||
it("blocks primary-screen and missing detail-card starts", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture({ captureTarget: "primary-screen" })).reason).toContain("Primary Screen");
|
||||
expect(validateAutoScanEntryPreflight(capture({ artifactDetail: { present: false, confidence: 12, orangeHits: 0, greenHits: 1, textHits: 4 } })).reason).toContain("Keine Artifact-Detailansicht");
|
||||
});
|
||||
|
||||
it("blocks the Paimon menu before grid clicks or OCR", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture({
|
||||
artifactDetail: {
|
||||
present: false,
|
||||
confidence: 8,
|
||||
orangeHits: 12,
|
||||
greenHits: 154,
|
||||
textHits: 20,
|
||||
titleOrangeHits: 4,
|
||||
upperTextHits: 11,
|
||||
lowerGreenHits: 0,
|
||||
},
|
||||
paimonMenu: {
|
||||
present: true,
|
||||
confidence: 81,
|
||||
profileLightPct: 56,
|
||||
profileCreamPct: 31.1,
|
||||
menuTileDarkPct: 67.7,
|
||||
},
|
||||
})).reason).toContain("Paimon-Menue erkannt");
|
||||
});
|
||||
|
||||
it("detects blocked key input", () => {
|
||||
expect(keyPressBlocked({ ok: false, key: "B", inputBlocked: true })).toBe(true);
|
||||
expect(keyPressBlocked({ ok: true, key: "B", inputBlocked: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CaptureResult, KeyPressResult } from "../types/global";
|
||||
import { validateLookupPackage, type LookupValidationStatus } from "./genshinLookup";
|
||||
|
||||
export type ScanEntryMode = "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
|
||||
|
||||
export interface AutoScanEntryAction {
|
||||
type: "key" | "click-artifact-tab" | "click-first-artifact" | "capture-preflight";
|
||||
key?: "ESC" | "B";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AutoScanEntryPreflight {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function buildAutoScanEntryPlan(mode: ScanEntryMode): AutoScanEntryAction[] {
|
||||
if (mode === "visible-inventory") {
|
||||
return [{ type: "capture-preflight", label: "Capture visible artifact inventory" }];
|
||||
}
|
||||
if (mode === "direct-inventory") {
|
||||
return [
|
||||
{ type: "key", key: "B", label: "Open inventory from the current world state" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
if (mode === "auto-entry") {
|
||||
return [
|
||||
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
|
||||
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
|
||||
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
|
||||
export function artifactTabClickTarget(capture: CaptureResult): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.round(capture.width * (448 / 1280)),
|
||||
y: Math.round(capture.height * (31 / 720)),
|
||||
};
|
||||
}
|
||||
|
||||
export function keyPressBlocked(result: KeyPressResult | null | undefined) {
|
||||
return !result?.ok || Boolean(result.inputBlocked);
|
||||
}
|
||||
|
||||
export function validateAutoScanEntryPreflight(
|
||||
capture: CaptureResult | null,
|
||||
lookupStatus: LookupValidationStatus = validateLookupPackage(),
|
||||
): AutoScanEntryPreflight {
|
||||
if (!lookupStatus.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown lookup error"}`,
|
||||
};
|
||||
}
|
||||
if (!capture) return { ok: false, reason: "Keine Capture-Daten fuer den Auto-Scan-Start." };
|
||||
if (capture.captureTarget === "primary-screen") {
|
||||
return { ok: false, reason: "Auto-Scan blockiert: Capture stammt vom Primary Screen statt vom Genshin-Client." };
|
||||
}
|
||||
if (capture.layout?.warning) return { ok: false, reason: capture.layout.warning };
|
||||
if (capture.paimonMenu?.present) {
|
||||
return { ok: false, reason: `Paimon-Menue erkannt (${capture.paimonMenu.confidence}%). Auto-Scan startet erst im Artifact-Inventar mit sichtbarer Detailkarte.` };
|
||||
}
|
||||
if (!capture.inventoryGrid || capture.inventoryGrid.source === "missing" || capture.inventoryGrid.centers.length === 0) {
|
||||
return { ok: false, reason: "Kein verlaessliches Artifact-Grid erkannt. Artifact-Inventar sichtbar lassen." };
|
||||
}
|
||||
if (!capture.artifactDetail?.present) {
|
||||
const confidence = capture.artifactDetail ? ` (${capture.artifactDetail.confidence}% Detail-Marker)` : "";
|
||||
return { ok: false, reason: `Keine Artifact-Detailansicht erkannt${confidence}. Artifact-Inventar mit sichtbarer Detailkarte oeffnen.` };
|
||||
}
|
||||
return { ok: true, reason: "" };
|
||||
}
|
||||
@@ -50,6 +50,7 @@ function capture(overrides: Partial<ScanTestCapture> = {}): ScanTestCapture {
|
||||
detailDataUrl: "data:image/png;base64,DETAIL-AAA",
|
||||
inventoryDataUrl: "data:image/png;base64,GRID-AAA",
|
||||
inventoryGrid: sampleGrid,
|
||||
artifactDetail: { present: true, confidence: 84, orangeHits: 12, greenHits: 8, textHits: 28 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +82,11 @@ describe("autoScanLoop fingerprints", () => {
|
||||
expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never));
|
||||
});
|
||||
|
||||
it("prefers native detail fingerprints when preview images are omitted", () => {
|
||||
expect(detailFingerprint({ detailFingerprint: "native-detail", dataUrl: "data:image/png;base64,FULL" } as never)).toBe("native-detail");
|
||||
expect(screenFingerprint({ inventoryFingerprint: "native-inventory", dataUrl: "data:image/png;base64,FULL" } as never)).toBe("native-inventory");
|
||||
});
|
||||
|
||||
it("uses the inventory preview for scroll verification when available", () => {
|
||||
const captureA = {
|
||||
inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64),
|
||||
@@ -101,7 +107,7 @@ describe("autoScanLoop fingerprints", () => {
|
||||
expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not block before first click when start capture is from the primary screen", async () => {
|
||||
it("blocks before first click when start capture is from the primary screen", async () => {
|
||||
const startCapture = capture({
|
||||
captureTarget: "primary-screen",
|
||||
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
|
||||
@@ -127,7 +133,7 @@ describe("autoScanLoop fingerprints", () => {
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }),
|
||||
captureFastSelectedSource: async () => startCapture,
|
||||
captureFastSelectedSource: async () => (clicked > 0 ? capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }) : startCapture),
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => false,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
@@ -141,8 +147,460 @@ describe("autoScanLoop fingerprints", () => {
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
|
||||
expect(result.blockedReason).toContain("Primary Screen");
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(clicked).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks before first click when no artifact detail card is visible", async () => {
|
||||
let clicked = 0;
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async () => {
|
||||
clicked += 1;
|
||||
return {
|
||||
ok: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture(),
|
||||
captureFastSelectedSource: async () => capture({ artifactDetail: { present: false, confidence: 10, orangeHits: 0, greenHits: 0, textHits: 3 } }),
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => false,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null, ocrEngine: "ik-traineddata" });
|
||||
expect(result.blockedReason).toContain("Keine Artifact-Detailansicht");
|
||||
expect(result.status).toBe("blocked");
|
||||
expect(clicked).toBe(0);
|
||||
});
|
||||
|
||||
it("continues when detail verification proves a helper-reported click miss still changed selection", async () => {
|
||||
const startCapture = capture({
|
||||
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
|
||||
});
|
||||
|
||||
let clicked = 0;
|
||||
const selectedCaptureOptions: unknown[] = [];
|
||||
const selectedFocusFlags: boolean[] = [];
|
||||
const fastFocusFlags: boolean[] = [];
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async () => {
|
||||
clicked += 1;
|
||||
if (clicked === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
x: 80,
|
||||
y: 90,
|
||||
cursorX: 960,
|
||||
cursorY: 539,
|
||||
clicked: false,
|
||||
moved: false,
|
||||
focused: true,
|
||||
isElevated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
x: 80,
|
||||
y: 90,
|
||||
cursorX: 80,
|
||||
cursorY: 90,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
isElevated: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async (_delayMs, focusGenshin, options) => {
|
||||
selectedFocusFlags.push(Boolean(focusGenshin));
|
||||
selectedCaptureOptions.push(options);
|
||||
return capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` });
|
||||
},
|
||||
captureFastSelectedSource: async (_delayMs, focusGenshin) => {
|
||||
fastFocusFlags.push(Boolean(focusGenshin));
|
||||
return clicked > 0 ? capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }) : startCapture;
|
||||
},
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => true,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null, ocrEngine: "ik-traineddata" });
|
||||
expect(result.blockedReason).toBe("");
|
||||
expect(result.status).toBe("done");
|
||||
expect(clicked).toBe(1);
|
||||
expect(result.stats.verified).toBe(1);
|
||||
expect(result.stats.parsed).toBe(1);
|
||||
expect(selectedFocusFlags).toEqual([false]);
|
||||
expect(fastFocusFlags.every((flag) => flag === false)).toBe(true);
|
||||
expect(selectedCaptureOptions).toContainEqual({
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "fast",
|
||||
ocrEngine: "ik-traineddata",
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("processes an initial Paimon-selected artifact before clicking the next tile", async () => {
|
||||
let clicked = 0;
|
||||
let persisted = 0;
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async () => {
|
||||
clicked += 1;
|
||||
return {
|
||||
ok: true,
|
||||
x: 80,
|
||||
y: 90,
|
||||
cursorX: 80,
|
||||
cursorY: 90,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
|
||||
captureFastSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => {
|
||||
persisted += 1;
|
||||
return true;
|
||||
},
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, {
|
||||
scanLimit: 1,
|
||||
skipRows: 0,
|
||||
detectedInventoryCount: null,
|
||||
processInitialSelection: true,
|
||||
});
|
||||
expect(result.status).toBe("done");
|
||||
expect(result.stats.parsed).toBe(1);
|
||||
expect(result.stats.stored).toBe(1);
|
||||
expect(clicked).toBe(0);
|
||||
expect(persisted).toBe(1);
|
||||
});
|
||||
|
||||
it("starts after the already selected initial tile", async () => {
|
||||
let clicked = 0;
|
||||
const clickedTargets: Array<{ x: number; y: number }> = [];
|
||||
const logs: string[] = [];
|
||||
const twoTileGrid = {
|
||||
centers: [
|
||||
{ x: 179, y: 254, row: 0, col: 0 },
|
||||
{ x: 325, y: 254, row: 0, col: 1 },
|
||||
],
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
confidence: 86,
|
||||
source: "detected" as const,
|
||||
};
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async (x, y) => {
|
||||
clicked += 1;
|
||||
clickedTargets.push({ x, y });
|
||||
return {
|
||||
ok: true,
|
||||
x: 80,
|
||||
y: 90,
|
||||
cursorX: 80,
|
||||
cursorY: 90,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
|
||||
captureFastSelectedSource: async () => capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => true,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: (line) => logs.push(line),
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
await runAutoScanLoop(deps, {
|
||||
scanLimit: 2,
|
||||
skipRows: 0,
|
||||
detectedInventoryCount: null,
|
||||
processInitialSelection: true,
|
||||
skipInitialGridTarget: true,
|
||||
});
|
||||
|
||||
expect(clicked).toBe(1);
|
||||
expect(clickedTargets[0]).toEqual({ x: 325, y: 254 });
|
||||
expect(logs.some((line) => line.includes("r0 c0"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not skip the first grid tile when the visible inventory selection was user-made", async () => {
|
||||
let clicked = 0;
|
||||
const clickedTargets: Array<{ x: number; y: number }> = [];
|
||||
const twoTileGrid = {
|
||||
centers: [
|
||||
{ x: 179, y: 254, row: 0, col: 0 },
|
||||
{ x: 325, y: 254, row: 0, col: 1 },
|
||||
],
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
confidence: 86,
|
||||
source: "detected" as const,
|
||||
};
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async (x, y) => {
|
||||
clicked += 1;
|
||||
clickedTargets.push({ x, y });
|
||||
return {
|
||||
ok: true,
|
||||
x,
|
||||
y,
|
||||
cursorX: x,
|
||||
cursorY: y,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"A".repeat(600)}` }),
|
||||
captureFastSelectedSource: async () => (clicked > 0
|
||||
? capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"A".repeat(600)}` })
|
||||
: capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` })),
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => true,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
await runAutoScanLoop(deps, {
|
||||
scanLimit: 2,
|
||||
skipRows: 0,
|
||||
detectedInventoryCount: null,
|
||||
processInitialSelection: true,
|
||||
skipInitialGridTarget: false,
|
||||
});
|
||||
|
||||
expect(clickedTargets[0]).toEqual({ x: 179, y: 254 });
|
||||
});
|
||||
|
||||
it("polls for the next inventory page after scroll instead of sleeping a fixed settle delay", async () => {
|
||||
const started = Date.now();
|
||||
let clicked = 0;
|
||||
let scrolled = false;
|
||||
let parsedIndex = 0;
|
||||
const waits: string[] = [];
|
||||
const singleTileGrid = {
|
||||
centers: [{ x: 179, y: 254, row: 0, col: 0 }],
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
confidence: 86,
|
||||
source: "detected" as const,
|
||||
};
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async (x, y) => {
|
||||
clicked += 1;
|
||||
return {
|
||||
ok: true,
|
||||
x,
|
||||
y,
|
||||
cursorX: x,
|
||||
cursorY: y,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => {
|
||||
scrolled = true;
|
||||
waits.push("scroll");
|
||||
return { ok: true, notchesSent: -9 };
|
||||
},
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({
|
||||
inventoryGrid: singleTileGrid,
|
||||
inventoryFingerprint: scrolled ? "page-2" : "page-1",
|
||||
detailFingerprint: clicked > 1 ? "detail-2-selected" : "detail-1-selected",
|
||||
}),
|
||||
captureFastSelectedSource: async () => {
|
||||
const pageFingerprint = scrolled ? "page-2" : "page-1";
|
||||
const detail = clicked > 1 ? "detail-2" : clicked > 0 ? "detail-1" : "detail-start";
|
||||
return capture({
|
||||
inventoryGrid: singleTileGrid,
|
||||
inventoryFingerprint: pageFingerprint,
|
||||
detailFingerprint: `${detail}:${pageFingerprint}`,
|
||||
});
|
||||
},
|
||||
parseArtifact: () => ({
|
||||
...sampleParse,
|
||||
name: `Artifact ${++parsedIndex}`,
|
||||
fields: {
|
||||
...sampleParse.fields,
|
||||
name: { value: `Artifact ${parsedIndex}`, confidence: 84, source: "ocr" as const },
|
||||
},
|
||||
}),
|
||||
persistParsedArtifact: async () => true,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, { scanLimit: 2, skipRows: 0, detectedInventoryCount: null });
|
||||
|
||||
expect(result.status).toBe("done");
|
||||
expect(result.stats.parsed).toBe(2);
|
||||
expect(waits).toEqual(["scroll"]);
|
||||
expect(Date.now() - started).toBeLessThan(700);
|
||||
});
|
||||
|
||||
it("queues store writes so the next tile can be clicked before persistence finishes", async () => {
|
||||
let clicked = 0;
|
||||
let selectedReads = 0;
|
||||
const persistResolvers: Array<(value: boolean) => void> = [];
|
||||
const twoTileGrid = {
|
||||
centers: [
|
||||
{ x: 179, y: 254, row: 0, col: 0 },
|
||||
{ x: 325, y: 254, row: 0, col: 1 },
|
||||
],
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
confidence: 86,
|
||||
source: "detected" as const,
|
||||
};
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async (x, y) => {
|
||||
clicked += 1;
|
||||
return {
|
||||
ok: true,
|
||||
x,
|
||||
y,
|
||||
cursorX: x,
|
||||
cursorY: y,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => {
|
||||
selectedReads += 1;
|
||||
return capture({
|
||||
inventoryGrid: twoTileGrid,
|
||||
detailFingerprint: `detail-read-${selectedReads}`,
|
||||
});
|
||||
},
|
||||
captureFastSelectedSource: async () => capture({
|
||||
inventoryGrid: twoTileGrid,
|
||||
detailFingerprint: clicked === 0 ? "detail-start" : `detail-click-${clicked}`,
|
||||
}),
|
||||
parseArtifact: () => ({
|
||||
...sampleParse,
|
||||
name: `Artifact ${selectedReads}`,
|
||||
fields: {
|
||||
...sampleParse.fields,
|
||||
name: { value: `Artifact ${selectedReads}`, confidence: 84, source: "ocr" as const },
|
||||
},
|
||||
}),
|
||||
persistParsedArtifact: async () => new Promise<boolean>((resolve) => {
|
||||
persistResolvers.push(resolve);
|
||||
}),
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const runPromise = runAutoScanLoop(deps, { scanLimit: 2, skipRows: 0, detectedInventoryCount: null });
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(clicked).toBe(2);
|
||||
expect(persistResolvers).toHaveLength(1);
|
||||
|
||||
persistResolvers[0](true);
|
||||
for (let attempt = 0; attempt < 10 && persistResolvers.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
expect(persistResolvers).toHaveLength(2);
|
||||
persistResolvers[1](true);
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.status).toBe("done");
|
||||
expect(result.stats.parsed).toBe(2);
|
||||
expect(result.stats.stored).toBe(2);
|
||||
expect(result.stats.activeScanMs).toBeGreaterThan(0);
|
||||
expect(result.stats.writeFlushMs).toBeGreaterThan(0);
|
||||
expect(result.stats.activeScanMs).toBeLessThanOrEqual(result.stats.elapsedMs);
|
||||
});
|
||||
});
|
||||
|
||||
+391
-70
@@ -1,4 +1,4 @@
|
||||
import type { BooleanResult, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global";
|
||||
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
||||
import { sessionSignature } from "./artifactStore";
|
||||
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
|
||||
@@ -6,7 +6,8 @@ import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./au
|
||||
import { waitForCardReady } from "./cardReadyGate";
|
||||
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
||||
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
||||
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
||||
import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
|
||||
import { validateAutoScanEntryPreflight } from "./autoScanEntry";
|
||||
|
||||
// Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md
|
||||
// ADR-007): one click per tile, a fixed settle delay, one retry if the detail
|
||||
@@ -22,8 +23,8 @@ type AutoScanApi = {
|
||||
|
||||
export type AutoScanLoopDependencies = {
|
||||
api: AutoScanApi;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (
|
||||
capture: CaptureResult | null,
|
||||
@@ -31,6 +32,14 @@ export type AutoScanLoopDependencies = {
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
) => Promise<number>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -49,6 +58,9 @@ export type AutoScanLoopOptions = {
|
||||
scanLimit: number;
|
||||
skipRows: number;
|
||||
detectedInventoryCount?: number | null;
|
||||
processInitialSelection?: boolean;
|
||||
skipInitialGridTarget?: boolean;
|
||||
ocrEngine?: CaptureOptions["ocrEngine"];
|
||||
};
|
||||
|
||||
export type AutoScanLoopResult = {
|
||||
@@ -61,10 +73,17 @@ export type AutoScanLoopResult = {
|
||||
};
|
||||
|
||||
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
|
||||
// until it has changed and stabilized (or the budget is spent). See cardReadyGate.
|
||||
const CARD_READY_MAX_MS = 900;
|
||||
const CARD_READY_POLL_MS = 90;
|
||||
const CARD_READY_STABLE_SAMPLES = 2;
|
||||
// until it has changed, then read the artifact immediately. See cardReadyGate.
|
||||
const CARD_READY_MAX_MS = 180;
|
||||
const CARD_READY_POLL_MS = 25;
|
||||
const CARD_READY_STABLE_SAMPLES = 1;
|
||||
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 0;
|
||||
const SCROLL_READY_MAX_MS = 760;
|
||||
const SCROLL_READY_POLL_MS = 80;
|
||||
const SCROLL_READY_STABLE_SAMPLES = 2;
|
||||
const SCROLL_READY_ACCEPT_CHANGED_AFTER_MS = 100;
|
||||
const STATS_PUBLISH_INTERVAL_MS = 250;
|
||||
const ROUTINE_CLICK_LOG_INTERVAL = 12;
|
||||
const MISS_ABORT_THRESHOLD = 3;
|
||||
const UNREADABLE_ABORT_THRESHOLD = 5;
|
||||
|
||||
@@ -78,6 +97,7 @@ export async function runAutoScanLoop(
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
@@ -89,6 +109,7 @@ export async function runAutoScanLoop(
|
||||
} = deps;
|
||||
|
||||
const stats: AutoScanStats = { ...emptyAutoScanStats };
|
||||
const startedAt = Date.now();
|
||||
const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
|
||||
const rowsToSkip = clampSkipRows(options.skipRows);
|
||||
const seen = new Set<string>();
|
||||
@@ -98,19 +119,82 @@ export async function runAutoScanLoop(
|
||||
let aborted = false;
|
||||
let consecutiveMisses = 0;
|
||||
let rowsQueued = 0;
|
||||
const primaryScreenStartWarning =
|
||||
"Start-Capture ist vom Primary-Screen, kein spezifischer Genshin-Client-Marker vorhanden - Auto-Scan wird mit Vorsicht fortgesetzt.";
|
||||
|
||||
function updateStats() {
|
||||
setAutoScanStats({ ...stats });
|
||||
let flushingWrites = false;
|
||||
const writeQueue: Array<{ label: string; task: () => Promise<void> }> = [];
|
||||
const batchedPersistQueue: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}> = [];
|
||||
let lastStatsPublishAt = 0;
|
||||
function updateStats(preserveActiveScanMs = false, forcePublish = false) {
|
||||
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs: preserveActiveScanMs || flushingWrites });
|
||||
const now = Date.now();
|
||||
if (forcePublish || now - lastStatsPublishAt >= STATS_PUBLISH_INTERVAL_MS) {
|
||||
lastStatsPublishAt = now;
|
||||
setAutoScanStats({ ...stats });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) {
|
||||
const saved = await saveReviewSample(capture, parsed, reason);
|
||||
if (saved?.ok) {
|
||||
stats.review++;
|
||||
updateStats();
|
||||
function enqueueWrite(label: string, task: () => Promise<void>) {
|
||||
writeQueue.push({ label, task });
|
||||
}
|
||||
|
||||
async function flushWrites() {
|
||||
flushingWrites = true;
|
||||
try {
|
||||
if (persistParsedArtifactsBatch && batchedPersistQueue.length > 0) {
|
||||
const items = batchedPersistQueue.splice(0);
|
||||
try {
|
||||
stats.stored += await persistParsedArtifactsBatch(items);
|
||||
} catch (error) {
|
||||
appendAutomationLog(`write failed persist-batch:${items.length}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
for (const item of writeQueue.splice(0)) {
|
||||
try {
|
||||
await item.task();
|
||||
} catch (error) {
|
||||
appendAutomationLog(`write failed ${item.label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushingWrites = false;
|
||||
}
|
||||
updateStats(true, true);
|
||||
}
|
||||
|
||||
async function finish(result: AutoScanLoopResult) {
|
||||
const flushStartedAt = Date.now();
|
||||
stats.activeScanMs = Math.max(0, flushStartedAt - startedAt);
|
||||
await flushWrites();
|
||||
stats.writeFlushMs += Math.max(0, Date.now() - flushStartedAt);
|
||||
updateStats(true, true);
|
||||
return { ...result, stats: { ...stats } };
|
||||
}
|
||||
|
||||
function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) {
|
||||
enqueueWrite(`review:${reason}`, async () => {
|
||||
const saved = await saveReviewSample(capture, parsed, reason);
|
||||
if (saved?.ok) {
|
||||
stats.review++;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistArtifactLater(capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) {
|
||||
if (persistParsedArtifactsBatch) {
|
||||
batchedPersistQueue.push({ capture, parsed, source, needsReview });
|
||||
return;
|
||||
}
|
||||
enqueueWrite(`persist:${source}:${parsed.name}`, async () => {
|
||||
if (await persistParsedArtifact(capture, parsed, source, needsReview)) {
|
||||
stats.stored++;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function checkGuard() {
|
||||
@@ -145,50 +229,221 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
|
||||
async function clickTarget(target: GridTarget, label: string) {
|
||||
appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
|
||||
const clickNumber = stats.clicked + 1;
|
||||
const routineLog = clickNumber <= 2 || clickNumber % ROUTINE_CLICK_LOG_INTERVAL === 0 || label !== "click";
|
||||
if (routineLog) appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
|
||||
const clickStartedAt = Date.now();
|
||||
const clickResult = await api.clickScreen(target.x, target.y);
|
||||
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
|
||||
stats.clickMs += Math.max(0, Date.now() - clickStartedAt);
|
||||
if (routineLog || reportedClickDeliveryFailure(clickResult) || clickResult.inputBlocked) {
|
||||
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
|
||||
}
|
||||
stats.clicked++;
|
||||
stats.attempted = stats.clicked;
|
||||
updateStats();
|
||||
return clickResult;
|
||||
}
|
||||
|
||||
let currentCapture = await captureSelectedSource(0, true);
|
||||
const isPrimaryCapture = currentCapture?.captureTarget === "primary-screen";
|
||||
const initialCaptureRejection = isPrimaryCapture ? "" : captureSourceRejectionReason(currentCapture);
|
||||
function reportedClickDeliveryFailure(result: ClickResult) {
|
||||
return result.moved === false || result.clicked === false;
|
||||
}
|
||||
|
||||
function clickDeliveryFailureReason(target: GridTarget, clickResult: ClickResult) {
|
||||
if (clickResult.inputBlocked) {
|
||||
return "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten').";
|
||||
}
|
||||
if (clickResult.isElevated === false) {
|
||||
return `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
|
||||
}
|
||||
const cursor = `${clickResult.cursorX ?? "?"},${clickResult.cursorY ?? "?"}`;
|
||||
return `Cursor kam nicht am Klick-Ziel ${target.x},${target.y} an (Cursor ${cursor}). Genshin im Vordergrund lassen und erneut versuchen.`;
|
||||
}
|
||||
|
||||
let currentCapture = await captureFastSelectedSource(0, false, {
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
});
|
||||
const initialSurfaceRejection = validateAutoScanEntryPreflight(currentCapture);
|
||||
const initialCaptureRejection = initialSurfaceRejection.ok ? captureSourceRejectionReason(currentCapture) : initialSurfaceRejection.reason;
|
||||
let gridModel = buildGridModel(currentCapture?.inventoryGrid);
|
||||
|
||||
if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) {
|
||||
const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren.";
|
||||
setReviewStatus(reason);
|
||||
return { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets };
|
||||
}
|
||||
|
||||
if (isPrimaryCapture) {
|
||||
appendAutomationLog(primaryScreenStartWarning);
|
||||
return finish({ status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets });
|
||||
}
|
||||
|
||||
let lastDetailSignature = "";
|
||||
const initialParsed = parseArtifact(currentCapture);
|
||||
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
||||
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
||||
|
||||
const shouldSkipInitialGridTarget = Boolean(options.processInitialSelection && options.skipInitialGridTarget);
|
||||
let initialProcessedOffset = 0;
|
||||
let initialSelectionDuplicateSkipped = false;
|
||||
if (options.processInitialSelection) {
|
||||
const initialCapture = await captureSelectedSource(0, false, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "fast",
|
||||
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
const initialSurfaceRejection = validateAutoScanEntryPreflight(initialCapture);
|
||||
if (!initialSurfaceRejection.ok) {
|
||||
return finish({
|
||||
status: "blocked",
|
||||
stats,
|
||||
blockedReason: initialSurfaceRejection.reason,
|
||||
pageCount: 0,
|
||||
gridLabel: initialSurfaceRejection.reason,
|
||||
targetCount: maxTargets,
|
||||
});
|
||||
}
|
||||
if (!initialCapture) {
|
||||
return finish({
|
||||
status: "blocked",
|
||||
stats,
|
||||
blockedReason: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
|
||||
pageCount: 0,
|
||||
gridLabel: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
|
||||
targetCount: maxTargets,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = parseArtifact(initialCapture);
|
||||
const rejection = captureRejectionReason(initialCapture, parsed);
|
||||
if (rejection || !parsed) {
|
||||
saveAutomaticReviewSample(initialCapture, parsed, `automatic:initial-selection-rejected`);
|
||||
stats.verified++;
|
||||
stats.misses++;
|
||||
addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs);
|
||||
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
|
||||
lastDetailViewFingerprint = detailFingerprint(initialCapture);
|
||||
updateStats();
|
||||
appendAutomationLog(`initial selection review: ${rejection || "kein Artifact lesbar"}; scan continues with next tile`);
|
||||
} else {
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs);
|
||||
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
|
||||
const signature = sessionSignature(parsed);
|
||||
seen.add(signature);
|
||||
lastDetailSignature = signature;
|
||||
lastDetailViewFingerprint = detailFingerprint(initialCapture);
|
||||
const reason = getAutoReviewReason(initialCapture, parsed);
|
||||
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
|
||||
if (reason) saveAutomaticReviewSample(initialCapture, parsed, `automatic:${reason}:initial-selection`);
|
||||
persistArtifactLater(initialCapture, parsed, "auto-scan-initial", needsReview);
|
||||
updateStats();
|
||||
appendAutomationLog(`initial selection parsed: ${parsed.name}`);
|
||||
if (stats.parsed >= maxTargets) {
|
||||
return finish({
|
||||
status: "done",
|
||||
stats,
|
||||
blockedReason: "",
|
||||
pageCount: 1,
|
||||
gridLabel: `Initial ausgewaehltes Artifact verarbeitet, Ziel ${maxTargets} Artifacts`,
|
||||
targetCount: maxTargets,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitCardReady() {
|
||||
return waitForCardReady(
|
||||
const startedAt = Date.now();
|
||||
const result = await waitForCardReady(
|
||||
{
|
||||
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, true)),
|
||||
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, false, {
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
})),
|
||||
wait,
|
||||
now: () => Date.now(),
|
||||
checkAbort: checkGuard,
|
||||
},
|
||||
lastDetailViewFingerprint,
|
||||
{ minStableSamples: CARD_READY_STABLE_SAMPLES, maxWaitMs: CARD_READY_MAX_MS, pollIntervalMs: CARD_READY_POLL_MS },
|
||||
{
|
||||
minStableSamples: CARD_READY_STABLE_SAMPLES,
|
||||
maxWaitMs: CARD_READY_MAX_MS,
|
||||
pollIntervalMs: CARD_READY_POLL_MS,
|
||||
acceptChangedAfterMs: CARD_READY_ACCEPT_CHANGED_AFTER_MS,
|
||||
},
|
||||
);
|
||||
addCardReadyTiming(stats, Date.now() - startedAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function awaitInventoryPageReady(previousFingerprint: string): Promise<{
|
||||
ready: Awaited<ReturnType<typeof waitForCardReady>>;
|
||||
capture: CaptureResult | null;
|
||||
}> {
|
||||
let latestCapture: CaptureResult | null = null;
|
||||
const startedAt = Date.now();
|
||||
const ready = await waitForCardReady(
|
||||
{
|
||||
sampleFingerprint: async () => {
|
||||
latestCapture = await captureFastSelectedSource(0, false, {
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
});
|
||||
return screenFingerprint(latestCapture);
|
||||
},
|
||||
wait,
|
||||
now: () => Date.now(),
|
||||
checkAbort: checkGuard,
|
||||
},
|
||||
previousFingerprint,
|
||||
{
|
||||
minStableSamples: SCROLL_READY_STABLE_SAMPLES,
|
||||
maxWaitMs: SCROLL_READY_MAX_MS,
|
||||
pollIntervalMs: SCROLL_READY_POLL_MS,
|
||||
acceptChangedAfterMs: SCROLL_READY_ACCEPT_CHANGED_AFTER_MS,
|
||||
},
|
||||
);
|
||||
addScrollReadyTiming(stats, Date.now() - startedAt);
|
||||
return { ready, capture: latestCapture };
|
||||
}
|
||||
|
||||
async function captureArtifactAfterClick(): Promise<{ capture: CaptureResult | null; fingerprint: string; abortReason: string }> {
|
||||
const capture = await captureSelectedSource(0, false, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile: "fast",
|
||||
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCropImages: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
return { capture, fingerprint: detailFingerprint(capture), abortReason: "" };
|
||||
}
|
||||
|
||||
function validateHotArtifactCapture(capture: CaptureResult | null) {
|
||||
const sourceRejection = captureSourceRejectionReason(capture);
|
||||
if (sourceRejection) return { ok: false, reason: sourceRejection };
|
||||
if (!capture?.artifactDetail?.present) {
|
||||
const confidence = capture?.artifactDetail ? ` (${capture.artifactDetail.confidence}% Detail-Marker)` : "";
|
||||
return { ok: false, reason: `Keine Artifact-Detailansicht erkannt${confidence}. Artifact-Inventar mit sichtbarer Detailkarte offen lassen.` };
|
||||
}
|
||||
return { ok: true, reason: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
|
||||
while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) {
|
||||
page++;
|
||||
stats.pages = page;
|
||||
updateStats();
|
||||
@@ -207,21 +462,23 @@ export async function runAutoScanLoop(
|
||||
cols: gridModel.cols,
|
||||
rows: Math.max(1, gridModel.rows - pageSkipRows),
|
||||
totalTargetCount: maxTargets,
|
||||
processedTargets: stats.clicked,
|
||||
processedTargets: stats.clicked + initialProcessedOffset,
|
||||
rowsQueued,
|
||||
});
|
||||
const targets = pagePlan.pageTargets;
|
||||
const targets = shouldSkipInitialGridTarget && page === 1
|
||||
? baseTargets.slice(initialProcessedOffset)
|
||||
: pagePlan.pageTargets;
|
||||
|
||||
if (targets.length === 0) {
|
||||
blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.clicked}/${maxTargets} geklickt.`);
|
||||
setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.parsed}/${maxTargets} gelesen.`);
|
||||
let newArtifactsOnPage = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
if (shouldStop() || stats.clicked >= maxTargets) break;
|
||||
if (shouldStop() || stats.parsed >= maxTargets) break;
|
||||
|
||||
const guardReason = await checkGuard();
|
||||
if (guardReason) {
|
||||
@@ -238,26 +495,33 @@ export async function runAutoScanLoop(
|
||||
break;
|
||||
}
|
||||
|
||||
if (clickResult.moved === false || clickResult.clicked === false) {
|
||||
// A structural failure (cursor could not be placed, or SendInput
|
||||
// was rejected outright) means clicks are not reaching Genshin at
|
||||
// all - almost always an elevation mismatch. Abort immediately
|
||||
// instead of clicking blindly through the rest of the inventory.
|
||||
blockedReason = clickResult.inputBlocked
|
||||
? "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten')."
|
||||
: `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
|
||||
if (clickResult.inputBlocked) {
|
||||
blockedReason = clickDeliveryFailureReason(target, clickResult);
|
||||
break;
|
||||
}
|
||||
if (reportedClickDeliveryFailure(clickResult)) {
|
||||
appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`);
|
||||
}
|
||||
|
||||
let ready = await awaitCardReady();
|
||||
if (ready.abortReason) {
|
||||
blockedReason = ready.abortReason;
|
||||
let read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
let changedDetail = ready.changed;
|
||||
let capture = read.capture;
|
||||
let currentDetailFingerprint = read.fingerprint;
|
||||
let changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint;
|
||||
|
||||
if (!changedDetail) {
|
||||
if (options.processInitialSelection && !initialSelectionDuplicateSkipped && !reportedClickDeliveryFailure(clickResult)) {
|
||||
initialSelectionDuplicateSkipped = true;
|
||||
consecutiveMisses = 0;
|
||||
stats.duplicates++;
|
||||
updateStats();
|
||||
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
|
||||
continue;
|
||||
}
|
||||
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
|
||||
clickResult = await clickTarget(target, "retry");
|
||||
stopReason = inputStopReason(clickResult);
|
||||
@@ -266,16 +530,37 @@ export async function runAutoScanLoop(
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
ready = await awaitCardReady();
|
||||
if (ready.abortReason) {
|
||||
blockedReason = ready.abortReason;
|
||||
if (clickResult.inputBlocked) {
|
||||
blockedReason = clickDeliveryFailureReason(target, clickResult);
|
||||
break;
|
||||
}
|
||||
if (reportedClickDeliveryFailure(clickResult)) {
|
||||
appendAutomationLog(`warn r${target.row} c${target.col}: retry helper reported cursor/click miss; verifying detail change`);
|
||||
}
|
||||
read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
changedDetail = ready.changed;
|
||||
capture = read.capture;
|
||||
currentDetailFingerprint = read.fingerprint;
|
||||
changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint;
|
||||
}
|
||||
|
||||
if (!changedDetail) {
|
||||
if (reportedClickDeliveryFailure(clickResult)) {
|
||||
blockedReason = clickDeliveryFailureReason(target, clickResult);
|
||||
break;
|
||||
}
|
||||
if (options.processInitialSelection && !initialSelectionDuplicateSkipped) {
|
||||
initialSelectionDuplicateSkipped = true;
|
||||
consecutiveMisses = 0;
|
||||
stats.duplicates++;
|
||||
updateStats();
|
||||
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
|
||||
continue;
|
||||
}
|
||||
stats.misses++;
|
||||
consecutiveMisses++;
|
||||
updateStats();
|
||||
@@ -289,8 +574,28 @@ export async function runAutoScanLoop(
|
||||
|
||||
stats.verified++;
|
||||
|
||||
const capture = await captureSelectedSource(0, true);
|
||||
let captureSurfaceRejection = validateHotArtifactCapture(capture);
|
||||
for (let retry = 1; !captureSurfaceRejection.ok && retry <= 2; retry++) {
|
||||
appendAutomationLog(`retry capture r${target.row} c${target.col}: ${captureSurfaceRejection.reason}`);
|
||||
await wait(120);
|
||||
read = await captureArtifactAfterClick();
|
||||
if (read.abortReason) {
|
||||
blockedReason = read.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
capture = read.capture;
|
||||
currentDetailFingerprint = read.fingerprint;
|
||||
captureSurfaceRejection = validateHotArtifactCapture(capture);
|
||||
}
|
||||
if (aborted) break;
|
||||
if (!captureSurfaceRejection.ok) {
|
||||
blockedReason = captureSurfaceRejection.reason;
|
||||
appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`);
|
||||
break;
|
||||
}
|
||||
if (capture?.ocrTimedOut) {
|
||||
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
|
||||
stats.misses++;
|
||||
consecutiveMisses++;
|
||||
lastDetailViewFingerprint = detailFingerprint(capture);
|
||||
@@ -303,11 +608,14 @@ export async function runAutoScanLoop(
|
||||
continue;
|
||||
}
|
||||
|
||||
const parseStartedAt = Date.now();
|
||||
const parsed = parseArtifact(capture);
|
||||
stats.parseMs += Math.max(0, Date.now() - parseStartedAt);
|
||||
const rejection = captureRejectionReason(capture, parsed);
|
||||
addCaptureTiming(stats, capture?.timings, capture?.elapsedMs);
|
||||
|
||||
if (rejection) {
|
||||
await saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`);
|
||||
saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`);
|
||||
if (parsed && shouldPersistParsedArtifact(parsed, true)) {
|
||||
consecutiveMisses = 0;
|
||||
const signature = sessionSignature(parsed);
|
||||
@@ -316,7 +624,7 @@ export async function runAutoScanLoop(
|
||||
lastDetailViewFingerprint = detailFingerprint(capture);
|
||||
seen.add(signature);
|
||||
newArtifactsOnPage++;
|
||||
if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++;
|
||||
persistArtifactLater(capture, parsed, "auto-scan-review", true);
|
||||
updateStats();
|
||||
continue;
|
||||
}
|
||||
@@ -375,9 +683,9 @@ export async function runAutoScanLoop(
|
||||
const reason = getAutoReviewReason(capture, parsed);
|
||||
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
|
||||
if (reason) {
|
||||
await saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`);
|
||||
saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`);
|
||||
}
|
||||
if (await persistParsedArtifact(capture, parsed, "auto-scan", needsReview)) stats.stored++;
|
||||
persistArtifactLater(capture, parsed, "auto-scan", needsReview);
|
||||
updateStats();
|
||||
}
|
||||
|
||||
@@ -386,12 +694,12 @@ export async function runAutoScanLoop(
|
||||
cols: gridModel.cols,
|
||||
rows: Math.max(1, gridModel.rows - pageSkipRows),
|
||||
totalTargetCount: maxTargets,
|
||||
processedTargets: stats.clicked,
|
||||
processedTargets: stats.clicked + initialProcessedOffset,
|
||||
rowsQueued,
|
||||
});
|
||||
rowsQueued = endOfPagePlan.rowsQueuedAfterPage;
|
||||
|
||||
if (aborted || stats.clicked >= maxTargets || shouldStop() || blockedReason) break;
|
||||
if (aborted || stats.parsed >= maxTargets || shouldStop() || blockedReason) break;
|
||||
|
||||
if (newArtifactsOnPage === 0 && page > 1) {
|
||||
blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`;
|
||||
@@ -425,16 +733,26 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
}
|
||||
|
||||
const scrollWaitStop = await waitDuringScan(760);
|
||||
if (scrollWaitStop) {
|
||||
blockedReason = scrollWaitStop;
|
||||
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
|
||||
const scrollReady = await awaitInventoryPageReady(beforeScrollFingerprint);
|
||||
if (scrollReady.ready.abortReason) {
|
||||
blockedReason = scrollReady.ready.abortReason;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
|
||||
currentCapture = await captureFastSelectedSource(0, true);
|
||||
const afterScrollFingerprint = screenFingerprint(currentCapture);
|
||||
const scrolledCapture = scrollReady.capture;
|
||||
if (!scrolledCapture) {
|
||||
blockedReason = "Keine Capture-Daten nach dem Scrollen.";
|
||||
break;
|
||||
}
|
||||
currentCapture = scrolledCapture;
|
||||
const scrolledSurfaceRejection = validateAutoScanEntryPreflight(scrolledCapture);
|
||||
if (!scrolledSurfaceRejection.ok) {
|
||||
blockedReason = scrolledSurfaceRejection.reason;
|
||||
break;
|
||||
}
|
||||
const afterScrollFingerprint = screenFingerprint(scrolledCapture);
|
||||
|
||||
if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) {
|
||||
blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert.";
|
||||
@@ -446,7 +764,7 @@ export async function runAutoScanLoop(
|
||||
break;
|
||||
}
|
||||
|
||||
const refreshedModel = buildGridModel(currentCapture?.inventoryGrid);
|
||||
const refreshedModel = buildGridModel(scrolledCapture.inventoryGrid);
|
||||
if (!refreshedModel) {
|
||||
blockedReason = "Kachel-Grid nach dem Scrollen verloren.";
|
||||
break;
|
||||
@@ -461,24 +779,27 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
|
||||
const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done";
|
||||
return {
|
||||
updateScanTiming(stats, startedAt);
|
||||
return finish({
|
||||
status,
|
||||
stats,
|
||||
blockedReason,
|
||||
pageCount: page,
|
||||
gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`,
|
||||
targetCount: maxTargets,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function detailFingerprint(capture: CaptureResult | null) {
|
||||
if (!capture) return "";
|
||||
if (capture.detailFingerprint) return capture.detailFingerprint;
|
||||
if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl);
|
||||
if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl);
|
||||
return "";
|
||||
}
|
||||
|
||||
export function screenFingerprint(capture: CaptureResult | null) {
|
||||
if (capture?.inventoryFingerprint) return capture.inventoryFingerprint;
|
||||
if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl);
|
||||
if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl);
|
||||
return "";
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("automationPlanner", () => {
|
||||
});
|
||||
|
||||
it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => {
|
||||
const targets = Array.from({ length: 40 }, (_, index) => ({
|
||||
const targets = Array.from({ length: 32 }, (_, index) => ({
|
||||
row: Math.floor(index / 8),
|
||||
col: index % 8,
|
||||
x: index * 10,
|
||||
@@ -52,31 +52,31 @@ describe("automationPlanner", () => {
|
||||
const firstPage = buildInventoryPagePlan({
|
||||
targets,
|
||||
cols: 8,
|
||||
rows: 5,
|
||||
totalTargetCount: 50,
|
||||
rows: 4,
|
||||
totalTargetCount: 45,
|
||||
processedTargets: 0,
|
||||
rowsQueued: 0,
|
||||
});
|
||||
expect(firstPage.pageTargets).toHaveLength(40);
|
||||
expect(firstPage.pageTargets).toHaveLength(32);
|
||||
expect(firstPage.startIndex).toBe(0);
|
||||
expect(firstPage.scrollRowsAfterPage).toBe(2);
|
||||
|
||||
const finalPage = buildInventoryPagePlan({
|
||||
targets,
|
||||
cols: 8,
|
||||
rows: 5,
|
||||
totalTargetCount: 50,
|
||||
processedTargets: 40,
|
||||
rowsQueued: 5,
|
||||
rows: 4,
|
||||
totalTargetCount: 45,
|
||||
processedTargets: 32,
|
||||
rowsQueued: 4,
|
||||
});
|
||||
expect(finalPage.pageTargets).toHaveLength(10);
|
||||
expect(finalPage.startIndex).toBe(24);
|
||||
expect(finalPage.pageTargets[0]).toMatchObject({ row: 3, col: 0 });
|
||||
expect(finalPage.pageTargets).toHaveLength(13);
|
||||
expect(finalPage.startIndex).toBe(16);
|
||||
expect(finalPage.pageTargets[0]).toMatchObject({ row: 2, col: 0 });
|
||||
expect(finalPage.scrollRowsAfterPage).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the first page top-aligned when the whole inventory fits inside one visible page", () => {
|
||||
const targets = Array.from({ length: 40 }, (_, index) => ({
|
||||
const targets = Array.from({ length: 32 }, (_, index) => ({
|
||||
row: Math.floor(index / 8),
|
||||
col: index % 8,
|
||||
x: index * 10,
|
||||
@@ -86,7 +86,7 @@ describe("automationPlanner", () => {
|
||||
const singlePage = buildInventoryPagePlan({
|
||||
targets,
|
||||
cols: 8,
|
||||
rows: 5,
|
||||
rows: 4,
|
||||
totalTargetCount: 16,
|
||||
processedTargets: 0,
|
||||
rowsQueued: 0,
|
||||
|
||||
@@ -46,6 +46,21 @@ describe("waitForCardReady", () => {
|
||||
expect(result.ready).toBe(true); // budget spent, but content did change
|
||||
});
|
||||
|
||||
it("proceeds with changed animated content after the configured minimum elapsed time", async () => {
|
||||
const animated = ["a1", "a2", "a3", "a4", "a5"];
|
||||
const { deps } = harness(animated, 90);
|
||||
const result = await waitForCardReady(deps, "old", {
|
||||
minStableSamples: 2,
|
||||
maxWaitMs: 900,
|
||||
pollIntervalMs: 90,
|
||||
acceptChangedAfterMs: 180,
|
||||
});
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.stable).toBe(false);
|
||||
expect(result.polls).toBe(3);
|
||||
});
|
||||
|
||||
it("reports not-ready when the detail never changes within budget", async () => {
|
||||
const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90);
|
||||
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 });
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface CardReadyOptions {
|
||||
maxWaitMs?: number;
|
||||
/** Delay between samples. */
|
||||
pollIntervalMs?: number;
|
||||
/** Proceed with changed-but-animated content after this much elapsed time. */
|
||||
acceptChangedAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface CardReadyDeps {
|
||||
@@ -53,6 +55,7 @@ export async function waitForCardReady(
|
||||
const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE);
|
||||
const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
|
||||
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS);
|
||||
const acceptChangedAfterMs = Math.max(0, options.acceptChangedAfterMs ?? maxWaitMs);
|
||||
|
||||
const start = deps.now();
|
||||
let previousSample = "";
|
||||
@@ -77,11 +80,13 @@ export async function waitForCardReady(
|
||||
const changed = Boolean(latest) && latest !== previousFingerprint;
|
||||
const stable = stableCount >= minStable;
|
||||
|
||||
if (changed && stable) {
|
||||
return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls };
|
||||
const elapsedMs = deps.now() - start;
|
||||
|
||||
if (changed && (stable || elapsedMs >= acceptChangedAfterMs)) {
|
||||
return { ready: true, changed: true, stable, fingerprint: latest, abortReason: "", polls };
|
||||
}
|
||||
|
||||
if (deps.now() - start >= maxWaitMs) {
|
||||
if (elapsedMs >= maxWaitMs) {
|
||||
// Budget spent. Proceed if the content has at least changed, even if it is
|
||||
// still animating (never fully stabilizes).
|
||||
return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls };
|
||||
|
||||
@@ -27,6 +27,27 @@ type GenshinGameDataContract = typeof gameData & {
|
||||
pieceAliases?: Record<string, string>;
|
||||
characterAliases?: Record<string, string>;
|
||||
};
|
||||
lookup?: {
|
||||
normalizedKeys?: {
|
||||
sets?: Record<string, string>;
|
||||
pieces?: Record<string, string>;
|
||||
slots?: Record<string, string>;
|
||||
stats?: Record<string, string>;
|
||||
characters?: Record<string, string>;
|
||||
};
|
||||
goodKeys?: {
|
||||
sets?: Record<string, string>;
|
||||
pieces?: Record<string, string>;
|
||||
stats?: Record<string, string>;
|
||||
characters?: Record<string, string>;
|
||||
};
|
||||
setToPieces?: Record<string, string[]>;
|
||||
validation?: {
|
||||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
};
|
||||
mainStatsBySlot?: Record<string, string[]>;
|
||||
mainStatValueReferences?: Record<string, Array<{ stat: string; base: number; max: number }>>;
|
||||
characters?: Character[];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchPiece, matchSet, matchSlot, matchStat, validateLookupPackage } from "./genshinLookup";
|
||||
import { genshinGameData } from "./genshinData";
|
||||
|
||||
describe("genshinLookup", () => {
|
||||
it("matches canonical, alias, and fuzzy artifact values", () => {
|
||||
expect(matchSlot("Sands of Eon Vi")).toMatchObject({ value: "Sands of Eon", source: "alias" });
|
||||
expect(matchSet("Viridescent Venere")).toMatchObject({ value: "Viridescent Venerer", source: "alias" });
|
||||
expect(matchPiece("Viridescent Venerers Determination").value).toBe("Viridescent Venerer's Determination");
|
||||
expect(matchStat("Crit Damage")).toMatchObject({ value: "CRIT DMG", source: "alias" });
|
||||
});
|
||||
|
||||
it("validates the bundled lookup package", () => {
|
||||
const status = validateLookupPackage();
|
||||
expect(status.valid).toBe(true);
|
||||
expect(status.errors).toEqual([]);
|
||||
expect(status.summary.artifactPieces).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects missing set references", () => {
|
||||
const broken = {
|
||||
...genshinGameData,
|
||||
artifactPieces: [{ name: "Broken Piece", setName: "Missing Set", slot: "Flower of Life", relicType: "EQUIP_BRACER" }],
|
||||
};
|
||||
const status = validateLookupPackage(broken as unknown as typeof genshinGameData);
|
||||
expect(status.valid).toBe(false);
|
||||
expect(status.errors.join("\n")).toContain("Missing Set");
|
||||
});
|
||||
|
||||
it("rejects duplicate GOOD set keys", () => {
|
||||
const broken = {
|
||||
...genshinGameData,
|
||||
lookup: {
|
||||
...genshinGameData.lookup,
|
||||
goodKeys: {
|
||||
...genshinGameData.lookup?.goodKeys,
|
||||
sets: {
|
||||
one: "Duplicate",
|
||||
two: "Duplicate",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const status = validateLookupPackage(broken as unknown as typeof genshinGameData);
|
||||
expect(status.valid).toBe(false);
|
||||
expect(status.errors.join("\n")).toContain("Duplicate GOOD set key");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { simplifyForMatch } from "./fuzzyMatch.js";
|
||||
import {
|
||||
artifactPieces,
|
||||
characterAliases,
|
||||
genshinGameData,
|
||||
globalMainStats,
|
||||
globalSubstats,
|
||||
knownCharacters,
|
||||
knownSets,
|
||||
mainStatsBySlot,
|
||||
pieceAliases,
|
||||
setAliases,
|
||||
slotAliases,
|
||||
slotNames,
|
||||
statAliases,
|
||||
} from "./genshinData.js";
|
||||
|
||||
export type LookupMatchSource = "exact" | "alias" | "fuzzy" | "missing";
|
||||
|
||||
export interface LookupMatch {
|
||||
value: string;
|
||||
confidence: number;
|
||||
source: LookupMatchSource;
|
||||
}
|
||||
|
||||
export interface LookupValidationStatus {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
summary: {
|
||||
artifactSets: number;
|
||||
artifactPieces: number;
|
||||
characters: number;
|
||||
stats: number;
|
||||
generatedAt: string;
|
||||
sourceVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
type LookupData = typeof genshinGameData & {
|
||||
lookup?: {
|
||||
normalizedKeys?: {
|
||||
sets?: Record<string, string>;
|
||||
pieces?: Record<string, string>;
|
||||
slots?: Record<string, string>;
|
||||
stats?: Record<string, string>;
|
||||
characters?: Record<string, string>;
|
||||
};
|
||||
goodKeys?: {
|
||||
sets?: Record<string, string>;
|
||||
pieces?: Record<string, string>;
|
||||
stats?: Record<string, string>;
|
||||
characters?: Record<string, string>;
|
||||
};
|
||||
setToPieces?: Record<string, string[]>;
|
||||
validation?: {
|
||||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const lookupData = genshinGameData as LookupData;
|
||||
|
||||
const normalizedSets = lookupData.lookup?.normalizedKeys?.sets ?? buildNormalizedMap(knownSets);
|
||||
const normalizedPieces = lookupData.lookup?.normalizedKeys?.pieces ?? buildNormalizedMap(artifactPieces.map((piece) => piece.name));
|
||||
const normalizedSlots = lookupData.lookup?.normalizedKeys?.slots ?? buildNormalizedMap(slotNames);
|
||||
const normalizedStats = lookupData.lookup?.normalizedKeys?.stats ?? buildNormalizedMap([...globalMainStats, ...globalSubstats]);
|
||||
const normalizedCharacters = lookupData.lookup?.normalizedKeys?.characters ?? buildNormalizedMap(knownCharacters);
|
||||
|
||||
export function normalizeLookupKey(value: string) {
|
||||
return simplifyForMatch(value).replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
export function matchPiece(raw: string, minConfidence = 0.72): LookupMatch {
|
||||
return matchLookup(raw, normalizedPieces, pieceAliases, minConfidence);
|
||||
}
|
||||
|
||||
export function matchSet(raw: string, minConfidence = 0.72): LookupMatch {
|
||||
return matchLookup(raw, normalizedSets, setAliases, minConfidence);
|
||||
}
|
||||
|
||||
export function matchSlot(raw: string, minConfidence = 0.72): LookupMatch {
|
||||
return matchLookup(raw, normalizedSlots, slotAliases, minConfidence);
|
||||
}
|
||||
|
||||
export function matchStat(raw: string, minConfidence = 0.72): LookupMatch {
|
||||
return matchLookup(raw, normalizedStats, statAliases, minConfidence);
|
||||
}
|
||||
|
||||
export function matchCharacter(raw: string, minConfidence = 0.72): LookupMatch {
|
||||
return matchLookup(raw, normalizedCharacters, characterAliases, minConfidence);
|
||||
}
|
||||
|
||||
export function validateLookupPackage(data: LookupData = lookupData): LookupValidationStatus {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const pieces = data.artifactPieces ?? artifactPieces;
|
||||
const sets = data.artifactSets ?? [];
|
||||
const characters = data.characters ?? [];
|
||||
const slots = data.slots ?? slotNames;
|
||||
const stats = data.stats?.main ?? data.mainStats ?? [];
|
||||
const setNames = new Set(sets.map((set) => set.name).filter(Boolean));
|
||||
const slotNameSet = new Set(slots);
|
||||
|
||||
if (!sets.length) errors.push("No artifact sets in lookup package.");
|
||||
if (!pieces.length) errors.push("No artifact pieces in lookup package.");
|
||||
if (!characters.length) warnings.push("No characters in lookup package.");
|
||||
|
||||
for (const piece of pieces) {
|
||||
if (!piece.name) errors.push("Artifact piece without name.");
|
||||
if (!piece.setName || !setNames.has(piece.setName)) errors.push(`Piece "${piece.name}" references missing set "${piece.setName}".`);
|
||||
if (!piece.slot || !slotNameSet.has(piece.slot)) errors.push(`Piece "${piece.name}" references missing slot "${piece.slot}".`);
|
||||
}
|
||||
|
||||
const goodSets = Object.values(data.lookup?.goodKeys?.sets ?? {});
|
||||
const duplicateGoodSet = firstDuplicate(goodSets);
|
||||
if (duplicateGoodSet) errors.push(`Duplicate GOOD set key "${duplicateGoodSet}".`);
|
||||
|
||||
for (const [alias, target] of Object.entries(data.aliases?.stats ?? {})) {
|
||||
if (!stats.includes(target) && !(data.stats?.sub ?? data.substats ?? []).includes(target)) {
|
||||
errors.push(`Stat alias "${alias}" targets unknown stat "${target}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const generatedAt = typeof data.generatedAt === "string" ? data.generatedAt : "";
|
||||
const sourceVersion = typeof data.sourceVersion === "string" ? data.sourceVersion : "unknown";
|
||||
if (!generatedAt) warnings.push("Lookup package has no generatedAt timestamp.");
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
summary: {
|
||||
artifactSets: sets.length,
|
||||
artifactPieces: pieces.length,
|
||||
characters: characters.length,
|
||||
stats: stats.length,
|
||||
generatedAt,
|
||||
sourceVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matchLookup(
|
||||
raw: string,
|
||||
normalized: Record<string, string>,
|
||||
aliases: Record<string, string>,
|
||||
minConfidence: number,
|
||||
): LookupMatch {
|
||||
const key = normalizeLookupKey(raw);
|
||||
if (!key) return missingMatch();
|
||||
|
||||
const alias = Object.entries(aliases).find(([from]) => normalizeLookupKey(from) === key);
|
||||
if (alias) return { value: alias[1], confidence: 96, source: "alias" };
|
||||
|
||||
if (normalized[key]) return { value: normalized[key], confidence: 100, source: "exact" };
|
||||
|
||||
let bestValue = "";
|
||||
let bestScore = 0;
|
||||
for (const [candidateKey, value] of Object.entries(normalized)) {
|
||||
const score = similarity(key, candidateKey);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore >= minConfidence
|
||||
? { value: bestValue, confidence: Math.round(bestScore * 100), source: "fuzzy" }
|
||||
: missingMatch();
|
||||
}
|
||||
|
||||
function missingMatch(): LookupMatch {
|
||||
return { value: "", confidence: 0, source: "missing" };
|
||||
}
|
||||
|
||||
function buildNormalizedMap(values: string[]) {
|
||||
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
|
||||
}
|
||||
|
||||
function firstDuplicate(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function similarity(a: string, b: string) {
|
||||
if (a === b) return 1;
|
||||
if (!a || !b) return 0;
|
||||
const distance = levenshtein(a, b);
|
||||
return 1 - distance / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string) {
|
||||
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
||||
const current = new Array<number>(b.length + 1);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
current[0] = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return previous[b.length];
|
||||
}
|
||||
@@ -46,13 +46,17 @@ describe("layoutProfile", () => {
|
||||
expect(profileDetailRect(HD)).toEqual({ x: 1308, y: 120, width: 492, height: 838 });
|
||||
});
|
||||
|
||||
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
|
||||
it("produces the split artifact crops in top-to-bottom order, all clamped", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const crops = detailCropRects(detail, QHD);
|
||||
expect(crops.map((crop) => crop.id)).toEqual([
|
||||
"artifact-title",
|
||||
"artifact-main-stat",
|
||||
"artifact-name",
|
||||
"artifact-slot",
|
||||
"artifact-main-stat-label",
|
||||
"artifact-main-stat-value",
|
||||
"artifact-level",
|
||||
"artifact-substats",
|
||||
"artifact-set-effects",
|
||||
"artifact-footer",
|
||||
]);
|
||||
let previousY = -1;
|
||||
@@ -65,6 +69,28 @@ describe("layoutProfile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("matches Inventory Kamera-like 1080p artifact field crops", () => {
|
||||
const detail = profileDetailRect(HD);
|
||||
const crops = Object.fromEntries(detailCropRects(detail, HD).map((crop) => [crop.id, crop.rect]));
|
||||
expect(crops["artifact-slot"]).toEqual({ x: 1328, y: 185, width: 234, height: 40 });
|
||||
expect(crops["artifact-main-stat-label"]).toEqual({ x: 1328, y: 264, width: 224, height: 35 });
|
||||
expect(crops["artifact-level"]).toEqual({ x: 1333, y: 425, width: 70, height: 35 });
|
||||
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 193 });
|
||||
});
|
||||
|
||||
it("uses Inventory Kamera's tighter substat crop in the fast auto-scan profile", () => {
|
||||
const detail = profileDetailRect(HD);
|
||||
const crops = Object.fromEntries(detailCropRects(detail, HD, { fastProfile: true }).map((crop) => [crop.id, crop.rect]));
|
||||
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 154 });
|
||||
});
|
||||
|
||||
it("shifts level and substat crops for sanctified artifacts like Inventory Kamera", () => {
|
||||
const detail = profileDetailRect(HD);
|
||||
const crops = Object.fromEntries(detailCropRects(detail, HD, { sanctified: true }).map((crop) => [crop.id, crop.rect]));
|
||||
expect(crops["artifact-level"]).toEqual({ x: 1333, y: 468, width: 70, height: 35 });
|
||||
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 517, width: 408, height: 193 });
|
||||
});
|
||||
|
||||
it("places the inventory count crop inside the inventory panel", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const inv = inventoryRect(QHD, detail);
|
||||
@@ -77,9 +103,9 @@ describe("layoutProfile", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const grid = inventoryGrid(QHD, detail);
|
||||
expect(grid.cols).toBe(8);
|
||||
expect(grid.rows).toBe(5);
|
||||
expect(grid.rows).toBe(4);
|
||||
expect(grid.source).toBe("detected");
|
||||
expect(grid.centers).toHaveLength(40);
|
||||
expect(grid.centers).toHaveLength(32);
|
||||
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -88,7 +114,7 @@ describe("layoutProfile", () => {
|
||||
const grid = inventoryGrid(HD, detail);
|
||||
expect(grid.centers[0]).toEqual({ x: 179, y: 254, row: 0, col: 0 });
|
||||
expect(grid.centers[7]).toEqual({ x: 1201, y: 254, row: 0, col: 7 });
|
||||
expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 958, row: 4, col: 7 });
|
||||
expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 782, row: 3, col: 7 });
|
||||
});
|
||||
|
||||
it("reports a missing grid when the inventory panel is too small", () => {
|
||||
|
||||
+67
-15
@@ -90,12 +90,21 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
|
||||
);
|
||||
}
|
||||
|
||||
// The four OCR crops inside the detail panel, as fractions of the detail rect.
|
||||
export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] {
|
||||
// OCR crops inside the detail panel, as fractions of the detail rect. These are
|
||||
// deliberately closer to Inventory Kamera's split-card model than the older
|
||||
// broad crops: small fields get small OCR profiles, while the legacy parser
|
||||
// still accepts old review samples with artifact-title/artifact-main-stat.
|
||||
export function detailCropRects(
|
||||
detailRect: LayoutRect,
|
||||
imageSize: { width: number; height: number },
|
||||
options: { sanctified?: boolean; fastProfile?: boolean } = {},
|
||||
): CropTemplateRect[] {
|
||||
const sanctifiedShift = options.sanctified ? 0.0520 : 0;
|
||||
const substatsHeight = options.fastProfile ? 0.1841 : 0.2301;
|
||||
const templates: CropTemplateRect[] = [
|
||||
{
|
||||
id: "artifact-title",
|
||||
label: "Artifact title",
|
||||
id: "artifact-name",
|
||||
label: "Artifact name",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x),
|
||||
y: Math.round(detailRect.y),
|
||||
@@ -104,33 +113,73 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-main-stat",
|
||||
label: "Main stat",
|
||||
id: "artifact-slot",
|
||||
label: "Artifact slot",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.0405),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.0772),
|
||||
width: Math.round(detailRect.width * 0.4757),
|
||||
height: Math.round(detailRect.height * 0.0475),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-main-stat-label",
|
||||
label: "Main stat label",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.0405),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.1722),
|
||||
width: Math.round(detailRect.width * 0.4555),
|
||||
height: Math.round(detailRect.height * 0.0416),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-main-stat-value",
|
||||
label: "Main stat value",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.075),
|
||||
width: Math.round(detailRect.width * 0.58),
|
||||
height: Math.round(detailRect.height * 0.26),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.205),
|
||||
width: Math.round(detailRect.width * 0.42),
|
||||
height: Math.round(detailRect.height * 0.105),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-level",
|
||||
label: "Artifact level",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.0506),
|
||||
y: Math.round(detailRect.y + detailRect.height * (0.3634 + sanctifiedShift)),
|
||||
width: Math.round(detailRect.width * 0.1417),
|
||||
height: Math.round(detailRect.height * 0.0416),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-substats",
|
||||
label: "Substats",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.0605),
|
||||
y: Math.round(detailRect.y + detailRect.height * (0.4216 + sanctifiedShift)),
|
||||
width: Math.round(detailRect.width * 0.8297),
|
||||
height: Math.round(detailRect.height * substatsHeight),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-set-effects",
|
||||
label: "Set effects",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.34),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.655),
|
||||
width: Math.round(detailRect.width * 0.86),
|
||||
height: Math.round(detailRect.height * 0.27),
|
||||
height: Math.round(detailRect.height * 0.16),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "artifact-footer",
|
||||
label: "Footer",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.82),
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.15),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.938),
|
||||
width: Math.round(detailRect.width * 0.86),
|
||||
height: Math.round(detailRect.height * 0.14),
|
||||
height: Math.round(detailRect.height * 0.06),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -173,7 +222,10 @@ export function inventoryGrid(imageSize: { width: number; height: number }, deta
|
||||
|
||||
const stepX = Math.round(imageSize.width * 0.076);
|
||||
const stepY = Math.round(imageSize.height * 0.163);
|
||||
const visibleRows = 5;
|
||||
// Inventory Kamera treats the artifact inventory as 32 safe click targets per
|
||||
// full page. The apparent fifth row sits in the bottom control band on 16:9
|
||||
// captures and is not a reliable target during automated scrolling.
|
||||
const visibleRows = 4;
|
||||
|
||||
const startX = Math.round(imageSize.width * 0.093);
|
||||
const startY = Math.round(imageSize.height * 0.235);
|
||||
|
||||
@@ -18,6 +18,18 @@ function bitmap(goldPixels: number, total: number): Bitmap {
|
||||
return { data, width: total, height: 1 };
|
||||
}
|
||||
|
||||
function solidPixels(pixels: Array<{ b: number; g: number; r: number }>): Bitmap {
|
||||
const data = Buffer.alloc(pixels.length * 4);
|
||||
pixels.forEach((pixel, index) => {
|
||||
const offset = index * 4;
|
||||
data[offset] = pixel.b;
|
||||
data[offset + 1] = pixel.g;
|
||||
data[offset + 2] = pixel.r;
|
||||
data[offset + 3] = 255;
|
||||
});
|
||||
return { data, width: pixels.length, height: 1 };
|
||||
}
|
||||
|
||||
describe("lockDetection", () => {
|
||||
it("places the lock crop on the lock button in the substat panel", () => {
|
||||
const size = { width: 2560, height: 1440 };
|
||||
@@ -41,4 +53,13 @@ describe("lockDetection", () => {
|
||||
expect(detectLockState(bitmap(20, 100))).toBe(true);
|
||||
expect(detectLockState(bitmap(1, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts the current red lock glyph but ignores grey unlocked button pixels", () => {
|
||||
expect(lockSignalRatio(solidPixels([{ b: 90, g: 92, r: 235 }]))).toBe(1);
|
||||
expect(lockSignalRatio({ data: Buffer.from([235, 92, 90, 255]), width: 1, height: 1 })).toBe(1);
|
||||
expect(lockSignalRatio({ data: Buffer.from([255, 235, 92, 90]), width: 1, height: 1 })).toBe(1);
|
||||
expect(lockSignalRatio(solidPixels([{ b: 235, g: 235, r: 235 }]))).toBe(0);
|
||||
expect(lockSignalRatio({ data: Buffer.from([255, 235, 235, 235]), width: 1, height: 1 })).toBe(0);
|
||||
expect(lockSignalRatio(solidPixels([{ b: 120, g: 122, r: 128 }]))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,15 @@ import { clampRect, type LayoutRect } from "./layoutProfile.js";
|
||||
import type { Bitmap } from "./ocrPreprocess.js";
|
||||
|
||||
// EXPERIMENTAL, read-only lock-status detection (nice-to-have). Genshin shows a
|
||||
// padlock at the top-right of the artifact detail card: a bright gold fill when
|
||||
// locked, a dim outline when not. This estimates that icon region and measures
|
||||
// the fraction of bright "lock-gold" pixels; above a threshold the piece is
|
||||
// considered locked.
|
||||
// padlock at the top-right of the artifact detail card: a highlighted red/pink
|
||||
// lock in the current UI when locked, and a dim grey/white button when not.
|
||||
// Older UI captures may still use gold highlights. This estimates that icon
|
||||
// region and measures the fraction of active lock-colour pixels; above a
|
||||
// threshold the piece is considered locked.
|
||||
//
|
||||
// The crop position and threshold need calibration against a reference 16:9
|
||||
// screenshot before this is wired into the capture pipeline, so it ships pure and
|
||||
// unit-tested but unused by main.ts. It never drives any in-game action - it only
|
||||
// reads state for triage.
|
||||
// The crop position and threshold were validated with unlocked=false and
|
||||
// locked=true live samples on 2026-07-09. It never drives any in-game action -
|
||||
// it only reads state for triage and export.
|
||||
|
||||
export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||
return clampRect(
|
||||
@@ -29,14 +29,52 @@ function isLockGold(b: number, g: number, r: number): boolean {
|
||||
return r >= 180 && g >= 140 && b <= 120 && r > b + 40 && g > b + 20;
|
||||
}
|
||||
|
||||
// Current Genshin detail lock indicator: pink/red lock glyph and dark button
|
||||
// when the selected artifact is locked. Unlocked buttons are mostly grey/white.
|
||||
function isLockRed(b: number, g: number, r: number): boolean {
|
||||
return r >= 180 && g <= 145 && b <= 145 && r > g + 35 && r > b + 35;
|
||||
}
|
||||
|
||||
function isActiveLockPixel(c0: number, c1: number, c2: number): boolean {
|
||||
const colorMatches = (left: number, middle: number, right: number) =>
|
||||
isLockGold(left, middle, right) ||
|
||||
isLockRed(left, middle, right) ||
|
||||
isLockGold(right, middle, left) ||
|
||||
isLockRed(right, middle, left);
|
||||
return colorMatches(c0, c1, c2);
|
||||
}
|
||||
|
||||
function inferAlphaChannel(data: Buffer | Uint8Array, pixels: number): number | null {
|
||||
const highCounts = [0, 0, 0, 0];
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const index = pixel * 4;
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
if (data[index + channel] >= 245) highCounts[channel]++;
|
||||
}
|
||||
}
|
||||
const ranked = highCounts
|
||||
.map((count, channel) => ({ count, channel }))
|
||||
.sort((left, right) => right.count - left.count);
|
||||
const best = ranked[0];
|
||||
const second = ranked[1];
|
||||
if (best.count / pixels < 0.9) return null;
|
||||
if (second && second.count / pixels > 0.8) return null;
|
||||
return best.channel;
|
||||
}
|
||||
|
||||
export function lockSignalRatio(bitmap: Bitmap): number {
|
||||
const { data, width, height } = bitmap;
|
||||
const pixels = width * height;
|
||||
if (pixels === 0) return 0;
|
||||
const alphaChannel = inferAlphaChannel(data, pixels);
|
||||
let gold = 0;
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const index = pixel * 4;
|
||||
if (isLockGold(data[index], data[index + 1], data[index + 2])) gold++;
|
||||
const colorChannels = [0, 1, 2, 3]
|
||||
.filter((channel) => channel !== alphaChannel)
|
||||
.map((channel) => data[index + channel])
|
||||
.slice(0, 3);
|
||||
if (colorChannels.length === 3 && isActiveLockPixel(colorChannels[0], colorChannels[1], colorChannels[2])) gold++;
|
||||
}
|
||||
return gold / pixels;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@ describe("ocrPreprocess", () => {
|
||||
expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127);
|
||||
});
|
||||
|
||||
it("can increase contrast before histogramming small numeric crops", () => {
|
||||
const bitmap = bitmapFrom([
|
||||
[118, 118, 118],
|
||||
[138, 138, 138],
|
||||
], 2, 1);
|
||||
const normal = computeLuminanceHistogram(bitmap);
|
||||
const contrasted = computeLuminanceHistogram(bitmap, { contrast: 80 });
|
||||
const normalValues = normal.flatMap((count, value) => Array.from({ length: count }, () => value));
|
||||
const contrastedValues = contrasted.flatMap((count, value) => Array.from({ length: count }, () => value));
|
||||
expect(Math.max(...contrastedValues) - Math.min(...contrastedValues)).toBeGreaterThan(Math.max(...normalValues) - Math.min(...normalValues));
|
||||
});
|
||||
|
||||
it("inverts bright foreground to black-on-white by default", () => {
|
||||
// Bright text pixel + dark background pixel.
|
||||
const bitmap = bitmapFrom([
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface BinarizeOptions {
|
||||
invertBrightForeground?: boolean;
|
||||
/** Override Otsu with a fixed 0-255 luminance threshold. */
|
||||
threshold?: number;
|
||||
/** Optional contrast adjustment in the usual -255..255 image-processing range. */
|
||||
contrast?: number;
|
||||
}
|
||||
|
||||
const BYTES_PER_PIXEL = 4;
|
||||
@@ -35,12 +37,19 @@ function luminanceAt(data: Uint8Array | Buffer, index: number): number {
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
|
||||
export function computeLuminanceHistogram(bitmap: Bitmap): number[] {
|
||||
function adjustContrast(value: number, contrast = 0) {
|
||||
if (!contrast) return value;
|
||||
const safeContrast = Math.max(-255, Math.min(255, contrast));
|
||||
const factor = (259 * (safeContrast + 255)) / (255 * (259 - safeContrast));
|
||||
return Math.max(0, Math.min(255, factor * (value - 128) + 128));
|
||||
}
|
||||
|
||||
export function computeLuminanceHistogram(bitmap: Bitmap, options: Pick<BinarizeOptions, "contrast"> = {}): number[] {
|
||||
const histogram = new Array<number>(256).fill(0);
|
||||
const { data, width, height } = bitmap;
|
||||
const pixels = width * height;
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const value = Math.round(luminanceAt(data, pixel * BYTES_PER_PIXEL));
|
||||
const value = Math.round(adjustContrast(luminanceAt(data, pixel * BYTES_PER_PIXEL), options.contrast));
|
||||
histogram[Math.max(0, Math.min(255, value))]++;
|
||||
}
|
||||
return histogram;
|
||||
@@ -82,13 +91,13 @@ export function otsuThreshold(histogram: readonly number[]): number {
|
||||
export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap {
|
||||
const { data, width, height } = bitmap;
|
||||
const invert = options.invertBrightForeground ?? true;
|
||||
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap));
|
||||
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap, options));
|
||||
|
||||
const output = Buffer.alloc(width * height * BYTES_PER_PIXEL);
|
||||
const pixels = width * height;
|
||||
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||
const index = pixel * BYTES_PER_PIXEL;
|
||||
const isBright = luminanceAt(data, index) > threshold;
|
||||
const isBright = adjustContrast(luminanceAt(data, index), options.contrast) > threshold;
|
||||
// Bright foreground text -> black; dark background -> white (inverted).
|
||||
const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0);
|
||||
output[index] = value;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { detailFingerprint } from "./autoScanLoop";
|
||||
import type { CaptureResult, ClickResult, KeyPressResult } from "../types/global";
|
||||
|
||||
export type ScanDiagnosticSeverity = "info" | "ok" | "warn" | "error";
|
||||
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
|
||||
type InventoryCount = NonNullable<CaptureResult["inventoryCount"]>;
|
||||
|
||||
export interface ScanDiagnosticEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
phase: string;
|
||||
severity: ScanDiagnosticSeverity;
|
||||
message: string;
|
||||
details?: Record<string, string | number | boolean | null | undefined>;
|
||||
capture?: {
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
capturedAt: string;
|
||||
target?: CaptureResult["captureTarget"];
|
||||
fingerprint: string;
|
||||
inventoryFingerprint?: string;
|
||||
layoutWarning?: string;
|
||||
grid?: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
confidence: number;
|
||||
source: InventoryGrid["source"];
|
||||
targets: number;
|
||||
};
|
||||
count?: {
|
||||
current: number;
|
||||
total: number;
|
||||
confidence: number;
|
||||
source: InventoryCount["source"];
|
||||
text: string;
|
||||
};
|
||||
artifactDetail?: NonNullable<CaptureResult["artifactDetail"]>;
|
||||
paimonMenu?: NonNullable<CaptureResult["paimonMenu"]>;
|
||||
sanctified?: boolean;
|
||||
screenshots?: {
|
||||
detail?: string;
|
||||
inventory?: string;
|
||||
full?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createScanDiagnosticEvent(input: {
|
||||
phase: string;
|
||||
severity?: ScanDiagnosticSeverity;
|
||||
message: string;
|
||||
details?: ScanDiagnosticEvent["details"];
|
||||
capture?: CaptureResult | null;
|
||||
includeFullScreenshot?: boolean;
|
||||
}): ScanDiagnosticEvent {
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
at: new Date().toISOString(),
|
||||
phase: input.phase,
|
||||
severity: input.severity ?? "info",
|
||||
message: input.message,
|
||||
details: input.details,
|
||||
capture: input.capture ? summarizeCapture(input.capture, Boolean(input.includeFullScreenshot)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeClickResult(result: ClickResult) {
|
||||
return {
|
||||
ok: result.ok,
|
||||
moved: result.moved,
|
||||
clicked: result.clicked,
|
||||
inputBlocked: result.inputBlocked,
|
||||
focused: result.focused,
|
||||
cursor: `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`,
|
||||
foreground: result.foregroundProcess,
|
||||
target: result.targetProcess,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeKeyPressResult(result: KeyPressResult | null | undefined) {
|
||||
return {
|
||||
ok: Boolean(result?.ok),
|
||||
key: result?.key,
|
||||
inputBlocked: Boolean(result?.inputBlocked),
|
||||
focused: Boolean(result?.focused),
|
||||
eventsSent: result?.eventsSent ?? 0,
|
||||
foreground: result?.foregroundProcess,
|
||||
target: result?.targetProcess,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCapture(capture: CaptureResult, includeFullScreenshot: boolean): NonNullable<ScanDiagnosticEvent["capture"]> {
|
||||
return {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
capturedAt: capture.capturedAt,
|
||||
target: capture.captureTarget,
|
||||
fingerprint: detailFingerprint(capture),
|
||||
inventoryFingerprint: capture.inventoryFingerprint,
|
||||
layoutWarning: capture.layout?.warning || undefined,
|
||||
grid: capture.inventoryGrid
|
||||
? {
|
||||
rows: capture.inventoryGrid.rows,
|
||||
cols: capture.inventoryGrid.cols,
|
||||
confidence: capture.inventoryGrid.confidence,
|
||||
source: capture.inventoryGrid.source,
|
||||
targets: capture.inventoryGrid.centers.length,
|
||||
}
|
||||
: undefined,
|
||||
count: capture.inventoryCount
|
||||
? {
|
||||
current: capture.inventoryCount.current,
|
||||
total: capture.inventoryCount.total,
|
||||
confidence: capture.inventoryCount.confidence,
|
||||
source: capture.inventoryCount.source,
|
||||
text: capture.inventoryCount.text,
|
||||
}
|
||||
: undefined,
|
||||
artifactDetail: capture.artifactDetail,
|
||||
paimonMenu: capture.paimonMenu,
|
||||
sanctified: capture.sanctified,
|
||||
screenshots: {
|
||||
detail: capture.detailDataUrl,
|
||||
inventory: capture.inventoryDataUrl,
|
||||
full: includeFullScreenshot ? capture.dataUrl : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
import type { ParsedArtifactCandidate, ParsedField } from "./artifactOcrParser";
|
||||
import { getAutoReviewReason } from "./scanReviewUtils";
|
||||
|
||||
function field(value: string): ParsedField {
|
||||
return { value, confidence: 95, source: "ocr" };
|
||||
}
|
||||
|
||||
describe("getAutoReviewReason", () => {
|
||||
it("does not require a detail preview image for clean automatic scan captures", () => {
|
||||
const capture: CaptureResult = {
|
||||
id: "window:test",
|
||||
name: "Genshin",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date(0).toISOString(),
|
||||
crops: [{ id: "artifact-name", label: "Artifact name", rect: { x: 0, y: 0, width: 10, height: 10 } }],
|
||||
ocr: [{ id: "artifact-name", label: "Artifact name", text: "Gladiator's Nostalgia", confidence: 95 }],
|
||||
};
|
||||
const parsed: ParsedArtifactCandidate = {
|
||||
name: "Gladiator's Nostalgia",
|
||||
slot: "Flower of Life",
|
||||
level: 20,
|
||||
mainStat: "HP",
|
||||
mainValue: "4780",
|
||||
substats: ["CRIT Rate", "CRIT DMG", "Energy Recharge", "ATK%"],
|
||||
setName: "Gladiator's Finale",
|
||||
equipped: "",
|
||||
confidence: 95,
|
||||
notes: [],
|
||||
fields: {
|
||||
name: field("Gladiator's Nostalgia"),
|
||||
slot: field("Flower of Life"),
|
||||
level: field("20"),
|
||||
mainStat: field("HP"),
|
||||
mainValue: field("4780"),
|
||||
setName: field("Gladiator's Finale"),
|
||||
equipped: field(""),
|
||||
substats: field("CRIT Rate, CRIT DMG, Energy Recharge, ATK%"),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAutoReviewReason(capture, parsed)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export interface ReviewReasonInput {
|
||||
}
|
||||
|
||||
export function getAutoReviewReason(capture: ReviewReasonInput["capture"], parsed: ReviewReasonInput["parsed"]) {
|
||||
if (!capture.detailDataUrl || !capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr";
|
||||
if (!capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr";
|
||||
if (!shouldSaveReviewSample(parsed)) return "";
|
||||
const lowFields = Object.entries(parsed.fields)
|
||||
.filter(([, field]) => field.confidence < 70)
|
||||
|
||||
@@ -23,6 +23,25 @@ describe("scannerLearning", () => {
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Energy Recharge+6.5%");
|
||||
});
|
||||
|
||||
it("applies field aliases and constrained fixes before parsing", () => {
|
||||
const learned = applyScannerLearningRules(capture("Equipped; Bennet\nAubade of Morningstar and Moor"), {
|
||||
fieldAliases: {
|
||||
equipped: {
|
||||
Bennet: "Bennett",
|
||||
},
|
||||
setName: {
|
||||
Moor: "Moon",
|
||||
},
|
||||
},
|
||||
constrainedFixes: {
|
||||
"Equipped;": "Equipped:",
|
||||
},
|
||||
});
|
||||
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Equipped: Bennett");
|
||||
expect(learned?.ocr?.[0]?.text).toContain("Aubade of Morningstar and Moon");
|
||||
});
|
||||
|
||||
it("marks low confidence or noted parses for review", () => {
|
||||
expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 95 } } })).toBe(false);
|
||||
expect(shouldSaveReviewSample({ confidence: 96, notes: ["Artifact name was fuzzy-matched"], fields: { name: { confidence: 95 } } })).toBe(false);
|
||||
@@ -30,6 +49,23 @@ describe("scannerLearning", () => {
|
||||
expect(shouldSaveReviewSample({ confidence: 90, notes: ["Main stat not confidently parsed."], fields: { mainStat: { confidence: 0 } } })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not save automatic review samples only because level or equipped is missing", () => {
|
||||
expect(shouldSaveReviewSample({
|
||||
confidence: 77,
|
||||
notes: ["Artifact level not confidently parsed.", "equipped confidence is low; review before trusting it."],
|
||||
fields: {
|
||||
name: { confidence: 96 },
|
||||
slot: { confidence: 96 },
|
||||
level: { confidence: 0 },
|
||||
mainStat: { confidence: 94 },
|
||||
mainValue: { confidence: 96 },
|
||||
setName: { confidence: 92 },
|
||||
equipped: { confidence: 45 },
|
||||
substats: { confidence: 96 },
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("derives conservative replacements from OCR review samples", () => {
|
||||
const reviewCapture: CaptureResult = {
|
||||
id: "review",
|
||||
@@ -53,8 +89,43 @@ describe("scannerLearning", () => {
|
||||
expect(learned?.textReplacements?.Moor).toBe("Moon");
|
||||
});
|
||||
|
||||
it("derives conservative replacements from split artifact OCR fields", () => {
|
||||
const reviewCapture: CaptureResult = {
|
||||
id: "review",
|
||||
name: "review",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date(0).toISOString(),
|
||||
ocr: [
|
||||
{ id: "artifact-name", label: "Name", text: "Heldenepos's Unspcken Tale", confidence: 73 },
|
||||
{ id: "artifact-slot", label: "Slot", text: "Goblet of Eonothen", confidence: 74 },
|
||||
{ id: "artifact-main-stat-label", label: "Main stat label", text: "Pvro DMG Bonus", confidence: 72 },
|
||||
{ id: "artifact-main-stat-value", label: "Main stat value", text: "46.G%", confidence: 66 },
|
||||
],
|
||||
};
|
||||
|
||||
const learned = deriveScannerLearningRules(reviewCapture, parsedArtifact({
|
||||
name: "Heldenepos's Unspoken Tale",
|
||||
slot: "Goblet of Eonothem",
|
||||
mainStat: "Pyro DMG Bonus",
|
||||
mainValue: "46.6%",
|
||||
}));
|
||||
|
||||
expect(learned?.textReplacements?.["Heldenepos's Unspcken Tale"]).toBe("Heldenepos's Unspoken Tale");
|
||||
expect(learned?.textReplacements?.Eonothen).toBe("Eonothem");
|
||||
expect(learned?.textReplacements?.Pvro).toBe("Pyro");
|
||||
expect(learned?.textReplacements?.["46.G%"]).toBe("46.6%");
|
||||
});
|
||||
|
||||
it("counts learned rules", () => {
|
||||
expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2);
|
||||
expect(countScannerLearningRules({
|
||||
textReplacements: { one: "1", two: "2" },
|
||||
fieldAliases: { equipped: { Bennet: "Bennett" } },
|
||||
cropAdjustments: { "artifact-footer": { dy: -2, approved: false } },
|
||||
uiProfileAdjustments: { "1080p-footer": { fieldId: "artifact-footer", dy: -2, approved: false } },
|
||||
constrainedFixes: { "Moor": "Moon" },
|
||||
})).toBe(6);
|
||||
});
|
||||
|
||||
it("does not flag DB review when only non-critical fields are weak", () => {
|
||||
|
||||
@@ -15,21 +15,35 @@ export const DEFAULT_SCANNER_LEARNING_RULES: ScannerLearningRules = {
|
||||
"Elemental Masterv": "Elemental Mastery",
|
||||
"Equipped;": "Equipped:",
|
||||
},
|
||||
fieldAliases: {},
|
||||
constrainedFixes: {},
|
||||
cropAdjustments: {},
|
||||
uiProfileAdjustments: {},
|
||||
};
|
||||
|
||||
export function mergeScannerLearningRules(...rules: Array<Partial<ScannerLearningRules> | null | undefined>): ScannerLearningRules {
|
||||
return rules.reduce<ScannerLearningRules>(
|
||||
(merged, rule) => ({
|
||||
textReplacements: { ...merged.textReplacements, ...(rule?.textReplacements ?? {}) },
|
||||
fieldAliases: deepMergeRecord(merged.fieldAliases, rule?.fieldAliases),
|
||||
constrainedFixes: { ...merged.constrainedFixes, ...(rule?.constrainedFixes ?? {}) },
|
||||
cropAdjustments: { ...merged.cropAdjustments, ...(rule?.cropAdjustments ?? {}) },
|
||||
uiProfileAdjustments: { ...merged.uiProfileAdjustments, ...(rule?.uiProfileAdjustments ?? {}) },
|
||||
}),
|
||||
{ textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements } },
|
||||
{
|
||||
textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements },
|
||||
fieldAliases: {},
|
||||
constrainedFixes: {},
|
||||
cropAdjustments: {},
|
||||
uiProfileAdjustments: {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function applyScannerLearningRules(capture: CaptureResult | null, rules?: Partial<ScannerLearningRules> | null) {
|
||||
if (!capture?.ocr?.length) return capture;
|
||||
const merged = mergeScannerLearningRules(rules);
|
||||
const replacements = Object.entries(merged.textReplacements ?? {}).filter(([from]) => from.length > 0);
|
||||
const replacements = replacementEntriesFromLearningRules(merged);
|
||||
if (replacements.length === 0) return capture;
|
||||
|
||||
return {
|
||||
@@ -43,12 +57,12 @@ export function applyScannerLearningRules(capture: CaptureResult | null, rules?:
|
||||
|
||||
export function shouldSaveReviewSample(parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }> } | null) {
|
||||
if (!parsed) return true;
|
||||
if (parsed.confidence < 82) return true;
|
||||
const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"];
|
||||
if (criticalFields.some((fieldName) => {
|
||||
const field = parsed.fields[fieldName];
|
||||
return field ? field.confidence < 70 : false;
|
||||
})) return true;
|
||||
if (reviewRelevantConfidence(parsed.fields) < 82) return true;
|
||||
if (parsed.notes.some((note) => /main stat not confidently parsed|main stat value not confidently parsed|set name not confidently parsed|slot not confidently parsed|artifact name not confidently parsed/i.test(note))) {
|
||||
return true;
|
||||
}
|
||||
@@ -60,7 +74,7 @@ export function shouldFlagArtifactForReview(
|
||||
parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }>; substats?: string[] } | null,
|
||||
) {
|
||||
if (!parsed) return true;
|
||||
if (parsed.confidence < 78) return true;
|
||||
if (reviewRelevantConfidence(parsed.fields) < 78) return true;
|
||||
|
||||
const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"];
|
||||
if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) return true;
|
||||
@@ -81,8 +95,23 @@ export function shouldFlagArtifactForReview(
|
||||
return false;
|
||||
}
|
||||
|
||||
function reviewRelevantConfidence(fields: Record<string, { confidence: number }>) {
|
||||
const relevant = Object.entries(fields)
|
||||
.filter(([fieldName]) => fieldName !== "level" && fieldName !== "equipped")
|
||||
.map(([, field]) => field.confidence);
|
||||
if (relevant.length === 0) return 0;
|
||||
return Math.round(relevant.reduce((sum, confidence) => sum + confidence, 0) / relevant.length);
|
||||
}
|
||||
|
||||
export function countScannerLearningRules(rules?: Partial<ScannerLearningRules> | null) {
|
||||
return Object.keys(rules?.textReplacements ?? {}).length;
|
||||
const fieldAliasCount = Object.values(rules?.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0);
|
||||
return (
|
||||
Object.keys(rules?.textReplacements ?? {}).length
|
||||
+ fieldAliasCount
|
||||
+ Object.keys(rules?.constrainedFixes ?? {}).length
|
||||
+ Object.keys(rules?.cropAdjustments ?? {}).length
|
||||
+ Object.keys(rules?.uiProfileAdjustments ?? {}).length
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveScannerLearningRules(
|
||||
@@ -120,8 +149,16 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate)
|
||||
switch (id) {
|
||||
case "artifact-title":
|
||||
return [parsed.name, parsed.slot];
|
||||
case "artifact-name":
|
||||
return [parsed.name];
|
||||
case "artifact-slot":
|
||||
return [parsed.slot];
|
||||
case "artifact-main-stat":
|
||||
return [parsed.mainStat, parsed.mainValue];
|
||||
case "artifact-main-stat-label":
|
||||
return [parsed.mainStat];
|
||||
case "artifact-main-stat-value":
|
||||
return [parsed.mainValue];
|
||||
case "artifact-substats":
|
||||
return parsed.substats;
|
||||
case "artifact-set-effects":
|
||||
@@ -133,6 +170,36 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
function replacementEntriesFromLearningRules(rules: ScannerLearningRules) {
|
||||
const entries = new Map<string, string>();
|
||||
for (const [from, to] of Object.entries(rules.textReplacements ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
for (const aliases of Object.values(rules.fieldAliases ?? {})) {
|
||||
for (const [from, to] of Object.entries(aliases ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
}
|
||||
for (const [from, to] of Object.entries(rules.constrainedFixes ?? {})) {
|
||||
if (from.length > 0) entries.set(from, to);
|
||||
}
|
||||
return [...entries.entries()];
|
||||
}
|
||||
|
||||
function deepMergeRecord(
|
||||
left: Record<string, Record<string, string>> | undefined,
|
||||
right: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, aliases] of Object.entries(left ?? {})) {
|
||||
merged[field] = { ...(aliases ?? {}) };
|
||||
}
|
||||
for (const [field, aliases] of Object.entries(right ?? {})) {
|
||||
merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function deriveReplacementPairs(rawText: string, expected: string) {
|
||||
if (!expected || expected.startsWith("Unknown")) return [];
|
||||
const normalizedExpected = normalizeLearningText(expected);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampScanLimit, clampSkipRows, resolveScanTargetCount } from "./scannerSession";
|
||||
import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampScanLimit, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
|
||||
|
||||
describe("scannerSession helpers", () => {
|
||||
it("clamps scan target counts into a sane range", () => {
|
||||
@@ -18,4 +18,65 @@ describe("scannerSession helpers", () => {
|
||||
expect(clampSkipRows(-5)).toBe(0);
|
||||
expect(clampSkipRows(99)).toBe(8);
|
||||
});
|
||||
|
||||
it("updates scan timing and projects the 100-artifact run", () => {
|
||||
const stats = { ...emptyAutoScanStats, parsed: 4, verified: 2 };
|
||||
addCaptureTiming(stats, { totalMs: 1200, ocrMs: 900 }, 1500);
|
||||
addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 }, 1000);
|
||||
updateScanTiming(stats, 1000, 9000);
|
||||
expect(stats.elapsedMs).toBe(8000);
|
||||
expect(stats.activeScanMs).toBe(8000);
|
||||
expect(stats.averageMsPerParsed).toBe(2000);
|
||||
expect(stats.activeAverageMsPerParsed).toBe(2000);
|
||||
expect(stats.artifactsPerMinute).toBe(30);
|
||||
expect(stats.activeArtifactsPerMinute).toBe(30);
|
||||
expect(stats.projectedMsFor100).toBe(200000);
|
||||
expect(stats.activeProjectedMsFor100).toBe(200000);
|
||||
expect(stats.captureMs).toBe(2000);
|
||||
expect(stats.captureRoundTripMs).toBe(2500);
|
||||
expect(stats.averageCaptureRoundTripMs).toBe(1250);
|
||||
expect(stats.captureRoundTripOverheadMs).toBe(500);
|
||||
expect(stats.averageCaptureRoundTripOverheadMs).toBe(250);
|
||||
expect(stats.ocrMs).toBe(1400);
|
||||
expect(stats.averageCaptureMs).toBe(1000);
|
||||
expect(stats.averageOcrMs).toBe(700);
|
||||
expect(stats.captureP50Ms).toBe(800);
|
||||
expect(stats.captureP90Ms).toBe(1200);
|
||||
expect(stats.ocrP50Ms).toBe(500);
|
||||
expect(stats.ocrP90Ms).toBe(900);
|
||||
});
|
||||
|
||||
it("tracks detail-card and scroll readiness timing separately from OCR", () => {
|
||||
const stats = { ...emptyAutoScanStats, parsed: 2, verified: 2 };
|
||||
addCardReadyTiming(stats, 180);
|
||||
addCardReadyTiming(stats, 220);
|
||||
addScrollReadyTiming(stats, 320);
|
||||
updateScanTiming(stats, 1000, 3000);
|
||||
expect(stats.cardReadyMs).toBe(400);
|
||||
expect(stats.cardReadyCount).toBe(2);
|
||||
expect(stats.averageCardReadyMs).toBe(200);
|
||||
expect(stats.scrollReadyMs).toBe(320);
|
||||
expect(stats.scrollReadyCount).toBe(1);
|
||||
expect(stats.averageScrollReadyMs).toBe(320);
|
||||
});
|
||||
|
||||
it("preserves active scan timing when final elapsed time includes queued write flush", () => {
|
||||
const stats = { ...emptyAutoScanStats, parsed: 5, verified: 5, activeScanMs: 4000, writeFlushMs: 900 };
|
||||
updateScanTiming(stats, 1000, 6000, { preserveActiveScanMs: true });
|
||||
expect(stats.elapsedMs).toBe(5000);
|
||||
expect(stats.activeScanMs).toBe(4000);
|
||||
expect(stats.writeFlushMs).toBe(900);
|
||||
expect(stats.averageMsPerParsed).toBe(1000);
|
||||
expect(stats.activeAverageMsPerParsed).toBe(800);
|
||||
expect(stats.projectedMsFor100).toBe(100000);
|
||||
expect(stats.activeProjectedMsFor100).toBe(80000);
|
||||
});
|
||||
|
||||
it("keeps active scan timing growing during the live scan phase", () => {
|
||||
const stats = { ...emptyAutoScanStats, parsed: 2, verified: 2, activeScanMs: 800 };
|
||||
updateScanTiming(stats, 1000, 5000);
|
||||
expect(stats.elapsedMs).toBe(4000);
|
||||
expect(stats.activeScanMs).toBe(4000);
|
||||
expect(stats.activeAverageMsPerParsed).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,39 @@ export type AutoScanStats = {
|
||||
duplicates: number;
|
||||
misses: number;
|
||||
pages: number;
|
||||
elapsedMs: number;
|
||||
activeScanMs: number;
|
||||
writeFlushMs: number;
|
||||
averageMsPerParsed: number;
|
||||
activeAverageMsPerParsed: number;
|
||||
artifactsPerMinute: number;
|
||||
activeArtifactsPerMinute: number;
|
||||
projectedMsFor100: number;
|
||||
activeProjectedMsFor100: number;
|
||||
captureMs: number;
|
||||
captureRoundTripMs: number;
|
||||
captureRoundTripOverheadMs: number;
|
||||
ocrMs: number;
|
||||
averageCaptureMs: number;
|
||||
averageCaptureRoundTripMs: number;
|
||||
averageCaptureRoundTripOverheadMs: number;
|
||||
averageOcrMs: number;
|
||||
clickMs: number;
|
||||
averageClickMs: number;
|
||||
parseMs: number;
|
||||
averageParseMs: number;
|
||||
loopOverheadMs: number;
|
||||
averageLoopOverheadMs: number;
|
||||
captureP50Ms: number;
|
||||
captureP90Ms: number;
|
||||
ocrP50Ms: number;
|
||||
ocrP90Ms: number;
|
||||
cardReadyMs: number;
|
||||
cardReadyCount: number;
|
||||
averageCardReadyMs: number;
|
||||
scrollReadyMs: number;
|
||||
scrollReadyCount: number;
|
||||
averageScrollReadyMs: number;
|
||||
};
|
||||
|
||||
export type ScanSummary = AutoScanStats & {
|
||||
@@ -27,8 +60,124 @@ export const emptyAutoScanStats: AutoScanStats = {
|
||||
duplicates: 0,
|
||||
misses: 0,
|
||||
pages: 0,
|
||||
elapsedMs: 0,
|
||||
activeScanMs: 0,
|
||||
writeFlushMs: 0,
|
||||
averageMsPerParsed: 0,
|
||||
activeAverageMsPerParsed: 0,
|
||||
artifactsPerMinute: 0,
|
||||
activeArtifactsPerMinute: 0,
|
||||
projectedMsFor100: 0,
|
||||
activeProjectedMsFor100: 0,
|
||||
captureMs: 0,
|
||||
captureRoundTripMs: 0,
|
||||
captureRoundTripOverheadMs: 0,
|
||||
ocrMs: 0,
|
||||
averageCaptureMs: 0,
|
||||
averageCaptureRoundTripMs: 0,
|
||||
averageCaptureRoundTripOverheadMs: 0,
|
||||
averageOcrMs: 0,
|
||||
clickMs: 0,
|
||||
averageClickMs: 0,
|
||||
parseMs: 0,
|
||||
averageParseMs: 0,
|
||||
loopOverheadMs: 0,
|
||||
averageLoopOverheadMs: 0,
|
||||
captureP50Ms: 0,
|
||||
captureP90Ms: 0,
|
||||
ocrP50Ms: 0,
|
||||
ocrP90Ms: 0,
|
||||
cardReadyMs: 0,
|
||||
cardReadyCount: 0,
|
||||
averageCardReadyMs: 0,
|
||||
scrollReadyMs: 0,
|
||||
scrollReadyCount: 0,
|
||||
averageScrollReadyMs: 0,
|
||||
};
|
||||
|
||||
const timingSamples = new WeakMap<AutoScanStats, { captureMs: number[]; ocrMs: number[] }>();
|
||||
|
||||
export function updateScanTiming(
|
||||
stats: AutoScanStats,
|
||||
startedAt: number,
|
||||
now = Date.now(),
|
||||
options: { preserveActiveScanMs?: boolean } = {},
|
||||
) {
|
||||
const elapsedMs = Math.max(0, Math.round(now - startedAt));
|
||||
stats.elapsedMs = elapsedMs;
|
||||
if (options.preserveActiveScanMs) {
|
||||
if (!stats.activeScanMs || stats.activeScanMs > elapsedMs) stats.activeScanMs = elapsedMs;
|
||||
} else {
|
||||
stats.activeScanMs = elapsedMs;
|
||||
}
|
||||
stats.averageMsPerParsed = stats.parsed > 0 ? Math.round(elapsedMs / stats.parsed) : 0;
|
||||
stats.activeAverageMsPerParsed = stats.parsed > 0 ? Math.round(stats.activeScanMs / stats.parsed) : 0;
|
||||
stats.artifactsPerMinute = elapsedMs > 0 && stats.parsed > 0
|
||||
? Math.round((stats.parsed * 60000 / elapsedMs) * 10) / 10
|
||||
: 0;
|
||||
stats.activeArtifactsPerMinute = stats.activeScanMs > 0 && stats.parsed > 0
|
||||
? Math.round((stats.parsed * 60000 / stats.activeScanMs) * 10) / 10
|
||||
: 0;
|
||||
stats.projectedMsFor100 = stats.averageMsPerParsed > 0 ? stats.averageMsPerParsed * 100 : 0;
|
||||
stats.activeProjectedMsFor100 = stats.activeAverageMsPerParsed > 0 ? stats.activeAverageMsPerParsed * 100 : 0;
|
||||
stats.averageCaptureMs = stats.verified > 0 ? Math.round(stats.captureMs / stats.verified) : 0;
|
||||
stats.averageCaptureRoundTripMs = stats.verified > 0 ? Math.round(stats.captureRoundTripMs / stats.verified) : 0;
|
||||
stats.captureRoundTripOverheadMs = Math.max(0, stats.captureRoundTripMs - stats.captureMs);
|
||||
stats.averageCaptureRoundTripOverheadMs = stats.verified > 0 ? Math.round(stats.captureRoundTripOverheadMs / stats.verified) : 0;
|
||||
stats.averageOcrMs = stats.verified > 0 ? Math.round(stats.ocrMs / stats.verified) : 0;
|
||||
stats.averageClickMs = stats.clicked > 0 ? Math.round(stats.clickMs / stats.clicked) : 0;
|
||||
stats.averageParseMs = stats.parsed > 0 ? Math.round(stats.parseMs / stats.parsed) : 0;
|
||||
stats.loopOverheadMs = Math.max(0, stats.activeScanMs - stats.captureMs - stats.clickMs - stats.parseMs - stats.cardReadyMs - stats.scrollReadyMs);
|
||||
stats.averageLoopOverheadMs = stats.parsed > 0 ? Math.round(stats.loopOverheadMs / stats.parsed) : 0;
|
||||
const samples = timingSamples.get(stats);
|
||||
stats.captureP50Ms = percentile(samples?.captureMs, 50);
|
||||
stats.captureP90Ms = percentile(samples?.captureMs, 90);
|
||||
stats.ocrP50Ms = percentile(samples?.ocrMs, 50);
|
||||
stats.ocrP90Ms = percentile(samples?.ocrMs, 90);
|
||||
stats.averageCardReadyMs = stats.cardReadyCount > 0 ? Math.round(stats.cardReadyMs / stats.cardReadyCount) : 0;
|
||||
stats.averageScrollReadyMs = stats.scrollReadyCount > 0 ? Math.round(stats.scrollReadyMs / stats.scrollReadyCount) : 0;
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function addCaptureTiming(
|
||||
stats: AutoScanStats,
|
||||
timing?: { totalMs?: number; ocrMs?: number } | null,
|
||||
elapsedMs?: number | null,
|
||||
) {
|
||||
if (!timing) return stats;
|
||||
const captureMs = Math.max(0, Math.round(timing.totalMs ?? 0));
|
||||
const ocrMs = Math.max(0, Math.round(timing.ocrMs ?? 0));
|
||||
stats.captureMs += captureMs;
|
||||
if (typeof elapsedMs === "number" && Number.isFinite(elapsedMs)) {
|
||||
stats.captureRoundTripMs += Math.max(0, Math.round(elapsedMs));
|
||||
}
|
||||
stats.ocrMs += ocrMs;
|
||||
const samples = timingSamples.get(stats) ?? { captureMs: [], ocrMs: [] };
|
||||
samples.captureMs.push(captureMs);
|
||||
samples.ocrMs.push(ocrMs);
|
||||
timingSamples.set(stats, samples);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function addCardReadyTiming(stats: AutoScanStats, elapsedMs: number) {
|
||||
stats.cardReadyMs += Math.max(0, Math.round(elapsedMs));
|
||||
stats.cardReadyCount += 1;
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function addScrollReadyTiming(stats: AutoScanStats, elapsedMs: number) {
|
||||
stats.scrollReadyMs += Math.max(0, Math.round(elapsedMs));
|
||||
stats.scrollReadyCount += 1;
|
||||
return stats;
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[] | undefined, p: number) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
export function clampScanLimit(value: number) {
|
||||
return Math.max(1, Math.min(1800, Math.round(value || 1)));
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AppPageLayout({ controller }: AppPageLayoutProps) {
|
||||
overlayIcon={<Eye size={16} />}
|
||||
demoIcon={<Play size={16} />}
|
||||
/>
|
||||
{activeView !== "diagnose" && <AppMetrics metricCards={metricCards} />}
|
||||
{activeView !== "scan" && activeView !== "diagnose" && <AppMetrics metricCards={metricCards} />}
|
||||
{(activeView === "scan" || activeView === "diagnose") && (
|
||||
<ScanView
|
||||
mode={activeView === "diagnose" ? "diagnose" : "workspace"}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
ScannerLearningRulePayload,
|
||||
KeyPressResult,
|
||||
} from "../types/global";
|
||||
import type { AppSnapshot } from "../types/domain";
|
||||
import type { StoredArtifactRecord } from "../types/storage";
|
||||
@@ -57,6 +58,7 @@ export interface AssistantBridge {
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
keyPress: (key: string) => Promise<KeyPressResult>;
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -99,6 +101,7 @@ export function getAssistantBridge(): AssistantBridge | null {
|
||||
focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()),
|
||||
clickScreen: (x: number, y: number) => api.clickScreen(x, y),
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY),
|
||||
keyPress: (key: string) => api.keyPress(key),
|
||||
showOverlay: () => api.showOverlay(),
|
||||
onScannerCommand: (callback) => api.onScannerCommand(callback),
|
||||
};
|
||||
|
||||
+2470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
/* Diagnose / Dev view - all developer info, separated from the Scan workspace. */
|
||||
.diagnose-view {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.diagnose-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnose-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.diagnose-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)),
|
||||
rgba(18, 12, 35, 0.7);
|
||||
box-shadow: var(--glass-shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.diagnose-card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnose-card-heading h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.app-diagnosis-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnosis-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-diagnosis-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.app-diagnosis-section.ok {
|
||||
border-left-color: var(--mint);
|
||||
}
|
||||
|
||||
.app-diagnosis-section.warn {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.app-diagnosis-section.risk {
|
||||
border-left-color: #ff6b8a;
|
||||
}
|
||||
|
||||
.app-diagnosis-section.next {
|
||||
border-left-color: var(--cyan);
|
||||
}
|
||||
|
||||
.app-diagnosis-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-diagnosis-title strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-diagnosis-section ul {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-diagnosis-section li::marker {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.diagnose-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.scan-evidence-capture {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scan-evidence-images {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-diagnosis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagnosis-source {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
+2
-2238
File diff suppressed because it is too large
Load Diff
Vendored
+106
-1
@@ -3,6 +3,18 @@ import type { StoredArtifactRecord } from "./storage";
|
||||
|
||||
export interface CaptureOptions {
|
||||
skipOcr?: boolean;
|
||||
skipOcrUnlessArtifactDetail?: boolean;
|
||||
ocrMode?: "full" | "artifact";
|
||||
ocrProfile?: "full" | "fast";
|
||||
ocrEngine?: "current" | "native-tesseract" | "benchmark" | "ik-traineddata";
|
||||
scanEntryMode?: "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
|
||||
omitFullFrame?: boolean;
|
||||
omitDetailPreview?: boolean;
|
||||
omitInventoryPreview?: boolean;
|
||||
omitCrops?: boolean;
|
||||
omitCropImages?: boolean;
|
||||
omitEquippedOcr?: boolean;
|
||||
omitLockState?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureSourceInfo {
|
||||
@@ -16,7 +28,7 @@ export interface CaptureCrop {
|
||||
id: string;
|
||||
label: string;
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
dataUrl: string;
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
export interface OcrResult {
|
||||
@@ -24,6 +36,7 @@ export interface OcrResult {
|
||||
label: string;
|
||||
text: string;
|
||||
confidence: number;
|
||||
elapsedMs?: number;
|
||||
}
|
||||
|
||||
export type CaptureTarget = "genshin-client" | "primary-screen" | "desktop-source";
|
||||
@@ -38,6 +51,8 @@ export interface CaptureResult {
|
||||
captureTarget?: CaptureTarget;
|
||||
detailDataUrl?: string;
|
||||
inventoryDataUrl?: string;
|
||||
detailFingerprint?: string;
|
||||
inventoryFingerprint?: string;
|
||||
ocrSkipped?: boolean;
|
||||
ocrTimedOut?: boolean;
|
||||
crops?: CaptureCrop[];
|
||||
@@ -49,6 +64,23 @@ export interface CaptureResult {
|
||||
confidence: number;
|
||||
source: "detected" | "fallback" | "missing";
|
||||
};
|
||||
artifactDetail?: {
|
||||
present: boolean;
|
||||
confidence: number;
|
||||
orangeHits: number;
|
||||
greenHits: number;
|
||||
textHits: number;
|
||||
titleOrangeHits?: number;
|
||||
upperTextHits?: number;
|
||||
lowerGreenHits?: number;
|
||||
};
|
||||
paimonMenu?: {
|
||||
present: boolean;
|
||||
confidence: number;
|
||||
profileLightPct: number;
|
||||
profileCreamPct: number;
|
||||
menuTileDarkPct: number;
|
||||
};
|
||||
inventoryCount?: {
|
||||
current: number;
|
||||
total: number;
|
||||
@@ -57,11 +89,34 @@ export interface CaptureResult {
|
||||
text: string;
|
||||
};
|
||||
locked?: boolean;
|
||||
lockSignal?: {
|
||||
ratio: number;
|
||||
threshold: number;
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
sanctified?: boolean;
|
||||
elapsedMs?: number;
|
||||
layout?: {
|
||||
aspect: string;
|
||||
isSixteenNine: boolean;
|
||||
warning: string;
|
||||
};
|
||||
timings?: {
|
||||
totalMs: number;
|
||||
prepareMs: number;
|
||||
ocrMs: number;
|
||||
cropCount: number;
|
||||
ocrEngine: CaptureOptions["ocrEngine"];
|
||||
ocrWorkerPoolSize?: number;
|
||||
ocrProfile?: CaptureOptions["ocrProfile"];
|
||||
ocrFieldMs?: Record<string, number>;
|
||||
ocrSkipped: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WindowBounds {
|
||||
@@ -97,6 +152,7 @@ export interface RuntimeInfo {
|
||||
ok: boolean;
|
||||
isElevated: boolean;
|
||||
platform: string;
|
||||
appBuild?: AppRuntimeInfo;
|
||||
hotkeys?: Record<string, boolean>;
|
||||
genshinFound?: boolean;
|
||||
genshinHwnd?: number;
|
||||
@@ -106,6 +162,15 @@ export interface RuntimeInfo {
|
||||
helperPid?: number;
|
||||
}
|
||||
|
||||
export interface AppRuntimeInfo {
|
||||
signature: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
cwd: string;
|
||||
isDev: boolean;
|
||||
expectedOcrWorkerPoolSize?: number;
|
||||
}
|
||||
|
||||
export interface FocusGenshinResult {
|
||||
focused: boolean;
|
||||
alreadyForeground: boolean;
|
||||
@@ -132,10 +197,31 @@ export type ScannerCommand =
|
||||
| {
|
||||
type: "start-auto";
|
||||
scanLimit?: number;
|
||||
scanEntryMode?: CaptureOptions["scanEntryMode"];
|
||||
ocrEngine?: CaptureOptions["ocrEngine"];
|
||||
};
|
||||
|
||||
export interface ScannerLearningRulePayload {
|
||||
textReplacements?: Record<string, string>;
|
||||
fieldAliases?: Record<string, Record<string, string>>;
|
||||
constrainedFixes?: Record<string, string>;
|
||||
cropAdjustments?: Record<string, {
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
dw?: number;
|
||||
dh?: number;
|
||||
reason?: string;
|
||||
approved?: boolean;
|
||||
}>;
|
||||
uiProfileAdjustments?: Record<string, {
|
||||
fieldId: string;
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
dw?: number;
|
||||
dh?: number;
|
||||
reason?: string;
|
||||
approved?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoadScannerLearningRulesResult {
|
||||
@@ -184,6 +270,17 @@ export interface ScrollResult {
|
||||
isElevated?: boolean;
|
||||
}
|
||||
|
||||
export interface KeyPressResult {
|
||||
ok: boolean;
|
||||
key: string;
|
||||
focused?: boolean;
|
||||
foregroundProcess?: string;
|
||||
targetProcess?: string;
|
||||
isElevated?: boolean;
|
||||
inputBlocked?: boolean;
|
||||
eventsSent?: number;
|
||||
}
|
||||
|
||||
export type ArtifactSaveResult = ArtifactStoreSaveResult;
|
||||
|
||||
export interface ArtifactStoreLoadResult {
|
||||
@@ -267,9 +364,16 @@ export interface ScannerStatusPayload {
|
||||
snapshotBuilds: number;
|
||||
grid: CaptureResult["inventoryGrid"] | null;
|
||||
automationLog: string[];
|
||||
diagnosticEvents?: unknown[];
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
appBuild?: AppRuntimeInfo;
|
||||
storedTotal: number | null;
|
||||
learningRuleCount: number;
|
||||
lookupStatus?: unknown;
|
||||
ocrEngine?: CaptureOptions["ocrEngine"];
|
||||
ocrWarmup?: unknown;
|
||||
entryMode?: CaptureOptions["scanEntryMode"];
|
||||
benchmarkSummary?: unknown;
|
||||
updatedAt: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -309,6 +413,7 @@ declare global {
|
||||
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
keyPress: (key: string) => Promise<KeyPressResult>;
|
||||
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||
focusMainWindow: () => Promise<BooleanResult>;
|
||||
focusGenshin: () => Promise<FocusGenshinResult>;
|
||||
|
||||
Reference in New Issue
Block a user