Files
genshin-assistant/src/features/builds/hooks/useBuildsViewModel.ts
T

211 lines
7.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import type { AppSnapshot } from "../../../types/domain";
import type { StoredScanResultEntry } from "../../../types/storage";
import {
BUILD_FIT_PROFILE_CORPUS_VERSION,
curatedBuildFitProfiles,
} from "../../../data/buildFitProfiles";
import {
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
BUILD_FIT_CONTRACT_VERSION,
BUILD_FIT_EVIDENCE_RANKING_VERSION,
type BuildFitEvidenceRankingResultV1,
} from "../../../types/buildFit";
import {
buildFitInputFromScanResult,
rankBuildFitEvidence,
} from "../../../lib/buildFitContract";
import {
aggregateContextKey,
buildExplicitAggregateContexts,
type BuildFitExplicitContextDraft,
} from "../../../lib/buildFitExplicitContext";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { AppLocale } from "../../../i18n";
import { getBuildCopy } from "../buildsCopy";
const MAX_NATIVE_BUILD_FIT_RESULTS = 2400;
interface UseBuildsViewModelInput {
snapshot: AppSnapshot;
locale: AppLocale;
}
export interface BuildsViewModel {
artifactCount: number;
hasScanData: boolean;
foundationLabel: string;
foundationDetail: string;
contractLabel: string;
contractDetail: string;
profileLabel: string;
profileDetail: string;
nativeRunDir: string;
nativeResultCount: number;
isLoadingBuildFit: boolean;
buildFitLoadError: string;
ranking: BuildFitEvidenceRankingResultV1;
aggregateContextFields: BuildFitAggregateContextField[];
setAggregateContextDraft: (
profileId: string,
targetKey: string,
update: Partial<BuildFitExplicitContextDraft>,
) => void;
refreshBuildFit: () => Promise<void>;
}
export interface BuildFitAggregateContextField {
profileId: string;
characterName: string;
targetLabel: string;
targetKey: string;
stat: string;
unit: "flat" | "percent";
minimum?: number;
maximum?: number;
reason: string;
value: string;
confirmed: boolean;
error: string;
}
export function useBuildsViewModel({ snapshot, locale }: UseBuildsViewModelInput): BuildsViewModel {
const copy = getBuildCopy(locale);
const repositories = useMemo(() => createRendererRepositories(), []);
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
const [nativeRunDir, setNativeRunDir] = useState("");
const [nativeResultCount, setNativeResultCount] = useState(0);
const [isLoadingBuildFit, setIsLoadingBuildFit] = useState(Boolean(repositories));
const [buildFitLoadError, setBuildFitLoadError] = useState("");
const [aggregateContextDrafts, setAggregateContextDrafts] = useState<Record<string, Record<string, BuildFitExplicitContextDraft>>>({});
const refreshBuildFit = useCallback(async () => {
if (!repositories?.automation) {
setNativeResults([]);
setNativeRunDir("");
setNativeResultCount(0);
setBuildFitLoadError(copy.noBridge);
setIsLoadingBuildFit(false);
return;
}
setIsLoadingBuildFit(true);
setBuildFitLoadError("");
try {
const result = await repositories.automation.nativeScannerLoadResults({
limit: MAX_NATIVE_BUILD_FIT_RESULTS,
});
if (!result.ok) {
setNativeResults([]);
setNativeRunDir("");
setNativeResultCount(0);
setBuildFitLoadError(result.error ?? copy.noCompleteRun);
return;
}
setNativeResults(result.results);
setNativeRunDir(result.runDir);
setNativeResultCount(result.total);
} catch (error) {
setNativeResults([]);
setNativeRunDir("");
setNativeResultCount(0);
setBuildFitLoadError(error instanceof Error ? error.message : copy.loadFailed);
} finally {
setIsLoadingBuildFit(false);
}
}, [copy.loadFailed, copy.noBridge, copy.noCompleteRun, repositories]);
useEffect(() => {
void refreshBuildFit();
}, [refreshBuildFit]);
const aggregateContextResults = useMemo(() => Object.fromEntries(
curatedBuildFitProfiles.map((profile) => [
profile.id,
buildExplicitAggregateContexts(profile, aggregateContextDrafts[profile.id] ?? {}),
]),
) as Record<string, ReturnType<typeof buildExplicitAggregateContexts>>, [aggregateContextDrafts]);
const aggregateContextByProfile = useMemo(() => Object.fromEntries(
Object.entries(aggregateContextResults)
.filter(([, result]) => result.contexts.length > 0)
.map(([profileId, result]) => [profileId, result.contexts]),
), [aggregateContextResults]);
const aggregateContextFields = useMemo(() => curatedBuildFitProfiles.flatMap((profile) => profile.aggregateTargets.map((target) => {
const targetKey = aggregateContextKey(target);
const draft = aggregateContextDrafts[profile.id]?.[targetKey] ?? { value: "", confirmed: false };
return {
profileId: profile.id,
characterName: profile.character.name,
targetLabel: profile.target.label,
targetKey,
stat: target.stat,
unit: target.unit,
...(target.minimum === undefined ? {} : { minimum: target.minimum }),
...(target.maximum === undefined ? {} : { maximum: target.maximum }),
reason: target.reason,
value: draft.value,
confirmed: draft.confirmed,
error: aggregateContextResults[profile.id]?.issues[targetKey] ?? "",
};
})), [aggregateContextDrafts, aggregateContextResults]);
const setAggregateContextDraft = useCallback((
profileId: string,
targetKey: string,
update: Partial<BuildFitExplicitContextDraft>,
) => {
setAggregateContextDrafts((current) => ({
...current,
[profileId]: {
...current[profileId],
[targetKey]: {
value: current[profileId]?.[targetKey]?.value ?? "",
confirmed: current[profileId]?.[targetKey]?.confirmed ?? false,
...update,
},
},
}));
}, []);
const ranking = useMemo(() => rankBuildFitEvidence({
version: BUILD_FIT_EVIDENCE_RANKING_VERSION,
profiles: curatedBuildFitProfiles,
artifacts: nativeResults
.map((result) => buildFitInputFromScanResult(result))
.filter((result): result is NonNullable<typeof result> => result !== null),
aggregateContextByProfile,
}), [aggregateContextByProfile, nativeResults]);
const artifactCount = Math.max(snapshot.artifacts.length, nativeResultCount);
const hasScanData = artifactCount > 0;
const profileCount = curatedBuildFitProfiles.length;
const suggestionCount = ranking.suggestions.length;
return {
artifactCount,
hasScanData,
foundationLabel: hasScanData ? copy.foundationReady : copy.foundationEmpty,
foundationDetail: hasScanData
? copy.foundationReadyDetail(nativeResultCount || artifactCount)
: copy.foundationEmptyDetail,
contractLabel: copy.contractLabel,
contractDetail: copy.contractDetail(
BUILD_FIT_CONTRACT_VERSION,
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
BUILD_FIT_EVIDENCE_RANKING_VERSION,
),
profileLabel: suggestionCount > 0
? copy.profilesReady(suggestionCount)
: copy.profilesChecked(profileCount),
profileDetail: suggestionCount > 0
? copy.profilesReadyDetail(BUILD_FIT_PROFILE_CORPUS_VERSION)
: copy.profilesBlockedDetail(BUILD_FIT_PROFILE_CORPUS_VERSION),
nativeRunDir,
nativeResultCount,
isLoadingBuildFit,
buildFitLoadError,
ranking,
aggregateContextFields,
setAggregateContextDraft,
refreshBuildFit,
};
}