chore: initialize repository baseline

Import the existing Electron + React + TypeScript app as the version-control
baseline before the scanner rework (C# input/capture sidecar, resolution-anchored
layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop).

Housekeeping in this commit:
- Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script).
- Ignore .claude/scheduled_tasks.lock local session state.
- Add .gitattributes to normalize line endings (LF in repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-07-05 20:31:01 +02:00
commit e76d88e0c7
147 changed files with 26220 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import { AppPage } from "./pages/AppPage";
export function App() {
return <AppPage />;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import type { CaptureOptions, CaptureResult } from "../../../types/global";
import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
import type { AppControllerContext } from "../services/appControllerService";
interface AppControllerActionsInput {
appControllerContext: AppControllerContext;
}
export interface AppControllerActionsResult {
loadStoredArtifactSnapshot: () => Promise<void>;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
runDemoScan: () => Promise<void>;
exportCurrentGood: () => Promise<void>;
showOverlay: () => Promise<void>;
}
export function useAppControllerActions({
appControllerContext,
}: AppControllerActionsInput): AppControllerActionsResult {
const loadStoredArtifactSnapshot = useCallback(async () => {
await loadStoredArtifactSnapshotAction(appControllerContext);
}, [appControllerContext]);
const refreshCaptureSources = useCallback(async () => {
await refreshCaptureSourcesAction(appControllerContext);
}, [appControllerContext]);
const captureSelectedSource = useCallback(async (delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
return captureSelectedSourceAction(appControllerContext, delayMs, focusGenshin, options);
}, [appControllerContext]);
const runDemoScan = useCallback(async () => {
await runDemoScanAction(appControllerContext, captureSelectedSource);
}, [appControllerContext, captureSelectedSource]);
const exportCurrentGood = useCallback(async () => {
await exportCurrentGoodAction(appControllerContext);
}, [appControllerContext]);
const showOverlay = useCallback(async () => {
await showOverlayAction(appControllerContext);
}, [appControllerContext]);
return {
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
};
}
@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { initializeAppControllerStateAction } from "../services/appControllerService";
import type { AppControllerContext } from "../services/appControllerService";
interface AppControllerLifecycleInput {
appControllerContext: AppControllerContext;
}
export function useAppControllerLifecycle({ appControllerContext }: AppControllerLifecycleInput) {
useEffect(() => {
void initializeAppControllerStateAction(appControllerContext);
}, [appControllerContext.artifactRepo, appControllerContext.captureRepo, appControllerContext.snapshotRepo, appControllerContext.isOverlay]);
}
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import type { AppSnapshot } from "../../../types/domain";
import type { CaptureResult, CaptureSourceInfo } from "../../../types/global";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import { createAppMetrics } from "../services/appMetricsService";
import type { AppControllerResult } from "../types";
import { createInitialSnapshot, createAppControllerContext } from "../services/appControllerService";
import { useAppControllerActions } from "./useAppControllerActions";
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
import { type NavigationId } from "../../layout/types";
export function useAppControllerState(): AppControllerResult {
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
const repositories = useMemo(() => createRendererRepositories(), []);
const [snapshot, setSnapshot] = useState<AppSnapshot>(createInitialSnapshot);
const [activeView, setActiveView] = useState<NavigationId>("scan");
const [isScanning, setIsScanning] = useState(false);
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState("");
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
const [topbarStatus, setTopbarStatus] = useState(
repositories
? "Electron bridge connected. Refresh sources after Genshin is open."
: "Electron bridge missing. Open the Electron app window, not the browser preview URL.",
);
const appControllerContext = useMemo(
() =>
createAppControllerContext(repositories, {
isOverlay,
selectedSourceId,
snapshot,
setCaptureSources,
setSelectedSourceId,
setTopbarStatus,
setSnapshot,
setLatestCapture,
setIsScanning,
}),
[
repositories,
isOverlay,
selectedSourceId,
snapshot,
setCaptureSources,
setSelectedSourceId,
setTopbarStatus,
setSnapshot,
setLatestCapture,
setIsScanning,
],
);
const bridgeReady = Boolean(repositories?.isAvailable);
const canExportGood = Boolean(repositories?.canExportGood);
const canShowOverlay = Boolean(repositories?.canShowOverlay);
const { cards: metricCards } = useMemo(() => createAppMetrics(snapshot), [snapshot]);
useAppControllerLifecycle({ appControllerContext });
const {
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
} = useAppControllerActions({ appControllerContext });
return {
isOverlay,
snapshot,
activeView,
setActiveView,
isScanning,
captureSources,
selectedSourceId,
setSelectedSourceId,
latestCapture,
topbarStatus,
bridgeReady,
canExportGood,
canShowOverlay,
metricCards,
loadStoredArtifactSnapshot,
refreshCaptureSources,
captureSelectedSource,
runDemoScan,
exportCurrentGood,
showOverlay,
};
}
@@ -0,0 +1,215 @@
import { createDemoSnapshot, getPresets } from "../../../lib/demoData";
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
import { exportGood } from "../../../lib/goodFormat";
import { runAccountScan } from "../../../lib/scanner";
import type { AppSnapshot } from "../../../types/domain";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
import type {
ArtifactRepositoryPort,
CaptureRepositoryPort,
OverlayRepositoryPort,
RendererRepositories,
ScanExportPort,
SnapshotRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { Dispatch, SetStateAction } from "react";
export interface AppControllerContext {
artifactRepo?: ArtifactRepositoryPort;
captureRepo?: CaptureRepositoryPort;
exportRepo?: ScanExportPort;
overlayRepo?: OverlayRepositoryPort;
snapshotRepo?: SnapshotRepositoryPort;
isOverlay: boolean;
selectedSourceId: string;
snapshot: AppSnapshot;
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
setSelectedSourceId: (value: string) => void;
setTopbarStatus: (value: string) => void;
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
setIsScanning?: Dispatch<SetStateAction<boolean>>;
}
interface AppControllerUIState {
isOverlay: boolean;
selectedSourceId: string;
snapshot: AppSnapshot;
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
setSelectedSourceId: (value: string) => void;
setTopbarStatus: (value: string) => void;
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
setIsScanning?: Dispatch<SetStateAction<boolean>>;
}
export function createAppControllerContext(
repositories: RendererRepositories | null,
uiState: AppControllerUIState,
): AppControllerContext {
return {
artifactRepo: repositories?.artifacts,
captureRepo: repositories?.capture,
exportRepo: repositories?.export,
overlayRepo: repositories?.overlay,
snapshotRepo: repositories?.snapshot,
isOverlay: uiState.isOverlay,
selectedSourceId: uiState.selectedSourceId,
snapshot: uiState.snapshot,
setCaptureSources: uiState.setCaptureSources,
setSelectedSourceId: uiState.setSelectedSourceId,
setTopbarStatus: uiState.setTopbarStatus,
setSnapshot: uiState.setSnapshot,
setLatestCapture: uiState.setLatestCapture,
setIsScanning: uiState.setIsScanning,
};
}
export function loadStoredArtifactSnapshotAction(context: AppControllerContext): Promise<void> {
const { artifactRepo, setSnapshot } = context;
if (!artifactRepo?.loadAll) {
return Promise.resolve();
}
return artifactRepo.loadAll().then((result: ArtifactStoreLoadResult) => {
if (!result?.ok || result.artifacts.length === 0) return;
const nextSnapshot = createLocalAccountSnapshot(result.artifacts, getPresets());
if (!nextSnapshot) return;
setSnapshot(nextSnapshot);
});
}
export async function initializeAppControllerStateAction(context: AppControllerContext): Promise<void> {
const { snapshotRepo, isOverlay, setTopbarStatus, setSnapshot } = context;
try {
await loadStoredArtifactSnapshotAction(context);
if (isOverlay) {
return;
}
const stored = await snapshotRepo?.load();
if (stored) {
setSnapshot((current) =>
current.artifacts.length > 0 && current.scanEvents[0]?.id === "local-db-loaded" ? current : stored,
);
}
await refreshCaptureSourcesAction(context);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed.");
}
}
export async function refreshCaptureSourcesAction(context: AppControllerContext): Promise<void> {
const { captureRepo, selectedSourceId, setCaptureSources, setSelectedSourceId, setTopbarStatus } = context;
if (!captureRepo?.listSources) {
setTopbarStatus("Electron bridge missing. Die Browser-Vorschau kann Genshin nicht scannen - Electron-App verwenden.");
return;
}
try {
const sources = await captureRepo.listSources();
setCaptureSources(sources);
const currentStillExists = sources.some((source: CaptureSourceInfo) => source.id === selectedSourceId);
const genshinCandidate = sources.find((source: CaptureSourceInfo) => source.isGenshinCandidate);
const current = currentStillExists ? sources.find((source: CaptureSourceInfo) => source.id === selectedSourceId) : null;
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
setTopbarStatus(
`${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`,
);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources.");
}
}
export async function captureSelectedSourceAction(
context: AppControllerContext,
delayMs = 0,
focusGenshin = false,
options?: CaptureOptions,
): Promise<CaptureResult | null> {
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
if (!captureRepo?.captureSource) {
setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app.");
return null;
}
if (!selectedSourceId) {
setTopbarStatus("No capture source selected. Press Sources after Genshin is open.");
return null;
}
if (!setLatestCapture) {
setTopbarStatus("Capture state cannot be updated.");
return null;
}
try {
if (delayMs > 0) {
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
}
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
setLatestCapture(capture);
const ocrStatus = capture.ocrSkipped
? "OCR skipped for fast scan."
: capture.ocrTimedOut
? "OCR timed out; review sample needed."
: "OCR handoff is next.";
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
return capture;
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
return null;
}
}
export async function runDemoScanAction(
context: AppControllerContext,
captureSelectedSource: () => Promise<CaptureResult | null>,
): Promise<void> {
const { snapshotRepo, setSnapshot, setIsScanning, setTopbarStatus } = context;
if (!setIsScanning) {
return;
}
setIsScanning(true);
try {
await captureSelectedSource();
const next = await (snapshotRepo?.runMockScan() ?? Promise.resolve(null));
const resolved = next ?? (await runAccountScan());
setSnapshot(resolved);
await snapshotRepo?.save(resolved);
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Demo scan failed.");
} finally {
setIsScanning(false);
}
}
export function createInitialSnapshot(): AppSnapshot {
return createDemoSnapshot();
}
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
const { exportRepo, snapshot, setTopbarStatus } = context;
if (!exportRepo?.exportGood) {
setTopbarStatus("GOOD-Export ist nur in der Electron-App verfuegbar.");
return Promise.resolve();
}
if (snapshot.artifacts.length === 0) {
setTopbarStatus("Keine Artifacts zum Exportieren vorhanden.");
return Promise.resolve();
}
return exportRepo
.exportGood(exportGood(snapshot.artifacts))
.then((result: SaveResultWithPath) => {
setTopbarStatus(result.ok ? `GOOD exportiert: ${result.path}` : "GOOD-Export fehlgeschlagen.");
});
}
export async function showOverlayAction(context: AppControllerContext): Promise<void> {
const { overlayRepo, setTopbarStatus } = context;
if (!overlayRepo?.show) {
setTopbarStatus("Overlay braucht die Electron-Bridge.");
return;
}
try {
await overlayRepo.show();
} catch {
setTopbarStatus("Overlay konnte nicht gestartet werden.");
}
}
@@ -0,0 +1,43 @@
import type { ArtifactVerdict, AppSnapshot } from "../../../types/domain";
import { summarizeSnapshot } from "../../../lib/snapshotSummary";
import type { AppMetricCard } from "../../layout/types";
const verdictLabelByType: Record<ArtifactVerdict, string> = {
keep: "Keep",
maybe_level: "Test level",
character_specific: "Character-specific",
trash_candidate: "Trash",
needs_review: "Needs review",
};
export interface AppTopbarSafetyCounts {
keep: number;
maybe: number;
trash: number;
review: number;
}
export interface AppMetricsModel {
topbarSafety: AppTopbarSafetyCounts;
cards: AppMetricCard[];
}
export function createAppMetrics(snapshot: AppSnapshot): AppMetricsModel {
const summary = summarizeSnapshot(snapshot);
const recommendations = snapshot.recommendations;
const topbarSafety: AppTopbarSafetyCounts = {
keep: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "keep").length,
maybe: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "maybe_level").length,
trash: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length,
review: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length,
};
const cards: AppMetricCard[] = [
{ label: verdictLabelByType.keep, value: summary.artifacts, detail: `${topbarSafety.review} brauchen Review` },
{ label: "Behalten", value: summary.useful, detail: `${topbarSafety.keep + topbarSafety.maybe} / ${snapshot.artifacts.length} usable` },
{ label: "Trash-Kandidaten", value: summary.trash, detail: "wird nie automatisch geloescht" },
{ label: "Build-Optionen", value: summary.builds, detail: "Top-Vorschlaege verfuegbar" },
];
return { topbarSafety, cards };
}
+26
View File
@@ -0,0 +1,26 @@
import type { AppSnapshot } from "../../types/domain";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global";
import { type AppMetricCard, type NavigationId } from "../layout/types";
export interface AppControllerResult {
activeView: NavigationId;
setActiveView: (value: NavigationId) => void;
isOverlay: boolean;
isScanning: boolean;
snapshot: AppSnapshot;
captureSources: CaptureSourceInfo[];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: CaptureResult | null;
topbarStatus: string;
bridgeReady: boolean;
canExportGood: boolean;
canShowOverlay: boolean;
metricCards: AppMetricCard[];
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
runDemoScan: () => Promise<void>;
exportCurrentGood: () => Promise<void>;
loadStoredArtifactSnapshot: () => Promise<void>;
showOverlay: () => Promise<void>;
}
+8
View File
@@ -0,0 +1,8 @@
import type { AppControllerResult } from "./types";
import { useAppControllerState } from "./hooks/useAppControllerState";
export function useAppController(): AppControllerResult {
return useAppControllerState();
}
+62
View File
@@ -0,0 +1,62 @@
import { AlertTriangle } from "lucide-react";
import { useBuildCardModel } from "./hooks/useBuildCardModel";
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
import type { BuildCardProps, BuildsViewProps } from "./types";
function BuildCard({ build, snapshot }: BuildCardProps) {
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
const {
characterName,
qualityLabel,
roundedScore,
explanation,
artifactRows,
warnings,
hasWarnings,
} = useBuildCardModel({
build,
characters: snapshot.characters,
artifactLookup,
});
return (
<section className="panel build-card">
<div className="panel-heading">
<div>
<p className="eyebrow">{qualityLabel}</p>
<h2>{characterName}</h2>
</div>
<strong className="score">{roundedScore}</strong>
</div>
<p className="build-copy">{explanation}</p>
<div className="build-pieces">
{artifactRows.map((artifact) => (
<div key={artifact.id}>
<span>{artifact.slot}</span>
<strong>{artifact.setName}</strong>
<small>{artifact.details}</small>
</div>
))}
</div>
{hasWarnings && (
<div className="warning-list">
{warnings.map((warning) => (
<span key={warning}><AlertTriangle size={14} />{warning}</span>
))}
</div>
)}
</section>
);
}
export function BuildsView({ snapshot }: BuildsViewProps) {
const { visibleBuilds } = useBuildsViewModel({ snapshot });
return (
<div className="build-grid">
{visibleBuilds.map(({ build }) => (
<BuildCard key={build.id} build={build} snapshot={snapshot} />
))}
</div>
);
}
@@ -0,0 +1,49 @@
import type { BuildSuggestion } from "../../../types/domain";
import type { Artifact, Character } from "../../../types/domain";
export interface BuildCardModel {
characterName: string;
qualityLabel: string;
roundedScore: number;
explanation: string;
hasWarnings: boolean;
artifactRows: Array<{
id: string;
slot: string;
setName: string;
details: string;
}>;
warnings: string[];
}
interface UseBuildCardModelInput {
build: BuildSuggestion;
characters: Character[];
artifactLookup: Map<string, Artifact>;
}
export function useBuildCardModel({
build,
characters,
artifactLookup,
}: UseBuildCardModelInput): BuildCardModel {
const character = characters.find((entry) => entry.id === build.characterId);
const artifacts = build.artifactIds
.map((id) => artifactLookup.get(id))
.filter((artifact): artifact is Artifact => Boolean(artifact));
return {
characterName: character?.name ?? build.characterId,
qualityLabel: build.quality.replace("_", " "),
roundedScore: Math.round(build.score),
explanation: build.explanation,
hasWarnings: build.warnings.length > 0,
artifactRows: artifacts.map((artifact) => ({
id: artifact.id,
slot: artifact.slot,
setName: artifact.setName,
details: `+${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
})),
warnings: build.warnings,
};
}
@@ -0,0 +1,18 @@
import type { AppSnapshot } from "../../../types/domain";
import type { BuildSuggestion } from "../../../types/domain";
interface UseBuildsViewModelInput {
snapshot: AppSnapshot;
}
export interface BuildsViewModel {
visibleBuilds: Array<{
build: BuildSuggestion;
}>;
}
export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel {
return {
visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })),
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
export interface BuildsViewProps {
snapshot: AppSnapshot;
}
export interface BuildCardProps {
build: BuildSuggestion;
snapshot: AppSnapshot;
}
+10
View File
@@ -0,0 +1,10 @@
import { AlertTriangle, BadgeCheck, Lock, Sparkles, Trash2 } from "lucide-react";
import type { ArtifactVerdict } from "../../types/domain";
export const verdictMeta: Record<ArtifactVerdict, { label: string; className: string; icon: JSX.Element }> = {
keep: { label: "Keep", className: "pill keep", icon: <Lock size={14} /> },
maybe_level: { label: "Test level", className: "pill maybe", icon: <Sparkles size={14} /> },
character_specific: { label: "Character-specific", className: "pill specific", icon: <BadgeCheck size={14} /> },
trash_candidate: { label: "Trash candidate", className: "pill trash", icon: <Trash2 size={14} /> },
needs_review: { label: "Needs review", className: "pill review", icon: <AlertTriangle size={14} /> },
};
+145
View File
@@ -0,0 +1,145 @@
import { ShieldCheck } from "lucide-react";
import type {
AppMetricsProps,
AppShellProps,
AppSidebarProps,
AppTopbarProps,
} from "./types";
import { useAppSidebarModel } from "./hooks/useAppSidebarModel";
import { useAppTopbarModel } from "./hooks/useAppTopbarModel";
export function AppSidebar({ activeView, items, onSelect }: AppSidebarProps) {
const { navigationItems } = useAppSidebarModel({ items, onSelect });
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">GA</div>
<div>
<div className="brand-title">Artifact Assistant</div>
<div className="brand-subtitle">Scanner-first MVP</div>
</div>
</div>
<nav className="nav-list">
{navigationItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
disabled={Boolean(item.disabled)}
onClick={item.onSelect}
title={item.disabled ? item.disabledReason : undefined}
>
<Icon size={18} />
{item.label}
</button>
);
})}
</nav>
<div className="safety-card">
<ShieldCheck size={18} />
<div>
<strong>Sicherheits-Grenzen</strong>
<span>Keine Eingriffe am Spielinventar, keine Ressourcen-Aktionen, nur Analyse und Scan.</span>
</div>
</div>
</aside>
);
}
export function AppTopbar({
topbarStatus,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
onShowOverlay,
onDemoScan,
onExportGood,
exportIcon,
overlayIcon,
demoIcon,
}: AppTopbarProps) {
const {
handleShowOverlay,
handleExportGood,
handleDemoScan,
isExportDisabled,
isDemoDisabled,
isOverlayDisabled,
exportButtonLabel,
exportButtonTitle,
overlayButtonLabel,
overlayButtonTitle,
demoButtonLabel,
demoButtonTitle,
} = useAppTopbarModel({
onExportGood,
onShowOverlay,
onDemoScan,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
});
return (
<header className="topbar">
<div>
<p className="eyebrow">Lokaler Windows-Assistent</p>
<h1>Scanne dein Inventar, triff einfache Artifact-Entscheidungen.</h1>
</div>
<div className="topbar-actions">
{topbarStatus && <span className="topbar-status">{topbarStatus}</span>}
<button className="ghost-button" onClick={handleExportGood} disabled={isExportDisabled} title={exportButtonTitle}>
{exportIcon}
<span>{exportButtonLabel}</span>
</button>
<button
className="ghost-button"
onClick={handleShowOverlay}
disabled={isOverlayDisabled}
title={overlayButtonTitle}
>
{overlayIcon}
<span>{overlayButtonLabel}</span>
</button>
<button
className="ghost-button"
onClick={handleDemoScan}
disabled={isDemoDisabled}
title={demoButtonTitle}
>
{demoIcon}
{demoButtonLabel}
</button>
</div>
</header>
);
}
export function AppMetrics({ metricCards }: AppMetricsProps) {
return (
<section className="metrics-grid">
{metricCards.map((metric) => (
<div className="metric" key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.detail}</small>
</div>
))}
</section>
);
}
export function AppShell({ sidebar, children }: AppShellProps) {
return (
<div className="app-shell">
{sidebar}
<main className="main-panel">{children}</main>
</div>
);
}
@@ -0,0 +1,27 @@
import { useMemo } from "react";
import type { NavigationId } from "../types";
import type { AppNavigationItem, AppSidebarItemAction } from "../types";
export interface AppSidebarModel {
navigationItems: AppSidebarItemAction[];
}
interface UseAppSidebarModelInput {
items: AppNavigationItem[];
onSelect: (id: NavigationId) => void;
}
export function useAppSidebarModel({ items, onSelect }: UseAppSidebarModelInput): AppSidebarModel {
const navigationItems = useMemo(
() =>
items.map((item) => ({
...item,
onSelect: () => onSelect(item.id),
})),
[items, onSelect],
);
return {
navigationItems,
};
}
@@ -0,0 +1,67 @@
import { useCallback } from "react";
export interface AppTopbarModel {
handleShowOverlay: () => void;
handleExportGood: () => void;
handleDemoScan: () => void;
isExportDisabled: boolean;
isDemoDisabled: boolean;
isOverlayDisabled: boolean;
exportButtonLabel: string;
exportButtonTitle: string;
overlayButtonLabel: string;
overlayButtonTitle: string;
demoButtonLabel: string;
demoButtonTitle: string;
}
interface UseAppTopbarModelInput {
onExportGood: () => Promise<void> | void;
onShowOverlay: () => Promise<void>;
onDemoScan: () => void;
canExportGood: boolean;
canShowOverlay: boolean;
isScanning: boolean;
artifactCount: number;
}
export function useAppTopbarModel({
onExportGood,
onShowOverlay,
onDemoScan,
canExportGood,
canShowOverlay,
isScanning,
artifactCount,
}: UseAppTopbarModelInput): AppTopbarModel {
const handleShowOverlay = useCallback(() => {
void onShowOverlay();
}, [onShowOverlay]);
const handleExportGood = useCallback(() => {
void onExportGood();
}, [onExportGood]);
const handleDemoScan = useCallback(() => {
onDemoScan();
}, [onDemoScan]);
const isExportDisabled = !canExportGood || artifactCount === 0;
const isDemoDisabled = isScanning;
const isOverlayDisabled = !canShowOverlay;
return {
handleShowOverlay,
handleExportGood,
handleDemoScan,
isExportDisabled,
isDemoDisabled,
isOverlayDisabled,
exportButtonLabel: "GOOD Export",
exportButtonTitle: canExportGood ? "GOOD-Export starten." : "GOOD-Export ist nur in der Electron-App verfuegbar.",
overlayButtonLabel: "Overlay",
overlayButtonTitle: canShowOverlay ? "Overlay oeffnen." : "Overlay benoetigt die Electron-Bridge.",
demoButtonLabel: isScanning ? "Laedt..." : "Demo-Daten",
demoButtonTitle: "Laedt Beispieldaten fuer die Triage- und Build-Ansicht.",
};
}
+12
View File
@@ -0,0 +1,12 @@
export { AppShell, AppMetrics, AppSidebar, AppTopbar } from "./AppLayout";
export {
type AppMetricCard,
type AppNavigationItem,
type AppBarAction,
type NavigationId,
type AppSidebarProps,
type AppTopbarProps,
type AppMetricsProps,
type AppShellProps,
} from "./types";
export { appNavigationItems } from "./navigation";
+10
View File
@@ -0,0 +1,10 @@
import { Layers3, Radar, Wand2, Eye } from "lucide-react";
import type { AppNavigationItem } from "./types";
export const appNavigationItems: AppNavigationItem[] = [
{ id: "scan", label: "Scan", icon: Radar },
{ id: "triage", label: "Triage", icon: Layers3 },
{ id: "builds", label: "Builds", icon: Wand2 },
{ id: "overlay", label: "Overlay", icon: Eye },
];
+60
View File
@@ -0,0 +1,60 @@
import { type ComponentType, type ReactNode } from "react";
import type { LucideProps } from "lucide-react";
export type NavigationId = "scan" | "triage" | "builds" | "overlay";
export interface AppNavigationItem {
id: NavigationId;
label: string;
icon: ComponentType<LucideProps>;
disabled?: boolean;
disabledReason?: string;
}
export interface AppSidebarItemAction extends AppNavigationItem {
onSelect: () => void;
}
export interface AppMetricCard {
label: string;
value: number;
detail: string;
}
export interface AppBarAction {
id: string;
icon?: ReactNode;
label: string;
disabled?: boolean;
onAction: () => void;
title?: string;
}
export interface AppSidebarProps {
activeView: NavigationId;
items: AppNavigationItem[];
onSelect: (id: NavigationId) => void;
}
export interface AppTopbarProps {
topbarStatus: string;
canExportGood: boolean;
canShowOverlay: boolean;
isScanning: boolean;
artifactCount: number;
onExportGood: () => Promise<void> | void;
onShowOverlay: () => Promise<void>;
onDemoScan: () => void;
exportIcon?: ReactNode;
overlayIcon?: ReactNode;
demoIcon?: ReactNode;
}
export interface AppMetricsProps {
metricCards: AppMetricCard[];
}
export interface AppShellProps {
sidebar: ReactNode;
children: ReactNode;
}
+93
View File
@@ -0,0 +1,93 @@
import { Eye } from "lucide-react";
import type { OverlayPreviewProps, OverlaySettingsProps } from "./types";
import { useOverlayPreviewModel } from "./hooks/useOverlayPreviewModel";
import { useOverlaySettingsModel } from "./hooks/useOverlaySettingsModel";
export function OverlaySettings({
canShowOverlay,
onShowOverlay,
}: OverlaySettingsProps) {
const {
handleShowOverlay,
eyebrow,
title,
statusHeadline,
statusText,
buttonLabel,
buttonTitle,
} = useOverlaySettingsModel({ onShowOverlay });
return (
<section className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h2>{title}</h2>
</div>
<Eye size={20} />
</div>
<div className="overlay-settings">
<div className="overlay-settings-copy">
<strong>{statusHeadline}</strong>
<span>{statusText}</span>
</div>
<button
className="primary-button"
onClick={handleShowOverlay}
disabled={!canShowOverlay}
title={canShowOverlay ? buttonTitle : "Overlay benoetigt die Electron-Bridge."}
>
<Eye size={16} />
{buttonLabel}
</button>
</div>
</section>
);
}
export function OverlayPreview({ snapshot }: OverlayPreviewProps) {
const {
artifactName,
artifactSlotLine,
artifactSubstatsText,
artifactMainStat,
characterNames,
scoreText,
metaClassName,
metaIcon,
metaLabel,
reason,
hasArtifact,
} = useOverlayPreviewModel({ snapshot });
return (
<div className="overlay-root">
<div className="overlay-card">
<div className="overlay-card-head">
<div>
<p className="eyebrow">Reward scan</p>
<h2>{artifactName}</h2>
</div>
<strong>{scoreText}</strong>
</div>
<div className={metaClassName}>
{metaIcon}
{metaLabel}
</div>
{hasArtifact ? (
<div className="overlay-artifact-mini">
<span>{artifactSlotLine}</span>
<strong>{artifactMainStat}</strong>
<small>{artifactSubstatsText}</small>
</div>
) : null}
<div className="overlay-character-list">
{characterNames.map((name) => (
<span key={name}>{name}</span>
))}
</div>
<p>{reason}</p>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import type { AppSnapshot, Recommendation } from "../../../types/domain";
import type { ReactNode } from "react";
import { verdictMeta } from "../../common/verdictMeta";
export interface OverlayPreviewModel {
candidate: Recommendation | null;
artifactName: string;
scoreText: string;
metaClassName: string;
metaLabel: string;
metaIcon: ReactNode;
artifactSlotLine: string;
artifactSubstatsText: string;
artifactMainStat: string;
characterNames: string[];
reason: string;
hasArtifact: boolean;
}
interface UseOverlayPreviewModelInput {
snapshot: AppSnapshot;
}
export function useOverlayPreviewModel({ snapshot }: UseOverlayPreviewModelInput): OverlayPreviewModel {
const candidate = snapshot.recommendations.find((entry) => entry.verdict === "keep")
?? snapshot.recommendations.find((entry) => entry.verdict === "character_specific")
?? snapshot.recommendations.find((entry) => entry.verdict === "maybe_level")
?? snapshot.recommendations[0]
?? null;
const artifact = snapshot.artifacts.find((entry) => entry.id === candidate?.artifactId);
const meta = verdictMeta[candidate?.verdict ?? "needs_review"];
const characterNames =
candidate?.bestCharacters.map((id) => snapshot.characters.find((character) => character.id === id)?.name ?? id).slice(0, 3) ?? [];
const characterDisplayNames = characterNames.length > 0 ? characterNames : ["Review first"];
return {
candidate,
artifactName: artifact?.setName ?? "Artifact detected",
scoreText: candidate ? `${Math.round(candidate.score)}` : "--",
metaClassName: meta.className,
metaLabel: meta.label,
metaIcon: meta.icon,
artifactSlotLine: artifact ? `${artifact.slot} | +${artifact.level}` : "No artifact",
artifactSubstatsText: artifact?.substats
.slice(0, 3)
.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`)
.join(" | ") ?? "",
artifactMainStat: artifact?.mainStat ?? "No main stat",
characterNames: characterDisplayNames,
reason: candidate?.reason ?? "Waiting for artifact detail view.",
hasArtifact: Boolean(artifact),
};
}
@@ -0,0 +1,32 @@
import { useCallback } from "react";
export interface OverlaySettingsModel {
handleShowOverlay: () => void;
eyebrow: string;
title: string;
statusHeadline: string;
statusText: string;
buttonLabel: string;
buttonTitle: string;
}
interface UseOverlaySettingsModelInput {
onShowOverlay: () => void;
}
export function useOverlaySettingsModel({ onShowOverlay }: UseOverlaySettingsModelInput): OverlaySettingsModel {
const handleShowOverlay = useCallback(() => {
void onShowOverlay();
}, [onShowOverlay]);
return {
handleShowOverlay,
eyebrow: "Farming overlay",
title: "Read-only reward assistant",
statusHeadline: "Aktueller MVP-Status",
statusText:
"Click-through Preview mit echten lokalen Recommendations. Live Reward Scan und Auto-DB-Sync kommen danach.",
buttonLabel: "Show overlay preview",
buttonTitle: "Overlay oeffnen.",
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { AppSnapshot } from "../../types/domain";
export interface OverlaySettingsProps {
canShowOverlay: boolean;
onShowOverlay: () => void;
}
export interface OverlayPreviewProps {
snapshot: AppSnapshot;
}
+9
View File
@@ -0,0 +1,9 @@
import { ScanViewLayout } from "./components/ScanViewLayout";
import { useScanViewController } from "./hooks/useScanViewController";
import type { ScanViewProps } from "./types";
export function ScanView(props: ScanViewProps) {
const controller = useScanViewController(props);
return <ScanViewLayout {...props} controller={controller} />;
}
@@ -0,0 +1,104 @@
import { AlertTriangle, Camera, Eye } from "lucide-react";
import { ArtifactResultCard } from "./ScanResultCards";
import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel";
import type { ScanMainSectionProps } from "./types";
export function ScanMainSection({
latestCapture,
captureStatus,
parsedArtifact,
sourceLabel,
gridLabel,
inventoryLabel,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
autoScanRunning,
canOpenReviewQueue,
openReviewQueue,
}: ScanMainSectionProps) {
const {
canOpenDetails,
handleOpenDetails,
handleOpenReviewQueue,
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">
{hasCapture ? (
<img src={captureImageSrc} alt={captureImageAlt} />
) : (
<div className="empty-stage">
<Camera size={28} />
<strong>Noch kein Bild</strong>
<span>{noCaptureMessage}</span>
</div>
)}
</div>
<div className="capture-stage-meta">
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
<div><span>Grid</span><strong>{gridLabel}</strong></div>
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
<div><span>Modus</span><strong>{captureModeText}</strong></div>
</div>
</div>
<aside className="scanner-result-panel">
<div className="result-heading">
<p className="eyebrow">Zuletzt gelesen</p>
<h3>{resultHeading}</h3>
</div>
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
<p className="result-empty">{noArtifactText}</p>
)}
<div className="scanner-result-brief">
<span>{targetLabel}</span>
<span>{dbLabel}</span>
<span>{reviewLabel}</span>
<span>{rulesLabel}</span>
</div>
<div className="scanner-result-actions">
<button
className="ghost-button"
onClick={handleOpenDetails}
disabled={!canOpenDetails}
>
<Eye size={15} />
Details
</button>
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
<AlertTriangle size={15} />
Review Queue
</button>
</div>
<p className="scanner-result-caption">{captureStatus}</p>
</aside>
</div>
);
}
@@ -0,0 +1,57 @@
import { ScanDiagnosticsModal } from "./modals/ScanDiagnosticsModal";
import { ScanSettingsModal } from "./modals/ScanSettingsModal";
import { ScanDetailsModal } from "./modals/ScanDetailsModal";
import { ScanReviewQueueModal } from "./modals/ScanReviewQueueModal";
import { ScanSummaryModal } from "./modals/ScanSummaryModal";
import type { ScanModalsSectionProps } from "./types";
export function ScanModalsSection({
captureStatus,
latestCapture,
diagnosticsOpen,
settingsOpen,
detailsOpen,
reviewQueueOpen,
controller,
setDiagnosticsOpen,
setSettingsOpen,
setDetailsOpen,
setReviewQueueOpen,
setScanSummary,
}: ScanModalsSectionProps) {
return (
<>
<ScanDiagnosticsModal
open={diagnosticsOpen}
captureStatus={captureStatus}
latestCapture={latestCapture}
controller={controller}
setDetailsOpen={setDetailsOpen}
setDiagnosticsOpen={setDiagnosticsOpen}
/>
<ScanSettingsModal
open={settingsOpen}
latestCapture={latestCapture}
controller={controller}
setSettingsOpen={setSettingsOpen}
/>
<ScanDetailsModal
open={detailsOpen}
latestCapture={latestCapture}
controller={controller}
setDetailsOpen={setDetailsOpen}
/>
<ScanReviewQueueModal
open={reviewQueueOpen}
controller={controller}
setReviewQueueOpen={setReviewQueueOpen}
/>
<ScanSummaryModal
open={Boolean(controller.scanSummary)}
controller={controller}
setScanSummary={setScanSummary}
devMode={controller.devMode}
/>
</>
);
}
@@ -0,0 +1,116 @@
import type { ArtifactResultCardProps, FieldConfidenceListProps, ReviewSampleCardProps, ScanSummaryFooterProps } from "./types";
import { useFieldConfidenceRowsModel, useReviewSampleCardModel, useScanResultCardModel } from "./hooks/useScanResultCardsModel";
import { useScanSummaryFooterModel } from "./hooks/useScanSummaryFooterModel";
export function FieldConfidenceList({ parsedArtifact }: FieldConfidenceListProps) {
const rows = useFieldConfidenceRowsModel({ parsedArtifact });
return (
<div className="field-confidence-list">
{rows.map(({ label, field, confidenceClassName }) => (
<div className={`field-confidence ${confidenceClassName}`} key={label}>
<span>{label}</span>
<strong>{field.confidence}%</strong>
<small>{field.source}</small>
</div>
))}
</div>
);
}
export function ArtifactResultCard({ parsed }: ArtifactResultCardProps) {
const { quality, levelText, equippedText, substats, noSubstatsText } = useScanResultCardModel({ parsed });
return (
<div className="artifact-result-card">
<div className="artifact-result-head">
<span className="artifact-set">{parsed.setName}</span>
<span className={`quality-chip ${quality.className}`}>{quality.label}</span>
</div>
<span className="artifact-slot">{parsed.slot}</span>
<div className="artifact-mainstat">
<span>Hauptwert</span>
<strong>{parsed.mainStat}</strong>
<em>{parsed.mainValue}</em>
</div>
<div className="artifact-equipped">
{levelText}
</div>
<div className="artifact-substats">
{substats.length > 0 ? substats.map((substat) => <span key={substat}>{substat}</span>) : <span className="none">{noSubstatsText}</span>}
</div>
<div className="artifact-equipped">
{equippedText}
</div>
</div>
);
}
export function ReviewSampleCard({ entry }: ReviewSampleCardProps) {
const model = useReviewSampleCardModel({ entry });
const {
artifactTitle,
reasonText,
sourceText,
captureTargetText,
resolutionText,
gridText,
ocrCountText,
parsedSlotText,
parsedMainText,
parsedSetText,
parsedEquippedText,
savedAtText,
showParsed,
ocrRows,
} = model;
return (
<article className="review-sample-card">
<div className="review-sample-head">
<div>
<strong>{artifactTitle}</strong>
<span>{reasonText}</span>
</div>
<time>{savedAtText}</time>
</div>
<div className="review-sample-meta">
<span>{sourceText}</span>
{captureTargetText && <span>{captureTargetText}</span>}
<span>{resolutionText}</span>
<span>{gridText}</span>
<span>{ocrCountText}</span>
</div>
{showParsed && (
<div className="review-sample-parsed">
<span>{parsedSlotText}</span>
<strong>{parsedMainText}</strong>
<span>{parsedSetText}</span>
<span>{parsedEquippedText}</span>
</div>
)}
{ocrRows.length > 0 && (
<div className="review-sample-ocr">
{ocrRows.map((ocrRow) => (
<span key={ocrRow.id}>{ocrRow.text}</span>
))}
</div>
)}
</article>
);
}
export function ScanSummaryFooter({ devMode, scanSummary, storedTotal }: ScanSummaryFooterProps) {
const { summaryCopy, devCopy } = useScanSummaryFooterModel({
devMode,
scanSummary,
storedTotal,
});
return (
<>
<p className="scan-summary-copy">{summaryCopy}</p>
{devCopy && <p className="scan-summary-dev">{devCopy}</p>}
</>
);
}
@@ -0,0 +1,188 @@
import {
AlertTriangle,
Camera,
Play,
Radar,
RefreshCw,
SlidersHorizontal,
Wrench,
} from "lucide-react";
import type { ScanTopControlsSectionProps } from "./types";
import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel";
export function ScanTopControlsSection({
captureSources,
selectedSourceId,
setSelectedSourceId,
refreshCaptureSources,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps) {
const {
shouldShowGenshinSourceButton,
bridgeDisabled,
canStartAutoScan,
canStartManualScan,
canCaptureSingle,
autoScanRunning,
handleSourceChange,
selectGenshinSource,
openSettings,
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
runtimeStatusText,
runtimePillClass,
playerStatusText,
autoScanButtonTitle,
autoScanButtonLabel,
manualScanButtonTitle,
captureSingleButtonTitle,
refreshCaptureSourcesTitle,
diagnosticsButtonTitle,
scanSetupButtonTitle,
showPlayerProgress,
progressWidth,
progressStats,
} = useScanTopControlsModel({
captureSources,
selectedSourceId,
setSelectedSourceId,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
});
return (
<>
<div className="scanner-header">
<div>
<p className="eyebrow">Scanner</p>
<h2>Artifact capture workspace</h2>
<p className="scanner-subcopy">Grosse Vorschau vorn, klare Aktionen oben, Diagnose und Review nur bei Bedarf.</p>
</div>
<div className="scanner-header-pills">
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
<span className={`runtime-pill ${runtimePillClass}`}>{runtimeStatusText}</span>
</div>
</div>
{!bridgeReady && (
<div className="bridge-banner">
<AlertTriangle size={16} />
Die Capture-Verbindung fehlt. Bitte das Electron-App-Fenster verwenden - die Browser-Vorschau kann Genshin nicht scannen.
</div>
)}
<div className="player-scan-card">
<div className="player-scan-row">
<label className="source-select">
<span>Quelle</span>
<select value={selectedSourceId} onChange={handleSourceChange} disabled={bridgeDisabled}>
{captureSources.length === 0 && <option value="">Keine Quellen gefunden</option>}
{captureSources.map((source) => (
<option key={source.id} value={source.id}>
{source.isGenshinCandidate ? "Genshin - " : ""}{source.name}
</option>
))}
</select>
</label>
<button
className="ghost-button"
onClick={refreshCaptureSources}
title={refreshCaptureSourcesTitle}
disabled={!bridgeReady}
>
<RefreshCw size={15} />
Neu suchen
</button>
{shouldShowGenshinSourceButton && (
<button className="ghost-button" onClick={selectGenshinSource} disabled={!bridgeReady}>
<Radar size={15} />
Genshin waehlen
</button>
)}
<button
className="ghost-button"
onClick={openSettings}
disabled={bridgeDisabled}
title={scanSetupButtonTitle}
>
<SlidersHorizontal size={15} />
Scan-Setup
</button>
<button
className="ghost-button dev-toggle"
onClick={openDiagnostics}
disabled={!bridgeReady}
title={diagnosticsButtonTitle}
>
<Wrench size={15} />
Scanner Diagnose
</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
</button>
)}
</div>
<p className="player-status">
{playerStatusText}
</p>
{showPlayerProgress && (
<div className="player-progress">
<div className="player-progress-bar">
<div style={{ width: `${progressWidth}%` }} />
</div>
<div className="player-progress-stats">
{progressStats.map((entry) => (
<span key={entry.label} className={entry.extraClass}>
<strong>{entry.value}</strong> {entry.label}
</span>
))}
</div>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,85 @@
import { ScanMainSection } from "./ScanMainSection";
import { ScanModalsSection } from "./ScanModalsSection";
import { ScanTopControlsSection } from "./ScanTopControlsSection";
import type { ScanViewLayoutProps } from "./types";
export function ScanViewLayout({
captureSources,
selectedSourceId,
setSelectedSourceId,
latestCapture,
captureStatus,
refreshCaptureSources,
captureSelectedSource,
bridgeReady,
controller,
isScanning,
}: ScanViewLayoutProps) {
const {
setDetailsOpen,
setDiagnosticsOpen,
setSettingsOpen,
setReviewQueueOpen,
setScanSummary,
reviewSampleTotal,
autoScanRunning,
storedTotal,
learningRulesLoaded,
parsedArtifact,
learningRuleCount,
activeTargetCount,
canReadReviewQueue,
sourceLabel,
gridLabel,
inventoryLabel,
openReviewQueue,
} = controller;
return (
<section className="scanner-workbench">
<ScanTopControlsSection
captureSources={captureSources}
selectedSourceId={selectedSourceId}
setSelectedSourceId={setSelectedSourceId}
refreshCaptureSources={refreshCaptureSources}
captureSelectedSource={captureSelectedSource}
bridgeReady={bridgeReady}
isScanning={isScanning}
controller={controller}
/>
<ScanMainSection
latestCapture={latestCapture}
captureStatus={captureStatus}
parsedArtifact={parsedArtifact}
sourceLabel={sourceLabel}
gridLabel={gridLabel}
inventoryLabel={inventoryLabel}
activeTargetCount={activeTargetCount}
storedTotal={storedTotal}
reviewSampleTotal={reviewSampleTotal}
learningRulesLoaded={learningRulesLoaded}
learningRuleCount={learningRuleCount}
canOpenReviewQueue={!autoScanRunning && canReadReviewQueue}
openReviewQueue={openReviewQueue}
autoScanRunning={autoScanRunning}
setDetailsOpen={setDetailsOpen}
/>
<ScanModalsSection
captureStatus={captureStatus}
latestCapture={latestCapture}
diagnosticsOpen={Boolean(controller.diagnosticsOpen)}
settingsOpen={Boolean(controller.settingsOpen)}
detailsOpen={Boolean(controller.detailsOpen)}
reviewQueueOpen={Boolean(controller.reviewQueueOpen)}
controller={controller}
setDiagnosticsOpen={setDiagnosticsOpen}
setSettingsOpen={setSettingsOpen}
setDetailsOpen={setDetailsOpen}
setReviewQueueOpen={setReviewQueueOpen}
setScanSummary={setScanSummary}
/>
</section>
);
}
@@ -0,0 +1,81 @@
import { useCallback } from "react";
import type { ScanMainSectionProps } from "../types";
export interface ScanMainSectionModel {
canOpenDetails: boolean;
handleOpenDetails: () => void;
handleOpenReviewQueue: () => void;
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<
ScanMainSectionProps,
| "latestCapture"
| "parsedArtifact"
| "activeTargetCount"
| "storedTotal"
| "reviewSampleTotal"
| "learningRulesLoaded"
| "learningRuleCount"
| "setDetailsOpen"
| "openReviewQueue"
>;
export function useScanMainSectionModel({
latestCapture,
parsedArtifact,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
openReviewQueue,
}: UseScanMainSectionModelProps): ScanMainSectionModel {
const handleOpenDetails = useCallback(() => {
setDetailsOpen(true);
}, [setDetailsOpen]);
const handleOpenReviewQueue = useCallback(() => {
void openReviewQueue();
}, [openReviewQueue]);
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
const captureImageSrc = latestCapture ? latestCapture.detailDataUrl ?? latestCapture.dataUrl : "";
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,
handleOpenDetails,
handleOpenReviewQueue,
captureImageSrc,
captureImageAlt,
hasCapture: Boolean(latestCapture),
captureModeText,
resultHeading,
noArtifactText,
noCaptureMessage,
targetLabel,
dbLabel,
reviewLabel,
rulesLabel,
};
}
@@ -0,0 +1,166 @@
import type { ArtifactResultCardProps, FieldConfidenceListProps } from "../types";
import type { ParsedArtifactCandidate, ParsedField } from "../../../../lib/artifactOcrParser";
import type { ReviewSampleRecord } from "../../../../types/global";
export interface FieldConfidenceRowModel {
label: string;
field: ParsedField;
confidenceClassName: string;
}
export interface ScanResultCardModel {
levelField: ParsedField;
quality: {
label: string;
className: string;
};
fieldRows: FieldConfidenceRowModel[];
levelText: string;
equippedText: string;
substats: string[];
noSubstatsText: string;
}
export interface ReviewSampleCardModel {
artifactTitle: string;
reasonText: string;
sourceText: string;
captureTargetText: string | null;
resolutionText: string;
gridText: string;
ocrCountText: string;
parsedSlotText: string;
parsedMainText: string;
parsedSetText: string;
parsedEquippedText: string;
savedAtText: string;
showParsed: boolean;
ocrRows: Array<{ id: string; label: string; text: string }>;
}
export function useFieldConfidenceRowsModel({ parsedArtifact }: FieldConfidenceListProps): FieldConfidenceRowModel[] {
const rows: Array<[string, ParsedField]> = [
["Name", parsedArtifact.fields.name],
["Slot", parsedArtifact.fields.slot],
["Level", getLevelField(parsedArtifact)],
["Main", mergeField(parsedArtifact.fields.mainStat, parsedArtifact.fields.mainValue)],
["Set", parsedArtifact.fields.setName],
["Equipped", parsedArtifact.fields.equipped],
["Substats", parsedArtifact.fields.substats],
];
return rows.map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
}));
}
export function useScanResultCardModel({
parsed,
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
const substats = parsed.substats;
return {
levelField: getLevelField(parsed),
quality: resolveQuality(parsed.confidence),
fieldRows: [
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
].map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
})),
levelText: resolveLevelText(parsed),
equippedText: resolveEquippedText(parsed.equipped),
substats,
noSubstatsText: "Keine Substats gelesen",
};
}
export function useReviewSampleCardModel({ entry }: { entry: ReviewSampleRecord }): ReviewSampleCardModel {
const parsed = entry.sample?.parsed as Partial<ParsedArtifactCandidate> | undefined;
const capture = entry.sample?.capture;
const ocr = capture?.ocr ?? [];
const grid = capture?.inventoryGrid;
const date = new Date(entry.savedAt);
const savedAtText = Number.isNaN(date.getTime()) ? "Unbekannter Zeitpunkt" : date.toLocaleString();
return {
artifactTitle: parsed?.name || "Unparsed capture",
reasonText: entry.sample?.reason || "manual",
sourceText: capture?.name || "Unknown source",
captureTargetText: capture?.captureTarget || null,
resolutionText: `${capture?.width ?? "?"}x${capture?.height ?? "?"}`,
gridText: grid ? `${grid.cols}x${grid.rows} grid ${grid.confidence}%` : "no grid",
ocrCountText: `${ocr.length} OCR fields`,
parsedSlotText: parsed?.slot || "Unknown slot",
parsedMainText: `${parsed?.mainStat || "Unknown main"} ${parsed?.mainValue || ""}`,
parsedSetText: parsed?.setName || "Unknown set",
parsedEquippedText: parsed?.equipped || "Not detected",
savedAtText,
showParsed: Boolean(parsed),
ocrRows: ocr.slice(0, 4).map((item) => ({
id: item.id,
label: item.label,
text: `${item.label}: ${item.confidence}%`,
})),
};
}
function getLevelField(parsedArtifact: ParsedArtifactCandidate): ParsedField {
const fieldLevel = parsedArtifact.fields.level;
if (fieldLevel?.value) {
return fieldLevel;
}
const derivedLevel = parsedArtifact.level;
return {
value: `${derivedLevel}`,
confidence: derivedLevel > 0 ? 80 : 0,
source: derivedLevel > 0 ? "derived" : "missing",
};
}
function resolveQuality(confidence: number): { label: string; className: string } {
return confidence >= 85
? { label: "Sauber gelesen", className: "good" }
: confidence >= 70
? { label: "Etwas unsicher", className: "mid" }
: { label: "Unsicher - bitte prüfen", className: "low" };
}
function mergeField(primary: ParsedField, secondary: ParsedField): ParsedField {
const confidence = primary.value && secondary.value
? Math.round((primary.confidence + secondary.confidence) / 2)
: Math.min(primary.confidence, secondary.confidence);
const source = primary.source === secondary.source
? primary.source
: primary.source === "missing"
? secondary.source
: primary.source;
return {
value: `${primary.value} ${secondary.value}`.trim(),
confidence,
source,
};
}
function resolveFieldConfidenceClass(field: ParsedField): string {
return field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high";
}
function resolveLevelText(parsed: ParsedArtifactCandidate): string {
return parsed.level > 0 ? `Level +${parsed.level}` : "Level nicht erkannt";
}
function resolveEquippedText(equipped: string | null | undefined): string {
return equipped && equipped !== "Not detected" ? `Ausgerüstet: ${equipped}` : "Nicht Ausgerüstet / nicht erkannt";
}
@@ -0,0 +1,25 @@
import type { ScanSummaryFooterProps } from "../types";
export interface ScanSummaryFooterModel {
summaryCopy: string;
devCopy: string | null;
}
export function useScanSummaryFooterModel({
devMode,
scanSummary,
storedTotal,
}: 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.`;
const devCopy = devMode
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
: null;
return {
summaryCopy,
devCopy,
};
}
@@ -0,0 +1,143 @@
import { useCallback, useMemo } from "react";
import type { ChangeEvent } from "react";
import type { ScanTopControlsSectionProps } from "../types";
export interface ScanTopControlsModel {
shouldShowGenshinSourceButton: boolean;
bridgeDisabled: boolean;
canStartAutoScan: boolean;
canStartManualScan: boolean;
canCaptureSingle: boolean;
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
selectGenshinSource: () => void;
openSettings: () => void;
openDiagnostics: () => void;
captureSingleArtifact: () => void;
stopScan: () => void;
runVisibleGridScan: () => void;
runAutoReviewScan: () => void;
bridgeStatusText: string;
bridgePillClass: string;
runtimeStatusText: string;
runtimePillClass: string;
playerStatusText: string;
autoScanButtonTitle: string;
autoScanButtonLabel: string;
manualScanButtonTitle: string;
captureSingleButtonTitle: string;
refreshCaptureSourcesTitle: string;
diagnosticsButtonTitle: string;
scanSetupButtonTitle: string;
showPlayerProgress: boolean;
progressWidth: number;
progressStats: Array<{ label: string; value: number | string; extraClass?: string }>;
}
export function useScanTopControlsModel({
captureSources,
selectedSourceId,
setSelectedSourceId,
captureSelectedSource,
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps): ScanTopControlsModel {
const {
setSettingsOpen,
setDiagnosticsOpen,
requestScanStop,
runVisibleGridScan,
runAutoReviewScan,
autoScanRunning,
canCaptureSource,
canAutoScan,
hasSourceSelected,
requiresAdminForAutoScan,
reviewStatus,
runtimeInfo,
autoScanStats,
storedTotal,
scanProgressPercent,
genshinSource,
} = controller;
const handleSourceChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => setSelectedSourceId(event.target.value),
[setSelectedSourceId],
);
const selectGenshinSource = useCallback(() => {
if (genshinSource) {
setSelectedSourceId(genshinSource.id);
}
}, [genshinSource, setSelectedSourceId]);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
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 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.";
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.";
const refreshCaptureSourcesTitle = "Fenster- und Bildschirmquellen neu suchen";
const diagnosticsButtonTitle = bridgeReady
? "Scanner Diagnose oeffnen: Rechte, Grid-Erkennung und Logs."
: "Scanner Diagnose ist nur in der Electron-App vollstaendig nutzbar.";
const scanSetupButtonTitle = bridgeReady
? "Scan-Ziel, Skip-Zeilen und Operator-Optionen anpassen."
: "Scan-Setup ist nur in der Electron-App vollstaendig nutzbar.";
const showPlayerProgress = autoScanRunning || autoScanStats.clicked > 0 || autoScanStats.parsed > 0;
const progressStats = useMemo(
() => [
{ label: "Klicks", value: autoScanStats.clicked },
{ label: "Positionen", value: autoScanStats.attempted },
{ label: "Verifiziert", value: autoScanStats.verified },
{ 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],
);
return {
shouldShowGenshinSourceButton: Boolean(genshinSource && selectedSourceId !== genshinSource.id),
bridgeDisabled: !bridgeReady || captureSources.length === 0,
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
handleSourceChange,
selectGenshinSource,
openSettings,
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
runtimeStatusText,
runtimePillClass,
playerStatusText,
autoScanButtonTitle,
autoScanButtonLabel,
manualScanButtonTitle,
captureSingleButtonTitle,
refreshCaptureSourcesTitle,
diagnosticsButtonTitle,
scanSetupButtonTitle,
showPlayerProgress,
progressWidth: scanProgressPercent,
progressStats,
};
}
@@ -0,0 +1,83 @@
import { FieldConfidenceList } from "../ScanResultCards";
import type { ScanDetailsModalProps } from "./types";
import { useScanDetailsModalModel } from "./hooks/useScanDetailsModalModel";
export function ScanDetailsModal({
open,
latestCapture,
controller,
setDetailsOpen,
}: ScanDetailsModalProps) {
const { parsedArtifact } = controller;
const {
closeDetails,
stopPropagation,
parsedNotes,
showParsedNotes,
cropRows,
ocrRows,
debugText,
showCrops,
showOcr,
} = useScanDetailsModalModel({
setDetailsOpen,
parsedArtifact,
latestCapture,
});
if (!open || !latestCapture) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeDetails}>
<div className="modal-panel" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Dev</p>
<h2>Crops, OCR & Confidence</h2>
</div>
<button className="ghost-button" onClick={closeDetails}>Schliessen</button>
</div>
<div className="modal-body">
{parsedArtifact && (
<>
<FieldConfidenceList parsedArtifact={parsedArtifact} />
{showParsedNotes && (
<div className="parsed-notes compact">
{parsedNotes.map((note) => <span key={note}>{note}</span>)}
</div>
)}
</>
)}
{showCrops && (
<div className="crop-grid details-grid">
{cropRows.map((crop) => (
<div className="crop-card" key={crop.id}>
<img src={crop.dataUrl} alt={crop.label} />
<div>
<strong>{crop.label}</strong>
<span>{crop.x},{crop.y} - {crop.width}x{crop.height}</span>
</div>
</div>
))}
</div>
)}
{showOcr && (
<div className="ocr-panel">
<strong>OCR candidates</strong>
{ocrRows.map((entry) => (
<div className="ocr-row" key={entry.id}>
<div>
<span>{entry.label}</span>
<small>{entry.confidence}% confidence</small>
</div>
<pre>{entry.text}</pre>
</div>
))}
</div>
)}
<p className="capture-debug">{debugText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
import { AlertTriangle, Eye, Wrench } from "lucide-react";
import type { ScanDiagnosticsModalProps } from "./types";
import { useScanDiagnosticsModalModel } from "./hooks/useScanDiagnosticsModalModel";
export function ScanDiagnosticsModal({
open,
captureStatus,
latestCapture,
controller,
setDetailsOpen,
setDiagnosticsOpen,
}: ScanDiagnosticsModalProps) {
const {
toggleDevMode,
} = controller;
const {
closeDiagnostics,
openDetails,
handleSaveReviewSample,
stopPropagation,
canOpenDetails,
statusTitle,
rightsClassName,
rightsValue,
genshinClassName,
genshinValue,
shouldShowAdminBanner,
gridSourceClass,
gridMainValue,
gridMetaValue,
learningRulesText,
learningRulesSubtext,
autoScanModeLabel,
autoScanRunning,
fingerprintText,
runtimeRows,
scanLimitText,
scanTipText,
autoScanStatsLines,
playerProgress,
showDevRows,
reviewStatus,
automationLogLines,
canSaveReviewSample,
} = useScanDiagnosticsModalModel({
setDetailsOpen,
setDiagnosticsOpen,
saveReviewSample: controller.saveReviewSample,
canSaveReviewSample: controller.canSaveReviewSample,
latestCapture,
controller,
captureStatus,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeDiagnostics}>
<div className="modal-panel scanner-diagnostics-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Scanner Diagnose</p>
<h2>Input, Grid & Lernstatus</h2>
</div>
<button className="ghost-button" onClick={closeDiagnostics}>Schliessen</button>
</div>
<div className="modal-body">
<div className="diagnostics-actions">
<button className="ghost-button" onClick={toggleDevMode}>
<Wrench size={15} />
{statusTitle}
</button>
</div>
<div className="scanner-preflight diagnostics-preflight">
<div className={rightsClassName}>
<span>App-Rechte</span>
<strong>{rightsValue}</strong>
</div>
<div className={genshinClassName}>
<span>Genshin</span>
<strong>{genshinValue}</strong>
</div>
</div>
{shouldShowAdminBanner && (
<p className="scanner-subcopy">
App laeuft im Standard-Modus. Auto-Scan braucht Administrator-Rechte: App schliessen und als Administrator neu starten (z.B. Terminal per Rechtsklick "Als Administrator ausfuehren" und darin "npm run dev").
</p>
)}
<div className={`grid-detection-strip ${gridSourceClass}`}>
<span>Tile grid</span>
<strong>{gridMainValue}</strong>
<small>{gridMetaValue}</small>
</div>
<div className="learning-strip">
<span>Learning</span>
<strong>{learningRulesText}</strong>
<small>{learningRulesSubtext}</small>
</div>
{playerProgress.show && (
<div className="auto-scan-strip">
<span>{autoScanModeLabel}</span>
{autoScanStatsLines.map((entry) => (
<span className="auto-scan-stat" key={entry.label}>
<strong>{entry.value}</strong>
<small>{entry.label}</small>
</span>
))}
</div>
)}
<div className="learning-strip">
<span>Fingerprint</span>
<strong>{fingerprintText}</strong>
<small>Active capture fingerprint used for deterministic duplicate guard checks.</small>
</div>
{showDevRows && (
<div className="scanner-status-row diagnostics-status">
{runtimeRows.map((row) => (
<span key={row}>{row}</span>
))}
</div>
)}
{autoScanRunning && playerProgress.show && (
<div className="player-progress">
<div className="player-progress-bar">
<div style={{ width: `${playerProgress.width}%` }} />
</div>
</div>
)}
{reviewStatus && (
<p className="review-status">{reviewStatus}</p>
)}
<div className="automation-log">
<span>Automation</span>
<div className="automation-log-lines">
{automationLogLines.length > 0 ? (
automationLogLines.map((line, index) => (
<span key={`${index}-${line}`}>{line}</span>
))
) : (
<strong>No scan activity yet.</strong>
)}
</div>
</div>
<div className="dev-section-actions">
<button className="ghost-button" onClick={openDetails} disabled={!canOpenDetails}>
<Eye size={15} />
Crops, OCR & Confidence
</button>
<button className="ghost-button" onClick={handleSaveReviewSample} disabled={!canSaveReviewSample}>
<AlertTriangle size={15} />
Review-Sample speichern
</button>
</div>
<p className="scanner-result-caption">{scanLimitText}</p>
<p className="scan-summary-copy">{scanTipText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { AlertTriangle } from "lucide-react";
import { ReviewSampleCard } from "../ScanResultCards";
import type { ScanReviewQueueModalProps } from "./types";
import { useScanReviewQueueModalModel } from "./hooks/useScanReviewQueueModalModel";
export function ScanReviewQueueModal({
open,
controller,
setReviewQueueOpen,
}: ScanReviewQueueModalProps) {
const { reviewSamples, reviewSampleTotal, reviewAnalysis, canReadReviewQueue, loadReviewQueue } = controller;
const {
closeReviewQueue,
refreshReviewQueue,
stopPropagation,
emptyText,
reviewAnalysisRows,
showReviewAnalysis,
} = useScanReviewQueueModalModel({
setReviewQueueOpen,
loadReviewQueue,
reviewAnalysis,
reviewSampleTotal,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeReviewQueue}>
<div className="modal-panel review-queue-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Review Queue</p>
<h2>Unsichere Scanner-Faelle</h2>
</div>
<button className="ghost-button" onClick={closeReviewQueue}>Schliessen</button>
</div>
<div className="modal-body">
<div className="review-queue-summary">
<strong>{reviewSampleTotal}</strong>
<span>Samples gespeichert</span>
<button className="ghost-button" onClick={refreshReviewQueue} disabled={!canReadReviewQueue}>Aktualisieren</button>
</div>
{showReviewAnalysis && (
<div className="review-analysis">
{reviewAnalysisRows.map((row) => (
<div key={row.label}>
<span>{row.label}</span><strong>{row.value}</strong>
</div>
))}
</div>
)}
{reviewSamples.length === 0 ? (
<div className="empty-stage compact">
<AlertTriangle size={22} />
<strong>{emptyText}</strong>
<span>Unsichere Auto-Scan- oder OCR-Faelle erscheinen hier automatisch.</span>
</div>
) : (
<div className="review-sample-list">
{reviewSamples.map((entry, index) => (
<ReviewSampleCard entry={entry} key={`${entry.savedAt}-${index}`} />
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
import type { ScanSettingsModalProps } from "./types";
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
export function ScanSettingsModal({
open,
latestCapture,
controller,
setSettingsOpen,
}: ScanSettingsModalProps) {
const {
scanLimit,
skipRows,
runtimeInfo,
activeTargetCount,
} = controller;
const {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
inventoryCountText,
inventoryClassName,
scanLimitClassName,
skipRowsClassName,
activeTargetText,
scanSummaryText,
} = useScanSettingsModalModel({
setSettingsOpen,
setScanLimit: controller.setScanLimit,
setScanLimitTouched: controller.setScanLimitTouched,
setSkipRows: controller.setSkipRows,
latestCapture,
scanLimit,
skipRows,
activeTargetCount,
runtimeInfo,
});
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onClick={closeSettings}>
<div className="modal-panel scanner-settings-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="modal-header">
<div>
<p className="eyebrow">Scan-Setup</p>
<h2>Operator-Einstellungen</h2>
</div>
<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>
<div className="scanner-preflight diagnostics-preflight">
<div className={inventoryClassName}>
<span>Inventarzaehler</span>
<strong>{inventoryCountText}</strong>
</div>
<div className={scanLimitClassName}>
<span>Limit</span>
<strong>{scanLimit}</strong>
</div>
<div className={skipRowsClassName}>
<span>Skip</span>
<strong>{skipRows}</strong>
</div>
<p>{activeTargetText}</p>
</div>
<p className="scan-summary-copy">{scanSummaryText}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { Play, AlertTriangle } from "lucide-react";
import { ScanSummaryFooter } from "../ScanResultCards";
import type { ScanSummaryModalProps } from "./types";
import { useScanSummaryModalModel } from "./hooks/useScanSummaryModalModel";
export function ScanSummaryModal({
open,
controller,
setScanSummary,
devMode,
}: ScanSummaryModalProps) {
const { closeSummary, stopPropagation, iconKind, summaryTitle, gridSummaryText } = useScanSummaryModalModel({
setScanSummary,
scanSummary: controller.scanSummary,
});
if (!open || !controller.scanSummary) return null;
return (
<div className="modal-backdrop scan-summary-backdrop" role="presentation" onClick={closeSummary}>
<div className="scan-summary-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
<div className="scan-summary-icon">
{iconKind === "blocked" ? <AlertTriangle size={24} /> : <Play size={24} />}
</div>
<div>
<p className="eyebrow">Scan-Ergebnis</p>
<h2>{summaryTitle}</h2>
{gridSummaryText && <p className="scan-summary-copy">{gridSummaryText}</p>}
</div>
<div className="scan-summary-grid player">
<div><strong>{controller.scanSummary.stored}</strong><span>neu gespeichert</span></div>
<div><strong>{controller.scanSummary.review}</strong><span>unsicher (Review)</span></div>
<div><strong>{controller.scanSummary.duplicates}</strong><span>Duplikate</span></div>
<div><strong>{controller.scanSummary.verified}</strong><span>verifizierte Ansichten</span></div>
</div>
<ScanSummaryFooter devMode={devMode} scanSummary={controller.scanSummary} storedTotal={controller.storedTotal} />
<button className="primary-button" onClick={closeSummary}>Ok</button>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanDetailsModalProps } from "../types";
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
export interface ScanDetailsModalModel {
closeDetails: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
parsedNotes: string[];
showParsedNotes: boolean;
cropRows: Array<{ id: string; dataUrl: string; label: string; x: number; y: number; width: number; height: number }>;
ocrRows: Array<{ id: string; label: string; confidence: number; text: string }>;
debugText: string;
showCrops: boolean;
showOcr: boolean;
}
interface UseScanDetailsModalModelInput {
setDetailsOpen: ScanDetailsModalProps["setDetailsOpen"];
parsedArtifact: ScanDetailsModalProps["controller"]["parsedArtifact"];
latestCapture: ScanDetailsModalProps["latestCapture"];
}
export function useScanDetailsModalModel({
setDetailsOpen,
parsedArtifact,
latestCapture,
}: UseScanDetailsModalModelInput): ScanDetailsModalModel {
const closeDetails = useCallback(() => setDetailsOpen(false), [setDetailsOpen]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const parsedNotes = (parsedArtifact?.notes ?? []) as ParsedArtifactCandidate["notes"];
const crops = latestCapture?.crops ?? [];
const ocr = latestCapture?.ocr ?? [];
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,
})),
ocrRows: ocr.map((entry) => ({
id: entry.id,
label: entry.label,
confidence: entry.confidence,
text: entry.text || "No text detected",
})),
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
showCrops: crops.length > 0,
showOcr: ocr.length > 0,
};
}
@@ -0,0 +1,185 @@
import { detailFingerprint } from "../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../lib/genshinData";
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types";
export interface ScanDiagnosticsModelProgress {
width: number;
show: boolean;
}
export interface ScanDiagnosticsModalModel {
closeDiagnostics: () => void;
openDetails: () => void;
handleSaveReviewSample: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
canOpenDetails: boolean;
statusTitle: string;
rightsClassName: string;
rightsValue: string;
genshinClassName: string;
genshinValue: string;
shouldShowAdminBanner: boolean;
gridSourceClass: string;
gridMainValue: string;
gridMetaValue: string;
learningRulesText: string;
learningRulesSubtext: string;
autoScanModeLabel: string;
fingerprintText: string;
runtimeRows: string[];
scanLimitText: string;
scanTipText: string;
autoScanStatsLines: Array<{ label: string; value: number }>;
playerProgress: ScanDiagnosticsModelProgress;
autoScanRunning: boolean;
showDevRows: boolean;
reviewStatus: string;
automationLogLines: string[];
canSaveReviewSample: boolean;
}
interface UseScanDiagnosticsModalModelInput extends Pick<
ScanDiagnosticsModalProps,
| "setDetailsOpen"
| "setDiagnosticsOpen"
| "saveReviewSample"
| "canSaveReviewSample"
| "latestCapture"
| "controller"
> {
captureStatus: string;
}
export function useScanDiagnosticsModalModel({
setDetailsOpen,
setDiagnosticsOpen,
saveReviewSample,
canSaveReviewSample,
latestCapture,
controller,
captureStatus,
}: UseScanDiagnosticsModalModelInput): ScanDiagnosticsModalModel {
const closeDiagnostics = useCallback(() => setDiagnosticsOpen(false), [setDiagnosticsOpen]);
const openDetails = useCallback(() => setDetailsOpen(true), [setDetailsOpen]);
const handleSaveReviewSample = useCallback(() => {
if (canSaveReviewSample && controller.parsedArtifact) {
void saveReviewSample();
}
}, [canSaveReviewSample, controller.parsedArtifact, saveReviewSample]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
const shouldShowAdminBanner = !controller.runtimeInfo?.isElevated;
const rightsClassName = controller.runtimeInfo?.isElevated ? "ok" : "blocked";
const rightsValue = controller.runtimeInfo?.isElevated ? "Admin" : "Standard";
const genshinClassName = controller.runtimeInfo?.genshinFound ? "ok" : "neutral";
const genshinValue = controller.runtimeInfo?.genshinFound ? "Gefunden" : "Nicht gefunden";
const statusTitle = controller.devMode ? "Dev-Ausgabe kompakt" : "Dev-Ausgabe erweitern";
const autoScanModeLabel = controller.autoScanRunning ? "Scan active" : "Last scan";
const inventoryGrid = latestCapture?.inventoryGrid;
const gridSourceClass = inventoryGrid?.source ?? "missing";
const gridMainValue = inventoryGrid
? inventoryGrid?.source === "missing"
? "not detected"
: `${inventoryGrid?.cols} x ${inventoryGrid?.rows}`
: "waiting";
const gridMetaValue = inventoryGrid
? `${inventoryGrid?.confidence}% confidence - ${inventoryGrid?.centers.length} click targets`
: "Run a capture once while the artifact inventory is visible.";
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`;
const playerProgress = useMemo(() => {
const width = Math.min(
100,
controller.autoScanStats.attempted > 0
? Math.round((controller.autoScanStats.verified / Math.max(1, controller.autoScanStats.attempted)) * 100)
: 0,
);
const show = controller.autoScanRunning || controller.autoScanStats.clicked > 0 || controller.autoScanStats.parsed > 0;
return { width, show };
}, [
controller.autoScanRunning,
controller.autoScanStats.attempted,
controller.autoScanStats.clicked,
controller.autoScanStats.parsed,
controller.autoScanStats.verified,
]);
const autoScanStatsLines = useMemo(
() => [
{ label: "clicked", value: controller.autoScanStats.clicked },
{ label: "attempted", value: controller.autoScanStats.attempted },
{ label: "verified", value: controller.autoScanStats.verified },
{ label: "parsed", value: controller.autoScanStats.parsed },
{ label: "stored", value: controller.autoScanStats.stored },
{ label: "review", value: controller.autoScanStats.review },
{ label: "duplicates", value: controller.autoScanStats.duplicates },
{ label: "misses", value: controller.autoScanStats.misses },
{ label: "pages", value: controller.autoScanStats.pages },
],
[
controller.autoScanStats.clicked,
controller.autoScanStats.attempted,
controller.autoScanStats.verified,
controller.autoScanStats.parsed,
controller.autoScanStats.stored,
controller.autoScanStats.review,
controller.autoScanStats.duplicates,
controller.autoScanStats.misses,
controller.autoScanStats.pages,
],
);
const runtimeRows = useMemo(() => {
const selectedSourceLabel = controller.selectedSource
? `Selected: ${controller.selectedSource.name}`
: "No source selected";
const storedTotalLabel = controller.storedTotal !== null ? `DB: ${controller.storedTotal} Artifacts` : "DB: -";
const runtime = controller.runtimeInfo
? `${controller.runtimeInfo?.isElevated ? "admin" : "standard"} - target ${controller.runtimeInfo?.genshinFound ? controller.runtimeInfo?.targetProcess || "Genshin" : "missing"} - fg ${controller.runtimeInfo?.foregroundProcess || "unknown"}`
: "unknown";
return [selectedSourceLabel, storedTotalLabel, runtime, captureStatus, controller.reviewStatus].filter(Boolean);
}, [controller.selectedSource, controller.storedTotal, controller.runtimeInfo, captureStatus, controller.reviewStatus]);
const fingerprintText = latestCapture ? detailFingerprint(latestCapture) : "--";
const scanLimitText = controller.scanLimit ? `Scan-Limit: ${controller.scanLimit}` : "Keine Limitinfo.";
const scanTipText = "Tip: Die Auto-Scan Logik bleibt aktivierbar, aber Dev-Ansicht ist rein informativ.";
return {
closeDiagnostics,
openDetails,
handleSaveReviewSample,
stopPropagation,
canOpenDetails,
statusTitle,
rightsClassName,
rightsValue,
genshinClassName,
genshinValue,
shouldShowAdminBanner,
gridSourceClass,
gridMainValue,
gridMetaValue,
learningRulesText,
learningRulesSubtext,
autoScanModeLabel,
fingerprintText,
runtimeRows,
scanLimitText,
scanTipText,
autoScanStatsLines,
playerProgress,
autoScanRunning: controller.autoScanRunning,
showDevRows: controller.devMode,
reviewStatus: controller.reviewStatus,
automationLogLines: controller.automationLog,
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
};
}
@@ -0,0 +1,68 @@
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
import type { ScanReviewQueueModalProps } from "../types";
export interface ScanReviewQueueRow {
label: string;
value: string;
}
export interface ScanReviewQueueModalModel {
closeReviewQueue: () => void;
refreshReviewQueue: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
emptyText: string;
reviewAnalysisRows: ScanReviewQueueRow[];
showReviewAnalysis: boolean;
}
interface UseScanReviewQueueModalModelInput extends Pick<
ScanReviewQueueModalProps,
"setReviewQueueOpen" | "loadReviewQueue"
> {
reviewAnalysis: ReviewSampleAnalysis;
reviewSampleTotal: number;
}
export function useScanReviewQueueModalModel({
setReviewQueueOpen,
loadReviewQueue,
reviewAnalysis,
reviewSampleTotal,
}: UseScanReviewQueueModalModelInput): ScanReviewQueueModalModel {
const closeReviewQueue = useCallback(() => setReviewQueueOpen(false), [setReviewQueueOpen]);
const refreshReviewQueue = useCallback(() => {
void loadReviewQueue();
}, [loadReviewQueue]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const weakFieldsSummary = useMemo(
() => reviewAnalysis.weakFields.slice(0, 3).map((entry) => `${entry.field} ${entry.count}`).join(", "),
[reviewAnalysis.weakFields],
);
const topReasonsSummary = useMemo(
() => reviewAnalysis.reasons.slice(0, 2).map((entry) => `${entry.reason} ${entry.count}`).join(", "),
[reviewAnalysis.reasons],
);
const reviewAnalysisRows = useMemo(
() => [
{ label: "Parsed", value: `${reviewAnalysis.withParsed}/${reviewAnalysis.total}` },
{ label: "Avg confidence", value: `${reviewAnalysis.averageConfidence}%` },
{ label: "Weak fields", value: weakFieldsSummary || "none" },
{ label: "Top reasons", value: topReasonsSummary || "none" },
],
[reviewAnalysis.averageConfidence, reviewAnalysis.total, reviewAnalysis.withParsed, topReasonsSummary, weakFieldsSummary],
);
return {
closeReviewQueue,
refreshReviewQueue,
stopPropagation,
emptyText: reviewSampleTotal > 0 ? "none" : "Keine Review-Samples",
reviewAnalysisRows,
showReviewAnalysis: reviewSampleTotal > 0,
};
}
@@ -0,0 +1,74 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
export interface ScanSettingsModalModel {
closeSettings: () => void;
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
inventoryCountText: string;
inventoryClassName: string;
scanLimitClassName: string;
skipRowsClassName: string;
activeTargetText: string;
scanSummaryText: string;
}
export function useScanSettingsModalModel({
setSettingsOpen,
setScanLimit,
setScanLimitTouched,
setSkipRows,
latestCapture,
scanLimit,
skipRows,
activeTargetCount,
runtimeInfo,
}: {
latestCapture: CaptureResult | null;
scanLimit: number;
skipRows: number;
activeTargetCount: number;
runtimeInfo: RuntimeInfo | null;
setSettingsOpen: (open: boolean) => void;
setScanLimit: (value: number) => void;
setScanLimitTouched: (touched: boolean) => void;
setSkipRows: (rows: number) => void;
}): ScanSettingsModalModel {
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 detectedInventoryCount = latestCapture?.inventoryCount?.current;
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
const inventoryCountText = detectedInventoryCount
? `${detectedInventoryCount}/${detectedInventoryTotal || "?"}`
: "Noch nicht erkannt";
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.";
return {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
inventoryCountText,
inventoryClassName,
scanLimitClassName,
skipRowsClassName,
activeTargetText,
scanSummaryText,
};
}
@@ -0,0 +1,37 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanSummaryModalProps } from "../types";
export interface ScanSummaryModalModel {
closeSummary: () => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
iconKind: "running" | "blocked" | "stopped" | "finished";
summaryTitle: string;
gridSummaryText: string | null;
}
interface UseScanSummaryModalModelInput extends Pick<ScanSummaryModalProps, "setScanSummary"> {
scanSummary: ScanSummaryModalProps["controller"]["scanSummary"];
}
export function useScanSummaryModalModel({
setScanSummary,
scanSummary,
}: UseScanSummaryModalModelInput): ScanSummaryModalModel {
const closeSummary = useCallback(() => setScanSummary(null), [setScanSummary]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
}, []);
const isBlocked = scanSummary?.status === "blocked";
const isStopped = scanSummary?.status === "stopped";
const summaryTitle = isStopped ? "Scan gestoppt" : isBlocked ? "Scan abgebrochen" : "Scan fertig";
const iconKind = isBlocked ? "blocked" : isStopped ? "stopped" : "finished";
const gridSummaryText = scanSummary?.gridLabel ?? null;
return {
closeSummary,
stopPropagation,
iconKind,
summaryTitle,
gridSummaryText,
};
}
@@ -0,0 +1,38 @@
import type { Dispatch, SetStateAction } from "react";
import type { ScanViewProps, ScanViewControllerResult } from "../../types";
export interface ScanDetailsModalProps {
open: boolean;
latestCapture: ScanViewProps["latestCapture"];
controller: Pick<ScanViewControllerResult, "parsedArtifact">;
setDetailsOpen: (open: boolean) => void;
}
export interface ScanDiagnosticsModalProps {
open: boolean;
captureStatus: string;
latestCapture: ScanViewProps["latestCapture"];
controller: ScanViewControllerResult;
setDetailsOpen: (open: boolean) => void;
setDiagnosticsOpen: (open: boolean) => void;
}
export interface ScanReviewQueueModalProps {
open: boolean;
controller: ScanViewControllerResult;
setReviewQueueOpen: (open: boolean) => void;
}
export interface ScanSettingsModalProps {
open: boolean;
latestCapture: ScanViewProps["latestCapture"];
controller: ScanViewControllerResult;
setSettingsOpen: (open: boolean) => void;
}
export interface ScanSummaryModalProps {
open: boolean;
controller: Pick<ScanViewControllerResult, "scanSummary" | "storedTotal">;
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
devMode: boolean;
}
+80
View File
@@ -0,0 +1,80 @@
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { ReviewSampleRecord } from "../../../types/global";
import type { ScanSummary } from "../../../lib/scannerSession";
import type { ScanViewControllerResult, ScanViewProps } from "../types";
import type { Dispatch, SetStateAction } from "react";
export interface FieldConfidenceListProps {
parsedArtifact: ParsedArtifactCandidate;
}
export interface ArtifactResultCardProps {
parsed: ParsedArtifactCandidate;
}
export interface ReviewSampleCardProps {
entry: ReviewSampleRecord;
}
export interface ScanSummaryFooterProps {
devMode: boolean;
scanSummary: ScanSummary;
storedTotal: number | null;
}
export interface ScanViewLayoutProps {
captureSources: ScanViewProps["captureSources"];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: ScanViewProps["latestCapture"];
captureStatus: string;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: ScanViewProps["captureSelectedSource"];
bridgeReady: boolean;
controller: ScanViewControllerResult;
isScanning: boolean;
}
export interface ScanMainSectionProps {
latestCapture: ScanViewProps["latestCapture"];
captureStatus: string;
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
sourceLabel: string;
gridLabel: string;
inventoryLabel: string;
activeTargetCount: number;
storedTotal: ScanViewControllerResult["storedTotal"];
reviewSampleTotal: number;
learningRulesLoaded: ScanViewControllerResult["learningRulesLoaded"];
learningRuleCount: number;
canOpenReviewQueue: boolean;
autoScanRunning: boolean;
openReviewQueue: () => void;
setDetailsOpen: (value: boolean) => void;
}
export interface ScanTopControlsSectionProps {
captureSources: ScanViewProps["captureSources"];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: ScanViewProps["captureSelectedSource"];
bridgeReady: boolean;
isScanning: boolean;
controller: ScanViewControllerResult;
}
export interface ScanModalsSectionProps {
captureStatus: string;
latestCapture: ScanViewProps["latestCapture"];
diagnosticsOpen: boolean;
settingsOpen: boolean;
detailsOpen: boolean;
reviewQueueOpen: boolean;
controller: ScanViewControllerResult;
setDiagnosticsOpen: (open: boolean) => void;
setSettingsOpen: (open: boolean) => void;
setDetailsOpen: (open: boolean) => void;
setReviewQueueOpen: (open: boolean) => void;
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
}
@@ -0,0 +1,109 @@
import { shouldFlagArtifactForReview } from "../../../lib/scannerLearning";
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 {
ReviewStateContext,
} from "./scanViewReviewHelpers";
import type { ArtifactRepositoryPort, ReviewSampleRepositoryPort, LearningRepositoryPort, AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord, RuntimeInfo } from "../../../types/global";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
export interface ReviewStateContextInput {
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: () => Promise<void> | void;
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
appendAutomationLog: (line: string) => void;
}
export interface ScanActionContextInput {
autoScanRunning: boolean;
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
runtimeRepo?: RuntimeRepositoryPort;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
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>;
focusDashboard: () => Promise<void>;
captureSelectedSource: (
delayMs?: number,
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
}
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
return {
artifactRepo: input.artifactRepo,
reviewSamplesRepo: input.reviewSamplesRepo,
learningRepo: input.learningRepo,
onStoredArtifactsChanged: input.onStoredArtifactsChanged,
setReviewSampleTotal: input.setReviewSampleTotal,
setReviewSamples: input.setReviewSamples,
setLearningRulesLoaded: input.setLearningRulesLoaded,
setScannerLearningRules: input.setScannerLearningRules,
setReviewStatus: input.setReviewStatus,
setStoredTotal: input.setStoredTotal,
appendAutomationLog: input.appendAutomationLog,
};
}
export function createScanActionContext(input: ScanActionContextInput): ScanActionContext {
return {
autoScanRunning: input.autoScanRunning,
setAutoScanRunning: input.setAutoScanRunning,
isScanning: input.isScanning,
stopVisibleScanRef: input.stopVisibleScanRef,
selectedSourceId: input.selectedSourceId,
bridgeReady: input.bridgeReady,
automationRepo: input.automationRepo,
runtimeInfo: input.runtimeInfo,
runtimeRepo: input.runtimeRepo,
scanLimit: input.scanLimit,
skipRows: input.skipRows,
detectedInventoryCount: input.detectedInventoryCount,
setScanSummary: input.setScanSummary,
setAutoScanStats: input.setAutoScanStats,
setReviewStatus: input.setReviewStatus,
appendAutomationLog: input.appendAutomationLog,
appendClickDiagnostics: input.appendClickDiagnostics,
parseArtifact: input.parseArtifact,
persistParsedArtifact: input.persistParsedArtifact,
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
saveReviewSample: input.saveReviewSample,
focusDashboard: input.focusDashboard,
captureSelectedSource: input.captureSelectedSource,
captureFastSelectedSource: input.captureFastSelectedSource,
};
}
@@ -0,0 +1,342 @@
import type { Dispatch, SetStateAction } from "react";
import {
applyScannerLearningRules,
countScannerLearningRules,
deriveScannerLearningRules,
deriveScannerLearningRulesFromReviewSamples,
shouldFlagArtifactForReview,
type ScannerLearningRules,
} from "../../../lib/scannerLearning";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality";
import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore";
import {
type ArtifactRepositoryPort,
type LearningRepositoryPort,
type ReviewSampleRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage";
const REVIEW_SAMPLE_LIMIT_INITIAL = 120;
const REVIEW_SAMPLE_LIMIT_QUEUE = 80;
export interface ReviewStateContext {
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: () => Promise<void> | void;
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
appendAutomationLog: (line: string) => void;
}
async function loadReviewSamplesForContext(
context: ReviewStateContext,
limit: number,
): Promise<ReturnType<ReviewSampleRepositoryPort["loadSamples"]> | null> {
return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null;
}
async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) {
const result = await loadReviewSamplesForContext(context, limit);
if (result?.ok) {
context.setReviewSampleTotal(result.total);
}
return result;
}
export function parseLearnedArtifact(
capture: CaptureResult | null,
scannerLearningRules: ScannerLearningRules,
): ParsedArtifactCandidate | null {
return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules));
}
export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null {
const capture = entry.sample?.capture;
if (!capture?.ocr) return null;
return {
id: `review-${entry.savedAt}`,
name: capture.name ?? "review-sample",
width: capture.width ?? 0,
height: capture.height ?? 0,
dataUrl: "",
capturedAt: capture.capturedAt ?? entry.savedAt,
captureTarget: capture.captureTarget,
crops: [],
inventoryDataUrl: capture.inventoryDataUrl,
ocr: capture.ocr,
inventoryGrid: capture.inventoryGrid,
};
}
function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) {
if (!record) return "";
return [
record.name,
record.slot,
record.level ?? 0,
record.mainStat,
record.mainValue,
record.setName,
record.equipped,
(record.substats ?? []).join("|"),
record.needsReview ? "review" : "clean",
].join("::");
}
function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) {
if (!existing) return true;
if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false;
const existingStrength = storedArtifactStrength(existing);
const incomingStrength = storedArtifactStrength(incoming);
const existingSubstats = existing.substats?.length ?? 0;
const incomingSubstats = incoming.substats?.length ?? 0;
const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped));
const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped));
if (existing.needsReview && !incoming.needsReview) return true;
if (incomingSubstats > existingSubstats) return true;
if (!existingEquippedKnown && incomingEquippedKnown) return true;
if (incomingStrength >= existingStrength + 6) return true;
if (isReviewOnlyArtifactSource(existing.source)) return true;
return false;
}
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
}
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
const reviewResult = await loadReviewSamplesAndSetTotal(context, limit);
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context);
}
return reviewResult;
}
export async function recoverArtifactsFromReviewSamples(
samples: ReviewSampleRecord[],
rules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<number> {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany || samples.length === 0) return 0;
const existingResult = await artifactRepo.loadAll().catch(() => null);
const existingById = new Map(
(existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]),
);
const recoveredCandidates = samples.flatMap((entry) => {
const capture = createCaptureFromReviewSample(entry);
const parsed = parseLearnedArtifact(capture, rules);
if (!capture || !parsed) return [];
if (captureSourceRejectionReason(capture)) return [];
const needsReview = shouldFlagArtifactForReview(parsed);
if (!shouldPersistParsedArtifact(parsed, needsReview)) return [];
const stored = toStoredArtifact(parsed, "review-reprocess", needsReview);
return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : [];
});
const recoveredById = new Map<string, StoredArtifactRecord>();
for (const record of recoveredCandidates) {
const existing = recoveredById.get(record.id);
if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) {
recoveredById.set(record.id, record);
}
}
const recovered = [...recoveredById.values()];
if (recovered.length === 0) return 0;
const result = await artifactRepo.saveMany(recovered).catch(() => null);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`);
return result.added + result.updated;
}
return 0;
}
export async function initializeLearningState(context: ReviewStateContext): Promise<void> {
const {
learningRepo,
setLearningRulesLoaded,
setScannerLearningRules,
} = context;
try {
const loadedRules = await learningRepo?.loadRules().catch(() => null);
const currentRules = getDefaultScannerRules(loadedRules);
if (countScannerLearningRules(currentRules) > 0) {
setScannerLearningRules(currentRules);
await loadReviewSamplesAndRecover(context, currentRules);
setLearningRulesLoaded(true);
return;
}
const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules;
if (countScannerLearningRules(derived) > 0) {
setScannerLearningRules(derived);
await learningRepo?.saveRules?.(derived).catch(() => null);
} else {
setScannerLearningRules(currentRules);
}
setLearningRulesLoaded(true);
} catch {
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL);
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
setScannerLearningRules(derived);
if (countScannerLearningRules(derived) > 0) {
await learningRepo?.saveRules?.(derived).catch(() => null);
}
if (reviewResult?.ok) {
await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context);
}
setLearningRulesLoaded(true);
}
}
export async function mergeLearningRules(
nextRules: Partial<ScannerLearningRules> | null | undefined,
currentRules: ScannerLearningRules,
context: ReviewStateContext,
) {
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
const merged = {
textReplacements: {
...currentRules.textReplacements,
...(nextRules.textReplacements ?? {}),
},
};
context.setScannerLearningRules(merged);
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
return { result, merged };
}
export async function persistParsedArtifact(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
context: ReviewStateContext,
) {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany) return false;
const rejection = captureRejectionReason(capture, parsed);
if (rejection) {
appendAutomationLog(`persist skip: ${rejection}`);
return false;
}
if (!shouldPersistParsedArtifact(parsed, needsReview)) {
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`);
return false;
}
try {
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
return true;
}
return false;
} catch {
return false;
}
}
export async function saveReviewSample(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
reason: string,
scannerLearningRules: ScannerLearningRules,
context: ReviewStateContext,
): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> {
const {
reviewSamplesRepo,
setReviewStatus,
appendAutomationLog,
setReviewSamples,
onStoredArtifactsChanged,
} = context;
if (!reviewSamplesRepo?.saveSample) {
setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar.");
return { result: null, recoveredParsed: null, recoveredToDb: false };
}
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
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,
ocr: capture.ocr,
},
parsed,
});
const learned = deriveScannerLearningRules(capture, parsed);
const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context);
const learnedCount = countScannerLearningRules(learned);
const activeRules = learnedResult?.merged ?? scannerLearningRules;
const recoveredParsed = parseLearnedArtifact(capture, activeRules);
const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed);
let recoveredToDb = false;
if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) {
const extendedContext: ReviewStateContext = {
...context,
artifactRepo: context.artifactRepo,
onStoredArtifactsChanged,
};
recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext);
}
setReviewStatus(
result?.path
? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}`
: "Review-Sample konnte nicht gespeichert werden.",
);
if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`);
if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`);
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (reviewResult?.ok) {
setReviewSamples(reviewResult.samples);
}
return { result, recoveredParsed, recoveredToDb };
}
export async function loadReviewQueue(context: ReviewStateContext): Promise<void> {
const { setReviewSamples } = context;
const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
if (!result?.ok) return;
setReviewSamples(result.samples);
}
@@ -0,0 +1,294 @@
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { MutableRefObject } from "react";
import type { Dispatch, SetStateAction } from "react";
export interface ScanActionContext {
autoScanRunning: boolean;
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeRepo?: RuntimeRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => 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>;
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
focusDashboard: () => Promise<void>;
}
function buildScanSignature(parsed: ParsedArtifactCandidate) {
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
}
export async function runAutoReviewScan(context: ScanActionContext): Promise<void> {
const {
autoScanRunning,
selectedSourceId,
stopVisibleScanRef,
setAutoScanRunning,
setReviewStatus,
setScanSummary,
setAutoScanStats,
appendAutomationLog,
scanLimit,
detectedInventoryCount,
captureSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
shouldFlagArtifactForReview,
focusDashboard,
} = context;
if (autoScanRunning || !selectedSourceId) return;
setAutoScanRunning(true);
stopVisibleScanRef.current = false;
setScanSummary(null);
setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Manueller Scan laeuft. Klicke in Genshin auf ein anderes Artifact; nur neue Artifacts werden verarbeitet.");
const seen = new Set<string>();
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
let idleTicks = 0;
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
const maxIdleTicks = 90;
while (!stopVisibleScanRef.current && stats.parsed < maxArtifacts && idleTicks < maxIdleTicks) {
const capture = await captureSelectedSource(0, true);
const rejection = captureRejectionReason(capture, parseArtifact(capture));
const parsed = parseArtifact(capture);
if (!capture || !parsed || rejection) {
if (capture && rejection) {
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
stats.review++;
setAutoScanStats({ ...stats });
}
idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
await wait(700);
continue;
}
const signature = buildScanSignature(parsed);
if (seen.has(signature)) {
idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein neues Artifact... (${stats.parsed}/${maxArtifacts})`);
await wait(700);
continue;
}
seen.add(signature);
idleTicks = 0;
stats.attempted++;
stats.verified++;
stats.parsed++;
const reason = getAutoReviewReason(capture, parsed);
const needsReview = shouldFlagArtifactForReview(parsed);
if (reason) {
await saveReviewSample(capture, parsed, `manual:${reason}`);
stats.review++;
}
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
stats.stored++;
}
setAutoScanStats({ ...stats });
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
await wait(700);
}
setAutoScanRunning(false);
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
await focusDashboard();
setReviewStatus(
`Manueller Scan ${status}. ${stats.verified} neue Ansichten verifiziert, ${stats.parsed} Artifacts gelesen, ${stats.stored} in der Datenbank gespeichert, ${stats.review} Review-Samples.${idleSuffix}`,
);
setScanSummary({
mode: "Manueller Scan",
status,
...stats,
targetCount: maxArtifacts,
gridLabel: "Nur neue, vom User angeklickte Artifacts wurden verarbeitet.",
});
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
}
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
const {
autoScanRunning,
bridgeReady,
selectedSourceId,
runtimeInfo,
automationRepo,
runtimeRepo,
stopVisibleScanRef,
setAutoScanRunning,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
shouldFlagArtifactForReview,
scanLimit,
skipRows,
detectedInventoryCount,
focusDashboard,
} = context;
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
if (requiresAdminForAutoScan) {
setReviewStatus(
"App laeuft nicht als Administrator. Bitte die App schliessen und als Administrator neu starten - Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.",
);
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: "App laeuft nicht als Administrator. Neustart als Administrator noetig.",
});
return;
}
setAutoScanRunning(true);
stopVisibleScanRef.current = false;
setScanSummary(null);
setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
const adminBlockReason = automationBlockReason(freshRuntime);
if (adminBlockReason) {
setAutoScanRunning(false);
setReviewStatus(adminBlockReason);
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: adminBlockReason,
});
return;
}
if (freshRuntime) {
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
}
setReviewStatus("Genshin wird in den Vordergrund geholt...");
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
if (focusResult) {
appendAutomationLog(
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
);
}
if (!focusResult?.focused) {
setAutoScanRunning(false);
const reason = !focusResult?.genshinFound
? "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);
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: reason,
});
return;
}
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const result = await runAutoScanLoop(
{
api: {
clickScreen: (x: number, y: number) => {
if (!automationRepo?.clickScreen) {
return Promise.resolve<ClickResult>({
ok: false,
x: 0,
y: 0,
clicked: false,
moved: false,
focused: false,
inputBlocked: false,
});
}
return automationRepo.clickScreen(x, y);
},
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => {
if (!automationRepo?.scrollScreen) {
return Promise.resolve<ScrollResult>({ ok: false, notchesSent: 0, inputBlocked: false });
}
return automationRepo.scrollScreen(notches, anchorX, anchorY);
},
getAutomationGuard: () =>
automationRepo?.getAutomationGuard?.() ??
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
getAutoReviewReason,
shouldFlagArtifactForReview,
appendAutomationLog,
appendClickDiagnostics,
setReviewStatus,
setAutoScanStats,
shouldStop: () => stopVisibleScanRef.current,
},
{
scanLimit,
skipRows,
detectedInventoryCount,
},
);
setAutoScanRunning(false);
if (result.blockedReason) appendAutomationLog(`stop: ${result.blockedReason}`);
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",
status: result.status,
...result.stats,
targetCount: result.targetCount,
gridLabel: result.gridLabel,
});
}
@@ -0,0 +1,34 @@
import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
interface ScanCommandListenerInput {
automationRepo?: AutomationRepositoryPort;
autoScanRunning: boolean;
isScanning: boolean;
selectedSourceId: string;
requestScanStop: (reason: string) => void;
runVisibleGridScan: () => Promise<void>;
}
export function useScanCommandListener({
automationRepo,
autoScanRunning,
isScanning,
selectedSourceId,
requestScanStop,
runVisibleGridScan,
}: ScanCommandListenerInput) {
useEffect(() => {
if (!automationRepo?.onCommand) return;
return automationRepo.onCommand((command: "start-auto" | "stop") => {
if (command === "stop") {
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
return;
}
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
void runVisibleGridScan();
}
});
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
}
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { RuntimeInfo } from "../../../types/global";
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
useEffect(() => {
let mounted = true;
runtimeRepo?.getRuntimeInfo().then((result) => {
if (mounted) {
setRuntimeInfo(result);
}
}).catch(() => undefined);
const timer = window.setInterval(() => {
runtimeRepo?.getRuntimeInfo().then((result) => {
if (mounted) setRuntimeInfo(result);
}).catch(() => undefined);
}, 5000);
return () => {
mounted = false;
window.clearInterval(timer);
};
}, [runtimeRepo]);
return runtimeInfo;
}
@@ -0,0 +1,75 @@
import { useEffect } from "react";
import { type AppSnapshot } from "../../../types/domain";
import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import type { RuntimeInfo } from "../../../types/global";
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { CaptureResult } from "../../../types/global";
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
interface ScanSnapshotPublisherInput {
autoScanRunning: boolean;
reviewStatus: string;
captureStatus: string;
selectedSourceName: string | null;
autoScanStats: AutoScanStats;
scanSummary: ScanSummary | null;
snapshot: AppSnapshot;
latestInventoryGrid: InventoryGrid | null | undefined;
automationLog: string[];
runtimeInfo: RuntimeInfo | null;
storedTotal: number | null;
learningRuleCount: number;
snapshotRepo?: SnapshotRepositoryPort;
}
export function useScanSnapshotPublisher({
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
}: ScanSnapshotPublisherInput) {
useEffect(() => {
void snapshotRepo?.publishScannerStatus({
running: autoScanRunning,
reviewStatus,
captureStatus,
selectedSource: selectedSourceName,
stats: autoScanStats,
summary: scanSummary,
snapshotArtifacts: snapshot.artifacts.length,
snapshotCharacters: snapshot.characters.filter((character) => character.owned).length,
snapshotRecommendations: snapshot.recommendations.length,
snapshotBuilds: snapshot.builds.length,
grid: latestInventoryGrid ?? null,
automationLog: automationLog.slice(-12),
runtimeInfo,
storedTotal,
learningRuleCount,
updatedAt: new Date().toISOString(),
}).catch(() => undefined);
}, [
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
]);
}
@@ -0,0 +1,296 @@
import { useCallback, useEffect, useMemo } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
import {
initializeLearningState,
loadReviewQueue as loadReviewQueueFromRepo,
persistParsedArtifact as persistParsedArtifactHelper,
saveReviewSample as saveReviewSampleHelper,
} from "./scanViewReviewHelpers";
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
import { useScanCommandListener } from "./useScanCommandListener";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import type {
ArtifactRepositoryPort,
ReviewSampleRepositoryPort,
LearningRepositoryPort,
RuntimeRepositoryPort,
AutomationRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { RuntimeInfo } from "../../../types/global";
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
type NumberSetter = Dispatch<SetStateAction<number>>;
type StringSetter = Dispatch<SetStateAction<string>>;
type NumberOrNullSetter = Dispatch<SetStateAction<number | null>>;
type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>;
interface ScanViewActionInput {
autoScanRunning: boolean;
setAutoScanRunning: BooleanSetter;
isScanning: boolean;
stopVisibleScanRef: MutableRefObject<boolean>;
selectedSourceId: string;
bridgeReady: boolean;
automationRepo?: AutomationRepositoryPort;
runtimeInfo?: RuntimeInfo | null;
runtimeRepo?: RuntimeRepositoryPort;
scanLimit: number;
skipRows: number;
detectedInventoryCount: number;
setScanSummary: ScanSummarySetter;
setAutoScanStats: AutoScanStatsSetter;
setReviewStatus: StringSetter;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort;
learningRepo?: LearningRepositoryPort;
onStoredArtifactsChanged?: (() => Promise<void>) | (() => void);
setReviewSampleTotal: NumberSetter;
setReviewSamples: ReviewSamplesSetter;
setLearningRulesLoaded: BooleanSetter;
setScannerLearningRules: ScannerRulesSetter;
setStoredTotal: NumberOrNullSetter;
scannerLearningRules: ScannerLearningRules;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
latestCapture: CaptureResult | null;
parsedArtifact: ParsedArtifactCandidate | null;
canCaptureSource: boolean;
setReviewQueueOpen: BooleanSetter;
}
export interface ScanViewActionResult {
requestScanStop: (reason?: string) => void;
saveReviewSample: (
capture?: CaptureResult | null,
parsed?: ParsedArtifactCandidate | null,
reason?: string,
) => Promise<BooleanResult | null>;
loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>;
runVisibleGridScan: () => Promise<void>;
}
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
const {
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setStoredTotal,
scannerLearningRules,
captureSelectedSource,
latestCapture,
parsedArtifact,
canCaptureSource,
setReviewQueueOpen,
} = input;
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
stopVisibleScanRef.current = true;
appendAutomationLog(`stop requested: ${reason}`);
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]);
const reviewContext = useMemo(
() =>
createReviewContext({
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setReviewStatus,
setStoredTotal,
appendAutomationLog,
}),
[
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setReviewStatus,
setStoredTotal,
appendAutomationLog,
],
);
useEffect(() => {
void initializeLearningState(reviewContext);
}, [reviewContext]);
const focusDashboard = useCallback(async () => {
try {
await automationRepo?.focusMainWindow?.();
} catch {
// Best-effort fallback; state remains in renderer.
}
}, [automationRepo?.focusMainWindow]);
const parseArtifactAndPersist = useCallback(
async function parseArtifactAndPersist(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
) {
return persistParsedArtifactHelper(capture, parsed, source, needsReview, reviewContext);
},
[reviewContext],
);
const handleSaveReviewSample = useCallback(
async function handleSaveReviewSample(
capture: CaptureResult | null = latestCapture,
parsed: ParsedArtifactCandidate | null = parsedArtifact,
reason = "manual",
): Promise<BooleanResult | null> {
const result = await saveReviewSampleHelper(
capture,
parsed,
reason,
scannerLearningRules,
reviewContext,
);
return result.result ? { ok: result.result.ok } : null;
},
[latestCapture, parsedArtifact, reviewContext, scannerLearningRules],
);
const scanActionContext = useMemo(
() =>
createScanActionContext({
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
persistParsedArtifact: parseArtifactAndPersist,
saveReviewSample: handleSaveReviewSample,
focusDashboard,
captureSelectedSource,
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
}),
[
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
parseArtifactAndPersist,
handleSaveReviewSample,
focusDashboard,
captureSelectedSource,
],
);
const loadReviewQueueAction = useCallback(async () => {
await loadReviewQueueFromRepo(reviewContext);
}, [reviewContext]);
const openReviewQueueModal = useCallback(async () => {
await loadReviewQueueAction();
setReviewQueueOpen(true);
}, [loadReviewQueueAction, setReviewQueueOpen]);
const runAutoReviewScan = useCallback(async () => {
if (autoScanRunning || !selectedSourceId || !canCaptureSource) return;
await runAutoReviewScanAction(scanActionContext);
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
const runVisibleGridScan = useCallback(async () => {
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
return;
}
await runVisibleGridScanAction(scanActionContext);
}, [
autoScanRunning,
bridgeReady,
selectedSourceId,
automationRepo?.clickScreen,
automationRepo?.scrollScreen,
scanActionContext,
]);
useScanCommandListener({
automationRepo,
autoScanRunning,
isScanning,
selectedSourceId,
requestScanStop,
runVisibleGridScan,
});
return {
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
};
}
@@ -0,0 +1,233 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { requiresAdminForAutomation } from "../../../lib/automationPlanner";
import {
countScannerLearningRules,
type ScannerLearningRules,
} from "../../../lib/scannerLearning";
import { type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import { analyzeReviewSamples } from "../../../lib/reviewSampleAnalysis";
import {
parseLearnedArtifact as parseLearnedArtifactHelper,
} from "./scanViewReviewHelpers";
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
import { useScanViewActions } from "./useScanViewActions";
import { useScanViewStateSync } from "./useScanViewStateSync";
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import type { ScanViewProps, ScanViewControllerResult } from "../types";
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
export function useScanViewController({
snapshot,
isScanning,
captureSources,
selectedSourceId,
latestCapture,
captureStatus,
captureSelectedSource,
bridgeReady,
onStoredArtifactsChanged,
}: ScanViewProps): ScanViewControllerResult {
const repositories = useMemo(() => createRendererRepositories(), []);
const artifactRepo = repositories?.artifacts;
const runtimeRepo = repositories?.runtime;
const reviewSamplesRepo = repositories?.reviewSamples;
const learningRepo = repositories?.learning;
const snapshotRepo = repositories?.snapshot;
const automationRepo = repositories?.automation;
const captureRepo = repositories?.capture;
const [detailsOpen, setDetailsOpen] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [reviewQueueOpen, setReviewQueueOpen] = useState(false);
const [reviewSamples, setReviewSamples] = useState<ReviewSampleRecord[]>([]);
const [reviewSampleTotal, setReviewSampleTotal] = useState(0);
const [reviewStatus, setReviewStatus] = useState("");
const [autoScanRunning, setAutoScanRunning] = useState(false);
const stopVisibleScanRef = useRef(false);
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
const [scanLimit, setScanLimit] = useState(16);
const [scanLimitTouched, setScanLimitTouched] = useState(false);
const [skipRows, setSkipRows] = useState(0);
const [automationLog, setAutomationLog] = useState<string[]>([]);
const [storedTotal, setStoredTotal] = useState<number | null>(null);
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
const [learningRulesLoaded, setLearningRulesLoaded] = useState(false);
const runtimeInfo = useScanRuntimeInfo(runtimeRepo);
const parsedArtifact = useMemo(
() => parseLearnedArtifactHelper(latestCapture, scannerLearningRules),
[latestCapture, scannerLearningRules],
);
const selectedSource = captureSources.find((source) => source.id === selectedSourceId);
const genshinSource = captureSources.find((source) => source.isGenshinCandidate);
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
const hasSourceSelected = selectedSourceId.length > 0;
const canCaptureSource = bridgeReady && Boolean(captureRepo?.captureSource);
const canReadReviewQueue = bridgeReady && Boolean(reviewSamplesRepo?.loadSamples);
const canSaveReviewSample = bridgeReady && Boolean(reviewSamplesRepo?.saveSample);
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
const activeTargetCount = autoScanRunning
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
const scanProgressBase = Math.max(autoScanStats.attempted, autoScanStats.parsed, autoScanStats.verified);
const scanProgressPercent = Math.min(100, Math.round((scanProgressBase / Math.max(1, activeTargetCount)) * 100));
const scannerModeLabel = autoScanRunning ? "Scan laeuft" : runtimeInfo?.isElevated ? "Admin bereit" : "Bereit";
const sourceLabel = selectedSource?.name ?? "Keine Quelle";
const gridLabel = latestCapture?.inventoryGrid ? `${latestCapture.inventoryGrid.cols} x ${latestCapture.inventoryGrid.rows}` : "warte auf Capture";
const inventoryLabel = latestCapture?.inventoryCount?.current ? `${latestCapture.inventoryCount.current}/${latestCapture.inventoryCount.total || "?"}` : "nicht erkannt";
const appendAutomationLog = useCallback((line: string) => {
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
}, []);
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]);
const toggleDevMode = useCallback(() => {
setDevMode((previous) => {
const next = !previous;
localStorage.setItem("gaa-dev-mode", next ? "1" : "0");
return next;
});
}, []);
const parseArtifact = useCallback(
(capture: CaptureResult | null) => parseLearnedArtifactHelper(capture, scannerLearningRules),
[scannerLearningRules],
);
const {
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
} = useScanViewActions({
autoScanRunning,
setAutoScanRunning,
isScanning,
stopVisibleScanRef,
selectedSourceId,
bridgeReady,
automationRepo,
runtimeInfo,
runtimeRepo,
scanLimit,
skipRows,
detectedInventoryCount,
setScanSummary,
setAutoScanStats,
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
parseArtifact,
artifactRepo,
reviewSamplesRepo,
learningRepo,
onStoredArtifactsChanged,
setReviewSampleTotal,
setReviewSamples,
setLearningRulesLoaded,
setScannerLearningRules,
setStoredTotal,
scannerLearningRules,
captureSelectedSource,
latestCapture,
parsedArtifact,
canCaptureSource,
setReviewQueueOpen,
});
useScanViewStateSync({
artifactRepo,
latestCapture,
scanLimitTouched,
setScanLimit,
setStoredTotal,
});
useScanSnapshotPublisher({
autoScanRunning,
reviewStatus,
captureStatus,
selectedSourceName: selectedSource?.name ?? null,
autoScanStats,
scanSummary,
snapshot,
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
automationLog,
runtimeInfo,
storedTotal,
learningRuleCount,
snapshotRepo,
});
return {
detailsOpen,
diagnosticsOpen,
settingsOpen,
reviewQueueOpen,
reviewSamples,
reviewSampleTotal,
reviewStatus,
autoScanRunning,
autoScanStats,
scanSummary,
scanLimit,
scanLimitTouched,
skipRows,
automationLog,
storedTotal,
devMode,
scannerLearningRules,
learningRulesLoaded,
runtimeInfo,
parsedArtifact,
reviewAnalysis,
learningRuleCount,
detectedInventoryCount,
scanProgressPercent,
activeTargetCount,
requiresAdminForAutoScan,
hasSourceSelected,
canCaptureSource,
canReadReviewQueue,
canSaveReviewSample,
canAutoScan,
scannerModeLabel,
sourceLabel,
gridLabel,
inventoryLabel,
selectedSource,
genshinSource,
setDetailsOpen,
setDiagnosticsOpen,
setSettingsOpen,
setReviewQueueOpen,
setScanSummary,
setScanLimit,
setScanLimitTouched,
setSkipRows,
toggleDevMode,
requestScanStop,
saveReviewSample: handleSaveReviewSample,
loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal,
runAutoReviewScan,
runVisibleGridScan,
};
}
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { CaptureResult } from "../../../types/global";
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import { clampScanLimit } from "../../../lib/scannerSession";
interface ScanViewStateSyncInput {
artifactRepo?: ArtifactRepositoryPort;
latestCapture: CaptureResult | null;
scanLimitTouched: boolean;
setScanLimit: Dispatch<SetStateAction<number>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>;
}
export function useScanViewStateSync({
artifactRepo,
latestCapture,
scanLimitTouched,
setScanLimit,
setStoredTotal,
}: ScanViewStateSyncInput) {
useEffect(() => {
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
if (!scanLimitTouched && detectedCount > 0) {
setScanLimit(clampScanLimit(detectedCount));
}
}, [latestCapture?.inventoryCount?.current, scanLimitTouched, setScanLimit]);
useEffect(() => {
artifactRepo?.loadAll().then((result) => {
if (result?.ok) setStoredTotal(result.total);
}).catch(() => undefined);
}, [artifactRepo, setStoredTotal]);
}
+82
View File
@@ -0,0 +1,82 @@
import type { AppSnapshot } from "../../types/domain";
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global";
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
import type { ScannerLearningRules } from "../../lib/scannerLearning";
import type { Dispatch, SetStateAction } from "react";
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
export interface ScanViewProps {
snapshot: AppSnapshot;
isScanning: boolean;
captureSources: CaptureSourceInfo[];
selectedSourceId: string;
setSelectedSourceId: (value: string) => void;
latestCapture: CaptureResult | null;
captureStatus: string;
refreshCaptureSources: () => Promise<void>;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
bridgeReady: boolean;
onStoredArtifactsChanged?: () => Promise<void>;
}
export interface ScanViewControllerResult {
detailsOpen: boolean;
diagnosticsOpen: boolean;
settingsOpen: boolean;
reviewQueueOpen: boolean;
reviewSamples: ReviewSampleRecord[];
reviewSampleTotal: number;
reviewStatus: string;
autoScanRunning: boolean;
autoScanStats: AutoScanStats;
scanSummary: ScanSummary | null;
scanLimit: number;
scanLimitTouched: boolean;
skipRows: number;
automationLog: string[];
storedTotal: number | null;
devMode: boolean;
scannerLearningRules: ScannerLearningRules;
learningRulesLoaded: boolean;
runtimeInfo: RuntimeInfo | null;
parsedArtifact: ParsedArtifactCandidate | null;
reviewAnalysis: ReturnType<typeof analyzeReviewSamples>;
learningRuleCount: number;
detectedInventoryCount: number;
scanProgressPercent: number;
activeTargetCount: number;
requiresAdminForAutoScan: boolean;
hasSourceSelected: boolean;
canCaptureSource: boolean;
canReadReviewQueue: boolean;
canSaveReviewSample: boolean;
canAutoScan: boolean;
scannerModeLabel: string;
sourceLabel: string;
gridLabel: string;
inventoryLabel: string;
selectedSource: CaptureSourceInfo | undefined;
genshinSource: CaptureSourceInfo | undefined;
setDetailsOpen: Dispatch<SetStateAction<boolean>>;
setDiagnosticsOpen: Dispatch<SetStateAction<boolean>>;
setSettingsOpen: Dispatch<SetStateAction<boolean>>;
setReviewQueueOpen: Dispatch<SetStateAction<boolean>>;
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
setScanLimit: Dispatch<SetStateAction<number>>;
setScanLimitTouched: Dispatch<SetStateAction<boolean>>;
setSkipRows: Dispatch<SetStateAction<number>>;
toggleDevMode: () => void;
requestScanStop: (reason?: string) => void;
saveReviewSample: (
capture?: CaptureResult | null,
parsed?: ParsedArtifactCandidate | null,
reason?: string,
) => Promise<BooleanResult | null>;
loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>;
runVisibleGridScan: () => Promise<void>;
}
+61
View File
@@ -0,0 +1,61 @@
import type { ArtifactRowProps, TriageViewProps } from "./types";
import { useArtifactRowModel } from "./hooks/useArtifactRowModel";
import { useTriageViewModel } from "./hooks/useTriageViewModel";
function ArtifactRow({
artifact,
recommendation,
characters,
}: ArtifactRowProps) {
const { metaLabel, metaClassName, metaIcon, score, characterNames, artifactSummary, substatRows } = useArtifactRowModel({
recommendation,
artifact,
characters,
characterIds: recommendation?.bestCharacters,
});
return (
<div className="artifact-row">
<div>
<strong>{artifact.setName}</strong>
<span>{artifactSummary}</span>
</div>
<div className="substats">
{substatRows.map((substat) => (
<span key={`${artifact.id}-${substat}`}>{substat}</span>
))}
</div>
<div className={metaClassName}>{metaIcon}{metaLabel}</div>
<div className="reason">
<strong>{score}</strong>
<span>{characterNames}</span>
</div>
</div>
);
}
export function TriageView({ snapshot }: TriageViewProps) {
const { panelEyebrow, panelTitle, artifactCountLabel, recommendationByArtifact } = useTriageViewModel({ snapshot });
return (
<section className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">{panelEyebrow}</p>
<h2>{panelTitle}</h2>
</div>
<span className="muted">{artifactCountLabel}</span>
</div>
<div className="artifact-table">
{snapshot.artifacts.map((artifact) => (
<ArtifactRow
key={artifact.id}
artifact={artifact}
recommendation={recommendationByArtifact.get(artifact.id)}
characters={snapshot.characters}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import { verdictMeta } from "../../common/verdictMeta";
import type { Artifact, Character, Recommendation } from "../../../types/domain";
import type { ReactNode } from "react";
export interface ArtifactRowModel {
metaLabel: string;
metaClassName: string;
metaIcon: ReactNode;
score: number;
characterNames: string;
artifactSummary: string;
substatRows: string[];
}
interface UseArtifactRowModelInput {
artifact: Artifact;
recommendation?: Recommendation;
characters: Character[];
characterIds?: string[];
}
export function useArtifactRowModel({
recommendation,
characters,
artifact,
characterIds,
}: UseArtifactRowModelInput): ArtifactRowModel {
const meta = verdictMeta[recommendation?.verdict ?? "needs_review"];
const characterNames = !characterIds || characterIds.length === 0
? "Review first"
: characterIds.map((id) => characters.find((character) => character.id === id)?.name ?? id).join(", ");
const score = recommendation ? Math.round(recommendation.score) : 0;
return {
metaLabel: meta.label,
metaClassName: meta.className,
metaIcon: meta.icon,
score,
characterNames,
artifactSummary: `${artifact.slot} - +${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
substatRows: artifact.substats.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`),
};
}
@@ -0,0 +1,27 @@
import type { AppSnapshot, Recommendation } from "../../../types/domain";
import type { Artifact, Character } from "../../../types/domain";
export interface TriageViewModel {
artifactCount: number;
panelEyebrow: string;
panelTitle: string;
artifactCountLabel: string;
recommendationByArtifact: Map<string, Recommendation>;
}
interface UseTriageViewModelInput {
snapshot: AppSnapshot;
}
export function useTriageViewModel({ snapshot }: UseTriageViewModelInput): TriageViewModel {
const recommendationByArtifact = new Map(snapshot.recommendations.map((entry) => [entry.artifactId, entry]));
const artifactCount = snapshot.artifacts.length;
return {
artifactCount,
panelEyebrow: "Artifact triage",
panelTitle: "No-brainer decisions",
artifactCountLabel: `${artifactCount} scanned pieces`,
recommendationByArtifact,
};
}
+11
View File
@@ -0,0 +1,11 @@
import type { AppSnapshot, Artifact, Character, Recommendation } from "../../types/domain";
export interface TriageViewProps {
snapshot: AppSnapshot;
}
export interface ArtifactRowProps {
artifact: Artifact;
recommendation?: Recommendation;
characters: Character[];
}
@@ -0,0 +1,2 @@
export * from "./rendererBridgeRepositoryTypes";
export { createRendererRepositories } from "./rendererBridgeRepositoryFactory";
@@ -0,0 +1,218 @@
import { getAssistantBridge } from "../../services/assistantBridge";
import {
type ArtifactRepositoryPort,
type AutomationRepositoryPort,
type CaptureRepositoryPort,
type RendererRepositories,
type RuntimeRepositoryPort,
type ReviewSampleRepositoryPort,
type LearningRepositoryPort,
type SnapshotRepositoryPort,
type OverlayRepositoryPort,
type ScanExportPort,
} from "./rendererBridgeRepositoryTypes";
import type { AppSnapshot } from "../../types/domain";
import type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
LoadScannerLearningRulesResult,
BooleanResult,
CaptureSourceInfo,
FocusGenshinResult,
RuntimeInfo,
SaveResultWithPath,
ScrollResult,
AutomationGuard,
ClickResult,
ReviewSampleListResult,
SaveScannerLearningRulesResult,
} from "../../types/global";
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
const EMPTY_CAPTURE_SOURCE_LIST: CaptureSourceInfo[] = [];
const EMPTY_SAVE_RESULT: SaveResultWithPath = { ok: false, path: "" };
const EMPTY_RUNTIME_INFO: RuntimeInfo = {
ok: false,
isElevated: false,
platform: "unknown",
};
const EMPTY_ARTIFACT_STORE_LOAD_RESULT: ArtifactStoreLoadResult = {
ok: false,
artifacts: [],
total: 0,
path: "",
};
const EMPTY_ARTIFACT_STORE_SAVE_RESULT: ArtifactStoreSaveResult = {
ok: false,
added: 0,
updated: 0,
total: 0,
path: "",
};
const EMPTY_REVIEW_SAMPLES_RESULT: ReviewSampleListResult = {
ok: false,
samples: [],
total: 0,
path: "",
};
const EMPTY_SCANNER_LEARNING_RULES_RESULT: LoadScannerLearningRulesResult = {
ok: false,
path: "",
rules: {},
};
const EMPTY_SCAN_STATUS_RESULT: SaveResultWithPath = { ok: false, path: "" };
const EMPTY_SAVE_RULES_RESULT: SaveScannerLearningRulesResult = {
ok: false,
path: "",
rules: {},
total: 0,
};
async function createBridgeSafeCall<TResult>(
callback: () => Promise<TResult> | TResult | null | undefined,
fallback: TResult,
): Promise<TResult> {
try {
const result = await callback();
return result == null ? fallback : result;
} catch {
return fallback;
}
}
function emptyAutomationGuard(): AutomationGuard {
return { ok: false, escapePressed: false };
}
function emptyFocusGenshinResult(): FocusGenshinResult {
return { focused: false, alreadyForeground: false };
}
function emptyClickResult(): ClickResult {
return {
ok: false,
x: 0,
y: 0,
clicked: false,
moved: false,
focused: false,
inputBlocked: false,
};
}
function emptyScrollResult(): ScrollResult {
return { ok: false, notchesSent: 0, inputBlocked: false };
}
function emptyBooleanResult(): BooleanResult {
return { ok: false };
}
export function createRendererRepositories(): RendererRepositories | null {
const bridge = getAssistantBridge();
if (!bridge) return null;
const artifactRepo: ArtifactRepositoryPort = {
loadAll: () =>
createBridgeSafeCall(
() => bridge.loadArtifacts(),
EMPTY_ARTIFACT_STORE_LOAD_RESULT,
),
saveMany: (records) =>
createBridgeSafeCall(
() => bridge.saveArtifacts(records),
EMPTY_ARTIFACT_STORE_SAVE_RESULT,
),
};
const captureRepo: CaptureRepositoryPort = {
listSources: () => createBridgeSafeCall(() => bridge.listCaptureSources(), EMPTY_CAPTURE_SOURCE_LIST),
captureSource: (sourceId, delayMs, focusGenshin, options) =>
bridge.captureSource(sourceId, delayMs, focusGenshin, options),
};
const runtimeRepo: RuntimeRepositoryPort = {
getRuntimeInfo: () =>
createBridgeSafeCall(
() => bridge.getRuntimeInfo(),
EMPTY_RUNTIME_INFO,
),
};
const reviewSamplesRepo: ReviewSampleRepositoryPort = {
loadSamples: (limit = 50) =>
createBridgeSafeCall(
() => bridge.loadReviewSamples(limit),
EMPTY_REVIEW_SAMPLES_RESULT,
),
saveSample: (sample) =>
createBridgeSafeCall(
() => bridge.saveReviewSample(sample),
EMPTY_SCAN_STATUS_RESULT,
),
};
const learningRepo: LearningRepositoryPort = {
loadRules: () =>
createBridgeSafeCall(
() => bridge.loadScannerLearningRules(),
EMPTY_SCANNER_LEARNING_RULES_RESULT,
),
saveRules: (rules) =>
createBridgeSafeCall(
() => bridge.saveScannerLearningRules(rules),
EMPTY_SAVE_RULES_RESULT,
),
};
const snapshotRepo: SnapshotRepositoryPort = {
load: () => createBridgeSafeCall(() => bridge.loadSnapshot(), EMPTY_SNAPSHOT),
save: (snapshot) => createBridgeSafeCall(() => bridge.saveSnapshot(snapshot), EMPTY_SAVE_RESULT),
runMockScan: () => createBridgeSafeCall(() => bridge.runMockScan(), EMPTY_SNAPSHOT),
publishScannerStatus: (status) =>
createBridgeSafeCall(() => bridge.publishScannerStatus(status), EMPTY_SCAN_STATUS_RESULT),
};
const automationRepo: AutomationRepositoryPort = {
getAutomationGuard: () =>
createBridgeSafeCall(
() => bridge.getAutomationGuard(),
emptyAutomationGuard(),
),
focusGenshin: () =>
createBridgeSafeCall(
() => bridge.focusGenshin(),
emptyFocusGenshinResult(),
),
focusMainWindow: () => createBridgeSafeCall(() => bridge.focusMainWindow(), emptyBooleanResult()),
clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()),
scrollScreen: (notches, anchorX, anchorY) =>
createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()),
onCommand: bridge.onScannerCommand,
};
const overlayRepo: OverlayRepositoryPort = {
show: () => createBridgeSafeCall(() => bridge.showOverlay(), emptyBooleanResult()),
};
const exportRepo: ScanExportPort = {
exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT),
};
return {
artifacts: artifactRepo,
capture: captureRepo,
runtime: runtimeRepo,
reviewSamples: reviewSamplesRepo,
learning: learningRepo,
snapshot: snapshotRepo,
automation: automationRepo,
overlay: overlayRepo,
export: exportRepo,
canExportGood: bridge.canExportGood,
canShowOverlay: bridge.canShowOverlay,
canAutoScan: bridge.canAutoScan,
canReviewSamples: bridge.canReviewSamples,
isAvailable: true,
};
}
@@ -0,0 +1,90 @@
import type {
AutomationGuard,
CaptureOptions,
CaptureResult,
CaptureSourceInfo,
ClickResult,
ReviewSampleListResult,
ScannerStatusPayload,
ReviewSamplePayload,
GoodDatabase,
FocusGenshinResult,
RuntimeInfo,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
BooleanResult,
SaveResultWithPath,
ScrollResult,
ScannerLearningRulePayload,
} from "../../types/global";
import type { AppSnapshot } from "../../types/domain";
import type { StoredArtifactRecord } from "../../types/storage";
export interface ArtifactRepositoryPort {
loadAll(): Promise<ArtifactStoreLoadResult>;
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
}
export interface CaptureRepositoryPort {
listSources(): Promise<CaptureSourceInfo[]>;
captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise<CaptureResult>;
}
export interface RuntimeRepositoryPort {
getRuntimeInfo(): Promise<RuntimeInfo>;
}
export interface ReviewSampleRepositoryPort {
loadSamples(limit?: number): Promise<ReviewSampleListResult>;
saveSample(sample: ReviewSamplePayload): Promise<SaveResultWithPath>;
}
export interface LearningRepositoryPort {
loadRules(): Promise<LoadScannerLearningRulesResult>;
saveRules(
rules: ScannerLearningRulePayload,
): Promise<SaveScannerLearningRulesResult>;
}
export interface SnapshotRepositoryPort {
load(): Promise<AppSnapshot | null>;
save(snapshot: AppSnapshot): Promise<SaveResultWithPath>;
runMockScan(): Promise<AppSnapshot | null>;
publishScannerStatus(status: ScannerStatusPayload): Promise<BooleanResult>;
}
export interface AutomationRepositoryPort {
getAutomationGuard(): Promise<AutomationGuard>;
focusGenshin(): Promise<FocusGenshinResult>;
focusMainWindow(): Promise<BooleanResult>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
onCommand(callback: (command: "start-auto" | "stop") => void): () => void;
}
export interface OverlayRepositoryPort {
show(): Promise<BooleanResult>;
}
export interface ScanExportPort {
exportGood(payload: GoodDatabase): Promise<SaveResultWithPath>;
}
export interface RendererRepositories {
artifacts: ArtifactRepositoryPort;
capture: CaptureRepositoryPort;
runtime: RuntimeRepositoryPort;
reviewSamples: ReviewSampleRepositoryPort;
learning: LearningRepositoryPort;
snapshot: SnapshotRepositoryPort;
automation: AutomationRepositoryPort;
overlay: OverlayRepositoryPort;
export: ScanExportPort;
canExportGood: boolean;
canShowOverlay: boolean;
canAutoScan: boolean;
canReviewSamples: boolean;
isAvailable: boolean;
}
+324
View File
@@ -0,0 +1,324 @@
import { describe, expect, it } from "vitest";
import { parseArtifactCandidate } from "./artifactOcrParser";
import type { CaptureResult } from "../types/global";
function captureFromOcr(textById: Record<string, string>): CaptureResult {
return {
id: "test",
name: "test capture",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
ocr: Object.entries(textById).map(([id, text]) => ({
id,
label: id,
text,
confidence: 80,
})),
};
}
describe("parseArtifactCandidate", () => {
it("matches artifact data from the generated Genshin data package", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "A Note in Spring's Lei\n| Sands of Eon Vi",
"artifact-main-stat": "Elemental Mastery\n187",
"artifact-substats": "+20\n+ ATK+29\n+ CRIT DMG+15.5%\n+ CRIT Rate+2.7%\nATK + 15",
"artifact-set-effects": "A Day Carved From Rising Winds\n2-Piece Set: ATK +18%.",
"artifact-footer": "WV Equipped: Citlali\naR 0",
}));
expect(parsed?.slot).toBe("Sands of Eon");
expect(parsed?.level).toBe(20);
expect(parsed?.mainStat).toBe("Elemental Mastery");
expect(parsed?.setName).toBe("A Day Carved From Rising Winds");
expect(parsed?.equipped).toBe("Citlali");
});
it("recognizes newer characters and artifact sets without hardcoded one-offs", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Opulent Dr\n| Flower of Life 2",
"artifact-main-stat": "4,780\nAhhh",
"artifact-substats": "+ Elemental Mastery+54\n+ CRIT Rate+7.4%\n+ ATK+4.7%",
"artifact-set-effects": "Aubade of Morningstar and Moor\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "? Equipped: Ineffa\naR 0",
}));
expect(parsed?.slot).toBe("Flower of Life");
expect(parsed?.mainStat).toBe("HP");
expect(parsed?.mainValue).toBe("4,780");
expect(parsed?.setName).toBe("Aubade of Morningstar and Moon");
expect(parsed?.equipped).toBe("Ineffa");
});
it("normalizes noisy slot aliases from title OCR before matching", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Viridescent Venerer's Determination\nSands of Eon Vi",
"artifact-main-stat": "Energy Recharge\n51.8%",
"artifact-substats": "+ ATK+5.8%\n+ Elemental Mastery+37\n+ HP+11.7%\n+ ATK+54",
"artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%",
"artifact-footer": "Equipped: Sucrose",
}));
expect(parsed?.slot).toBe("Sands of Eon");
expect(parsed?.mainStat).toBe("Energy Recharge");
});
it("uses slot rules and fuzzy set matching for plume artifacts", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Pristine Plume of the Bles\n| Plume of Death",
"artifact-main-stat": "31 !\npr",
"artifact-substats": "+20\n+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\nATK +5.8%",
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Aino\nBR",
}));
expect(parsed?.slot).toBe("Plume of Death");
expect(parsed?.mainStat).toBe("ATK");
expect(parsed?.mainValue).toBe("311");
expect(parsed?.setName).toBe("Silken Moon's Serenade");
expect(parsed?.equipped).toBe("Aino");
});
it("keeps goblet elemental damage main stats separate from crit substats", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Deep Gallery's Bestowed Banquet\nGoblet of Eonothem",
"artifact-main-stat": "Cryo DMG Bonus\n46.6%",
"artifact-substats": "+ CRIT Rate+6.6%\n+ CRIT DMG+12.4%\n+ HP+269\n+ ATK+16.3%",
"artifact-set-effects": "Finale of the Deep Galleries:\n2-Piece Set: Cryo DMG Bonus +15%",
"artifact-footer": "Equipped: Skirk",
}));
expect(parsed?.slot).toBe("Goblet of Eonothem");
expect(parsed?.mainStat).toBe("Cryo DMG Bonus");
expect(parsed?.mainValue).toBe("46.6%");
});
it("does not let substats override circlet crit main stats", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Holy Crown of the Believer\nCirclet of Logos",
"artifact-main-stat": "CRIT Rate\n31.1%",
"artifact-substats": "+ ATK+4.7%\n+ Elemental Mastery+56\n+ DEF+37\n+ Energy Recharge+11.7%",
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Chongyun",
}));
expect(parsed?.slot).toBe("Circlet of Logos");
expect(parsed?.mainStat).toBe("CRIT Rate");
expect(parsed?.mainValue).toBe("31.1%");
});
it("keeps percent substats distinct from flat ATK HP and DEF", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Beast Tamer's Talisman\nFlower of Life",
"artifact-main-stat": "HP\n4,780",
"artifact-substats": "+ HP+16.3%\n+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
"artifact-set-effects": "Scroll of the Hero of Cinder City:\n2-Piece Set: When a nearby party member triggers a Nightsoul Burst",
"artifact-footer": "Equipped: Citlali",
}));
expect(parsed?.substats).toContain("HP%+16.3%");
expect(parsed?.setName).toBe("Scroll of the Hero of Cinder City");
});
it("falls back from artifact piece name to the owning set", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Holy Crown of the Believer\nCirclet of Logos",
"artifact-main-stat": "CRIT Rate\n31.1%",
"artifact-substats": "+ ATK+4.7%\n+ Elemental Mastery+56\n+ DEF+37",
"artifact-set-effects": "unreadable noisy set text",
"artifact-footer": "Equipped: Chongyun",
}));
expect(parsed?.setName).toBe("Silken Moon's Serenade");
});
it("falls back from artifact piece name to the owning slot", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Viridescent Venerer's Determination",
"artifact-main-stat": "Energy Recharge\n51.8%",
"artifact-substats": "+ ATK+5.8%\n+ Elemental Mastery+37\n+ HP+11.7%\n+ ATK+54",
"artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%",
"artifact-footer": "Equipped: Sucrose",
}));
expect(parsed?.slot).toBe("Sands of Eon");
expect(parsed?.setName).toBe("Viridescent Venerer");
});
it("promotes ATK HP and DEF main stats to percent variants when the value is percent", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Hourglass of Thunder\nSands of Eon",
"artifact-main-stat": "ATK\n40.7%",
"artifact-substats": "+17\n+ CRIT DMG+14.8%\n+ Elemental Mastery+21\n+ ATK+53\n+ DEF+19",
"artifact-set-effects": "Thundering Fury:\n2-Piece Set: Electro DMG Bonus +15%",
"artifact-footer": "Equipped: Fischl",
}));
expect(parsed?.level).toBe(17);
expect(parsed?.slot).toBe("Sands of Eon");
expect(parsed?.mainStat).toBe("ATK%");
expect(parsed?.mainValue).toBe("40.7%");
});
it("keeps the main value even when level helps but the stat family is still ambiguous", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Myths of the Night Realm\nSands of Eon",
"artifact-main-stat": "30.8%",
"artifact-substats": "+12\n+ Elemental Mastery+16\n+ CRIT DMG+6.2%\n+ DEF+42\n+ HP+9.9%",
"artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing",
"artifact-footer": "Equipped: Sandrone",
}));
expect(parsed?.level).toBe(12);
expect(parsed?.mainValue).toBe("30.8%");
expect(parsed?.mainStat).toBe("Unknown main stat");
});
it("cleans equipped footer noise before fuzzy-matching the character", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Gladiator's Nostalgia\nFlower of Life",
"artifact-main-stat": "HP\n4,780",
"artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%",
"artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%",
"artifact-footer": "1 gv 0RY If tha anuninnina\nJl Equipped: Bennett\nCEE",
}));
expect(parsed?.equipped).toBe("Bennett");
});
it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => {
const sands = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Myths of the Night Realm\nSands of Eon",
"artifact-main-stat": "ATK\n30.8%",
"artifact-substats": "+ Elemental Mastery+16\n+ CRIT DMG+6.2%\n+ DEF+42\n+ HP+9.9%",
"artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing",
"artifact-footer": "Equipped: Sandrone",
}));
const circlet = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Maiden's Fading Beauty\nCirclet of Logos",
"artifact-main-stat": "ATK\n46.6%",
"artifact-substats": "+ ATK+29\n+ CRIT DMG+10.9%\n+ CRIT Rate+7.0%",
"artifact-set-effects": "Maiden Beloved:\n2-Piece Set: Character Healing Effectiveness +15%",
"artifact-footer": "Equipped: Qiqi",
}));
const goblet = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Viridescent Venerer's Vessel\nGoblet of Eonothem",
"artifact-main-stat": "ATK\n46.6%",
"artifact-substats": "+ HP+209\n+ CRIT DMG+25.6%\n+ Elemental Mastery+37",
"artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%",
"artifact-footer": "Equipped: Ganyu",
}));
expect(sands?.mainStat).toBe("ATK%");
expect(sands?.mainValue).toBe("30.8%");
expect(circlet?.mainStat).toBe("ATK%");
expect(circlet?.mainValue).toBe("46.6%");
expect(goblet?.mainStat).toBe("ATK%");
expect(goblet?.mainValue).toBe("46.6%");
});
it("normalizes garbled percent punctuation in stat values", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Pristine Circlet of the Bles\n| Circlet of Logos",
"artifact-main-stat": "ATK\n46",
"artifact-substats": "+ Elemental Mastery+20" + String.fromCharCode(0x00b7) + "5%\n+ CRIT DMG+6.3%\n+ ATK+19\n+ DEF+12",
"artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%",
"artifact-footer": "Equipped: Aino",
}));
expect(parsed?.slot).toBe("Circlet of Logos");
expect(parsed?.substats).toContain("Elemental Mastery+20.5%");
expect(parsed?.substats).toContain("CRIT DMG+6.3%");
});
it("handles garbled quotation marks in copied text without failing slot and set parsing", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Aloy's " + String.fromCharCode(0x201c) + "Gift" + String.fromCharCode(0x201d) + "\n| Circlet of Logos",
"artifact-main-stat": "Elemental Mastery\n46.6%",
"artifact-substats": "+ ATK+4.7\n+ CRIT DMG+12.4%\n+ Energy Recharge+8.1%\n+ HP+11",
"artifact-set-effects": "Maiden" + String.fromCharCode(0x2019) + "s Beloved:\n2-Piece Set: Energy Recharge +16%",
"artifact-footer": "Equipped: Shenhe",
}));
expect(parsed?.slot).toBe("Circlet of Logos");
expect(parsed?.mainStat).toBe("Elemental Mastery");
expect(parsed?.setName).toBe("Maiden Beloved");
});
it("recovers substats that overflow into the set effects OCR crop", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Opulent Dr\nFlower of Life",
"artifact-main-stat": "HP\n4,780",
"artifact-substats": "+ CRIT DMG+13.2%",
"artifact-set-effects": "+ HP+15.7%\n+ DEF+12.4%\n+ Energy Recharge+5.2%\nAubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "Equipped: Venti",
}));
expect(parsed?.substats).toEqual([
"CRIT DMG+13.2%",
"HP%+15.7%",
"DEF%+12.4%",
"Energy Recharge+5.2%",
]);
expect(parsed?.fields.substats.confidence).toBe(96);
});
it("keeps a percent main value even when OCR misses the main stat label", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
"artifact-main-stat": "46.6%\n1S 2.5.8 J\nSe",
"artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%",
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "",
}));
expect(parsed?.mainStat).toBe("Unknown main stat");
expect(parsed?.mainValue).toBe("46.6%");
expect(parsed?.fields.mainValue.confidence).toBeGreaterThanOrEqual(80);
});
it("derives unique main stats from slot and value when the OCR label is missing", () => {
const sands = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Revelation's Toll\nSands of Eon",
"artifact-main-stat": "58.3%\n1S 2.5.8 J\nSe",
"artifact-substats": "+ CRIT Rate+14.0%\n+ Elemental Mastery+33\n+ Energy Recharge+6.5%",
"artifact-set-effects": "Night of the Sky's Unveiling:\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "Equipped: Zibai",
}));
const circlet = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Crown of the Saints\nCirclet of Logos",
"artifact-main-stat": "62.2%",
"artifact-substats": "+ ATK+18\n+ ATK+10.5%\n+ DEF+17.5%",
"artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing",
"artifact-footer": "Equipped: Aino",
}));
expect(sands?.mainValue).toBe("58.3%");
expect(sands?.mainStat).toBe("DEF%");
expect(circlet?.mainValue).toBe("62.2%");
expect(circlet?.mainStat).toBe("CRIT DMG");
});
it("recovers unique max-value mains from noisy digit fragments but keeps ambiguous 46-values conservative", () => {
const circlet = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Silver Crown\nCirclet of Logos",
"artifact-main-stat": "6\n2 D",
"artifact-substats": "+ ATK+29\n+ ATK+5.8%\n+ Elemental Mastery+77\n- DEF+5.1%",
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "",
}));
const sands = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Moonlit Offering's Final Hour\nSands of Eon",
"artifact-main-stat": "46.6% |\nPEE",
"artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53",
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "",
}));
expect(circlet?.mainValue).toBe("62.2%");
expect(circlet?.mainStat).toBe("CRIT DMG");
expect(sands?.mainValue).toBe("46.6%");
expect(sands?.mainStat).toBe("Unknown main stat");
});
});
+584
View File
@@ -0,0 +1,584 @@
import type { CaptureResult } from "../types/global.js";
import {
allowedMainStatsForSlot,
canonicalStatName,
fixedMainStatBySlot,
globalMainStats,
globalSubstats,
knownCharacters,
knownPieceNames,
knownSets,
mainStatValueReferences,
normalizeCharacterAlias,
normalizePieceAlias,
normalizeSetAlias,
normalizeSlotAlias,
pieceToSet,
pieceToSlot,
slotNames,
textReplacements,
} from "./genshinData.js";
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
type MainStatValueReference = { stat: string; base: number; max: number };
export interface ParsedField {
value: string;
confidence: number;
source: "ocr" | "database" | "derived" | "fallback" | "missing";
}
export interface ParsedArtifactCandidate {
name: string;
slot: string;
level: number;
mainStat: string;
mainValue: string;
substats: string[];
setName: string;
equipped: string;
confidence: number;
notes: string[];
fields: {
name: ParsedField;
slot: ParsedField;
level?: ParsedField;
mainStat: ParsedField;
mainValue: ParsedField;
setName: ParsedField;
equipped: ParsedField;
substats: ParsedField;
};
}
const mainStatNames = sortLongestFirst(globalMainStats);
const substatNames = sortLongestFirst(globalSubstats);
export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArtifactCandidate | null {
if (!capture?.ocr?.length) return null;
const byId = new Map(
(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 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 parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
const level = parsedLevel ?? 0;
let mainStatField = inferMainStat(slotField.value, mainText);
let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
if (!mainStatField.value && mainValueField.value) {
const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel);
if (inferredFromValue.value) {
mainStatField = inferredFromValue;
mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
}
}
if (!mainStatField.value && mainValueField.value) {
const exactReferenceMatch = deriveMainStatFromExactReferenceValue(slotField.value, mainValueField.value, mainText, parsedLevel);
if (exactReferenceMatch.value) {
mainStatField = exactReferenceMatch;
mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
}
}
if ((!mainStatField.value || !mainValueField.value) && slotField.value) {
const noisyReferenceMatch = deriveMainStatAndValueFromNoisyReference(slotField.value, mainText, parsedLevel);
if (!mainStatField.value && noisyReferenceMatch.mainStat.value) {
mainStatField = noisyReferenceMatch.mainStat;
}
if (!mainValueField.value && noisyReferenceMatch.mainValue.value) {
mainValueField = noisyReferenceMatch.mainValue;
}
}
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 equippedField = parseEquippedCharacter(footerText + "\n" + allText);
const notes: string[] = [];
if (!nameField.value) notes.push("Artifact name not confidently parsed.");
if (nameField.value && nameField.confidence < 84) notes.push("Artifact name was fuzzy-matched; review if this piece matters.");
if (!slotField.value) notes.push("Slot not confidently parsed.");
if (!levelField.value) notes.push("Artifact level not confidently parsed.");
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
if (!setField.value) notes.push("Set name not confidently parsed.");
for (const [label, parsedField] of Object.entries({
name: nameField,
slot: slotField,
level: levelField,
mainStat: mainStatField,
mainValue: mainValueField,
set: setField,
equipped: equippedField,
}) as Array<[string, ParsedField]>) {
if (parsedField.value && parsedField.confidence < 70) notes.push(`${label} confidence is low; review before trusting it.`);
}
const fields = {
name: nameField,
slot: slotField,
level: levelField,
mainStat: mainStatField,
mainValue: mainValueField,
setName: setField,
equipped: equippedField,
substats: substatsField,
};
const confidence = Math.round(Object.values(fields).reduce((sum, parsedField) => sum + parsedField.confidence, 0) / Object.values(fields).length);
return {
name: nameField.value || "Unknown artifact",
slot: slotField.value || "Unknown slot",
level,
mainStat: mainStatField.value || "Unknown main stat",
mainValue: mainValueField.value || "?",
substats,
setName: setField.value || "Unknown set",
equipped: equippedField.value || "Not detected",
confidence,
notes: [...new Set(notes)],
fields,
};
}
function parseArtifactName(titleText: string): ParsedField {
const titleLines = titleText
.split("\n")
.map((line) => cleanupOcrLabel(line))
.filter(Boolean);
for (const line of titleLines) {
const alias = normalizePieceAlias(line);
if (alias) return field(alias, 96, "database");
}
const knownPiece = fuzzyFindKnown(titleText, knownPieceNames, 0.72);
if (knownPiece) return field(knownPiece.value, Math.round(knownPiece.score * 100), knownPiece.score >= 0.98 ? "database" : "fallback");
const fallback = firstUsefulLine(titleText, slotNames);
return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing");
}
function parseSlot(text: string, artifactName: string): ParsedField {
const slotLines = text
.split("\n")
.map((line) => cleanupOcrLabel(line))
.filter(Boolean);
for (const line of slotLines) {
const alias = normalizeSlotAlias(line);
if (alias) return field(alias, 96, "ocr");
}
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");
}
function parseSetName(setText: string, artifactName: string): ParsedField {
const setFromPiece = artifactName ? pieceToSet.get(artifactName) : 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 alias = normalizeSetAlias(line);
if (alias) return field(alias, 96, "database");
}
const directLine = candidateLines.find((line) => line.length > 8);
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");
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
}
function parseArtifactLevel(text: string): ParsedField {
const lines = normalizeText(text)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const match = line.match(/^\+\s*(20|1[0-9]|[0-9])\b/);
if (match?.[1]) return field(match[1], 96, "ocr");
}
const anywhere = normalizeText(text).match(/(?:^|\s)\+\s*(20|1[0-9]|[0-9])\b/);
return anywhere?.[1] ? field(anywhere[1], 84, "ocr") : field("", 0, "missing");
}
function normalizeText(text: string) {
return applyTextReplacements(text)
.replace(/[\u201c\u201d]/g, '"')
.replace(/[\u2019]/g, "'")
.replace(/[\u00B7]/g, ".")
.replace(/\r/g, "")
.replace(/[|]/g, "I")
.replace(/\s+\n/g, "\n")
.trim();
}
function applyTextReplacements(text: string) {
return Object.entries(textReplacements as Record<string, string>).reduce(
(current, [from, to]) => current.replace(new RegExp(escapeRegex(from), "gi"), to),
text,
);
}
function firstUsefulLine(text: string, rejectIncludes: string[]) {
return text
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? "";
}
function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField {
const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0");
const percentValue = extractPercentValue(cleaned);
let ocrField = field("", 0, "missing");
if (percentValue && !mainStat) ocrField = field(percentValue, 84, "ocr");
if (percentValue && isPercentMainStat(mainStat)) ocrField = field(percentValue, 96, "ocr");
const flat = /\b([0-9]{1,2},[0-9]{3}|[0-9]{2,4})\b/.exec(cleaned);
if (!ocrField.value && flat) ocrField = field(flat[1], 92, "ocr");
const derivedField = deriveMainValueFromLevel(slot, mainStat, level);
if (!ocrField.value) return derivedField;
if (!derivedField.value) return ocrField;
if (["HP", "ATK", "DEF", "Elemental Mastery"].includes(mainStat)) {
const ocrNumeric = parseNumericValue(ocrField.value);
const derivedNumeric = parseNumericValue(derivedField.value);
const derivedIntDigits = String(Math.round(derivedNumeric)).length;
const ocrIntDigits = String(Math.round(ocrNumeric)).length;
if (!Number.isFinite(ocrNumeric) || ocrNumeric < derivedNumeric * 0.5 || ocrIntDigits + 1 < derivedIntDigits) return derivedField;
if (Math.abs(ocrNumeric - derivedNumeric) >= Math.max(4, derivedNumeric * 0.22)) return derivedField;
}
return ocrField;
}
function extractPercentValue(text: string) {
const lineMatches = text
.split("\n")
.map((line) => line.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/))
.filter((match): match is RegExpMatchArray => Boolean(match));
const preferred = lineMatches[0] ?? text.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/);
if (!preferred?.[1]) return "";
return `${preferred[1].replace(/[:,\u00B7]/g, ".")}%`;
}
function inferMainStat(slot: string, text: string): ParsedField {
if (fixedMainStatBySlot[slot]) return field(fixedMainStatBySlot[slot], 100, "derived");
const direct = findDirectMainStat(text);
if (direct) return field(promotePercentVariant(direct, text), 94, "ocr");
const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot));
const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68);
if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback");
const fuzzy = fuzzyFindKnown(text, mainStatNames, 0.72);
return fuzzy ? field(promotePercentVariant(fuzzy.value, text), Math.round(fuzzy.score * 100), "fallback") : field("", 0, "missing");
}
function findDirectMainStat(text: string) {
const compact = simplifyForMatch(text);
const hasPercentValue = /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text);
const priority = [
"Physical DMG Bonus",
"Elemental Mastery",
"Energy Recharge",
"Healing Bonus",
"Hydro DMG Bonus",
"Pyro DMG Bonus",
"Electro DMG Bonus",
"Cryo DMG Bonus",
"Dendro DMG Bonus",
"Anemo DMG Bonus",
"Geo DMG Bonus",
"CRIT Rate",
"CRIT DMG",
"ATK%",
"HP%",
"DEF%",
"ATK",
"HP",
"DEF",
];
const direct = priority.find((stat) => compact.includes(simplifyForMatch(stat))) ?? "";
if (direct) return direct;
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%";
return "";
}
function inferMainStatFromValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField {
const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", ""));
if (!Number.isFinite(numeric)) return field("", 0, "missing");
const candidates = getSlotMainStatValueReferences(slot)
.map((candidate) => {
const expected = expectedMainStatValue(candidate, level);
return {
...candidate,
expected,
delta: Math.abs(expected - numeric),
};
})
.sort((left, right) => left.delta - right.delta);
const best = candidates[0];
const tolerance = toleranceForMainStatValue(best?.stat ?? "", mainValue);
const competing = candidates.filter((candidate) => candidate.delta <= tolerance);
if (best && competing.length === 1) {
return field(promotePercentVariant(best.stat, text), Math.max(72, Math.round(92 - best.delta * 24)), "derived");
}
return field("", 0, "missing");
}
function deriveMainStatFromExactReferenceValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField {
const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", ""));
if (!Number.isFinite(numeric)) return field("", 0, "missing");
const matches = getSlotMainStatValueReferences(slot)
.filter((candidate) => Math.abs(expectedMainStatValue(candidate, level) - numeric) <= toleranceForMainStatValue(candidate.stat, mainValue))
.sort((left, right) => Math.abs(expectedMainStatValue(left, level) - numeric) - Math.abs(expectedMainStatValue(right, level) - numeric));
if (matches.length !== 1) return field("", 0, "missing");
return field(promotePercentVariant(matches[0].stat, text || mainValue), 88, "derived");
}
function deriveMainStatAndValueFromNoisyReference(slot: string, text: string, level: number | null) {
const fragment = text.replace(/[^\d]/g, "");
if (fragment.length < 2) {
return {
mainStat: field("", 0, "missing"),
mainValue: field("", 0, "missing"),
};
}
const candidates = getSlotMainStatValueReferences(slot)
.map((candidate) => {
const formattedValue = formatExpectedValue(candidate, level);
const digits = formattedValue.replace(/[^\d]/g, "");
return {
candidate,
formattedValue,
score: digitReferenceScore(fragment, digits),
};
})
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score);
const best = candidates[0];
const second = candidates[1];
if (!best) {
return {
mainStat: field("", 0, "missing"),
mainValue: field("", 0, "missing"),
};
}
if (second && best.score - second.score < 0.2) {
return {
mainStat: field("", 0, "missing"),
mainValue: field("", 0, "missing"),
};
}
return {
mainStat: field(promotePercentVariant(best.candidate.stat, text), Math.round(72 + best.score * 18), "derived"),
mainValue: field(best.formattedValue, Math.round(78 + best.score * 14), "derived"),
};
}
function parseSubstats(text: string) {
const normalized = normalizeText(text)
.replace(/CRIT\s*DMG/gi, "CRIT DMG")
.replace(/CRIT\s*Rate/gi, "CRIT Rate")
.replace(/Energy\s*Recharge/gi, "Energy Recharge")
.replace(/Elemental\s*Mastery/gi, "Elemental Mastery")
.replace(/([A-Z]{2,4})\s*\+/g, "$1+");
const statPattern = new RegExp(`(${substatNames.map(escapeRegex).join("|")})\\s*\\+\\s*([0-9]+(?:\\.[0-9])?%?)`, "gi");
const results: string[] = [];
let match: RegExpExecArray | null;
while ((match = statPattern.exec(normalized))) {
const stat = canonicalSubstatName(match[1], match[2]);
results.push(`${stat}+${match[2]}`);
}
return [...new Set(results)].slice(0, 4);
}
function leadingSetEffectText(text: string) {
const lines = normalizeText(text).split("\n");
const result: string[] = [];
for (const line of lines) {
if (/^\s*\d+\s*-\s*Piece Set/i.test(line) || /piece set/i.test(line)) break;
result.push(line);
}
return result.join("\n");
}
function canonicalSubstatName(rawStat: string, rawValue: string) {
const cleaned = rawStat.replace(/\s+/g, " ").trim();
const known = canonicalStatName(cleaned) || fuzzyFindKnown(cleaned, substatNames, 0.8)?.value || cleaned;
const canonical = canonicalStatName(known);
if (["ATK", "HP", "DEF"].includes(canonical) && rawValue.includes("%")) return `${canonical}%`;
return canonical;
}
function parseEquippedCharacter(text: string): ParsedField {
const equippedLine = text
.split("\n")
.map((line) => line.trim())
.find((line) => /equipped/i.test(line));
if (!equippedLine) return field("Not detected", 45, "missing");
const afterLabel = cleanupCharacterNoise(equippedLine);
const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : "";
if (alias) return field(alias, 96, "database");
const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null;
if (known) return field(known.value, Math.round(known.score * 100), known.score >= 0.95 ? "ocr" : "fallback");
const fallbackSearch = cleanupCharacterNoise(text);
const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null;
if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback");
return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing");
}
function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField {
return { value, confidence: Math.max(0, Math.min(100, confidence)), source };
}
function isPercentMainStat(stat: string) {
return /%|Rate|DMG|Bonus|Recharge/i.test(stat);
}
function promotePercentVariant(stat: string, text: string) {
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text)) return `${stat}%`;
return stat;
}
function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] {
const valueReferences = mainStatValueReferences[slot];
return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : [];
}
function normalizeMainValue(value: string) {
return value.replace(/[:,\u00B7]/g, ".").replace(/\s+/g, "");
}
function deriveMainValueFromLevel(slot: string, mainStat: string, level: number | null): ParsedField {
if (!mainStat) return field("", 0, "missing");
const reference = getSlotMainStatValueReferences(slot).find((candidate) => candidate.stat === mainStat);
if (!reference) return field("", 0, "missing");
if (level === null) {
if (slot === "Flower of Life" || slot === "Plume of Death") return field(formatExpectedValue(reference, null), 72, "derived");
return field("", 0, "missing");
}
if (level < 0 || level > 20) return field("", 0, "missing");
return field(formatExpectedValue(reference, level), 88, "derived");
}
function expectedMainStatValue(reference: { base: number; max: number }, level: number | null) {
if (level === null || !Number.isFinite(level)) return reference.max;
const clampedLevel = Math.max(0, Math.min(20, level));
return reference.base + (reference.max - reference.base) * (clampedLevel / 20);
}
function formatExpectedValue(reference: { stat: string; base: number; max: number }, level: number | null) {
const numeric = expectedMainStatValue(reference, level);
const rounded = isPercentMainStat(reference.stat)
? roundTo(numeric, 1)
: Math.round(numeric);
return isPercentMainStat(reference.stat) ? `${rounded.toFixed(1)}%` : rounded.toLocaleString("en-US");
}
function toleranceForMainStatValue(stat: string, mainValue: string) {
if (mainValue.includes("%") || isPercentMainStat(stat)) return 0.45;
if (stat === "Elemental Mastery") return 2.5;
return 6;
}
function parseNumericValue(value: string) {
return Number.parseFloat(
value
.replace(/,/g, "")
.replace("%", "")
.trim(),
);
}
function roundTo(value: number, digits: number) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function digitReferenceScore(fragment: string, referenceDigits: string) {
if (!fragment || !referenceDigits) return 0;
if (fragment === referenceDigits) return 1;
if (referenceDigits.startsWith(fragment)) {
return Math.max(0, 0.95 - (referenceDigits.length - fragment.length) * 0.08);
}
if (fragment.length >= 3 && isDigitSubsequence(fragment, referenceDigits)) {
return 0.72;
}
return 0;
}
function isDigitSubsequence(fragment: string, referenceDigits: string) {
let index = 0;
for (const char of referenceDigits) {
if (char === fragment[index]) index++;
if (index >= fragment.length) return true;
}
return false;
}
function cleanupOcrLabel(line: string) {
return line
.replace(/^[^A-Za-z]+/, "")
.replace(/[^A-Za-z'\s]+$/g, "")
.replace(/\s+/g, " ")
.trim();
}
function cleanupCharacterNoise(text: string) {
return text
.replace(/^.*?equipped\s*:?\s*/i, "")
.replace(/[^A-Za-z'\-\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function sortLongestFirst(values: string[]) {
return [...values].sort((a, b) => b.length - a.length);
}
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import {
hashId,
isReviewOnlyArtifactSource,
resolveStoredArtifactSource,
sessionSignature,
storeSignature,
storedArtifactStrength,
toStoredArtifact,
} from "./artifactStore";
function candidate(overrides: Partial<ParsedArtifactCandidate> = {}): ParsedArtifactCandidate {
const field = { value: "", confidence: 90, source: "ocr" as const };
return {
name: "Gladiator's Nostalgia",
slot: "Flower of Life",
level: 20,
mainStat: "HP",
mainValue: "4,780",
substats: ["CRIT Rate+3.9%", "ATK%+5.8%", "Energy Recharge+5.2%", "ATK+19"],
setName: "Gladiator's Finale",
equipped: "Hu Tao",
confidence: 91,
notes: [],
fields: {
name: field,
slot: field,
level: field,
mainStat: field,
mainValue: field,
setName: field,
equipped: field,
substats: field,
},
...overrides,
};
}
describe("artifactStore", () => {
it("keeps the session signature sensitive to the equipped character", () => {
const a = sessionSignature(candidate());
const b = sessionSignature(candidate({ equipped: "Xiangling" }));
expect(a).not.toBe(b);
});
it("ignores the equipped character in the store signature", () => {
const a = storeSignature(candidate());
const b = storeSignature(candidate({ equipped: "Xiangling" }));
expect(a).toBe(b);
});
it("changes the store signature when substats change", () => {
const a = storeSignature(candidate());
const b = storeSignature(candidate({ substats: ["CRIT Rate+7.8%", "ATK%+5.8%", "Energy Recharge+5.2%", "ATK+19"] }));
expect(a).not.toBe(b);
});
it("changes the store signature when the level changes", () => {
const a = storeSignature(candidate({ level: 20 }));
const b = storeSignature(candidate({ level: 16 }));
expect(a).not.toBe(b);
});
it("produces a stable id for the same artifact", () => {
const recordA = toStoredArtifact(candidate(), "auto-scan", false);
const recordB = toStoredArtifact(candidate({ equipped: "Xiangling" }), "manual-scan", true);
expect(recordA.id).toBe(recordB.id);
expect(recordA.id).toBe(hashId(storeSignature(candidate())));
});
it("copies parsed values into the stored record", () => {
const record = toStoredArtifact(candidate(), "auto-scan", true);
expect(record.name).toBe("Gladiator's Nostalgia");
expect(record.slot).toBe("Flower of Life");
expect(record.level).toBe(20);
expect(record.setName).toBe("Gladiator's Finale");
expect(record.substats).toHaveLength(4);
expect(record.needsReview).toBe(true);
expect(record.source).toBe("auto-scan");
});
it("treats review recovery sources as lower priority than verified scan sources", () => {
expect(isReviewOnlyArtifactSource("review-reprocess")).toBe(true);
expect(resolveStoredArtifactSource("auto-scan", "review-reprocess")).toBe("auto-scan");
expect(resolveStoredArtifactSource("review-recovered", "manual-scan")).toBe("manual-scan");
});
it("scores confirmed artifacts above uncertain review-only records", () => {
const review = toStoredArtifact(candidate({ confidence: 74 }), "review-reprocess", true);
const confirmed = toStoredArtifact(candidate({ confidence: 92 }), "auto-scan", false);
expect(storedArtifactStrength(confirmed)).toBeGreaterThan(storedArtifactStrength(review));
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { ParsedArtifactCandidate } from "./artifactOcrParser.js";
import type { StoredArtifactRecord } from "../types/storage.js";
/**
* Signature used inside one scan session to detect that the detail panel
* actually changed after a click. Includes the equipped character so two
* otherwise identical pieces on different characters still count as new.
*/
export function sessionSignature(parsed: ParsedArtifactCandidate) {
return [
parsed.name,
parsed.slot,
parsed.level,
parsed.mainStat,
parsed.mainValue,
parsed.setName,
parsed.equipped,
parsed.substats.join("|"),
].join("::");
}
/**
* Signature used for the persistent store. Excludes the equipped character,
* so re-equipping an artifact updates the existing record instead of
* duplicating it. Level is part of the identity because the local DB now
* persists partially leveled pieces as distinct scan states.
*/
export function storeSignature(parsed: ParsedArtifactCandidate) {
return [
parsed.name,
parsed.slot,
parsed.level,
parsed.mainStat,
parsed.mainValue,
parsed.setName,
parsed.substats.join("|"),
].join("::");
}
export function hashId(value: string) {
let hash = 5381;
for (let index = 0; index < value.length; index++) {
hash = ((hash << 5) + hash + value.charCodeAt(index)) >>> 0;
}
return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`;
}
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean): StoredArtifactRecord {
return {
id: hashId(storeSignature(parsed)),
name: parsed.name,
slot: parsed.slot,
level: parsed.level,
setName: parsed.setName,
mainStat: parsed.mainStat,
mainValue: parsed.mainValue,
substats: [...parsed.substats],
equipped: parsed.equipped,
confidence: parsed.confidence,
needsReview,
source,
};
}
export function isReviewOnlyArtifactSource(source: string | null | undefined) {
return /^review-/i.test(source ?? "");
}
export function storedArtifactStrength(record: StoredArtifactRecord | null | undefined) {
if (!record) return 0;
return (
(record.confidence ?? 0)
+ Math.min(16, (record.substats?.length ?? 0) * 4)
+ (record.needsReview ? -10 : 8)
+ (record.equipped && !/not detected/i.test(record.equipped) ? 3 : 0)
);
}
export function resolveStoredArtifactSource(existingSource: string | null | undefined, incomingSource: string | null | undefined) {
const existing = existingSource ?? "";
const incoming = incomingSource ?? "";
if (!incoming) return existing;
if (!existing) return incoming;
if (isReviewOnlyArtifactSource(incoming) && !isReviewOnlyArtifactSource(existing)) return existing;
return incoming;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
describe("autoScanController", () => {
it("does not count empty signatures as scanned artifacts", () => {
expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" });
});
it("separates stuck detail views from duplicates", () => {
const seen = new Set(["same"]);
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" });
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" });
});
it("accepts a new signature as a readable artifact", () => {
expect(classifyAutoScanCapture({ signature: "new", lastDetailSignature: "old", seen: new Set(["old"]) })).toMatchObject({ kind: "new" });
});
it("uses a conservative miss threshold", () => {
expect(shouldAbortAfterConsecutiveMisses(2)).toBe(false);
expect(shouldAbortAfterConsecutiveMisses(3)).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
export type AutoScanCaptureDecision =
| { kind: "unreadable"; countAsMiss: true }
| { kind: "stuck"; countAsMiss: true; signature: string }
| { kind: "duplicate"; countAsDuplicate: true; signature: string }
| { kind: "new"; countAsParsed: true; signature: string };
export function classifyAutoScanCapture({
signature,
lastDetailSignature,
seen,
}: {
signature: string;
lastDetailSignature: string;
seen: ReadonlySet<string>;
}): AutoScanCaptureDecision {
if (!signature) return { kind: "unreadable", countAsMiss: true };
if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature };
if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature };
return { kind: "new", countAsParsed: true, signature };
}
export function shouldAbortAfterConsecutiveMisses(consecutiveMisses: number, threshold = 3) {
return consecutiveMisses >= threshold;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { detailFingerprint, fingerprintDataUrl, isRepeatedProcessedPageFingerprint, screenFingerprint } from "./autoScanLoop";
describe("autoScanLoop fingerprints", () => {
it("distinguishes captures that share the same prefix but differ later", () => {
const sharedPrefix = "data:image/png;base64," + "A".repeat(180);
const left = sharedPrefix + "LEFT-" + "B".repeat(600);
const right = sharedPrefix + "RIGHT-" + "C".repeat(600);
expect(fingerprintDataUrl(left)).not.toBe(fingerprintDataUrl(right));
});
it("stays stable for the same capture string", () => {
const dataUrl = "data:image/png;base64," + "Q".repeat(2048);
expect(fingerprintDataUrl(dataUrl)).toBe(fingerprintDataUrl(dataUrl));
});
it("uses the detail preview for detail-change verification instead of the full frame", () => {
const captureA = {
detailDataUrl: "data:image/png;base64," + "DETAIL-A".repeat(64),
dataUrl: "data:image/png;base64," + "FULL-A".repeat(256),
} as const;
const captureB = {
detailDataUrl: captureA.detailDataUrl,
dataUrl: "data:image/png;base64," + "FULL-B".repeat(256),
} as const;
expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never));
});
it("uses the inventory preview for scroll verification when available", () => {
const captureA = {
inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64),
dataUrl: "data:image/png;base64," + "FRAME-A".repeat(256),
} as const;
const captureB = {
inventoryDataUrl: "data:image/png;base64," + "GRID-B".repeat(64),
dataUrl: captureA.dataUrl,
} as const;
expect(screenFingerprint(captureA as never)).not.toBe(screenFingerprint(captureB as never));
});
it("treats repeated page fingerprints as a loop only after page one", () => {
const seen = new Set(["abc"]);
expect(isRepeatedProcessedPageFingerprint("abc", seen, 1)).toBe(false);
expect(isRepeatedProcessedPageFingerprint("abc", seen, 2)).toBe(true);
expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false);
});
});
+487
View File
@@ -0,0 +1,487 @@
import type { BooleanResult, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { sessionSignature } from "./artifactStore";
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
import type { AutoScanStats, ScanSummary } from "./scannerSession";
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
// 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
// view did not change, then move on. No click-profile matrix, no offset
// retries, no double-read conflict resolution - those never fixed a single
// click and only made failures harder to diagnose.
type AutoScanApi = {
clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
getAutomationGuard?: () => Promise<AutomationGuard>;
};
export type AutoScanLoopDependencies = {
api: AutoScanApi;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => 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>;
getAutoReviewReason: (capture: CaptureResult, parsed: ParsedArtifactCandidate) => string;
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
setReviewStatus: (value: string) => void;
setAutoScanStats: (stats: AutoScanStats) => void;
shouldStop: () => boolean;
};
export type AutoScanLoopOptions = {
scanLimit: number;
skipRows: number;
detectedInventoryCount?: number | null;
};
export type AutoScanLoopResult = {
status: ScanSummary["status"];
stats: AutoScanStats;
blockedReason: string;
pageCount: number;
gridLabel: string;
targetCount: number;
};
const CLICK_SETTLE_MS = 280;
const MISS_ABORT_THRESHOLD = 3;
const UNREADABLE_ABORT_THRESHOLD = 5;
export async function runAutoScanLoop(
deps: AutoScanLoopDependencies,
options: AutoScanLoopOptions,
): Promise<AutoScanLoopResult> {
const {
api,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
getAutoReviewReason,
shouldFlagArtifactForReview,
appendAutomationLog,
appendClickDiagnostics,
setReviewStatus,
setAutoScanStats,
shouldStop,
} = deps;
const stats: AutoScanStats = { ...emptyAutoScanStats };
const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
const rowsToSkip = clampSkipRows(options.skipRows);
const seen = new Set<string>();
const seenPageFingerprints = new Set<string>();
let page = 0;
let blockedReason = "";
let aborted = false;
let consecutiveMisses = 0;
let rowsQueued = 0;
function updateStats() {
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();
}
}
async function checkGuard() {
if (shouldStop()) return "Stop-Button gedrueckt.";
if (!api.getAutomationGuard) return "";
try {
const guard = await api.getAutomationGuard();
if (guard.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt.";
if (guard.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt.";
if (guard.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt.";
return "";
} catch {
return "";
}
}
function inputStopReason(result: ClickResult) {
if (result.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt.";
if (result.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt.";
if (result.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt.";
return "";
}
async function waitDuringScan(ms: number) {
const startedAt = Date.now();
while (Date.now() - startedAt < ms) {
const guardReason = await checkGuard();
if (guardReason) return guardReason;
await wait(Math.min(120, ms - (Date.now() - startedAt)));
}
return "";
}
async function clickTarget(target: GridTarget, label: string) {
appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
const clickResult = await api.clickScreen(target.x, target.y);
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
stats.clicked++;
stats.attempted = stats.clicked;
updateStats();
return clickResult;
}
let currentCapture = await captureSelectedSource(0, true);
const initialCaptureRejection = captureSourceRejectionReason(currentCapture);
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 };
}
let lastDetailSignature = "";
const initialParsed = parseArtifact(currentCapture);
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
try {
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
page++;
stats.pages = page;
updateStats();
const currentPageFingerprint = screenFingerprint(currentCapture);
if (isRepeatedProcessedPageFingerprint(currentPageFingerprint, seenPageFingerprints, page)) {
blockedReason = `Inventarseite ${page} wurde bereits zuvor gesehen. Scrollen hat wahrscheinlich keine neue Seite geliefert; Scan gestoppt, um keine Duplikat-Schleife zu erzeugen.`;
break;
}
if (currentPageFingerprint) seenPageFingerprints.add(currentPageFingerprint);
const pageSkipRows = page === 1 ? Math.min(rowsToSkip, Math.max(0, gridModel.rows - 1)) : 0;
const baseTargets = gridModel.targets.filter((target) => target.row >= pageSkipRows);
const pagePlan = buildInventoryPagePlan({
targets: baseTargets,
cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets,
processedTargets: stats.clicked,
rowsQueued,
});
const targets = 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.`);
let newArtifactsOnPage = 0;
for (const target of targets) {
if (shouldStop() || stats.clicked >= maxTargets) break;
const guardReason = await checkGuard();
if (guardReason) {
blockedReason = guardReason;
aborted = true;
break;
}
let clickResult = await clickTarget(target, "click");
let stopReason = inputStopReason(clickResult);
if (stopReason) {
blockedReason = stopReason;
aborted = true;
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.`;
break;
}
let waitStop = await waitDuringScan(CLICK_SETTLE_MS);
if (waitStop) {
blockedReason = waitStop;
aborted = true;
break;
}
let previewCapture = await captureFastSelectedSource(0, true);
let previewFingerprint = detailFingerprint(previewCapture);
let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
if (!changedDetail) {
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
clickResult = await clickTarget(target, "retry");
stopReason = inputStopReason(clickResult);
if (stopReason) {
blockedReason = stopReason;
aborted = true;
break;
}
waitStop = await waitDuringScan(CLICK_SETTLE_MS);
if (waitStop) {
blockedReason = waitStop;
aborted = true;
break;
}
previewCapture = await captureFastSelectedSource(0, true);
previewFingerprint = detailFingerprint(previewCapture);
changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
}
if (!changedDetail) {
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: Detailansicht unveraendert`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) {
blockedReason = "Mehrere Klicks hintereinander haben die Detailansicht nicht veraendert. Auto-Scan gestoppt: Klicks landen wahrscheinlich nicht auf neuen Artifacts.";
break;
}
continue;
}
stats.verified++;
const capture = await captureSelectedSource(0, true);
if (capture?.ocrTimedOut) {
stats.misses++;
consecutiveMisses++;
lastDetailViewFingerprint = detailFingerprint(capture);
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: OCR timeout`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere OCR-Timeouts hintereinander. Auto-Scan gestoppt, damit die Session nicht haengen bleibt.";
break;
}
continue;
}
const parsed = parseArtifact(capture);
const rejection = captureRejectionReason(capture, parsed);
if (rejection) {
await 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);
stats.parsed++;
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(capture);
seen.add(signature);
newArtifactsOnPage++;
if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++;
updateStats();
continue;
}
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: ${rejection}`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere unlesbare Artifact-Captures hintereinander. Auto-Scan gestoppt, damit nicht blind weitergeklickt wird.";
break;
}
continue;
}
const signature = parsed ? sessionSignature(parsed) : "";
const decision = classifyAutoScanCapture({ signature, lastDetailSignature, seen });
if (!capture || !parsed || decision.kind === "unreadable") {
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: kein Artifact lesbar`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere unlesbare Artifacts hintereinander. Auto-Scan gestoppt: Klicks treffen wahrscheinlich nicht das Artifact-Raster.";
break;
}
continue;
}
if (decision.kind === "stuck") {
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: Detail zeigt weiterhin "${parsed.name}"`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) {
blockedReason = `Mehrere Klicks blieben auf "${parsed.name}". Auto-Scan gestoppt.`;
break;
}
continue;
}
consecutiveMisses = 0;
stats.parsed++;
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(capture);
if (decision.kind === "duplicate") {
stats.duplicates++;
updateStats();
continue;
}
seen.add(signature);
newArtifactsOnPage++;
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}`);
}
if (await persistParsedArtifact(capture, parsed, "auto-scan", needsReview)) stats.stored++;
updateStats();
}
const endOfPagePlan = buildInventoryPagePlan({
targets: gridModel.targets.filter((target) => target.row >= pageSkipRows),
cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets,
processedTargets: stats.clicked,
rowsQueued,
});
rowsQueued = endOfPagePlan.rowsQueuedAfterPage;
if (aborted || stats.clicked >= maxTargets || shouldStop() || blockedReason) break;
if (newArtifactsOnPage === 0 && page > 1) {
blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`;
break;
}
const guardReason = await checkGuard();
if (guardReason) {
blockedReason = guardReason;
aborted = true;
break;
}
const rowsToScroll = endOfPagePlan.scrollRowsAfterPage;
if (rowsToScroll <= 0) break;
const scrollNotches = Math.min(60, Math.max(1, rowsToScroll * 10 - 1));
setReviewStatus(`Automatischer Scan Seite ${page} fertig. Scrolle zur naechsten Inventory-Seite...`);
appendAutomationLog(`scroll ${scrollNotches} (${rowsToScroll} row(s)) @ ${gridModel.anchorX},${gridModel.anchorY}`);
const scrollResult = await api.scrollScreen(-scrollNotches, gridModel.anchorX, gridModel.anchorY);
if (scrollResult.inputBlocked) {
blockedReason = "Scroll-Input wurde von Windows blockiert. Starte die App als Administrator.";
break;
}
if (page % 12 === 0) {
appendAutomationLog(`scroll correction p${page}: +1 notch @ ${gridModel.anchorX},${gridModel.anchorY}`);
const correctionResult = await api.scrollScreen(1, gridModel.anchorX, gridModel.anchorY);
if (correctionResult.inputBlocked) {
blockedReason = "Scroll-Korrektur wurde von Windows blockiert.";
break;
}
}
const scrollWaitStop = await waitDuringScan(760);
if (scrollWaitStop) {
blockedReason = scrollWaitStop;
aborted = true;
break;
}
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
currentCapture = await captureFastSelectedSource(0, true);
const afterScrollFingerprint = screenFingerprint(currentCapture);
if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) {
blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert.";
break;
}
if (afterScrollFingerprint && seenPageFingerprints.has(afterScrollFingerprint)) {
blockedReason = "Scrollen hat erneut eine bereits verarbeitete Inventarseite gezeigt.";
break;
}
const refreshedModel = buildGridModel(currentCapture?.inventoryGrid);
if (!refreshedModel) {
blockedReason = "Kachel-Grid nach dem Scrollen verloren.";
break;
}
if (refreshedModel.source === "detected" && refreshedModel.confidence >= 72) {
gridModel = refreshedModel;
}
}
} catch (error) {
blockedReason = `Fehler waehrend des Scans: ${error instanceof Error ? error.message : String(error)}`;
appendAutomationLog(blockedReason);
}
const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done";
return {
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.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl);
if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return "";
}
export function screenFingerprint(capture: CaptureResult | null) {
if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl);
if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return "";
}
export function isRepeatedProcessedPageFingerprint(
fingerprint: string,
seenPageFingerprints: ReadonlySet<string>,
page: number,
) {
return page > 1 && Boolean(fingerprint) && seenPageFingerprints.has(fingerprint);
}
export function fingerprintDataUrl(dataUrl: string) {
let hash = 2166136261;
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
for (let index = 0; index < dataUrl.length; index += stride) {
hash ^= dataUrl.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
automationBlockReason,
buildInventoryPagePlan,
buildGridModel,
requiresAdminForAutomation,
} from "./automationPlanner";
describe("automationPlanner", () => {
it("requires admin whenever this app's own process is not elevated", () => {
expect(requiresAdminForAutomation({ ok: true, isElevated: false, platform: "win32" })).toBe(true);
expect(requiresAdminForAutomation({ ok: true, isElevated: true, platform: "win32" })).toBe(false);
expect(requiresAdminForAutomation({ ok: false, isElevated: false, platform: "win32" })).toBe(false);
expect(requiresAdminForAutomation(null)).toBe(false);
});
it("returns a user-facing admin block reason when not elevated", () => {
expect(automationBlockReason({ ok: true, isElevated: false, platform: "win32" })).toContain("Administrator");
expect(automationBlockReason({ ok: true, isElevated: true, platform: "win32" })).toBe("");
});
it("builds a dense click grid from partial detected centers", () => {
const grid = buildGridModel({
rows: 3,
cols: 4,
confidence: 96,
source: "detected",
centers: [
{ row: 0, col: 0, x: 100, y: 200 },
{ row: 0, col: 1, x: 220, y: 200 },
{ row: 0, col: 3, x: 460, y: 200 },
{ row: 2, col: 0, x: 100, y: 500 },
{ row: 2, col: 3, x: 460, y: 500 },
],
});
expect(grid).not.toBeNull();
expect(grid?.targets).toHaveLength(12);
expect(grid?.stepX).toBe(120);
expect(grid?.stepY).toBe(150);
expect(grid?.targets.find((target) => target.row === 1 && target.col === 2)).toMatchObject({ x: 340, y: 350 });
});
it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => {
const targets = Array.from({ length: 40 }, (_, index) => ({
row: Math.floor(index / 8),
col: index % 8,
x: index * 10,
y: index * 10,
}));
const firstPage = buildInventoryPagePlan({
targets,
cols: 8,
rows: 5,
totalTargetCount: 50,
processedTargets: 0,
rowsQueued: 0,
});
expect(firstPage.pageTargets).toHaveLength(40);
expect(firstPage.startIndex).toBe(0);
expect(firstPage.scrollRowsAfterPage).toBe(2);
const finalPage = buildInventoryPagePlan({
targets,
cols: 8,
rows: 5,
totalTargetCount: 50,
processedTargets: 40,
rowsQueued: 5,
});
expect(finalPage.pageTargets).toHaveLength(10);
expect(finalPage.startIndex).toBe(24);
expect(finalPage.pageTargets[0]).toMatchObject({ row: 3, 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) => ({
row: Math.floor(index / 8),
col: index % 8,
x: index * 10,
y: index * 10,
}));
const singlePage = buildInventoryPagePlan({
targets,
cols: 8,
rows: 5,
totalTargetCount: 16,
processedTargets: 0,
rowsQueued: 0,
});
expect(singlePage.pageTargets).toHaveLength(16);
expect(singlePage.startIndex).toBe(0);
expect(singlePage.pageTargets[0]).toMatchObject({ row: 0, col: 0 });
expect(singlePage.pageTargets[15]).toMatchObject({ row: 1, col: 7 });
expect(singlePage.scrollRowsAfterPage).toBe(0);
});
});
+193
View File
@@ -0,0 +1,193 @@
import type { CaptureResult, RuntimeInfo } from "../types/global";
export type GridTarget = { x: number; y: number; row: number; col: number };
export type GridModel = {
targets: GridTarget[];
cols: number;
rows: number;
startX: number;
startY: number;
stepX: number;
stepY: number;
anchorX: number;
anchorY: number;
confidence: number;
source: "detected" | "fallback" | "missing";
};
export type InventoryPagePlan = {
pageTargets: GridTarget[];
remainingTargets: number;
totalRows: number;
remainingRows: number;
startIndex: number;
rowsQueuedAfterPage: number;
scrollRowsAfterPage: number;
};
// Matches Inventory Kamera's model: the whole app simply always runs
// elevated (see docs/DECISIONS.md ADR-007), so auto-scan only needs to check
// our own process elevation - no per-process target-elevation probing or
// separate broker process required.
export function requiresAdminForAutomation(runtime: RuntimeInfo | null | undefined) {
return runtime?.ok === true && runtime.isElevated !== true;
}
export function automationBlockReason(runtime: RuntimeInfo | null | undefined) {
if (!requiresAdminForAutomation(runtime)) return "";
return "Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.";
}
export function buildGridModel(grid: CaptureResult["inventoryGrid"] | undefined): GridModel | null {
if (!grid || grid.source === "missing" || grid.centers.length === 0) return null;
const centers = grid.centers
.filter((center) => Number.isFinite(center.x) && Number.isFinite(center.y))
.map((center) => ({
x: Math.round(center.x),
y: Math.round(center.y),
row: Math.max(0, Math.round(center.row)),
col: Math.max(0, Math.round(center.col)),
}));
if (centers.length === 0) return null;
const cols = Math.max(1, grid.cols || Math.max(...centers.map((center) => center.col)) + 1);
const rows = Math.max(1, grid.rows || Math.max(...centers.map((center) => center.row)) + 1);
const xCoords = buildAxisCoordinates(centers, "col", "x", cols);
const yCoords = buildAxisCoordinates(centers, "row", "y", rows);
if (xCoords.length !== cols || yCoords.length !== rows) return null;
const targets = yCoords.flatMap((y, row) =>
xCoords.map((x, col) => ({
x: Math.round(x),
y: Math.round(y),
row,
col,
})),
);
const stepX = Math.max(1, Math.round(medianDiff(xCoords) || 1));
const stepY = Math.max(1, Math.round(medianDiff(yCoords) || 1));
return {
targets,
cols,
rows,
startX: targets[0]?.x ?? Math.round(xCoords[0]),
startY: targets[0]?.y ?? Math.round(yCoords[0]),
stepX,
stepY,
anchorX: Math.round(xCoords[Math.min(1, xCoords.length - 1)] ?? xCoords[0]),
anchorY: Math.round(yCoords[Math.floor(yCoords.length / 2)] ?? yCoords[0]),
confidence: grid.confidence,
source: grid.source,
};
}
export function buildInventoryPagePlan({
targets,
cols,
rows,
totalTargetCount,
processedTargets,
rowsQueued,
}: {
targets: GridTarget[];
cols: number;
rows: number;
totalTargetCount: number;
processedTargets: number;
rowsQueued: number;
}): InventoryPagePlan {
const safeCols = Math.max(1, cols);
const safeRows = Math.max(1, rows);
const safeTotal = Math.max(0, totalTargetCount);
const safeProcessed = Math.max(0, Math.min(safeTotal, processedTargets));
const remainingTargets = Math.max(0, safeTotal - safeProcessed);
const totalRows = Math.max(0, Math.ceil(safeTotal / safeCols));
const remainingRows = Math.max(0, totalRows - rowsQueued);
const fullPage = safeCols * safeRows;
const pageTargetCount = Math.min(remainingTargets, fullPage);
const partialPage = pageTargetCount > 0 && pageTargetCount < fullPage;
// Only bottom-align a partial page after we have already consumed at least
// one full visible page. The first page of a small inventory starts at the
// top of the grid, while the final scrolled page is bottom-aligned because
// it overlaps previous rows.
const alignPartialPageToBottom = rowsQueued > 0 && partialPage && remainingRows > 0 && remainingRows < safeRows;
const startIndex = alignPartialPageToBottom ? Math.max(0, (safeRows - remainingRows) * safeCols) : 0;
const pageTargets = targets.slice(startIndex, Math.min(targets.length, startIndex + pageTargetCount));
const rowsQueuedAfterPage = Math.min(totalRows, rowsQueued + safeRows);
const remainingRowsAfterPage = Math.max(0, totalRows - rowsQueuedAfterPage);
const scrollRowsAfterPage = remainingRowsAfterPage > 0 ? Math.min(safeRows, remainingRowsAfterPage) : 0;
return {
pageTargets,
remainingTargets,
totalRows,
remainingRows,
startIndex,
rowsQueuedAfterPage,
scrollRowsAfterPage,
};
}
function buildAxisCoordinates(
centers: GridTarget[],
groupKey: "row" | "col",
valueKey: "x" | "y",
expectedCount: number,
) {
const known = Array.from({ length: expectedCount }, (_, index) => {
const values = centers
.filter((center) => center[groupKey] === index)
.map((center) => center[valueKey]);
return values.length > 0 ? median(values) : null;
});
const knownPairs = known
.map((value, index) => ({ index, value }))
.filter((entry): entry is { index: number; value: number } => typeof entry.value === "number");
if (knownPairs.length === expectedCount) return known as number[];
if (knownPairs.length === 0) return [];
const indexedSteps = knownPairs
.slice(1)
.map((entry, index) => {
const previous = knownPairs[index];
const indexDiff = entry.index - previous.index;
return indexDiff > 0 ? Math.abs(entry.value - previous.value) / indexDiff : 0;
})
.filter((step) => step > 3);
const step = median(indexedSteps) || estimateStepFromAllCenters(centers.map((center) => center[valueKey]));
if (!step || !Number.isFinite(step)) return [];
const first = knownPairs[0];
const start = first.value - step * first.index;
return Array.from({ length: expectedCount }, (_, index) => Math.round(start + step * index));
}
function estimateStepFromAllCenters(values: number[]) {
const sorted = [...new Set(values.map((value) => Math.round(value)))].sort((a, b) => a - b);
return medianDiff(sorted);
}
function median(values: number[]) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
function medianDiff(values: number[]) {
if (values.length < 2) return 0;
const sorted = [...values].sort((a, b) => a - b);
const diffs = sorted
.slice(1)
.map((value, index) => Math.abs(value - sorted[index]))
.filter((diff) => diff > 3);
return diffs.length > 0 ? median(diffs) : 0;
}
+268
View File
@@ -0,0 +1,268 @@
import presetsJson from "../../data/presets.json";
import { recommendArtifacts, suggestBuilds } from "./scoring";
import type { AppSnapshot, Artifact, Character, CharacterPreset, ScanEvent } from "../types/domain";
const presets = presetsJson.characters.map((entry) => ({
...entry,
characterId: entry.id,
})) as unknown as CharacterPreset[];
export function createDemoArtifacts(): Artifact[] {
const now = new Date().toISOString();
return [
{
id: "art-001",
setKey: "marechaussee_hunter",
setName: "Marechaussee Hunter",
slot: "sands",
rarity: 5,
level: 20,
mainStat: "HP%",
substats: [
{ key: "CRIT Rate", value: 10.1, unit: "%" },
{ key: "CRIT DMG", value: 14.0, unit: "%" },
{ key: "Energy Recharge", value: 5.8, unit: "%" },
{ key: "ATK%", value: 4.1, unit: "%" },
],
locked: true,
source: "mock",
confidence: 0.97,
lastSeenAt: now,
},
{
id: "art-002",
setKey: "golden_troupe",
setName: "Golden Troupe",
slot: "goblet",
rarity: 5,
level: 16,
mainStat: "HP%",
substats: [
{ key: "Energy Recharge", value: 11.0, unit: "%" },
{ key: "CRIT Rate", value: 6.6, unit: "%" },
{ key: "CRIT DMG", value: 13.2, unit: "%" },
{ key: "Elemental Mastery", value: 19, unit: "flat" },
],
locked: false,
source: "mock",
confidence: 0.94,
lastSeenAt: now,
},
{
id: "art-003",
setKey: "emblem_of_severed_fate",
setName: "Emblem of Severed Fate",
slot: "circlet",
rarity: 5,
level: 20,
mainStat: "CRIT Rate",
substats: [
{ key: "Energy Recharge", value: 17.5, unit: "%" },
{ key: "CRIT DMG", value: 19.4, unit: "%" },
{ key: "ATK%", value: 5.3, unit: "%" },
{ key: "HP%", value: 4.7, unit: "%" },
],
locked: true,
source: "mock",
confidence: 0.99,
lastSeenAt: now,
},
{
id: "art-004",
setKey: "deepwood_memories",
setName: "Deepwood Memories",
slot: "goblet",
rarity: 5,
level: 4,
mainStat: "Elemental Mastery",
substats: [
{ key: "CRIT Rate", value: 3.9, unit: "%" },
{ key: "Energy Recharge", value: 6.5, unit: "%" },
{ key: "ATK%", value: 4.7, unit: "%" },
],
locked: false,
source: "mock",
confidence: 0.91,
lastSeenAt: now,
},
{
id: "art-005",
setKey: "viridescent_venerer",
setName: "Viridescent Venerer",
slot: "circlet",
rarity: 5,
level: 0,
mainStat: "Elemental Mastery",
substats: [
{ key: "Energy Recharge", value: 5.2, unit: "%" },
{ key: "DEF%", value: 7.3, unit: "%" },
{ key: "HP", value: 269, unit: "flat" },
],
locked: false,
source: "mock",
confidence: 0.86,
lastSeenAt: now,
},
{
id: "art-006",
setKey: "noblesse_oblige",
setName: "Noblesse Oblige",
slot: "sands",
rarity: 5,
level: 0,
mainStat: "DEF%",
substats: [
{ key: "DEF", value: 23, unit: "flat" },
{ key: "HP", value: 209, unit: "flat" },
{ key: "ATK", value: 19, unit: "flat" },
],
locked: false,
source: "mock",
confidence: 0.96,
lastSeenAt: now,
},
{
id: "art-007",
setKey: "heart_of_depth",
setName: "Heart of Depth",
slot: "plume",
rarity: 5,
level: 12,
mainStat: "ATK",
substats: [
{ key: "CRIT Rate", value: 6.2, unit: "%" },
{ key: "CRIT DMG", value: 12.4, unit: "%" },
{ key: "HP%", value: 9.9, unit: "%" },
{ key: "Energy Recharge", value: 4.5, unit: "%" },
],
locked: false,
source: "mock",
confidence: 0.8,
lastSeenAt: now,
},
{
id: "art-008",
setKey: "viridescent_venerer",
setName: "Viridescent Venerer",
slot: "sands",
rarity: 5,
level: 20,
mainStat: "Elemental Mastery",
substats: [
{ key: "Energy Recharge", value: 18.1, unit: "%" },
{ key: "CRIT Rate", value: 3.1, unit: "%" },
{ key: "HP%", value: 8.7, unit: "%" },
{ key: "ATK%", value: 4.7, unit: "%" },
],
locked: true,
source: "mock",
confidence: 0.97,
lastSeenAt: now,
},
{
id: "art-009",
setKey: "viridescent_venerer",
setName: "Viridescent Venerer",
slot: "flower",
rarity: 5,
level: 16,
mainStat: "HP",
substats: [
{ key: "Elemental Mastery", value: 63, unit: "flat" },
{ key: "Energy Recharge", value: 11.7, unit: "%" },
{ key: "CRIT Rate", value: 3.5, unit: "%" },
{ key: "DEF%", value: 5.8, unit: "%" },
],
locked: true,
source: "mock",
confidence: 0.95,
lastSeenAt: now,
},
{
id: "art-010",
setKey: "viridescent_venerer",
setName: "Viridescent Venerer",
slot: "plume",
rarity: 5,
level: 16,
mainStat: "ATK",
substats: [
{ key: "Elemental Mastery", value: 82, unit: "flat" },
{ key: "Energy Recharge", value: 10.4, unit: "%" },
{ key: "HP%", value: 5.3, unit: "%" },
{ key: "DEF", value: 21, unit: "flat" },
],
locked: true,
source: "mock",
confidence: 0.94,
lastSeenAt: now,
},
];
}
export function createDemoCharacters(): Character[] {
return [
{ id: "neuvillette", name: "Neuvillette", owned: true, level: 90, constellation: 0, rolePreference: "main_dps", confidence: 0.93 },
{ id: "furina", name: "Furina", owned: true, level: 90, constellation: 0, rolePreference: "sub_dps", confidence: 0.91 },
{ id: "raiden_shogun", name: "Raiden Shogun", owned: true, level: 80, constellation: 0, rolePreference: "main_dps", confidence: 0.88 },
{ id: "nahida", name: "Nahida", owned: true, level: 90, constellation: 0, rolePreference: "support", confidence: 0.92 },
{ id: "kazuha", name: "Kaedehara Kazuha", owned: true, level: 80, constellation: 0, rolePreference: "support", confidence: 0.87 },
];
}
export function createDemoScanEvents(): ScanEvent[] {
return [
{
id: "scan-001",
type: "environment",
label: "Environment check",
detail: "Borderless Windowed, 2560x1440 profile, English UI expected.",
confidence: 0.92,
},
{
id: "scan-002",
type: "character",
label: "Character pass",
detail: "5 owned characters detected from the prepared scanner adapter.",
confidence: 0.89,
},
{
id: "scan-003",
type: "artifact",
label: "Artifact pass",
detail: "7 artifacts parsed. 1 needs review because confidence is under threshold.",
confidence: 0.9,
},
{
id: "scan-004",
type: "complete",
label: "Account snapshot ready",
detail: "Recommendations and build candidates are available.",
confidence: 0.95,
},
];
}
export function createDemoSnapshot(): AppSnapshot {
const artifacts = createDemoArtifacts();
const characters = createDemoCharacters();
const recommendations = recommendArtifacts(artifacts, characters, presets);
const builds = suggestBuilds(artifacts, characters, presets);
return {
artifacts,
characters,
recommendations,
builds,
scanEvents: createDemoScanEvents(),
};
}
export async function runMockScan() {
await new Promise((resolve) => window.setTimeout(resolve, 900));
return createDemoSnapshot();
}
export function getPresets() {
return presets;
}
+65
View File
@@ -0,0 +1,65 @@
export interface FuzzyMatchResult {
value: string;
score: number;
}
export function simplifyForMatch(text: string) {
return text.toLowerCase().replace(/[^a-z0-9%]+/g, "");
}
export function fuzzyFindKnown(text: string, values: string[], minimumScore = 0.72): FuzzyMatchResult | null {
const haystack = simplifyForMatch(text);
if (!haystack) return null;
let best: FuzzyMatchResult | null = null;
for (const value of values) {
const needle = simplifyForMatch(value);
if (!needle) continue;
const score = scoreCandidate(haystack, needle);
if (!best || score > best.score) best = { value, score };
}
return best && best.score >= minimumScore ? best : null;
}
function scoreCandidate(haystack: string, needle: string) {
if (haystack.includes(needle)) return 1;
if (needle.includes(haystack) && haystack.length >= Math.min(8, needle.length)) return haystack.length / needle.length;
const windows = slidingWindows(haystack, needle.length);
const bestDistance = Math.min(...windows.map((window) => levenshtein(window, needle)));
const normalized = 1 - bestDistance / Math.max(needle.length, 1);
const prefixBonus = needle.startsWith(haystack.slice(0, Math.min(haystack.length, needle.length))) ? 0.08 : 0;
return Math.max(0, Math.min(1, normalized + prefixBonus));
}
function slidingWindows(text: string, size: number) {
if (text.length <= size) return [text];
const windows: string[] = [];
const minSize = Math.max(4, Math.floor(size * 0.7));
for (let start = 0; start <= text.length - minSize; start++) {
windows.push(text.slice(start, Math.min(text.length, start + size)));
}
return windows;
}
function levenshtein(a: string, b: string) {
const dp = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost,
);
}
}
return dp[a.length][b.length];
}
+164
View File
@@ -0,0 +1,164 @@
import gameData from "../data/genshinGameData.json" with { type: "json" };
import { simplifyForMatch } from "./fuzzyMatch.js";
type ArtifactPiece = {
name: string;
setName: string;
slot: string;
relicType: string;
};
type Character = {
name: string;
};
type GenshinGameDataContract = typeof gameData & {
artifactPieces?: ArtifactPiece[];
stats?: {
main?: string[];
mainBySlot?: Record<string, string[]>;
sub?: string[];
};
aliases?: {
stats?: Record<string, string>;
textReplacements?: Record<string, string>;
slotAliases?: Record<string, string>;
setAliases?: Record<string, string>;
pieceAliases?: Record<string, string>;
characterAliases?: Record<string, string>;
};
mainStatsBySlot?: Record<string, string[]>;
mainStatValueReferences?: Record<string, Array<{ stat: string; base: number; max: number }>>;
characters?: Character[];
};
export const genshinGameData = gameData as GenshinGameDataContract;
export const slotNames = genshinGameData.slots;
export const statAliases = genshinGameData.aliases?.stats ?? {};
export const textReplacements = genshinGameData.aliases?.textReplacements ?? {};
export const slotAliases = genshinGameData.aliases?.slotAliases ?? {};
export const setAliases = genshinGameData.aliases?.setAliases ?? {};
export const pieceAliases = genshinGameData.aliases?.pieceAliases ?? {};
export const characterAliases = genshinGameData.aliases?.characterAliases ?? {};
export const knownSets = genshinGameData.artifactSets.map((set) => set.name);
export const knownCharacters = (genshinGameData.characters ?? []).map((character) => character.name);
export const sourceVersion = genshinGameData.sourceVersion ?? "unknown";
export const fixedMainStatBySlot: Record<string, string> = {
"Flower of Life": "HP",
"Plume of Death": "ATK",
};
const bundledMainStats = genshinGameData.stats?.main ?? genshinGameData.mainStats;
const bundledSubstats = genshinGameData.stats?.sub ?? genshinGameData.substats;
export const mainStatsBySlot: Record<string, string[]> = genshinGameData.stats?.mainBySlot ?? genshinGameData.mainStatsBySlot ?? {
"Flower of Life": ["HP"],
"Plume of Death": ["ATK"],
"Sands of Eon": ["HP%", "ATK%", "DEF%", "Energy Recharge", "Elemental Mastery"],
"Goblet of Eonothem": [
"HP%",
"ATK%",
"DEF%",
"Elemental Mastery",
"Hydro DMG Bonus",
"Pyro DMG Bonus",
"Electro DMG Bonus",
"Cryo DMG Bonus",
"Dendro DMG Bonus",
"Anemo DMG Bonus",
"Geo DMG Bonus",
"Physical DMG Bonus",
],
"Circlet of Logos": ["HP%", "ATK%", "DEF%", "Elemental Mastery", "CRIT Rate", "CRIT DMG", "Healing Bonus"],
};
const fallbackArtifactPieces = genshinGameData.artifactSets.flatMap((artifactSet) =>
artifactSet.pieces.map((piece) => ({
name: piece.name,
setName: artifactSet.name,
slot: slotFromRelicType(piece.relicType),
relicType: piece.relicType,
})),
);
export const artifactPieces: ArtifactPiece[] = (genshinGameData.artifactPieces ?? fallbackArtifactPieces)
.filter((piece) => piece.name && piece.setName && piece.slot);
export const pieceToSet = new Map(artifactPieces.map((piece) => [piece.name, piece.setName]));
export const pieceToSlot = new Map(artifactPieces.map((piece) => [piece.name, piece.slot]));
export const knownPieceNames = artifactPieces.map((piece) => piece.name);
export const globalMainStats = unique([
...bundledMainStats,
...Object.keys(statAliases),
]);
export const globalSubstats = unique([
...bundledSubstats,
...Object.keys(statAliases),
]);
export const mainStatValueReferences = genshinGameData.mainStatValueReferences ?? {};
export function allowedMainStatsForSlot(slot: string) {
return mainStatsBySlot[slot] ?? globalMainStats;
}
export function canonicalStatName(raw: string) {
return statAliases[raw] ?? raw;
}
export function normalizeSlotAlias(raw: string) {
const simplified = simplifyForMatch(raw);
const aliasMatch = Object.entries(slotAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
if (aliasMatch) return aliasMatch[1];
const direct = slotNames.find((slot) => simplifyForMatch(slot) === simplified);
return direct ?? "";
}
export function normalizeSetAlias(raw: string) {
const simplified = simplifyForMatch(raw);
const aliasMatch = Object.entries(setAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
if (aliasMatch) return aliasMatch[1];
const direct = knownSets.find((set) => simplifyForMatch(set) === simplified);
return direct ?? "";
}
export function normalizePieceAlias(raw: string) {
const simplified = simplifyForMatch(raw);
const aliasMatch = Object.entries(pieceAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
if (aliasMatch) return aliasMatch[1];
const direct = knownPieceNames.find((piece) => simplifyForMatch(piece) === simplified);
return direct ?? "";
}
export function normalizeCharacterAlias(raw: string) {
const simplified = simplifyForMatch(raw);
const aliasMatch = Object.entries(characterAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
if (aliasMatch) return aliasMatch[1];
const direct = knownCharacters.find((character) => simplifyForMatch(character) === simplified);
return direct ?? "";
}
function unique(values: string[]) {
return [...new Set(values)];
}
function slotFromRelicType(relicType: string) {
switch (relicType) {
case "EQUIP_BRACER":
return "Flower of Life";
case "EQUIP_NECKLACE":
return "Plume of Death";
case "EQUIP_SHOES":
return "Sands of Eon";
case "EQUIP_RING":
return "Goblet of Eonothem";
case "EQUIP_DRESS":
return "Circlet of Logos";
default:
return "";
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Artifact } from "../types/domain";
import type { GoodDatabase } from "../types/global";
const slotToGood: Record<Artifact["slot"], string> = {
flower: "flower",
plume: "plume",
sands: "sands",
goblet: "goblet",
circlet: "circlet",
};
export function exportGood(artifacts: Artifact[]): GoodDatabase {
return {
format: "GOOD",
version: 2,
source: "Genshin Artifact Assistant",
artifacts: artifacts.map((artifact) => ({
setKey: artifact.setKey,
slotKey: slotToGood[artifact.slot],
rarity: artifact.rarity,
level: artifact.level,
mainStatKey: artifact.mainStat,
substats: artifact.substats.map((substat) => ({
key: substat.key,
value: substat.value,
})),
lock: artifact.locked,
})),
};
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getPresets } from "./demoData";
import { createLocalAccountSnapshot } from "./localAccountSnapshot";
import type { StoredArtifactRecord } from "../types/storage";
function artifact(id: string, equipped: string, slot: string, setName = "Viridescent Venerer"): StoredArtifactRecord {
return {
id,
name: `${slot} Piece`,
slot,
setName,
mainStat: slot === "Flower of Life" ? "HP" : slot === "Plume of Death" ? "ATK" : "Elemental Mastery",
mainValue: slot === "Flower of Life" ? "4,780" : slot === "Plume of Death" ? "311" : "187",
substats: ["CRIT Rate+7.0%", "CRIT DMG+14.0%", "Energy Recharge+11.0%", "ATK+29"],
equipped,
confidence: 94,
needsReview: false,
source: "manual-scan",
};
}
describe("localAccountSnapshot", () => {
it("infers owned characters from equipped artifact records", () => {
const snapshot = createLocalAccountSnapshot([
artifact("a", "Sucrose", "Flower of Life"),
artifact("b", "Sucrose", "Plume of Death"),
], getPresets());
expect(snapshot?.characters.find((character) => character.name === "Sucrose")?.owned).toBe(true);
expect(snapshot?.recommendations.length).toBeGreaterThan(0);
});
it("creates partial build suggestions for locally inferred characters", () => {
const snapshot = createLocalAccountSnapshot([
artifact("a", "Aino", "Flower of Life", "Silken Moon's Serenade"),
artifact("b", "Aino", "Plume of Death", "Silken Moon's Serenade"),
artifact("c", "Aino", "Sands of Eon", "Silken Moon's Serenade"),
], getPresets());
const aino = snapshot?.characters.find((character) => character.name === "Aino");
const ainoBuilds = snapshot?.builds.filter((build) => build.characterId === aino?.id) ?? [];
expect(aino?.owned).toBe(true);
expect(ainoBuilds.length).toBeGreaterThan(0);
expect(ainoBuilds[0].warnings.join(" ")).toContain("Missing");
});
});
+151
View File
@@ -0,0 +1,151 @@
import type { StoredArtifactRecord } from "../types/storage";
import { storedArtifactsToDomain } from "./storedArtifactAdapter";
import { recommendArtifacts, suggestBuilds } from "./scoring";
import type { AppSnapshot, Artifact, ArtifactSlot, Character, CharacterPreset, CharacterRole, ScanEvent } from "../types/domain";
const slotOrder: ArtifactSlot[] = ["flower", "plume", "sands", "goblet", "circlet"];
export function createLocalAccountSnapshot(records: StoredArtifactRecord[], basePresets: CharacterPreset[]): AppSnapshot | null {
const artifacts = storedArtifactsToDomain(records);
if (artifacts.length === 0) return null;
const characters = inferCharacters(records, basePresets);
const generatedPresets = createGeneratedPresets(characters, artifacts, basePresets);
const presets = mergePresets(basePresets, generatedPresets);
const recommendations = recommendArtifacts(artifacts, characters, presets);
const builds = suggestBuilds(artifacts, characters, presets);
const events: ScanEvent[] = [
{
id: "local-db-loaded",
type: "complete",
label: "Lokale Artifact-DB geladen",
detail: `${artifacts.length} gespeicherte Artifacts, ${characters.filter((character) => character.owned).length} lokale Charaktere, ${builds.length} Build-Vorschlaege.`,
confidence: 0.95,
},
];
return { artifacts, characters, recommendations, builds, scanEvents: events };
}
function inferCharacters(records: StoredArtifactRecord[], basePresets: CharacterPreset[]): Character[] {
const equippedNames = [...new Set(records.map((record) => record.equipped.trim()).filter(isUsefulCharacterName))];
const presetByName = new Map(basePresets.map((preset) => [simplify(preset.characterId), preset]));
const characters: Character[] = [];
for (const name of equippedNames) {
const id = slug(name);
const preset = presetByName.get(simplify(name)) ?? basePresets.find((entry) => simplify(entry.characterId).includes(simplify(name)));
characters.push({
id: preset?.characterId ?? id,
name,
owned: true,
level: 90,
constellation: 0,
rolePreference: preset?.role ?? inferRoleFromRecords(records.filter((record) => record.equipped === name)),
confidence: 0.82,
});
}
for (const preset of basePresets) {
if (characters.some((character) => character.id === preset.characterId)) continue;
characters.push({
id: preset.characterId,
name: titleCase(preset.characterId.replaceAll("_", " ")),
owned: false,
level: 1,
constellation: 0,
rolePreference: preset.role,
confidence: 0.5,
});
}
return characters;
}
function createGeneratedPresets(characters: Character[], artifacts: Artifact[], basePresets: CharacterPreset[]): CharacterPreset[] {
return characters
.filter((character) => character.owned && !basePresets.some((preset) => preset.characterId === character.id))
.map((character) => {
const currentSets = mostCommonSetsForCharacter(character.name, artifacts);
return {
characterId: character.id,
role: character.rolePreference,
recommendedSets: currentSets.slice(0, 1),
alternativeSets: currentSets.slice(1, 3),
mainStats: inferMainStatsForRole(character.rolePreference),
substatWeights: inferWeightsForRole(character.rolePreference),
explanation: "Generated from locally scanned equipped artifacts. Review once curated character presets are added.",
};
});
}
function mostCommonSetsForCharacter(characterName: string, artifacts: Artifact[]) {
const counts = new Map<string, number>();
const equippedArtifacts = artifacts.filter((artifact) => artifact.equipped === characterName);
const sourceArtifacts = equippedArtifacts.length ? equippedArtifacts : artifacts;
for (const artifact of sourceArtifacts) {
counts.set(artifact.setKey, (counts.get(artifact.setKey) ?? 0) + 1);
}
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([setKey]) => setKey);
return ranked.length ? ranked : [`${slug(characterName)}_current_set`];
}
function mergePresets(basePresets: CharacterPreset[], generatedPresets: CharacterPreset[]) {
const byId = new Map<string, CharacterPreset>();
for (const preset of basePresets) byId.set(preset.characterId, preset);
for (const preset of generatedPresets) if (!byId.has(preset.characterId)) byId.set(preset.characterId, preset);
return [...byId.values()];
}
function inferRoleFromRecords(records: StoredArtifactRecord[]): CharacterRole {
const text = records.flatMap((record) => [record.setName, record.mainStat, ...record.substats]).join(" ");
if (/healing|maiden|clam/i.test(text)) return "healer";
if (/viridescent|noblesse|scroll|cinder/i.test(text)) return "support";
if (/elemental mastery|reaction|gilded|deepwood/i.test(text)) return "reaction";
return "sub_dps";
}
function inferMainStatsForRole(role: CharacterRole): Partial<Record<ArtifactSlot, string[]>> {
if (role === "support" || role === "reaction") {
return {
sands: ["Elemental Mastery", "Energy Recharge", "ATK%", "HP%"],
goblet: ["Elemental Mastery", "Elemental DMG Bonus", "ATK%", "HP%"],
circlet: ["Elemental Mastery", "CRIT Rate", "CRIT DMG", "Healing Bonus"],
};
}
if (role === "healer") {
return {
sands: ["HP%", "Energy Recharge", "ATK%"],
goblet: ["HP%", "Healing Bonus", "ATK%"],
circlet: ["Healing Bonus", "HP%", "CRIT Rate"],
};
}
return {
sands: ["ATK%", "Energy Recharge", "Elemental Mastery", "HP%"],
goblet: ["ATK%", "Physical DMG Bonus", "Hydro DMG Bonus", "Pyro DMG Bonus", "Electro DMG Bonus", "Cryo DMG Bonus", "Dendro DMG Bonus", "Anemo DMG Bonus", "Geo DMG Bonus"],
circlet: ["CRIT Rate", "CRIT DMG", "ATK%", "Elemental Mastery"],
};
}
function inferWeightsForRole(role: CharacterRole): Record<string, number> {
if (role === "support") return { "Energy Recharge": 1.2, "CRIT Rate": 0.35, "CRIT DMG": 0.3, "ATK%": 0.25, "HP%": 0.25, "Elemental Mastery": 0.75 };
if (role === "reaction") return { "Elemental Mastery": 1.25, "Energy Recharge": 0.8, "CRIT Rate": 0.5, "CRIT DMG": 0.45, "ATK%": 0.35 };
if (role === "healer") return { "HP%": 1.1, "ATK%": 0.7, "Energy Recharge": 0.85, "CRIT Rate": 0.25, "CRIT DMG": 0.2 };
return { "CRIT Rate": 1.15, "CRIT DMG": 1.1, "ATK%": 0.9, "Energy Recharge": 0.7, "Elemental Mastery": 0.45, "HP%": 0.3 };
}
function isUsefulCharacterName(value: string) {
return Boolean(value && value !== "Not detected" && !/unknown|missing|detected/i.test(value));
}
function simplify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
}
function slug(value: string) {
return simplify(value).replace(/\s+/g, "_") || "unknown_character";
}
function titleCase(value: string) {
return value.replace(/\b\w/g, (match) => match.toUpperCase());
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { analyzeReviewSamples } from "./reviewSampleAnalysis";
import type { ReviewSampleRecord } from "../types/global";
describe("analyzeReviewSamples", () => {
it("summarizes weak fields, reasons, and average confidence", () => {
const samples: ReviewSampleRecord[] = [
sample("2026-01-01T00:00:00.000Z", "automatic:low-total-confidence-72:p1:r0c5", 72, {
name: 78,
substats: 0,
equipped: 45,
}),
sample("2026-01-02T00:00:00.000Z", "manual:low-field-mainStat-substats", 86, {
mainStat: 62,
substats: 55,
}),
{ savedAt: "2026-01-03T00:00:00.000Z", sample: { reason: "probe:no-detail-change:r1c1" } },
];
const analysis = analyzeReviewSamples(samples);
expect(analysis.total).toBe(3);
expect(analysis.withParsed).toBe(2);
expect(analysis.averageConfidence).toBe(79);
expect(analysis.latestSavedAt).toBe("2026-01-03T00:00:00.000Z");
expect(analysis.reasons.map((entry) => entry.reason)).toEqual([
"low-field-mainStat-substats",
"low-total-confidence-72",
"no-detail-change:r1c1",
]);
expect(analysis.weakFields[0]).toMatchObject({ field: "substats", count: 2, average: 28 });
expect(analysis.weakFields).toContainEqual({ field: "equipped", count: 1, average: 45 });
});
});
function sample(savedAt: string, reason: string, confidence: number, fields: Record<string, number>): ReviewSampleRecord {
return {
savedAt,
sample: {
reason,
parsed: {
confidence,
fields: Object.fromEntries(
Object.entries(fields).map(([key, fieldConfidence]) => [key, { confidence: fieldConfidence }]),
),
},
},
};
}
+72
View File
@@ -0,0 +1,72 @@
import type { ReviewSampleRecord } from "../types/global";
export type ReviewSampleAnalysis = {
total: number;
withParsed: number;
averageConfidence: number;
weakFields: Array<{ field: string; count: number; average: number }>;
reasons: Array<{ reason: string; count: number }>;
latestSavedAt?: string;
};
type ParsedLike = {
confidence?: unknown;
fields?: Record<string, { confidence?: unknown } | undefined>;
};
export function analyzeReviewSamples(samples: ReviewSampleRecord[]): ReviewSampleAnalysis {
const reasonCounts = new Map<string, number>();
const fieldCounts = new Map<string, { count: number; sum: number }>();
let confidenceSum = 0;
let parsedCount = 0;
let latestSavedAt = "";
for (const entry of samples) {
if (entry.savedAt && (!latestSavedAt || entry.savedAt > latestSavedAt)) latestSavedAt = entry.savedAt;
const reason = normalizeReason(entry.sample?.reason);
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
const parsed = entry.sample?.parsed as ParsedLike | undefined;
if (!parsed || typeof parsed !== "object") continue;
const confidence = typeof parsed.confidence === "number" ? parsed.confidence : null;
if (confidence !== null) {
confidenceSum += confidence;
parsedCount++;
}
for (const [field, value] of Object.entries(parsed.fields ?? {})) {
const fieldConfidence = value && typeof value.confidence === "number" ? value.confidence : null;
if (fieldConfidence === null || fieldConfidence >= 70) continue;
const current = fieldCounts.get(field) ?? { count: 0, sum: 0 };
current.count++;
current.sum += fieldConfidence;
fieldCounts.set(field, current);
}
}
return {
total: samples.length,
withParsed: parsedCount,
averageConfidence: parsedCount > 0 ? Math.round(confidenceSum / parsedCount) : 0,
weakFields: [...fieldCounts.entries()]
.map(([field, value]) => ({ field, count: value.count, average: Math.round(value.sum / value.count) }))
.sort((a, b) => b.count - a.count || a.average - b.average || a.field.localeCompare(b.field)),
reasons: [...reasonCounts.entries()]
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)),
latestSavedAt: latestSavedAt || undefined,
};
}
function normalizeReason(reason: string | undefined) {
if (!reason) return "unknown";
return reason
.replace(/^automatic:/, "")
.replace(/^manual:/, "")
.replace(/^probe:/, "")
.replace(/:p\d+.*$/, "")
.replace(/:preflight.*$/, "")
.trim() || "unknown";
}
+24
View File
@@ -0,0 +1,24 @@
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { shouldSaveReviewSample } from "./scannerLearning";
import type { CaptureResult } from "../types/global";
export interface ReviewReasonInput {
capture: CaptureResult;
parsed: ParsedArtifactCandidate;
}
export function getAutoReviewReason(capture: ReviewReasonInput["capture"], parsed: ReviewReasonInput["parsed"]) {
if (!capture.detailDataUrl || !capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr";
if (!shouldSaveReviewSample(parsed)) return "";
const lowFields = Object.entries(parsed.fields)
.filter(([, field]) => field.confidence < 70)
.map(([key]) => key);
if (parsed.confidence < 82) return `low-total-confidence-${parsed.confidence}`;
if (lowFields.length > 0) return `low-field-${lowFields.join("-")}`;
if (parsed.notes.some((note) => /not confidently|incomplete|low/i.test(note))) return "parser-notes";
return "";
}
export function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
+82
View File
@@ -0,0 +1,82 @@
import type { AppSnapshot, Artifact, Character, ScanEvent } from "../types/domain";
import { createDemoArtifacts, createDemoCharacters, createDemoScanEvents } from "./demoData";
import { recommendArtifacts, suggestBuilds } from "./scoring";
import { getPresets } from "./demoData";
export interface ScannerEnvironment {
borderlessWindowed: boolean;
language: "en" | "unknown";
resolution: string;
hdrWarning: boolean;
}
export interface ScannerAdapter {
checkEnvironment(): Promise<ScannerEnvironment>;
scanCharacters(): Promise<{ characters: Character[]; events: ScanEvent[] }>;
scanArtifacts(): Promise<{ artifacts: Artifact[]; events: ScanEvent[] }>;
}
export class MockScannerAdapter implements ScannerAdapter {
async checkEnvironment(): Promise<ScannerEnvironment> {
return {
borderlessWindowed: true,
language: "en",
resolution: "2560x1440",
hdrWarning: false,
};
}
async scanCharacters() {
await delay(180);
return {
characters: createDemoCharacters(),
events: createDemoScanEvents().filter((event) => event.type === "character"),
};
}
async scanArtifacts() {
await delay(260);
return {
artifacts: createDemoArtifacts(),
events: createDemoScanEvents().filter((event) => event.type === "artifact"),
};
}
}
export async function runAccountScan(adapter: ScannerAdapter = new MockScannerAdapter()): Promise<AppSnapshot> {
const environment = await adapter.checkEnvironment();
const characterPass = await adapter.scanCharacters();
const artifactPass = await adapter.scanArtifacts();
const presets = getPresets();
const recommendations = recommendArtifacts(artifactPass.artifacts, characterPass.characters, presets);
const builds = suggestBuilds(artifactPass.artifacts, characterPass.characters, presets);
return {
artifacts: artifactPass.artifacts,
characters: characterPass.characters,
recommendations,
builds,
scanEvents: [
{
id: "environment-live",
type: "environment",
label: "Environment check",
detail: `${environment.borderlessWindowed ? "Borderless ready" : "Borderless required"} · ${environment.resolution} · ${environment.language.toUpperCase()}`,
confidence: environment.hdrWarning ? 0.78 : 0.94,
},
...characterPass.events,
...artifactPass.events,
{
id: "complete-live",
type: "complete",
label: "Account snapshot ready",
detail: "Local recommendation engine updated with the latest scan.",
confidence: 0.95,
},
],
};
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
import type { CaptureResult } from "../types/global";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
function capture(overrides: Partial<CaptureResult> = {}): CaptureResult {
return {
id: "test",
name: "test",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
captureTarget: "genshin-client",
ocr: [],
...overrides,
};
}
function parsed(overrides: Partial<ParsedArtifactCandidate> = {}): ParsedArtifactCandidate {
return {
name: "A Note in Spring's Leich",
slot: "Sands of Eon",
level: 20,
mainStat: "Elemental Mastery",
mainValue: "187",
substats: ["ATK+29", "CRIT DMG+15.5%", "CRIT Rate+2.7%"],
setName: "A Day Carved From Rising Winds",
equipped: "Citlali",
confidence: 92,
notes: [],
fields: {
name: { value: "A Note in Spring's Leich", confidence: 92, source: "ocr" },
slot: { value: "Sands of Eon", confidence: 92, source: "ocr" },
level: { value: "20", confidence: 96, source: "ocr" },
mainStat: { value: "Elemental Mastery", confidence: 92, source: "ocr" },
mainValue: { value: "187", confidence: 92, source: "ocr" },
setName: { value: "A Day Carved From Rising Winds", confidence: 92, source: "ocr" },
equipped: { value: "Citlali", confidence: 92, source: "ocr" },
substats: { value: "ATK+29, CRIT DMG+15.5%, CRIT Rate+2.7%", confidence: 92, source: "ocr" },
},
...overrides,
};
}
describe("scannerCaptureQuality", () => {
it("rejects primary-screen fallback captures", () => {
expect(captureRejectionReason(capture({ captureTarget: "primary-screen" }), parsed())).toContain("Primary Screen");
expect(captureSourceRejectionReason(capture({ captureTarget: "primary-screen" }))).toContain("Primary Screen");
});
it("accepts a direct desktop/window capture when the OCR content looks like Genshin", () => {
expect(captureSourceRejectionReason(capture({
captureTarget: "desktop-source",
ocr: [{ id: "artifact-title", label: "title", text: "Gladiator's Nostalgia\nFlower of Life", confidence: 90 }],
}))).toBe("");
});
it("rejects captures that clearly contain the app UI", () => {
expect(
captureRejectionReason(
capture({
ocr: [{ id: "artifact-title", label: "title", text: "Artifacts scannen\nScanner Diagnose", confidence: 90 }],
}),
parsed(),
),
).toContain("App");
});
it("does not persist obviously incomplete review parses", () => {
expect(shouldPersistParsedArtifact(parsed({ mainStat: "Unknown main stat" }), true)).toBe(false);
expect(shouldPersistParsedArtifact(parsed({ substats: [] }), true)).toBe(false);
expect(shouldPersistParsedArtifact(parsed({ confidence: 59 }), true)).toBe(false);
expect(shouldPersistParsedArtifact(parsed({ confidence: 80 }), true)).toBe(true);
});
it("persists complete artifacts even if they do not need review", () => {
expect(shouldPersistParsedArtifact(parsed(), false)).toBe(true);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import type { CaptureResult } from "../types/global";
const assistantUiNeedles = [
"artifacts scannen",
"scanner diagnose",
"review queue",
"build-optionen",
"build options",
"input-broker",
"auto-scan",
"manuell mitlesen",
"einzelnes artifact lesen",
];
export function captureRejectionReason(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null) {
if (!capture) return "Keine Capture-Daten vorhanden.";
const sourceRejection = captureSourceRejectionReason(capture);
if (sourceRejection) return sourceRejection;
if (!parsed) return "Artifact konnte aus dem Capture nicht geparst werden.";
if (parsed.name === "Unknown artifact") return "Artifact-Name ist unbekannt.";
if (parsed.slot === "Unknown slot") return "Artifact-Slot ist unbekannt.";
if (parsed.setName === "Unknown set") return "Artifact-Set ist unbekannt.";
if (parsed.mainStat === "Unknown main stat" || parsed.mainValue === "?") return "Main Stat ist unvollstaendig.";
return "";
}
export function captureSourceRejectionReason(capture: CaptureResult | null) {
if (!capture) return "Keine Capture-Daten vorhanden.";
if (capture.captureTarget === "primary-screen") {
return "Capture stammt nur vom Primary Screen statt vom Genshin-Client.";
}
if (looksLikeAssistantUi(capture)) return "Capture enthaelt UI-Text der App statt eines Genshin-Artifacts.";
return "";
}
export function shouldPersistParsedArtifact(parsed: ParsedArtifactCandidate, needsReview: boolean) {
if (parsed.name === "Unknown artifact") return false;
if (parsed.slot === "Unknown slot") return false;
if (parsed.setName === "Unknown set") return false;
if (parsed.mainStat === "Unknown main stat") return false;
if (parsed.mainValue === "?") return false;
if (parsed.substats.length === 0) return false;
if (!needsReview && parsed.confidence < 68) return false;
if (needsReview && parsed.confidence < 60) return false;
return true;
}
function looksLikeAssistantUi(capture: CaptureResult) {
const joined = (capture.ocr ?? [])
.map((entry) => entry.text ?? "")
.join("\n")
.toLowerCase();
return assistantUiNeedles.some((needle) => joined.includes(needle));
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { applyScannerLearningRules, countScannerLearningRules, deriveScannerLearningRules, deriveScannerLearningRulesFromReviewSamples, shouldFlagArtifactForReview, shouldSaveReviewSample } from "./scannerLearning";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import type { CaptureResult } from "../types/global";
function capture(text: string): CaptureResult {
return {
id: "test",
name: "test",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
ocr: [{ id: "artifact-substats", label: "Substats", text, confidence: 72 }],
};
}
describe("scannerLearning", () => {
it("applies deterministic OCR text replacements before parsing", () => {
const learned = applyScannerLearningRules(capture("CIT DMG+7.0%\nEnergv Recharge+6.5%"));
expect(learned?.ocr?.[0]?.text).toContain("CRIT DMG+7.0%");
expect(learned?.ocr?.[0]?.text).toContain("Energy Recharge+6.5%");
});
it("marks low confidence or noted parses for review", () => {
expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 95 } } })).toBe(false);
expect(shouldSaveReviewSample({ confidence: 96, notes: ["Artifact name was fuzzy-matched"], fields: { name: { confidence: 95 } } })).toBe(false);
expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 62 } } })).toBe(true);
expect(shouldSaveReviewSample({ confidence: 90, notes: ["Main stat not confidently parsed."], fields: { mainStat: { confidence: 0 } } })).toBe(true);
});
it("derives conservative replacements from OCR review samples", () => {
const reviewCapture: CaptureResult = {
id: "review",
name: "review",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
ocr: [
{ id: "artifact-main-stat", label: "Main", text: "Elemental Masterv\n187", confidence: 62 },
{ id: "artifact-set-effects", label: "Set", text: "Aubade of Morningstar and Moor", confidence: 68 },
],
};
const learned = deriveScannerLearningRules(reviewCapture, parsedArtifact({
mainStat: "Elemental Mastery",
setName: "Aubade of Morningstar and Moon",
}));
expect(learned?.textReplacements?.["Elemental Masterv"]).toBe("Elemental Mastery");
expect(learned?.textReplacements?.Moor).toBe("Moon");
});
it("counts learned rules", () => {
expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2);
});
it("does not flag DB review when only non-critical fields are weak", () => {
expect(shouldFlagArtifactForReview(parsedArtifact({
confidence: 92,
notes: [],
substats: ["CRIT DMG+13.2%", "HP%+15.7%", "DEF%+12.4%", "Energy Recharge+5.2%"],
fields: {
...parsedArtifact().fields,
mainStat: { value: "HP", confidence: 100, source: "derived" },
mainValue: { value: "4,780", confidence: 100, source: "derived" },
name: { value: "Moonlit Offering's Opulent Dream", confidence: 88, source: "fallback" },
setName: { value: "Aubade of Morningstar and Moon", confidence: 88, source: "fallback" },
equipped: { value: "Not detected", confidence: 45, source: "missing" },
substats: { value: "CRIT DMG+13.2%, HP%+15.7%, DEF%+12.4%, Energy Recharge+5.2%", confidence: 82, source: "ocr" },
},
}))).toBe(false);
});
it("still flags DB review when critical parsing is incomplete", () => {
expect(shouldFlagArtifactForReview(parsedArtifact({
confidence: 82,
notes: ["Main stat not confidently parsed."],
mainStat: "Unknown main stat",
fields: {
...parsedArtifact().fields,
mainStat: { value: "", confidence: 0, source: "missing" },
},
}))).toBe(true);
});
it("can derive startup learning rules from persisted review samples", () => {
const rules = deriveScannerLearningRulesFromReviewSamples([
{
savedAt: new Date(0).toISOString(),
sample: {
capture: {
name: "sample",
width: 1920,
height: 1080,
ocr: [{ id: "artifact-set-effects", label: "Set", text: "Aubade of Morningstar and Moor", confidence: 60 }],
},
parsed: parsedArtifact({
setName: "Aubade of Morningstar and Moon",
}),
},
},
]);
expect(rules.textReplacements?.Moor).toBe("Moon");
});
});
function parsedArtifact(overrides: Partial<ParsedArtifactCandidate> = {}): ParsedArtifactCandidate {
return {
name: "Sample Artifact",
slot: "Sands of Eon",
level: 20,
mainStat: "ATK%",
mainValue: "46.6%",
substats: ["CRIT Rate+3.9%"],
setName: "Aubade of Morningstar and Moon",
equipped: "Citlali",
confidence: 84,
notes: ["Main stat not confidently parsed."],
fields: {
name: { value: "Sample Artifact", confidence: 82, source: "fallback" },
slot: { value: "Sands of Eon", confidence: 96, source: "ocr" },
level: { value: "20", confidence: 96, source: "ocr" },
mainStat: { value: "ATK%", confidence: 44, source: "fallback" },
mainValue: { value: "46.6%", confidence: 94, source: "ocr" },
setName: { value: "Aubade of Morningstar and Moon", confidence: 92, source: "derived" },
equipped: { value: "Citlali", confidence: 78, source: "fallback" },
substats: { value: "CRIT Rate+3.9%", confidence: 76, source: "ocr" },
},
...overrides,
};
}
+242
View File
@@ -0,0 +1,242 @@
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { simplifyForMatch } from "./fuzzyMatch";
import type { CaptureResult, ReviewSampleRecord } from "../types/global";
import type { ScannerLearningRulePayload } from "../types/global";
export type ScannerLearningRules = ScannerLearningRulePayload;
export const DEFAULT_SCANNER_LEARNING_RULES: ScannerLearningRules = {
textReplacements: {
"CIT DMG": "CRIT DMG",
"CRIT DMC": "CRIT DMG",
"CRIT Rate+Z": "CRIT Rate+2",
"Energv Recharge": "Energy Recharge",
"Elemental Masterv": "Elemental Mastery",
"Equipped;": "Equipped:",
},
};
export function mergeScannerLearningRules(...rules: Array<Partial<ScannerLearningRules> | null | undefined>): ScannerLearningRules {
return rules.reduce<ScannerLearningRules>(
(merged, rule) => ({
textReplacements: { ...merged.textReplacements, ...(rule?.textReplacements ?? {}) },
}),
{ textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements } },
);
}
export function applyScannerLearningRules(capture: CaptureResult | null, rules?: Partial<ScannerLearningRules> | null) {
if (!capture?.ocr?.length) return capture;
const merged = mergeScannerLearningRules(rules);
const replacements = Object.entries(merged.textReplacements ?? {}).filter(([from]) => from.length > 0);
if (replacements.length === 0) return capture;
return {
...capture,
ocr: capture.ocr.map((entry) => ({
...entry,
text: applyTextReplacements(entry.text, replacements),
})),
};
}
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 (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;
}
if (/Substats look incomplete/i.test(parsed.notes.join(" ")) && (parsed.fields.substats?.confidence ?? 0) < 70) return true;
return false;
}
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;
const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"];
if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) 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;
}
const substatCount = parsed.substats?.length ?? 0;
const substatConfidence = parsed.fields.substats?.confidence ?? 100;
if (substatCount === 0) return true;
if (substatCount < 3 && substatConfidence < 70) return true;
return false;
}
export function countScannerLearningRules(rules?: Partial<ScannerLearningRules> | null) {
return Object.keys(rules?.textReplacements ?? {}).length;
}
export function deriveScannerLearningRules(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
): Partial<ScannerLearningRules> | null {
if (!capture?.ocr?.length || !parsed) return null;
const replacements: Record<string, string> = {};
for (const entry of capture.ocr) {
const expectedValues = expectedValuesForOcrEntry(entry.id, parsed);
for (const expected of expectedValues) {
for (const [from, to] of deriveReplacementPairs(entry.text, expected)) {
if (!from || !to || simplifyForMatch(from) === simplifyForMatch(to)) continue;
replacements[from] = to;
}
}
}
return Object.keys(replacements).length > 0 ? { textReplacements: replacements } : null;
}
export function deriveScannerLearningRulesFromReviewSamples(samples: ReviewSampleRecord[] | null | undefined) {
const merged: Partial<ScannerLearningRules>[] = [];
for (const entry of samples ?? []) {
const capture = normalizeReviewCapture(entry);
const parsed = normalizeReviewParsed(entry);
const learned = deriveScannerLearningRules(capture, parsed);
if (learned) merged.push(learned);
}
return mergeScannerLearningRules(...merged);
}
function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate) {
switch (id) {
case "artifact-title":
return [parsed.name, parsed.slot];
case "artifact-main-stat":
return [parsed.mainStat, parsed.mainValue];
case "artifact-substats":
return parsed.substats;
case "artifact-set-effects":
return [parsed.setName];
case "artifact-footer":
return parsed.equipped && parsed.equipped !== "Not detected" ? [`Equipped: ${parsed.equipped}`, parsed.equipped] : [];
default:
return [];
}
}
function deriveReplacementPairs(rawText: string, expected: string) {
if (!expected || expected.startsWith("Unknown")) return [];
const normalizedExpected = normalizeLearningText(expected);
if (normalizedExpected.length < 4) return [];
const pairs = new Map<string, string>();
const lines = rawText
.split("\n")
.map((line) => normalizeLearningText(line))
.filter((line) => line.length >= 3);
for (const line of lines) {
const score = similarityScore(line, normalizedExpected);
if (score >= 0.76 && score < 0.995 && Math.abs(line.length - normalizedExpected.length) <= Math.max(12, Math.round(normalizedExpected.length * 0.45))) {
pairs.set(line, normalizedExpected);
}
const lineWords = tokenizeLearningWords(line);
const expectedWords = tokenizeLearningWords(normalizedExpected);
if (lineWords.length === expectedWords.length && lineWords.length > 0 && lineWords.length <= 7) {
for (let index = 0; index < lineWords.length; index++) {
const rawWord = lineWords[index];
const expectedWord = expectedWords[index];
if (rawWord.length < 4 || expectedWord.length < 4) continue;
const wordScore = similarityScore(rawWord, expectedWord);
if (wordScore >= 0.7 && wordScore < 0.995) pairs.set(rawWord, expectedWord);
}
}
}
return [...pairs.entries()];
}
function applyTextReplacements(text: string, replacements: Array<[string, string]>) {
return replacements.reduce((current, [from, to]) => current.replace(new RegExp(escapeRegex(from), "gi"), to), text);
}
function normalizeReviewCapture(entry: ReviewSampleRecord): CaptureResult | null {
const capture = entry.sample?.capture;
if (!capture?.ocr) return null;
return {
id: "review-sample",
name: capture.name ?? "review-sample",
width: capture.width ?? 0,
height: capture.height ?? 0,
dataUrl: capture.dataUrl ?? "",
capturedAt: capture.capturedAt ?? entry.savedAt,
captureTarget: capture.captureTarget,
detailDataUrl: capture.detailDataUrl,
inventoryDataUrl: capture.inventoryDataUrl,
ocr: capture.ocr,
crops: (capture.crops ?? []).map((crop) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl ?? "",
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
};
}
function normalizeReviewParsed(entry: ReviewSampleRecord): ParsedArtifactCandidate | null {
const parsed = entry.sample?.parsed;
if (!parsed || typeof parsed !== "object") return null;
const candidate = parsed as Partial<ParsedArtifactCandidate>;
if (!candidate.fields || typeof candidate.fields !== "object") return null;
return candidate as ParsedArtifactCandidate;
}
function normalizeLearningText(text: string) {
return text.replace(/\s+/g, " ").trim();
}
function tokenizeLearningWords(text: string) {
return text
.split(/\s+/)
.map((part) => part.replace(/^[^A-Za-z0-9%+.-]+|[^A-Za-z0-9%+.-]+$/g, ""))
.filter(Boolean);
}
function similarityScore(left: string, right: string) {
const a = simplifyForMatch(left);
const b = simplifyForMatch(right);
if (!a || !b) return 0;
if (a === b) return 1;
const distance = levenshtein(a, b);
return Math.max(0, 1 - distance / Math.max(a.length, b.length, 1));
}
function levenshtein(a: string, b: string) {
const dp = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost,
);
}
}
return dp[a.length][b.length];
}
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { clampScanLimit, clampSkipRows, resolveScanTargetCount } from "./scannerSession";
describe("scannerSession helpers", () => {
it("clamps scan target counts into a sane range", () => {
expect(clampScanLimit(0)).toBe(1);
expect(clampScanLimit(2500)).toBe(1800);
});
it("respects the manual scan limit and only caps it against the detected inventory count", () => {
expect(resolveScanTargetCount(16, 124)).toBe(16);
expect(resolveScanTargetCount(200, 124)).toBe(124);
expect(resolveScanTargetCount(16, 0)).toBe(16);
expect(resolveScanTargetCount(16, null)).toBe(16);
});
it("keeps skipped rows within the visible grid range", () => {
expect(clampSkipRows(-5)).toBe(0);
expect(clampSkipRows(99)).toBe(8);
});
});
+45
View File
@@ -0,0 +1,45 @@
export type AutoScanStats = {
clicked: number;
attempted: number;
verified: number;
parsed: number;
stored: number;
review: number;
duplicates: number;
misses: number;
pages: number;
};
export type ScanSummary = AutoScanStats & {
mode: string;
status: "done" | "stopped" | "blocked";
targetCount?: number;
gridLabel?: string;
};
export const emptyAutoScanStats: AutoScanStats = {
clicked: 0,
attempted: 0,
verified: 0,
parsed: 0,
stored: 0,
review: 0,
duplicates: 0,
misses: 0,
pages: 0,
};
export function clampScanLimit(value: number) {
return Math.max(1, Math.min(1800, Math.round(value || 1)));
}
export function clampSkipRows(value: number) {
return Math.max(0, Math.min(8, Math.round(value || 0)));
}
export function resolveScanTargetCount(scanLimit: number, detectedInventoryCount?: number | null) {
const detected = Number.isFinite(detectedInventoryCount) ? Number(detectedInventoryCount) : 0;
const requested = clampScanLimit(scanLimit);
if (detected > 0) return clampScanLimit(Math.min(requested, detected));
return requested;
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { createDemoArtifacts, createDemoCharacters, getPresets } from "./demoData";
import { recommendArtifacts, suggestBuilds } from "./scoring";
import type { Artifact, ArtifactSlot, Character, CharacterPreset } from "../types/domain";
describe("recommendation engine", () => {
it("flags low-confidence artifacts for review", () => {
const artifacts = createDemoArtifacts();
const recommendations = recommendArtifacts(artifacts, createDemoCharacters(), getPresets());
const reviewed = recommendations.find((entry) => entry.artifactId === "art-007");
expect(reviewed?.verdict).toBe("needs_review");
});
it("suggests build candidates for owned characters", () => {
const builds = suggestBuilds(createDemoArtifacts(), createDemoCharacters(), getPresets());
expect(builds.length).toBeGreaterThan(0);
expect(builds[0].artifactIds.length).toBe(5);
});
it("suggests partial builds when the scanned inventory has missing slots", () => {
const partialArtifacts = createDemoArtifacts().filter((artifact) => artifact.slot === "sands" || artifact.slot === "goblet");
const builds = suggestBuilds(partialArtifacts, createDemoCharacters(), getPresets());
expect(builds.length).toBeGreaterThan(0);
expect(builds[0].artifactIds.length).toBeLessThan(5);
expect(builds[0].warnings.join(" ")).toContain("Missing");
});
it("warns when a suggested build wants an artifact equipped by another character", () => {
const artifacts = createDemoArtifacts().map((artifact) =>
artifact.slot === "flower" ? { ...artifact, equipped: "Furina" } : artifact,
);
const characters = createDemoCharacters().map((character) =>
character.id === "furina" ? { ...character, owned: false } : character,
);
const builds = suggestBuilds(artifacts, characters, getPresets());
const conflictBuild = builds.find((build) => build.warnings.some((warning) => warning.includes("Conflicts")));
expect(conflictBuild?.warnings.join(" ")).toContain("Furina");
});
it("allows a preferred 4-piece build with one off-piece", () => {
const preset = testPreset(["preferred"], ["fallback"]);
const builds = suggestBuilds([
artifact("a", "flower", "preferred", "HP"),
artifact("b", "plume", "preferred", "ATK"),
artifact("c", "sands", "preferred", "ATK%"),
artifact("d", "goblet", "preferred", "ATK%"),
artifact("e", "circlet", "off_piece", "CRIT Rate"),
], [testCharacter()], [preset]);
const recommended = builds.find((build) => build.quality === "recommended_set");
expect(recommended?.artifactIds).toEqual(expect.arrayContaining(["a", "b", "c", "d", "e"]));
});
it("creates a practical 2pc+2pc fallback build", () => {
const preset = testPreset(["preferred"], ["fallback"]);
const builds = suggestBuilds([
artifact("a", "flower", "preferred", "HP"),
artifact("b", "plume", "preferred", "ATK"),
artifact("c", "sands", "fallback", "ATK%"),
artifact("d", "goblet", "fallback", "ATK%"),
artifact("e", "circlet", "off_piece", "CRIT Rate"),
], [testCharacter()], [preset]);
const fallback = builds.find((build) => build.quality === "alternative_set");
expect(fallback?.explanation).toContain("2pc+2pc");
});
});
function testCharacter(): Character {
return { id: "tester", name: "Tester", owned: true, level: 90, constellation: 0, rolePreference: "main_dps", confidence: 1 };
}
function testPreset(recommendedSets: string[], alternativeSets: string[]): CharacterPreset {
return {
characterId: "tester",
role: "main_dps",
recommendedSets,
alternativeSets,
mainStats: {
sands: ["ATK%"],
goblet: ["ATK%"],
circlet: ["CRIT Rate"],
},
substatWeights: { "CRIT Rate": 1, "CRIT DMG": 1, "ATK%": 0.8 },
explanation: "Test preset",
};
}
function artifact(id: string, slot: ArtifactSlot, setKey: string, mainStat: string): Artifact {
return {
id,
setKey,
setName: setKey,
slot,
rarity: 5,
level: 20,
mainStat,
substats: [
{ key: "CRIT Rate", value: 7, unit: "%" },
{ key: "CRIT DMG", value: 14, unit: "%" },
],
locked: false,
source: "mock",
confidence: 0.99,
lastSeenAt: "2026-07-04T00:00:00.000Z",
};
}
+304
View File
@@ -0,0 +1,304 @@
import type {
Artifact,
ArtifactSlot,
BuildSuggestion,
Character,
CharacterPreset,
Recommendation,
} from "../types/domain";
const freeSlots: ArtifactSlot[] = ["flower", "plume"];
const slots: ArtifactSlot[] = ["flower", "plume", "sands", "goblet", "circlet"];
function statValueScore(stat: string, value: number) {
if (stat.includes("CRIT")) return value / 7;
if (stat === "Energy Recharge") return value / 6;
if (stat === "Elemental Mastery") return value / 30;
if (stat.endsWith("%")) return value / 7;
return value / 50;
}
export function scoreArtifactForPreset(artifact: Artifact, preset: CharacterPreset) {
const allowedMainStats = preset.mainStats[artifact.slot];
const mainStatFits =
freeSlots.includes(artifact.slot) ||
!allowedMainStats ||
allowedMainStats.includes(artifact.mainStat);
if (!mainStatFits) return 0;
const setBonus =
preset.recommendedSets.includes(artifact.setKey) ? 22 :
preset.alternativeSets.includes(artifact.setKey) ? 12 :
0;
const mainStatBonus = freeSlots.includes(artifact.slot) ? 8 : 24;
const substatScore = artifact.substats.reduce((total, substat) => {
const weight = preset.substatWeights[substat.key] ?? 0;
return total + weight * statValueScore(substat.key, substat.value);
}, 0);
return Math.round((setBonus + mainStatBonus + substatScore + artifact.level * 0.35) * 10) / 10;
}
export function recommendArtifacts(
artifacts: Artifact[],
characters: Character[],
presets: CharacterPreset[],
): Recommendation[] {
const ownedPresets = presets.filter((preset) =>
characters.some((character) => character.id === preset.characterId && character.owned),
);
return artifacts.map((artifact) => {
if (artifact.confidence < 0.82) {
return {
artifactId: artifact.id,
verdict: "needs_review",
score: 0,
bestCharacters: [],
reason: "Scanner confidence is low. Review this piece before trusting any recommendation.",
};
}
const ranked = ownedPresets
.map((preset) => ({
preset,
score: scoreArtifactForPreset(artifact, preset),
}))
.sort((a, b) => b.score - a.score);
const best = ranked[0];
const bestCharacters = ranked
.filter((entry) => entry.score >= Math.max(22, best?.score * 0.78))
.slice(0, 3)
.map((entry) => entry.preset.characterId);
if (!best || best.score < 12) {
return {
artifactId: artifact.id,
verdict: "trash_candidate",
score: best?.score ?? 0,
bestCharacters: [],
reason: "No owned character preset strongly wants this main stat, set, or substat mix.",
};
}
const verdict =
best.score >= 48 ? "keep" :
best.score >= 34 ? "character_specific" :
best.score >= 24 ? "maybe_level" :
"trash_candidate";
return {
artifactId: artifact.id,
verdict,
score: best.score,
bestCharacters,
reason: `Best fit is ${best.preset.characterId.replaceAll("_", " ")}: ${best.preset.explanation}`,
};
});
}
export function suggestBuilds(
artifacts: Artifact[],
characters: Character[],
presets: CharacterPreset[],
): BuildSuggestion[] {
const suggestions: BuildSuggestion[] = [];
for (const character of characters.filter((entry) => entry.owned)) {
const preset = presets.find((entry) => entry.characterId === character.id);
if (!preset) continue;
const bySlot = new Map<ArtifactSlot, Artifact[]>();
for (const slot of slots) {
bySlot.set(
slot,
artifacts
.filter((artifact) => artifact.slot === slot && artifact.confidence >= 0.82)
.map((artifact) => ({
artifact,
score: scoreArtifactForPreset(artifact, preset),
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 8)
.map((entry) => entry.artifact),
);
}
const candidates = ["recommended_set", "alternative_set", "rainbow"] as const;
let addedForCharacter = 0;
const usedBuilds = new Set<string>();
for (const quality of candidates) {
const build = pickBuild(quality, preset, bySlot);
if (!build) continue;
const buildKey = build.map((artifact) => artifact.id).sort().join("|");
if (usedBuilds.has(buildKey)) continue;
usedBuilds.add(buildKey);
const score = build.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0);
const warnings = buildWarnings(build, preset, character);
suggestions.push({
id: `${character.id}-${quality}`,
characterId: character.id,
label:
quality === "recommended_set" ? "Best recommended set" :
quality === "alternative_set" ? "Best fallback set" :
"Best stat-stick build",
quality,
artifactIds: build.map((artifact) => artifact.id),
score: Math.round(score * 10) / 10,
warnings,
explanation:
quality === "recommended_set"
? "Uses the preferred 4-piece path with the best available off-piece."
: quality === "alternative_set"
? describeFallbackBuild(build, preset)
: "Ignores set perfection and takes the strongest available stats.",
});
addedForCharacter++;
}
if (addedForCharacter === 0) {
const partial = pickPartialBuild(bySlot);
if (partial.length > 0) {
const score = partial.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0);
const missingSlots = slots.filter((slot) => !partial.some((artifact) => artifact.slot === slot));
suggestions.push({
id: `${character.id}-partial`,
characterId: character.id,
label: "Best partial build",
quality: "rainbow",
artifactIds: partial.map((artifact) => artifact.id),
score: Math.round(score * 10) / 10,
warnings: [
`Missing ${missingSlots.join(", ")} before this becomes a full build.`,
...equippedConflictWarnings(partial, character),
],
explanation: "Uses the strongest currently scanned pieces and shows what is still missing.",
});
}
}
}
return suggestions.sort((a, b) => b.score - a.score);
}
function pickBuild(
quality: BuildSuggestion["quality"],
preset: CharacterPreset,
bySlot: Map<ArtifactSlot, Artifact[]>,
) {
const combinations = enumerateBuilds(bySlot);
if (combinations.length === 0) return null;
return combinations
.filter((build) => buildMatchesQuality(build, quality, preset))
.sort((a, b) => buildScore(b, preset) - buildScore(a, preset))[0] ?? null;
}
function pickPartialBuild(bySlot: Map<ArtifactSlot, Artifact[]>) {
const build: Artifact[] = [];
for (const artifacts of bySlot.values()) {
if (artifacts[0]) build.push(artifacts[0]);
}
return build;
}
function enumerateBuilds(bySlot: Map<ArtifactSlot, Artifact[]>) {
const candidates = slots.map((slot) => bySlot.get(slot)?.slice(0, 6) ?? []);
if (candidates.some((artifacts) => artifacts.length === 0)) return [];
const builds: Artifact[][] = [];
for (const flower of candidates[0]) {
for (const plume of candidates[1]) {
for (const sands of candidates[2]) {
for (const goblet of candidates[3]) {
for (const circlet of candidates[4]) {
builds.push([flower, plume, sands, goblet, circlet]);
}
}
}
}
}
return builds;
}
function buildMatchesQuality(build: Artifact[], quality: BuildSuggestion["quality"], preset: CharacterPreset) {
if (quality === "rainbow") return true;
const setCounts = countSets(build);
if (quality === "recommended_set") {
return preset.recommendedSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4);
}
if (preset.alternativeSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return true;
const twoPieceSets = Object.entries(setCounts)
.filter(([setKey, count]) => count >= 2 && [...preset.recommendedSets, ...preset.alternativeSets].includes(setKey))
.map(([setKey]) => setKey);
return twoPieceSets.length >= 2;
}
function buildScore(build: Artifact[], preset: CharacterPreset) {
return build.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0) + setShapeBonus(build, preset);
}
function setShapeBonus(build: Artifact[], preset: CharacterPreset) {
const setCounts = countSets(build);
if (preset.recommendedSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return 30;
if (preset.alternativeSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return 22;
const twoPieceSets = Object.entries(setCounts).filter(([, count]) => count >= 2).length;
return twoPieceSets >= 2 ? 14 : 0;
}
function countSets(build: Artifact[]) {
return build.reduce<Record<string, number>>((counts, artifact) => {
counts[artifact.setKey] = (counts[artifact.setKey] ?? 0) + 1;
return counts;
}, {});
}
function describeFallbackBuild(build: Artifact[], preset: CharacterPreset) {
const setCounts = countSets(build);
const alternative4p = preset.alternativeSets.find((setKey) => (setCounts[setKey] ?? 0) >= 4);
if (alternative4p) return "Uses the best available alternative 4-piece path because the ideal set is incomplete.";
const twoPieceSets = Object.entries(setCounts)
.filter(([, count]) => count >= 2)
.map(([setKey]) => setKey.replaceAll("_", " "))
.slice(0, 2);
if (twoPieceSets.length >= 2) return `Uses a practical 2pc+2pc fallback: ${twoPieceSets.join(" + ")}.`;
return "Uses the next best set path because the ideal pieces are incomplete.";
}
function buildWarnings(build: Artifact[], preset: CharacterPreset, character: Character) {
const warnings: string[] = [];
const setCounts = countSets(build);
if (!preset.recommendedSets.some((setKey) => setCounts[setKey] >= 4)) {
warnings.push("No full preferred 4-piece set yet.");
}
const hasEr = build.some((artifact) =>
artifact.mainStat === "Energy Recharge" ||
artifact.substats.some((substat) => substat.key === "Energy Recharge"),
);
if (preset.erTarget && !hasEr) warnings.push(`ER target around ${preset.erTarget}% may be hard to reach.`);
warnings.push(...equippedConflictWarnings(build, character));
return warnings;
}
function equippedConflictWarnings(build: Artifact[], character: Character) {
const conflicts = build
.filter((artifact) => artifact.equipped && simplify(artifact.equipped) !== simplify(character.name))
.map((artifact) => `${artifact.slot} from ${artifact.equipped}`);
return conflicts.length ? [`Conflicts: ${conflicts.slice(0, 3).join(", ")}${conflicts.length > 3 ? "..." : ""}.`] : [];
}
function simplify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
}
+17
View File
@@ -0,0 +1,17 @@
import type { AppSnapshot, ArtifactVerdict } from "../types/domain";
export function summarizeSnapshot(snapshot: AppSnapshot) {
const useful = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) =>
["keep", "character_specific", "maybe_level"].includes(entry.verdict),
).length;
const trash = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length;
const review = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length;
return {
artifacts: snapshot.artifacts.length,
useful,
trash,
review,
builds: snapshot.builds.length,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { storedArtifactsToDomain } from "./storedArtifactAdapter";
import type { StoredArtifactRecord } from "../types/storage";
function record(overrides: Partial<StoredArtifactRecord> = {}): StoredArtifactRecord {
return {
id: "stored-1",
name: "A Note in Spring's Leich",
slot: "Sands of Eon",
level: 16,
setName: "Viridescent Venerer",
mainStat: "ATK",
mainValue: "46.6%",
substats: ["ATK+29", "ATK+15.2%", "CRIT DMG+15.5%", "Energy Recharge+11.7%"],
equipped: "Sucrose",
confidence: 96,
needsReview: false,
source: "auto-scan",
lastSeenAt: "2026-07-04T00:00:00.000Z",
...overrides,
};
}
describe("storedArtifactAdapter", () => {
it("converts stored OCR artifacts into recommendation-domain artifacts", () => {
const [artifact] = storedArtifactsToDomain([record()]);
expect(artifact.slot).toBe("sands");
expect(artifact.setKey).toBe("viridescent_venerer");
expect(artifact.mainStat).toBe("ATK%");
expect(artifact.level).toBe(16);
expect(artifact.equipped).toBe("Sucrose");
expect(artifact.confidence).toBe(0.96);
expect(artifact.source).toBe("screen");
});
it("keeps flat and percent ATK substats distinct", () => {
const [artifact] = storedArtifactsToDomain([record()]);
expect(artifact.substats).toEqual(
expect.arrayContaining([
{ key: "ATK", value: 29, unit: "flat" },
{ key: "ATK%", value: 15.2, unit: "%" },
]),
);
});
});
+139
View File
@@ -0,0 +1,139 @@
import presetsJson from "../../data/presets.json";
import type { StoredArtifactRecord } from "../types/storage";
import type { Artifact, ArtifactSlot, ScanSource, ArtifactSubstat } from "../types/domain";
const setNameToKey = new Map(
Object.entries(presetsJson.sets).map(([key, name]) => [simplify(String(name)), key]),
);
const slotMap: Record<string, ArtifactSlot> = {
[simplify("Flower of Life")]: "flower",
[simplify("Plume of Death")]: "plume",
[simplify("Sands of Eon")]: "sands",
[simplify("Goblet of Eonothem")]: "goblet",
[simplify("Circlet of Logos")]: "circlet",
};
const statNames = [
"CRIT Rate",
"CRIT DMG",
"Energy Recharge",
"Elemental Mastery",
"Physical DMG Bonus",
"Hydro DMG Bonus",
"Pyro DMG Bonus",
"Electro DMG Bonus",
"Cryo DMG Bonus",
"Dendro DMG Bonus",
"Anemo DMG Bonus",
"Geo DMG Bonus",
"Healing Bonus",
"ATK",
"HP",
"DEF",
"ATK%",
"HP%",
"DEF%",
];
export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifact[] {
const now = new Date().toISOString();
return records
.map((record): Artifact | null => {
const slot = toSlot(record.slot);
if (!slot) return null;
return {
id: record.id,
setKey: toSetKey(record.setName),
setName: record.setName || "Unknown set",
slot,
rarity: 5,
level: typeof record.level === "number" ? record.level : inferLevel(record),
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
locked: !record.needsReview && record.confidence >= 90,
source: toSource(record.source),
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
};
})
.filter(Boolean) as Artifact[];
}
function isUsefulEquippedName(value: string) {
return Boolean(value?.trim() && !/unknown|missing|not detected/i.test(value));
}
function toSlot(value: string): ArtifactSlot | null {
return slotMap[simplify(value)] ?? null;
}
function toSetKey(value: string) {
const simplified = simplify(value);
return setNameToKey.get(simplified) ?? slug(value || "unknown_set");
}
function normalizeMainStat(stat: string, value: string) {
const cleanStat = stat.replace(/\s+/g, " ").trim();
const cleanValue = value.trim();
if (/^(ATK|HP|DEF)$/i.test(cleanStat) && cleanValue.includes("%")) return `${cleanStat.toUpperCase()}%`;
return canonicalStatName(cleanStat, cleanValue) || cleanStat || "Unknown main stat";
}
function parseStoredSubstat(raw: string): ArtifactSubstat | null {
const text = raw.replace(/[•+]/g, " ").replace(/\s+/g, " ").trim();
if (!text) return null;
const stat = statNames.find((name) => simplify(text).includes(simplify(name.replace("%", ""))));
const valueMatch = /([0-9]+(?:\.[0-9])?)\s*%?/.exec(text);
if (!stat || !valueMatch) return null;
const value = Number(valueMatch[1]);
if (!Number.isFinite(value)) return null;
const unit: ArtifactSubstat["unit"] = text.includes("%") ? "%" : "flat";
return {
key: canonicalStatName(stat, unit === "%" ? `${value}%` : `${value}`) || stat,
value,
unit,
};
}
function canonicalStatName(stat: string, value: string) {
const simple = simplify(stat);
if (simple === "crit rate") return "CRIT Rate";
if (simple === "crit dmg" || simple === "crit damage") return "CRIT DMG";
if (simple === "energy recharge") return "Energy Recharge";
if (simple === "elemental mastery") return "Elemental Mastery";
if (simple === "atk" && value.includes("%")) return "ATK%";
if (simple === "hp" && value.includes("%")) return "HP%";
if (simple === "def" && value.includes("%")) return "DEF%";
if (simple === "atk") return "ATK";
if (simple === "hp") return "HP";
if (simple === "def") return "DEF";
return statNames.find((name) => simplify(name) === simple) ?? "";
}
function inferLevel(record: StoredArtifactRecord) {
// Backward compatibility for older OCR records written before level
// persistence landed in the local store.
return record.source === "manual-scan" || record.source === "auto-scan" ? 20 : 0;
}
function toSource(value: string): ScanSource {
if (value === "manual-scan") return "manual";
if (value === "overlay") return "overlay";
if (value === "good_import") return "good_import";
return "screen";
}
function simplify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim();
}
function slug(value: string) {
return simplify(value).replace(/%/g, "percent").replace(/\s+/g, "_") || "unknown_set";
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+8
View File
@@ -0,0 +1,8 @@
import { AppPageLayout } from "./app/AppPageLayout";
import { useAppController } from "../features/app/useAppController";
export function AppPage() {
const controller = useAppController();
return <AppPageLayout controller={controller} />;
}
+79
View File
@@ -0,0 +1,79 @@
import { Eye, Play, Save } from "lucide-react";
import { BuildsView } from "../../features/builds/BuildsView";
import { OverlayPreview, OverlaySettings } from "../../features/overlay/OverlayViews";
import { ScanView } from "../../features/scan/ScanView";
import { TriageView } from "../../features/triage/TriageView";
import { AppMetrics, AppShell, AppSidebar, AppTopbar } from "../../features/layout";
import type { AppPageLayoutProps } from "./types";
import { useAppPageLayoutModel } from "./hooks/useAppPageLayoutModel";
export function AppPageLayout({ controller }: AppPageLayoutProps) {
const {
activeView,
setActiveView,
isOverlay,
isScanning,
snapshot,
captureSources,
selectedSourceId,
setSelectedSourceId,
latestCapture,
topbarStatus,
bridgeReady,
canExportGood,
canShowOverlay,
metricCards,
refreshCaptureSources,
captureSelectedSource,
exportCurrentGood,
loadStoredArtifactSnapshot,
showOverlay,
} = controller;
const { renderNavigation, handleDemoScan } = useAppPageLayoutModel({ controller });
if (isOverlay) {
return <OverlayPreview snapshot={snapshot} />;
}
return (
<AppShell
sidebar={<AppSidebar activeView={activeView} items={renderNavigation} onSelect={setActiveView} />}
children={
<>
<AppTopbar
topbarStatus={topbarStatus}
canExportGood={canExportGood}
canShowOverlay={canShowOverlay}
isScanning={isScanning}
artifactCount={snapshot.artifacts.length}
onExportGood={exportCurrentGood}
onShowOverlay={showOverlay}
onDemoScan={handleDemoScan}
exportIcon={<Save size={16} />}
overlayIcon={<Eye size={16} />}
demoIcon={<Play size={16} />}
/>
<AppMetrics metricCards={metricCards} />
{activeView === "scan" && (
<ScanView
isScanning={isScanning}
snapshot={snapshot}
captureSources={captureSources}
selectedSourceId={selectedSourceId}
setSelectedSourceId={setSelectedSourceId}
latestCapture={latestCapture}
captureStatus={topbarStatus}
refreshCaptureSources={refreshCaptureSources}
captureSelectedSource={captureSelectedSource}
bridgeReady={bridgeReady}
onStoredArtifactsChanged={loadStoredArtifactSnapshot}
/>
)}
{activeView === "triage" && <TriageView snapshot={snapshot} />}
{activeView === "builds" && <BuildsView snapshot={snapshot} />}
{activeView === "overlay" && <OverlaySettings canShowOverlay={canShowOverlay} onShowOverlay={showOverlay} />}
</>
}
/>
);
}
@@ -0,0 +1,29 @@
import { useCallback, useMemo } from "react";
import { appNavigationItems } from "../../../features/layout/navigation";
import type { AppPageLayoutProps } from "../types";
export function useAppPageLayoutModel({ controller }: AppPageLayoutProps) {
const {
runDemoScan,
canShowOverlay,
} = controller;
const renderNavigation = useMemo(
() =>
appNavigationItems.map((item) => ({
...item,
disabled: item.id === "overlay" && !canShowOverlay,
disabledReason: item.id === "overlay" && !canShowOverlay ? "Overlay benoetigt die Electron-Bridge." : undefined,
})),
[canShowOverlay],
);
const handleDemoScan = useCallback(() => {
void runDemoScan();
}, [runDemoScan]);
return {
renderNavigation,
handleDemoScan,
};
}
+1
View File
@@ -0,0 +1 @@
export { AppPageLayout } from "./AppPageLayout";
+5
View File
@@ -0,0 +1,5 @@
import type { AppControllerResult } from "../../features/app/types";
export interface AppPageLayoutProps {
controller: AppControllerResult;
}

Some files were not shown because too many files have changed in this diff Show More