Improve IK-style artifact scanner pipeline

This commit is contained in:
AzuTear
2026-07-07 22:02:24 +02:00
parent 8ebbe91c39
commit f791d1464c
70 changed files with 7408 additions and 445 deletions
File diff suppressed because it is too large Load Diff
@@ -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,53 @@ 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 Text-Lernregeln, GOOD Import/Export 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 an einem sicher gesperrten Artifact fehlt.",
"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.",
"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 +86,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
playerProgress,
reviewStatus,
automationLogLines,
diagnosticEvents,
canSaveReviewSample,
handleSaveReviewSample,
} = useScanDiagnosticsModalModel({
@@ -106,6 +154,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 +275,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 | 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,25 @@ 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: "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 +165,25 @@ 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.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 +231,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,
@@ -60,7 +62,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,6 +104,7 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
setReviewStatus: input.setReviewStatus,
appendAutomationLog: input.appendAutomationLog,
appendClickDiagnostics: input.appendClickDiagnostics,
appendDiagnosticEvent: input.appendDiagnosticEvent,
parseArtifact: input.parseArtifact,
persistParsedArtifact: input.persistParsedArtifact,
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
@@ -277,29 +277,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,
});
+346 -7
View File
@@ -1,8 +1,10 @@
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
import { artifactTabClickTarget, keyPressBlocked, 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 { summarizeClickResult, summarizeKeyPressResult, type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type {
AutomationGuard,
@@ -36,8 +38,9 @@ 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>;
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
@@ -47,6 +50,9 @@ export interface ScanActionContext {
export interface VisibleGridScanOptions {
scanLimit?: number;
scanEntryMode?: ScanEntryMode;
processInitialSelection?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
}
function buildScanSignature(parsed: ParsedArtifactCandidate) {
@@ -83,6 +89,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 +107,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 +128,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
stats.attempted++;
stats.verified++;
stats.parsed++;
addCaptureTiming(stats, capture.timings);
const reason = getAutoReviewReason(capture, parsed);
const needsReview = shouldFlagArtifactForReview(parsed);
@@ -127,12 +139,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,6 +178,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
@@ -177,8 +191,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 +220,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 +233,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 +252,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 +277,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 +301,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 +321,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,7 +389,7 @@ 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,
@@ -302,6 +406,9 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
scanLimit,
skipRows,
detectedInventoryCount,
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
skipInitialGridTarget: scanEntryMode !== "visible-inventory",
ocrEngine,
},
);
@@ -310,10 +417,242 @@ 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,
gridLabel: result.gridLabel,
});
}
async function prepareAutoScanEntry({
mode,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
}: {
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;
}) {
if (mode === "visible-inventory") {
const capture = await captureFastSelectedSource(0, true);
appendDiagnosticEvent({
phase: "entry-visible",
severity: capture ? "ok" : "error",
message: capture ? "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;
}
@@ -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]);
}
@@ -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,
+68 -4
View File
@@ -1,4 +1,4 @@
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 {
@@ -21,6 +21,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 +51,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 +80,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 +103,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
@@ -116,6 +121,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 +160,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 {
@@ -215,12 +224,25 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact,
persistParsedArtifact: parseArtifactAndPersist,
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,6 +262,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact,
parseArtifactAndPersist,
handleSaveReviewSample,
@@ -276,12 +299,52 @@ 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; trying direct inventory entry, then Inventory Kamera fallback.",
capture: preflightCapture,
});
await runVisibleGridScanAction(scanActionContext, {
scanLimit: options.scanLimit,
scanEntryMode: visibleInventoryReady ? "visible-inventory" : "auto-entry",
processInitialSelection: visibleInventoryReady,
ocrEngine: options.ocrEngine,
});
}, [
autoScanRunning,
bridgeReady,
selectedSourceId,
automationRepo?.clickScreen,
automationRepo?.scrollScreen,
setReviewStatus,
captureSelectedSource,
appendDiagnosticEvent,
scanActionContext,
]);
useScanCommandListener({
automationRepo,
autoScanRunning,
isScanning,
selectedSourceId,
requestScanStop,
runGuidedAutoScan,
runVisibleGridScan,
});
@@ -291,6 +354,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan,
};
}
@@ -14,6 +14,7 @@ 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";
@@ -56,6 +57,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: {} });
@@ -92,12 +94,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 +136,7 @@ export function useScanViewController({
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan,
} = useScanViewActions({
autoScanRunning,
@@ -137,6 +156,7 @@ export function useScanViewController({
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
@@ -209,6 +229,7 @@ export function useScanViewController({
snapshot,
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
automationLog,
diagnosticEvents,
runtimeInfo,
storedTotal,
learningRuleCount,
@@ -230,6 +251,7 @@ export function useScanViewController({
scanLimitTouched,
skipRows,
automationLog,
diagnosticEvents,
storedTotal,
devMode,
scannerLearningRules,
@@ -267,6 +289,7 @@ export function useScanViewController({
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan,
canGoodInterop,
exportGoodFromStore,
+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;
}
+91
View File
@@ -95,6 +95,83 @@ 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 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",
@@ -342,4 +419,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");
});
});
+45 -12
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,15 +63,22 @@ 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);
@@ -100,7 +108,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 +169,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 +184,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 +202,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 +226,15 @@ 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");
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")
@@ -294,6 +319,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 +361,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 +491,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;
@@ -585,4 +619,3 @@ function sortLongestFirst(values: string[]) {
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+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,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: 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);
});
});
+315 -58
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,
@@ -49,6 +50,9 @@ export type AutoScanLoopOptions = {
scanLimit: number;
skipRows: number;
detectedInventoryCount?: number | null;
processInitialSelection?: boolean;
skipInitialGridTarget?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
};
export type AutoScanLoopResult = {
@@ -62,9 +66,14 @@ 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_MAX_MS = 420;
const CARD_READY_POLL_MS = 60;
const CARD_READY_STABLE_SAMPLES = 2;
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 200;
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 MISS_ABORT_THRESHOLD = 3;
const UNREADABLE_ABORT_THRESHOLD = 5;
@@ -89,28 +98,66 @@ 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>();
const seenDetailFingerprints = new Set<string>();
const seenPageFingerprints = new Set<string>();
let page = 0;
let blockedReason = "";
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() {
let writeQueue: Promise<void> = Promise.resolve();
function updateStats(preserveActiveScanMs = false) {
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs });
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 = writeQueue
.catch(() => undefined)
.then(async () => {
try {
await task();
} catch (error) {
appendAutomationLog(`write failed ${label}: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
async function flushWrites() {
await writeQueue.catch(() => undefined);
updateStats(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);
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) {
enqueueWrite(`persist:${source}:${parsed.name}`, async () => {
if (await persistParsedArtifact(capture, parsed, source, needsReview)) {
stats.stored++;
updateStats();
}
});
}
async function checkGuard() {
@@ -154,41 +201,183 @@ export async function runAutoScanLoop(
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);
if (lastDetailViewFingerprint) seenDetailFingerprints.add(lastDetailViewFingerprint);
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,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: 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);
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);
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 };
}
try {
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) {
page++;
stats.pages = page;
updateStats();
@@ -207,21 +396,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,16 +429,13 @@ 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) {
@@ -258,6 +446,14 @@ export async function runAutoScanLoop(
let changedDetail = ready.changed;
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,6 +462,13 @@ export async function runAutoScanLoop(
aborted = true;
break;
}
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`);
}
ready = await awaitCardReady();
if (ready.abortReason) {
blockedReason = ready.abortReason;
@@ -276,6 +479,18 @@ export async function runAutoScanLoop(
}
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 +504,36 @@ export async function runAutoScanLoop(
stats.verified++;
const capture = await captureSelectedSource(0, true);
if (ready.fingerprint) {
if (seenDetailFingerprints.has(ready.fingerprint)) {
consecutiveMisses = 0;
stats.duplicates++;
lastDetailViewFingerprint = ready.fingerprint;
updateStats();
appendAutomationLog(`duplicate visual r${target.row} c${target.col}: OCR uebersprungen`);
continue;
}
seenDetailFingerprints.add(ready.fingerprint);
}
const capture = await captureSelectedSource(0, false, {
ocrMode: "artifact",
ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
const captureSurfaceRejection = validateAutoScanEntryPreflight(capture);
if (!captureSurfaceRejection.ok) {
blockedReason = captureSurfaceRejection.reason;
appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`);
break;
}
if (capture?.ocrTimedOut) {
addCaptureTiming(stats, capture.timings);
stats.misses++;
consecutiveMisses++;
lastDetailViewFingerprint = detailFingerprint(capture);
@@ -305,9 +548,10 @@ export async function runAutoScanLoop(
const parsed = parseArtifact(capture);
const rejection = captureRejectionReason(capture, parsed);
addCaptureTiming(stats, capture?.timings);
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 +560,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 +619,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 +630,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 +669,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 +700,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 +715,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);
+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,
},
};
}
+46
View File
@@ -30,6 +30,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,6 +70,35 @@ 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);
});
+18 -2
View File
@@ -43,12 +43,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 +60,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,6 +81,14 @@ 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;
}
@@ -120,8 +128,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":
+58 -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,61 @@ 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 });
addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 });
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.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);
});
});
+118
View File
@@ -8,6 +8,29 @@ 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;
ocrMs: number;
averageCaptureMs: number;
averageOcrMs: 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 +50,103 @@ 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,
ocrMs: 0,
averageCaptureMs: 0,
averageOcrMs: 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.averageOcrMs = stats.verified > 0 ? Math.round(stats.ocrMs / stats.verified) : 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,
) {
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;
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),
};
+464 -68
View File
@@ -24,6 +24,14 @@
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
min-width: 1100px;
@@ -187,7 +195,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%),
linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%);
@@ -197,6 +206,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex;
flex-direction: column;
gap: 24px;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--line);
background: rgba(13, 9, 25, 0.72);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
@@ -301,8 +312,13 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.main-panel {
padding: 26px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 18px;
}
.topbar {
@@ -314,9 +330,9 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.topbar h1 {
max-width: 780px;
margin: 4px 0 0;
margin: 2px 0 0;
color: #fbf8ff;
font-size: 30px;
font-size: 22px;
letter-spacing: 0;
text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25);
}
@@ -334,6 +350,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.topbar-status {
@@ -347,8 +364,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.ghost-button,
.primary-button {
height: 40px;
padding: 0 14px;
height: 36px;
padding: 0 12px;
}
.ghost-button {
@@ -450,6 +467,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.content-grid {
display: grid;
gap: 14px;
min-height: 0;
overflow: hidden;
}
.scan-layout {
@@ -457,6 +476,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.panel {
min-height: 0;
overflow: hidden;
padding: 18px;
}
@@ -1044,9 +1065,13 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-workbench {
display: grid;
gap: 14px;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px;
min-height: 0;
height: 100%;
width: 100%;
justify-self: stretch;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background:
@@ -1054,7 +1079,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
rgba(18, 12, 35, 0.7);
box-shadow: var(--glass-shadow);
backdrop-filter: blur(22px);
padding: 18px;
padding: 14px;
}
.scanner-header,
@@ -1071,7 +1096,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-header h2,
.modal-header h2 {
margin: 3px 0 0;
font-size: 22px;
font-size: 20px;
}
.scanner-subcopy {
@@ -1102,7 +1127,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.source-select {
display: grid;
min-width: 0;
gap: 6px;
gap: 4px;
}
.source-select span {
@@ -1113,7 +1138,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.source-select select {
width: 100%;
min-height: 42px;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(15, 10, 29, 0.78);
@@ -1155,7 +1180,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
color: var(--danger);
font-size: 13px;
font-weight: 700;
padding: 12px 14px;
padding: 10px 12px;
}
.dev-toggle {
@@ -1170,22 +1195,23 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-scan-card {
display: grid;
gap: 12px;
gap: 8px;
min-height: 0;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: 8px;
background: rgba(15, 10, 29, 0.6);
padding: 16px;
padding: 10px;
}
.player-scan-row {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
.player-scan-row .source-select {
min-width: 260px;
min-width: 240px;
flex: 1;
}
@@ -1220,7 +1246,14 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.player-scan-lower {
display: grid;
grid-template-columns: minmax(360px, auto) minmax(360px, 1fr);
gap: 10px;
align-items: center;
}
.learning-strip {
@@ -1259,10 +1292,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.runtime-pill {
min-height: 34px;
min-height: 30px;
display: inline-flex;
align-items: center;
padding: 0 12px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(188, 154, 255, 0.22);
background: rgba(15, 10, 29, 0.55);
@@ -1284,14 +1317,14 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.scan-cta {
min-height: 44px;
padding: 0 22px;
min-height: 36px;
padding: 0 16px;
font-size: 14px;
}
.stop-button {
min-height: 44px;
padding: 0 20px;
min-height: 36px;
padding: 0 18px;
border: 1px solid rgba(255, 140, 157, 0.5);
border-radius: 10px;
background: rgba(255, 140, 157, 0.14);
@@ -1310,8 +1343,11 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-status {
margin: 0;
color: var(--text-soft);
font-size: 13px;
line-height: 1.5;
font-size: 12px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scanner-preflight {
@@ -1362,11 +1398,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-progress {
display: grid;
gap: 8px;
gap: 7px;
min-width: 0;
}
.player-progress-bar {
height: 8px;
height: 7px;
border-radius: 999px;
background: rgba(188, 154, 255, 0.12);
overflow: hidden;
@@ -1382,9 +1419,21 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-progress-stats {
display: flex;
flex-wrap: wrap;
gap: 14px;
gap: 6px;
color: var(--text-muted);
font-size: 12px;
font-size: 11px;
}
.player-progress-stats span {
display: inline-flex;
align-items: center;
min-height: 26px;
gap: 4px;
padding: 0 8px;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 999px;
background: rgba(8, 6, 18, 0.38);
white-space: nowrap;
}
.player-progress-stats strong {
@@ -1414,7 +1463,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.scanner-settings-modal {
width: min(760px, 92vw);
width: min(820px, 92vw);
}
.diagnostics-actions {
@@ -1427,6 +1476,25 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.settings-preflight {
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(160px, 1.1fr);
}
.settings-preflight p {
align-self: stretch;
display: grid;
align-content: center;
margin: 0;
border: 1px solid rgba(188, 154, 255, 0.12);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
color: #f4efff;
font-size: 15px;
font-weight: 800;
line-height: 1.35;
padding: 10px 12px;
}
.diagnostics-status {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1569,48 +1637,123 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
font-size: 11px;
}
.scan-config-strip {
.scan-settings-layout {
display: grid;
gap: 12px;
}
.scan-settings-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-stepper {
display: grid;
grid-template-columns: 170px 170px minmax(0, 1fr);
gap: 10px;
align-items: end;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px;
background: rgba(10, 7, 20, 0.36);
padding: 12px;
}
.scan-config-strip label {
display: grid;
gap: 6px;
.settings-stepper-head {
min-height: 42px;
}
.scan-config-strip span {
.settings-stepper-head div {
display: grid;
gap: 4px;
}
.settings-stepper-head span,
.scan-settings-note strong {
color: var(--text-soft);
font-size: 11px;
font-weight: 800;
}
.scan-config-strip input {
min-height: 38px;
.settings-stepper-head small,
.scan-settings-note span {
color: var(--text-muted);
font-size: 12px;
line-height: 1.35;
}
.settings-stepper-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
gap: 8px;
}
.settings-stepper-row input {
min-width: 0;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(15, 10, 29, 0.78);
color: #f4efff;
padding: 0 10px;
padding: 0 12px;
font-size: 18px;
font-weight: 800;
text-align: center;
outline: none;
}
.scan-config-strip input:focus {
.settings-stepper-row input:focus {
border-color: rgba(126, 231, 242, 0.52);
box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08);
}
.scan-config-strip p {
.settings-stepper-row input::-webkit-outer-spin-button,
.settings-stepper-row input::-webkit-inner-spin-button {
margin: 0;
color: var(--text-muted);
appearance: none;
}
.stepper-button,
.settings-presets button {
min-height: 44px;
border: 1px solid rgba(188, 154, 255, 0.22);
border-radius: 8px;
background: rgba(188, 154, 255, 0.08);
color: #f4efff;
font-weight: 900;
cursor: pointer;
}
.stepper-button {
font-size: 22px;
line-height: 1;
}
.stepper-button:hover,
.stepper-button:focus-visible,
.settings-presets button:hover,
.settings-presets button:focus-visible {
border-color: rgba(126, 231, 242, 0.45);
background: rgba(126, 231, 242, 0.12);
}
.settings-presets {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.settings-presets button {
min-height: 34px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.45;
}
.scan-settings-note {
display: grid;
gap: 4px;
border: 1px solid rgba(126, 231, 242, 0.16);
border-radius: 8px;
background: rgba(126, 231, 242, 0.06);
padding: 11px 12px;
}
.grid-detection-strip {
@@ -1682,23 +1825,142 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
overflow-wrap: anywhere;
}
.scan-evidence-timeline {
display: grid;
gap: 10px;
max-height: 520px;
overflow-y: auto;
padding-right: 4px;
}
.scan-evidence-event {
display: grid;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid rgba(255, 255, 255, 0.24);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
padding: 10px;
}
.scan-evidence-event.ok {
border-left-color: var(--mint);
}
.scan-evidence-event.warn {
border-left-color: var(--amber);
}
.scan-evidence-event.error {
border-left-color: var(--danger);
}
.scan-evidence-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.scan-evidence-header span,
.scan-evidence-header em {
color: var(--text-muted);
font-size: 11px;
font-style: normal;
font-weight: 800;
text-transform: uppercase;
}
.scan-evidence-header strong {
color: var(--text);
font-size: 13px;
}
.scan-evidence-event p {
margin: 0;
color: var(--text-soft);
font-size: 12px;
line-height: 1.35;
}
.scan-evidence-details {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.scan-evidence-details span {
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 999px;
background: rgba(0, 0, 0, 0.16);
color: var(--text-muted);
font-size: 11px;
padding: 4px 7px;
}
.scan-evidence-capture {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1.2fr);
gap: 10px;
align-items: start;
}
.scan-evidence-capture > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.scan-evidence-capture strong {
color: var(--text);
font-size: 12px;
}
.scan-evidence-capture span {
color: var(--text-muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.scan-evidence-capture .evidence-warning {
color: var(--amber);
}
.scan-evidence-images {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.scan-evidence-images img {
width: 100%;
max-height: 160px;
object-fit: contain;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
}
.scanner-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
align-items: stretch;
grid-template-columns: minmax(190px, 210px) minmax(520px, 1fr);
gap: 12px;
align-items: start;
}
.capture-stage-shell {
display: grid;
gap: 10px;
gap: 8px;
min-width: 0;
}
.capture-stage {
display: grid;
min-height: clamp(440px, 58vh, 700px);
max-height: 720px;
width: 100%;
aspect-ratio: 41 / 80;
min-height: 0;
max-height: 410px;
place-items: center;
overflow: hidden;
border: 1px solid rgba(188, 154, 255, 0.16);
@@ -1706,19 +1968,31 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
background: rgba(5, 4, 12, 0.72);
}
.capture-stage-shell.is-empty .capture-stage {
aspect-ratio: 41 / 80;
max-height: 410px;
}
.capture-stage-shell.has-capture .capture-stage {
background:
radial-gradient(circle at 50% 34%, rgba(126, 231, 242, 0.08), transparent 44%),
rgba(5, 4, 12, 0.78);
}
.capture-stage img {
display: block;
width: auto;
height: auto;
max-width: min(100%, 980px);
max-height: min(100%, 680px);
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center top;
}
.capture-stage-meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.capture-stage-meta div {
@@ -1727,7 +2001,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
padding: 10px 12px;
padding: 7px 8px;
}
.capture-stage-meta span {
@@ -1738,36 +2012,45 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.capture-stage-meta strong {
color: #f4efff;
font-size: 12px;
font-size: 11px;
overflow-wrap: anywhere;
}
.empty-stage {
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 18px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.35;
text-align: center;
}
.empty-stage strong {
color: #f4efff;
font-size: 14px;
}
.empty-stage span {
max-width: 22ch;
}
.scanner-result-panel {
display: grid;
align-content: start;
max-width: 360px;
gap: 12px;
max-width: none;
gap: 10px;
border: 1px solid rgba(126, 231, 242, 0.18);
border-radius: 8px;
background: rgba(126, 231, 242, 0.07);
padding: 14px;
padding: 12px;
}
.result-heading h3 {
margin: 3px 0 0;
font-size: 18px;
font-size: 17px;
}
.result-score {
@@ -1798,8 +2081,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-result-brief span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
min-height: 28px;
padding: 0 9px;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 999px;
background: rgba(8, 6, 18, 0.38);
@@ -1811,7 +2094,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
gap: 8px;
}
.scanner-result-caption {
@@ -2045,6 +2328,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
grid-template-columns: 1fr;
}
.player-scan-lower {
grid-template-columns: 1fr;
}
.scanner-toolbar {
align-items: stretch;
flex-direction: column;
@@ -2054,12 +2341,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
justify-content: flex-start;
}
.scan-config-strip {
grid-template-columns: 1fr 1fr;
.scan-settings-controls {
grid-template-columns: 1fr;
}
.scan-config-strip p {
grid-column: 1 / -1;
.settings-preflight {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.capture-stage-meta {
@@ -2068,7 +2355,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
}
.capture-stage img {
max-height: 640px;
max-height: 100%;
}
button:disabled {
@@ -2186,6 +2473,11 @@ button:disabled {
.diagnose-view {
display: grid;
gap: 14px;
min-height: 0;
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
width: 100%;
}
@@ -2230,9 +2522,113 @@ button:disabled {
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;
}
}
+76 -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,23 @@ export interface CaptureResult {
text: string;
};
locked?: boolean;
sanctified?: boolean;
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 +141,7 @@ export interface RuntimeInfo {
ok: boolean;
isElevated: boolean;
platform: string;
appBuild?: AppRuntimeInfo;
hotkeys?: Record<string, boolean>;
genshinFound?: boolean;
genshinHwnd?: number;
@@ -106,6 +151,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,6 +186,8 @@ export type ScannerCommand =
| {
type: "start-auto";
scanLimit?: number;
scanEntryMode?: CaptureOptions["scanEntryMode"];
ocrEngine?: CaptureOptions["ocrEngine"];
};
export interface ScannerLearningRulePayload {
@@ -184,6 +240,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 +334,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 +383,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>;