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 = { "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 = 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; }