feat(scanner): validate elevated live automation

This commit is contained in:
AzuTear
2026-07-07 07:49:22 +02:00
parent 7930e369a7
commit ef65c3e6a0
37 changed files with 826 additions and 217 deletions
@@ -1,8 +1,7 @@
import { useRef, useState, type ChangeEvent } from "react";
import { useState } from "react";
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
import type { CaptureResult } from "../../../types/global";
import type { ScanViewControllerResult } from "../types";
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase } from "../../../lib/goodInterop";
import { FieldConfidenceList } from "./ScanResultCards";
import { useScanDiagnosticsModalModel } from "./modals/hooks/useScanDiagnosticsModalModel";
import { useScanDetailsModalModel } from "./modals/hooks/useScanDetailsModalModel";
@@ -59,7 +58,6 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
});
const [interopStatus, setInteropStatus] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExportGood = async () => {
setInteropStatus("Exportiere GOOD...");
@@ -71,27 +69,20 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
);
};
const handleImportGood = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const database = JSON.parse(await file.text()) as GoodImportDatabase;
const records = goodDatabaseToStoredArtifacts(database);
if (records.length === 0) {
setInteropStatus("Keine gueltigen Artifacts in der Datei gefunden.");
return;
}
setInteropStatus(`Importiere ${records.length} Artifacts...`);
const result = await controller.importGoodArtifacts(records);
setInteropStatus(
result.ok
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert.`
: "Import fehlgeschlagen (App im Electron-Fenster oeffnen).",
);
} catch {
setInteropStatus("Datei ist kein gueltiges GOOD/JSON.");
const handleImportGood = async () => {
setInteropStatus("Waehle GOOD-Datei...");
const result = await controller.importGoodFromFile();
if (result.canceled) {
setInteropStatus("GOOD-Import abgebrochen.");
return;
}
setInteropStatus(
result.ok
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert (${result.count} gelesen).`
: result.error === "No valid GOOD artifacts found."
? "Keine gueltigen Artifacts in der Datei gefunden."
: "Import fehlgeschlagen (Datei ist kein gueltiges GOOD/JSON oder Bridge fehlt).",
);
};
return (
@@ -182,11 +173,10 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
<Download size={15} />
GOOD exportieren
</button>
<button className="ghost-button" onClick={() => fileInputRef.current?.click()} disabled={!controller.canGoodInterop}>
<button className="ghost-button" onClick={handleImportGood} disabled={!controller.canGoodInterop}>
<Upload size={15} />
GOOD importieren
</button>
<input ref={fileInputRef} type="file" accept="application/json,.json" hidden onChange={handleImportGood} />
</div>
</div>
<p className="scanner-subcopy">
@@ -244,7 +244,7 @@ export async function persistParsedArtifact(
return false;
}
try {
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview, capture?.locked)]);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
@@ -297,6 +297,7 @@ export async function saveReviewSample(
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
locked: capture.locked,
ocr: capture.ocr,
},
parsed,
@@ -45,6 +45,10 @@ export interface ScanActionContext {
focusDashboard: () => Promise<void>;
}
export interface VisibleGridScanOptions {
scanLimit?: number;
}
function buildScanSignature(parsed: ParsedArtifactCandidate) {
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
}
@@ -146,7 +150,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
}
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
export async function runVisibleGridScan(context: ScanActionContext, options: VisibleGridScanOptions = {}): Promise<void> {
const {
autoScanRunning,
bridgeReady,
@@ -167,11 +171,12 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
persistParsedArtifact,
saveReviewSample,
shouldFlagArtifactForReview,
scanLimit,
scanLimit: configuredScanLimit,
skipRows,
detectedInventoryCount,
focusDashboard,
} = context;
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
@@ -1,5 +1,7 @@
import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { ScannerCommand } from "../../../types/global";
import type { VisibleGridScanOptions } from "./scanViewScanActions";
interface ScanCommandListenerInput {
automationRepo?: AutomationRepositoryPort;
@@ -7,7 +9,7 @@ interface ScanCommandListenerInput {
isScanning: boolean;
selectedSourceId: string;
requestScanStop: (reason: string) => void;
runVisibleGridScan: () => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
}
export function useScanCommandListener({
@@ -20,15 +22,16 @@ export function useScanCommandListener({
}: ScanCommandListenerInput) {
useEffect(() => {
if (!automationRepo?.onCommand) return;
return automationRepo.onCommand((command: "start-auto" | "stop") => {
return automationRepo.onCommand((command: ScannerCommand) => {
if (command === "stop") {
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
return;
}
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
void runVisibleGridScan();
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);
}
});
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
import {
initializeLearningState,
loadReviewQueue as loadReviewQueueFromRepo,
@@ -77,7 +77,7 @@ export interface ScanViewActionResult {
loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>;
runVisibleGridScan: () => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
}
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
@@ -262,11 +262,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
await runAutoReviewScanAction(scanActionContext);
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
const runVisibleGridScan = useCallback(async () => {
const runVisibleGridScan = useCallback(async (options: VisibleGridScanOptions = {}) => {
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
return;
}
await runVisibleGridScanAction(scanActionContext);
await runVisibleGridScanAction(scanActionContext, options);
}, [
autoScanRunning,
bridgeReady,
@@ -17,7 +17,7 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc
import type { ScanViewProps, ScanViewControllerResult } from "../types";
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage";
import { storedArtifactsToGood } from "../../../lib/goodInterop";
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
export function useScanViewController({
@@ -174,6 +174,23 @@ export function useScanViewController({
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]);
useScanViewStateSync({
artifactRepo,
latestCapture,
@@ -253,6 +270,7 @@ export function useScanViewController({
runVisibleGridScan,
canGoodInterop,
exportGoodFromStore,
importGoodFromFile,
importGoodArtifacts,
};
}
+1
View File
@@ -82,5 +82,6 @@ export interface ScanViewControllerResult {
runVisibleGridScan: () => Promise<void>;
canGoodInterop: boolean;
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
importGoodFromFile: () => Promise<{ ok: boolean; added: number; updated: number; count: number; canceled?: boolean; path?: string; error?: string }>;
importGoodArtifacts: (records: StoredArtifactRecord[]) => Promise<{ ok: boolean; added: number; updated: number }>;
}
@@ -26,6 +26,7 @@ import type {
ClickResult,
ReviewSampleListResult,
SaveScannerLearningRulesResult,
GoodImportFileResult,
} from "../../types/global";
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
@@ -67,6 +68,12 @@ const EMPTY_SAVE_RULES_RESULT: SaveScannerLearningRulesResult = {
rules: {},
total: 0,
};
const EMPTY_GOOD_IMPORT_FILE_RESULT: GoodImportFileResult = {
ok: false,
canceled: false,
path: "",
error: "Electron bridge unavailable.",
};
async function createBridgeSafeCall<TResult>(
callback: () => Promise<TResult> | TResult | null | undefined,
@@ -205,6 +212,7 @@ export function createRendererRepositories(): RendererRepositories | null {
const exportRepo: ScanExportPort = {
exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT),
importGoodFile: () => createBridgeSafeCall(() => bridge.importGoodFile(), EMPTY_GOOD_IMPORT_FILE_RESULT),
};
return {
@@ -8,8 +8,10 @@ import type {
ScannerStatusPayload,
ReviewSamplePayload,
GoodDatabase,
GoodImportFileResult,
FocusGenshinResult,
RuntimeInfo,
ScannerCommand,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
ArtifactStoreLoadResult,
@@ -62,7 +64,7 @@ export interface AutomationRepositoryPort {
focusMainWindow(): Promise<BooleanResult>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
onCommand(callback: (command: "start-auto" | "stop") => void): () => void;
onCommand(callback: (command: ScannerCommand) => void): () => void;
}
export interface OverlayRepositoryPort {
@@ -71,6 +73,7 @@ export interface OverlayRepositoryPort {
export interface ScanExportPort {
exportGood(payload: GoodDatabase): Promise<SaveResultWithPath>;
importGoodFile(): Promise<GoodImportFileResult>;
}
export interface RendererRepositories {
+21
View File
@@ -264,6 +264,27 @@ describe("parseArtifactCandidate", () => {
expect(parsed?.fields.substats.confidence).toBe(96);
});
it("parses the live calibrated 1080p Conductor circlet capture", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Conductor's Top Hat",
"artifact-main-stat": "Circlet of Logos\nHP\n7. 0 % i",
"artifact-substats": "a +\n+ Energy Recharge+4.5%\n+ ATK+14\n- Elemental Mastery+19\n- ATK+5.3% (unactivated)",
"artifact-footer": "",
}));
expect(parsed?.name).toBe("Conductor's Top Hat");
expect(parsed?.slot).toBe("Circlet of Logos");
expect(parsed?.setName).toBe("Wanderer's Troupe");
expect(parsed?.mainStat).toBe("HP%");
expect(parsed?.mainValue).toBe("7.0%");
expect(parsed?.substats).toEqual([
"Energy Recharge+4.5%",
"ATK+14",
"Elemental Mastery+19",
"ATK%+5.3%",
]);
});
it("keeps a percent main value even when OCR misses the main stat label", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
+6 -5
View File
@@ -276,14 +276,15 @@ function findMainValue(text: string, mainStat: string, slot: string, level: numb
}
function extractPercentValue(text: string) {
const percentPattern = /([0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?)\s*%/;
const lineMatches = text
.split("\n")
.map((line) => line.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/))
.map((line) => line.match(percentPattern))
.filter((match): match is RegExpMatchArray => Boolean(match));
const preferred = lineMatches[0] ?? text.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/);
const preferred = lineMatches[0] ?? text.match(percentPattern);
if (!preferred?.[1]) return "";
return `${preferred[1].replace(/[:,\u00B7]/g, ".")}%`;
return `${normalizeMainValue(preferred[1])}%`;
}
function inferMainStat(slot: string, text: string): ParsedField {
@@ -302,7 +303,7 @@ function inferMainStat(slot: string, text: string): ParsedField {
function findDirectMainStat(text: string) {
const compact = simplifyForMatch(text);
const hasPercentValue = /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text);
const hasPercentValue = /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text);
const priority = [
"Physical DMG Bonus",
"Elemental Mastery",
@@ -481,7 +482,7 @@ function isPercentMainStat(stat: string) {
}
function promotePercentVariant(stat: string, text: string) {
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text)) return `${stat}%`;
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text)) return `${stat}%`;
return stat;
}
+2 -1
View File
@@ -45,7 +45,7 @@ export function hashId(value: string) {
return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`;
}
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean): StoredArtifactRecord {
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean, locked?: boolean): StoredArtifactRecord {
return {
id: hashId(storeSignature(parsed)),
name: parsed.name,
@@ -58,6 +58,7 @@ export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string
equipped: parsed.equipped,
confidence: parsed.confidence,
needsReview,
locked,
source,
};
}
+17 -4
View File
@@ -42,6 +42,10 @@ describe("layoutProfile", () => {
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
});
it("matches the calibrated 1080p artifact detail panel", () => {
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", () => {
const detail = profileDetailRect(QHD);
const crops = detailCropRects(detail, QHD);
@@ -65,19 +69,28 @@ describe("layoutProfile", () => {
const detail = profileDetailRect(QHD);
const inv = inventoryRect(QHD, detail);
const count = inventoryCountCropRect(inv, QHD);
expect(count.x).toBeGreaterThanOrEqual(inv.x);
expect(count.x).toBeGreaterThan(QHD.width * 0.75);
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
});
it("builds a 5-column inventory grid on the left", () => {
it("builds the calibrated 8-column inventory grid on the left", () => {
const detail = profileDetailRect(QHD);
const grid = inventoryGrid(QHD, detail);
expect(grid.cols).toBe(5);
expect(grid.cols).toBe(8);
expect(grid.rows).toBe(5);
expect(grid.source).toBe("detected");
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
expect(grid.centers).toHaveLength(40);
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
});
it("matches the live 1080p artifact grid centers", () => {
const detail = profileDetailRect(HD);
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 });
});
it("reports a missing grid when the inventory panel is too small", () => {
const tiny = { width: 320, height: 180 };
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
+36 -42
View File
@@ -5,9 +5,9 @@
// of that geometry; electron/main.ts consumes it for cropping and keeps a
// colour-based detail-rect detector only as a fallback for off-profile setups.
//
// NOTE: the per-field detail crop fractions below are the current working values.
// True IK-style fixed coordinates need calibration against a reference 16:9
// screenshot; the structure here is what those calibrated numbers slot into.
// Calibrated from a 1920x1080 English artifact-inventory screenshot and scaled
// by client size. This follows Inventory Kamera's stable approach: fixed
// 16:9-relative UI regions first, visual detection only as a fallback.
export interface LayoutRect {
x: number;
@@ -81,10 +81,10 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
return clampRect(
{
x: Math.round(width * 0.5),
y: Math.round(height * 0.08),
width: Math.round(width * 0.46),
height: Math.round(height * 0.74),
x: Math.round(width * 0.681),
y: Math.round(height * 0.111),
width: Math.round(width * 0.256),
height: Math.round(height * 0.776),
},
imageSize,
);
@@ -97,10 +97,10 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
id: "artifact-title",
label: "Artifact title",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.05),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
x: Math.round(detailRect.x),
y: Math.round(detailRect.y),
width: Math.round(detailRect.width),
height: Math.round(detailRect.height * 0.07),
},
},
{
@@ -108,9 +108,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
label: "Main stat",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.2),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.18),
y: Math.round(detailRect.y + detailRect.height * 0.075),
width: Math.round(detailRect.width * 0.58),
height: Math.round(detailRect.height * 0.26),
},
},
{
@@ -118,9 +118,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.41),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.25),
y: Math.round(detailRect.y + detailRect.height * 0.34),
width: Math.round(detailRect.width * 0.86),
height: Math.round(detailRect.height * 0.27),
},
},
{
@@ -128,9 +128,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
label: "Footer",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.78),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
y: Math.round(detailRect.y + detailRect.height * 0.82),
width: Math.round(detailRect.width * 0.86),
height: Math.round(detailRect.height * 0.14),
},
},
];
@@ -139,12 +139,13 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
}
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
const { width, height } = imageSize;
return clampRect(
{
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
width: Math.round(inventoryRect.width * 0.34),
height: Math.round(inventoryRect.height * 0.09),
x: Math.round(width * 0.795),
y: Math.round(height * 0.02),
width: Math.round(width * 0.145),
height: Math.round(height * 0.055),
},
imageSize,
);
@@ -152,18 +153,12 @@ export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { w
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
const { width, height } = imageSize;
const preferredWidth = Math.max(140, Math.round(width * 0.48));
const x = Math.round(width * 0.03);
const y = Math.round(detailRect.y + detailRect.height * 0.09);
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
return clampRect(
{
x,
y,
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
height: Math.max(140, Math.round(height * 0.7)),
x: Math.round(width * 0.055),
y: Math.round(height * 0.155),
width: Math.max(140, Math.round(Math.min(detailRect.x - width * 0.07, width * 0.63))),
height: Math.max(140, Math.round(height * 0.74)),
},
imageSize,
);
@@ -171,18 +166,17 @@ export function inventoryRect(imageSize: { width: number; height: number }, deta
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
const rect = inventoryRect(imageSize, detailRect);
const cols = 5;
if (rect.width < 160 || rect.height < 140) {
const cols = 8;
if (imageSize.width < 800 || imageSize.height < 450 || rect.width < 160 || rect.height < 140) {
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
}
const cellWidth = Math.max(56, Math.round(rect.width / cols));
const stepX = Math.round(cellWidth * 0.96);
const stepY = Math.round(cellWidth * 1.03);
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
const stepX = Math.round(imageSize.width * 0.076);
const stepY = Math.round(imageSize.height * 0.163);
const visibleRows = 5;
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
const startX = Math.round(imageSize.width * 0.093);
const startY = Math.round(imageSize.height * 0.235);
const centers: InventoryGridLayout["centers"] = [];
for (let row = 0; row < visibleRows; row++) {
for (let col = 0; col < cols; col++) {
+3 -2
View File
@@ -19,13 +19,14 @@ function bitmap(goldPixels: number, total: number): Bitmap {
}
describe("lockDetection", () => {
it("places the lock crop in the top-right of the detail card", () => {
it("places the lock crop on the lock button in the substat panel", () => {
const size = { width: 2560, height: 1440 };
const detail = profileDetailRect(size);
const rect = lockIconCropRect(detail, size);
expect(rect.x).toBeGreaterThan(detail.x + detail.width * 0.5);
expect(rect.x + rect.width).toBeLessThanOrEqual(size.width);
expect(rect.y).toBeLessThan(detail.y + detail.height * 0.5);
expect(rect.y).toBeGreaterThan(detail.y + detail.height * 0.3);
expect(rect.y).toBeLessThan(detail.y + detail.height * 0.45);
});
it("measures the gold-pixel ratio", () => {
+6 -6
View File
@@ -1,5 +1,5 @@
import { clampRect, type LayoutRect } from "./layoutProfile";
import type { Bitmap } from "./ocrPreprocess";
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
@@ -15,10 +15,10 @@ import type { Bitmap } from "./ocrPreprocess";
export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
return clampRect(
{
x: Math.round(detailRect.x + detailRect.width * 0.8),
y: Math.round(detailRect.y + detailRect.height * 0.03),
width: Math.round(detailRect.width * 0.16),
height: Math.round(detailRect.height * 0.09),
x: Math.round(detailRect.x + detailRect.width * 0.735),
y: Math.round(detailRect.y + detailRect.height * 0.355),
width: Math.round(detailRect.width * 0.105),
height: Math.round(detailRect.height * 0.07),
},
imageSize,
);
+8 -1
View File
@@ -23,7 +23,7 @@ function record(overrides: Partial<StoredArtifactRecord> = {}): StoredArtifactRe
describe("storedArtifactAdapter", () => {
it("converts stored OCR artifacts into recommendation-domain artifacts", () => {
const [artifact] = storedArtifactsToDomain([record()]);
const [artifact] = storedArtifactsToDomain([record({ locked: true })]);
expect(artifact.slot).toBe("sands");
expect(artifact.setKey).toBe("viridescent_venerer");
@@ -32,6 +32,13 @@ describe("storedArtifactAdapter", () => {
expect(artifact.equipped).toBe("Sucrose");
expect(artifact.confidence).toBe(0.96);
expect(artifact.source).toBe("screen");
expect(artifact.locked).toBe(true);
});
it("does not invent lock state from confidence", () => {
const [artifact] = storedArtifactsToDomain([record({ locked: undefined, confidence: 100 })]);
expect(artifact.locked).toBe(false);
});
it("keeps flat and percent ATK substats distinct", () => {
+1 -1
View File
@@ -54,7 +54,7 @@ export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifa
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
locked: !record.needsReview && record.confidence >= 90,
locked: Boolean(record.locked),
source: toSource(record.source),
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
+5 -1
View File
@@ -15,6 +15,8 @@ import type {
SaveScannerLearningRulesResult,
SaveSnapshotResult,
GoodDatabase,
GoodImportFileResult,
ScannerCommand,
ScannerStatusPayload,
ScannerLearningRulePayload,
} from "../types/global";
@@ -39,6 +41,7 @@ export interface AssistantBridge {
options?: CaptureOptions,
) => Promise<CaptureResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: () => Promise<GoodImportFileResult>;
showOverlay: () => Promise<BooleanResult>;
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
@@ -54,7 +57,7 @@ export interface AssistantBridge {
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
}
function hasFunction(api: Record<string, unknown>, key: string): boolean {
@@ -81,6 +84,7 @@ export function getAssistantBridge(): AssistantBridge | null {
listCaptureSources: () => api.listCaptureSources(),
captureSource: (sourceId, delayMs, focusGenshin, options) => api.captureSource(sourceId, delayMs, focusGenshin, options),
exportGood: (payload) => api.exportGood(payload),
importGoodFile: () => api.importGoodFile(),
loadArtifacts: () => api.loadArtifacts(),
saveArtifacts: (records) => api.saveArtifacts(records),
loadReviewSamples: (limit = 50) => api.loadReviewSamples(limit),
+21 -1
View File
@@ -56,6 +56,7 @@ export interface CaptureResult {
source: "ocr" | "missing";
text: string;
};
locked?: boolean;
layout?: {
aspect: string;
isSixteenNine: boolean;
@@ -125,6 +126,14 @@ export interface SaveResultWithPath {
export type SaveSnapshotResult = SaveResultWithPath;
export type ScannerCommand =
| "start-auto"
| "stop"
| {
type: "start-auto";
scanLimit?: number;
};
export interface ScannerLearningRulePayload {
textReplacements?: Record<string, string>;
}
@@ -211,6 +220,7 @@ export interface ReviewSampleRecord {
ocr?: OcrResult[];
inventoryGrid?: CaptureResult["inventoryGrid"];
inventoryCount?: CaptureResult["inventoryCount"];
locked?: boolean;
};
};
}
@@ -239,6 +249,7 @@ export interface ReviewSamplePayload {
ocr?: OcrResult[];
inventoryGrid?: CaptureResult["inventoryGrid"];
inventoryCount?: CaptureResult["inventoryCount"];
locked?: boolean;
};
[key: string]: unknown;
}
@@ -280,6 +291,14 @@ export interface GoodDatabase {
artifacts: GoodExportArtifact[];
}
export interface GoodImportFileResult {
ok: boolean;
canceled: boolean;
path: string;
database?: unknown;
error?: string;
}
declare global {
interface Window {
assistantApi?: {
@@ -302,10 +321,11 @@ declare global {
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: () => Promise<GoodImportFileResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
showOverlay: () => Promise<BooleanResult>;
hideOverlay: () => Promise<BooleanResult>;
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
};
}
}
+1
View File
@@ -10,6 +10,7 @@ export interface StoredArtifactRecord {
equipped: string;
confidence: number;
needsReview: boolean;
locked?: boolean;
source: string;
firstSeenAt?: string;
lastSeenAt?: string;