feat(store): GOOD interop, rescan-merge, data staleness, lock detection
Task #5 building blocks, each a pure + unit-tested module: - goodInterop.ts: GOOD (Genshin Optimizer / Inventory Kamera / Akasha) import and export for scanned StoredArtifactRecords - slot/stat/set key maps both ways, substat string <-> { key, value }, main-value reconstruction on import (ADR-003). Export is lossless; import is best-effort (GOOD lacks piece names). - artifactMerge.ts: rescan-merge (ADR-006 follow-up). Level-independent identity (set + slot + main + substat NAME set) collapses leveled re-scan duplicates, keeping the higher-level/stronger record and summing timesSeen. Conservative: differing substat lineups never merge. - dataPackageStatus.ts: warns when the genshin-db package is older than ~45 days (a patch cycle) so new sets/characters aren't silently missed; surfaced in the Scanner Diagnose data-package line. Adds dataGeneratedAt to genshinData. - lockDetection.ts: EXPERIMENTAL read-only lock-status heuristic (gold-pixel ratio in a top-right icon crop). Pure + tested but not wired into capture; crop position and threshold need calibration against a reference 16:9 screenshot. 120 tests + build green. Remaining wiring (needs UI / live calibration): GOOD import/export buttons and live lock detection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import type { GoodExportArtifact } from "../types/global";
|
||||
import type { StoredArtifactRecord } from "../types/storage";
|
||||
import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData";
|
||||
import { simplifyForMatch } from "./fuzzyMatch";
|
||||
|
||||
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
|
||||
// local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha
|
||||
// (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: 5,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user