Files
genshin-assistant/src/lib/goodInterop.ts
T
AzuTear 639b0b7f59 feat(scanner): add native artifact pipeline
Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
2026-07-09 23:30:42 +02:00

203 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GoodExportArtifact } from "../types/global";
import type { StoredArtifactRecord } from "../types/storage";
import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData";
import { simplifyForMatch } from "./fuzzyMatch";
import { inferRarity } from "./substatRolls";
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
// local store can round-trip with GOOD-compatible tools (ADR-003). Export is
// lossless for the fields GOOD carries; import is
// best-effort because GOOD does not store piece names or main-stat values.
export interface GoodImportArtifact {
setKey: string;
slotKey: string;
rarity?: number;
level?: number;
mainStatKey: string;
substats?: Array<{ key: string; value: number }>;
location?: string;
lock?: boolean;
}
export interface GoodImportDatabase {
format?: string;
version?: number;
source?: string;
artifacts?: GoodImportArtifact[];
}
const SLOT_TO_GOOD: Record<string, string> = {
"Flower of Life": "flower",
"Plume of Death": "plume",
"Sands of Eon": "sands",
"Goblet of Eonothem": "goblet",
"Circlet of Logos": "circlet",
};
const GOOD_TO_SLOT: Record<string, string> = Object.fromEntries(
Object.entries(SLOT_TO_GOOD).map(([display, key]) => [key, display]),
);
// display name (as used across the app, including the HP%/ATK%/DEF% variants) ->
// GOOD stat key + whether it is a percent stat.
interface StatEntry {
display: string;
key: string;
percent: boolean;
}
const STAT_ENTRIES: StatEntry[] = [
{ display: "HP", key: "hp", percent: false },
{ display: "HP%", key: "hp_", percent: true },
{ display: "ATK", key: "atk", percent: false },
{ display: "ATK%", key: "atk_", percent: true },
{ display: "DEF", key: "def", percent: false },
{ display: "DEF%", key: "def_", percent: true },
{ display: "Elemental Mastery", key: "eleMas", percent: false },
{ display: "Energy Recharge", key: "enerRech_", percent: true },
{ display: "CRIT Rate", key: "critRate_", percent: true },
{ display: "CRIT DMG", key: "critDMG_", percent: true },
{ display: "Healing Bonus", key: "heal_", percent: true },
{ display: "Physical DMG Bonus", key: "physical_dmg_", percent: true },
{ display: "Pyro DMG Bonus", key: "pyro_dmg_", percent: true },
{ display: "Hydro DMG Bonus", key: "hydro_dmg_", percent: true },
{ display: "Electro DMG Bonus", key: "electro_dmg_", percent: true },
{ display: "Cryo DMG Bonus", key: "cryo_dmg_", percent: true },
{ display: "Anemo DMG Bonus", key: "anemo_dmg_", percent: true },
{ display: "Geo DMG Bonus", key: "geo_dmg_", percent: true },
{ display: "Dendro DMG Bonus", key: "dendro_dmg_", percent: true },
];
const DISPLAY_TO_STAT = new Map(STAT_ENTRIES.map((entry) => [entry.display, entry]));
const KEY_TO_STAT = new Map(STAT_ENTRIES.map((entry) => [entry.key, entry]));
export function statDisplayToGoodKey(display: string): string {
return DISPLAY_TO_STAT.get(display)?.key ?? "";
}
export function goodKeyToStatDisplay(key: string): string {
return KEY_TO_STAT.get(key)?.display ?? "";
}
export function setNameToKey(name: string): string {
// GOOD removes apostrophes without re-capitalizing ("Gladiator's" ->
// "Gladiators"), then PascalCases the remaining whitespace/hyphen words.
return name
.replace(/[']/g, "")
.split(/[\s-]+/)
.map((word) => word.replace(/[^A-Za-z0-9]/g, ""))
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
}
const SET_KEY_TO_NAME = new Map(knownSets.map((name) => [setNameToKey(name), name]));
export function setKeyToName(key: string): string {
const direct = SET_KEY_TO_NAME.get(key);
if (direct) return direct;
const simplifiedKey = simplifyForMatch(key);
const match = knownSets.find((name) => simplifyForMatch(setNameToKey(name)) === simplifiedKey);
return match ?? key;
}
// "CRIT DMG+13.2%" / "ATK+19" -> GOOD { key, value }.
export function substatStringToGood(entry: string): { key: string; value: number } | null {
const plusIndex = entry.indexOf("+");
if (plusIndex <= 0) return null;
const display = entry.slice(0, plusIndex).trim();
const key = statDisplayToGoodKey(display);
if (!key) return null;
const value = Number.parseFloat(entry.slice(plusIndex + 1).replace(/[%,\s]/g, ""));
if (!Number.isFinite(value)) return null;
return { key, value };
}
export function goodSubstatToString(substat: { key: string; value: number }): string {
const entry = KEY_TO_STAT.get(substat.key);
if (!entry) return "";
return entry.percent ? `${entry.display}+${substat.value}%` : `${entry.display}+${substat.value}`;
}
export function storedArtifactToGood(record: StoredArtifactRecord): GoodExportArtifact {
const substats = record.substats
.map((entry) => substatStringToGood(entry))
.filter((entry): entry is { key: string; value: number } => entry !== null);
return {
setKey: setNameToKey(record.setName),
slotKey: SLOT_TO_GOOD[record.slot] ?? "",
rarity: inferRarity(record.level ?? 0, record.substats),
level: record.level ?? 0,
mainStatKey: statDisplayToGoodKey(record.mainStat),
substats,
lock: Boolean((record as { locked?: boolean }).locked),
};
}
export function storedArtifactsToGood(records: readonly StoredArtifactRecord[], source = "Genshin Artifact Assistant") {
return {
format: "GOOD" as const,
version: 2,
source,
artifacts: records.map(storedArtifactToGood),
};
}
function reversePieceLookup(setName: string, slotDisplay: string): string {
for (const [piece, set] of pieceToSet.entries()) {
if (set === setName && pieceToSlot.get(piece) === slotDisplay) return piece;
}
return "";
}
function computeMainValue(slotDisplay: string, mainStatDisplay: string, level: number): string {
const references = mainStatValueReferences[slotDisplay];
const reference = Array.isArray(references)
? (references as Array<{ stat: string; base: number; max: number }>).find((entry) => entry.stat === mainStatDisplay)
: undefined;
if (!reference) return "";
const clamped = Math.max(0, Math.min(20, level));
const value = reference.base + (reference.max - reference.base) * (clamped / 20);
const isPercent = DISPLAY_TO_STAT.get(mainStatDisplay)?.percent ?? false;
return isPercent ? `${(Math.round(value * 10) / 10).toFixed(1)}%` : Math.round(value).toLocaleString("en-US");
}
export function goodToStoredArtifact(good: GoodImportArtifact, index = 0): StoredArtifactRecord | null {
const slot = GOOD_TO_SLOT[good.slotKey];
const mainStat = goodKeyToStatDisplay(good.mainStatKey);
if (!slot || !mainStat) return null;
const setName = setKeyToName(good.setKey);
const level = typeof good.level === "number" ? good.level : 0;
const substats = (good.substats ?? [])
.map((substat) => goodSubstatToString(substat))
.filter(Boolean);
const name = reversePieceLookup(setName, slot) || setName;
return {
id: `good-${good.setKey}-${good.slotKey}-${index}`,
name,
slot,
level,
setName,
mainStat,
mainValue: computeMainValue(slot, mainStat, level),
substats,
equipped: good.location || "Not detected",
confidence: 100,
needsReview: false,
source: "good-import",
};
}
export function goodDatabaseToStoredArtifacts(database: GoodImportDatabase | null | undefined): StoredArtifactRecord[] {
const records: StoredArtifactRecord[] = [];
(database?.artifacts ?? []).forEach((artifact, index) => {
const record = goodToStoredArtifact(artifact, index);
if (record) records.push(record);
});
return records;
}