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:
AzuTear
2026-07-05 21:55:20 +02:00
parent 6ea3d9e714
commit dcac155887
10 changed files with 658 additions and 2 deletions
+201
View File
@@ -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;
}