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:
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AppControllerResult } from "./types";
|
||||
import { useAppControllerState } from "./hooks/useAppControllerState";
|
||||
|
||||
export function useAppController(): AppControllerResult {
|
||||
return useAppControllerState();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
|
||||
|
||||
export interface BuildsViewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export interface BuildCardProps {
|
||||
build: BuildSuggestion;
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
@@ -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} /> },
|
||||
};
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
|
||||
export interface OverlaySettingsProps {
|
||||
canShowOverlay: boolean;
|
||||
onShowOverlay: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayPreviewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }]),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: "%" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AppPageLayout } from "./AppPageLayout";
|
||||
@@ -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
Reference in New Issue
Block a user