Merge scanner readiness work

This commit is contained in:
AzuTear
2026-07-09 08:45:25 +02:00
104 changed files with 13182 additions and 3298 deletions
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);
});
});
+14
View File
@@ -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[] = [];
+23
View File
@@ -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;
}
+56
View File
@@ -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");
});
});
+9 -9
View File
@@ -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
+36
View File
@@ -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 });
}
});
});
+1 -1
View File
@@ -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 &amp; 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;
}
+122 -28
View File
@@ -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,
});
+121 -10
View File
@@ -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,
+84 -4
View File
@@ -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
View File
@@ -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;
}
+176
View File
@@ -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");
});
});
+80 -13
View File
@@ -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, "\\$&");
}
+2 -2
View File
@@ -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" });
});
-1
View File
@@ -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 };
}
+93
View File
@@ -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);
});
});
+89
View File
@@ -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: "" };
}
+460 -2
View File
@@ -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
View File
@@ -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 "";
+13 -13
View File
@@ -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,
+15
View File
@@ -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 });
+8 -3
View File
@@ -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 };
+21
View File
@@ -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[];
+48
View File
@@ -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");
});
});
+211
View File
@@ -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];
}
+32 -6
View File
@@ -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
View File
@@ -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);
+21
View File
@@ -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);
});
});
+47 -9
View File
@@ -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;
}
+12
View File
@@ -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([
+13 -4
View File
@@ -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;
+132
View File
@@ -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,
},
};
}
+47
View File
@@ -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("");
});
});
+1 -1
View File
@@ -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)
+72 -1
View File
@@ -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", () => {
+72 -5
View File
@@ -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);
+62 -1
View File
@@ -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);
});
});
+149
View File
@@ -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)));
}
+1 -1
View File
@@ -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"}
+3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+106 -1
View File
@@ -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>;