feat(scanner): validate elevated live automation
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Vendored
+21
-1
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface StoredArtifactRecord {
|
||||
equipped: string;
|
||||
confidence: number;
|
||||
needsReview: boolean;
|
||||
locked?: boolean;
|
||||
source: string;
|
||||
firstSeenAt?: string;
|
||||
lastSeenAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user