639b0b7f59
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.
203 lines
7.5 KiB
TypeScript
203 lines
7.5 KiB
TypeScript
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;
|
||
}
|