dcac155887
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>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import gameData from "../data/genshinGameData.json" with { type: "json" };
|
|
import { simplifyForMatch } from "./fuzzyMatch.js";
|
|
|
|
type ArtifactPiece = {
|
|
name: string;
|
|
setName: string;
|
|
slot: string;
|
|
relicType: string;
|
|
};
|
|
|
|
type Character = {
|
|
name: string;
|
|
};
|
|
|
|
type GenshinGameDataContract = typeof gameData & {
|
|
artifactPieces?: ArtifactPiece[];
|
|
stats?: {
|
|
main?: string[];
|
|
mainBySlot?: Record<string, string[]>;
|
|
sub?: string[];
|
|
};
|
|
aliases?: {
|
|
stats?: Record<string, string>;
|
|
textReplacements?: Record<string, string>;
|
|
slotAliases?: Record<string, string>;
|
|
setAliases?: Record<string, string>;
|
|
pieceAliases?: Record<string, string>;
|
|
characterAliases?: Record<string, string>;
|
|
};
|
|
mainStatsBySlot?: Record<string, string[]>;
|
|
mainStatValueReferences?: Record<string, Array<{ stat: string; base: number; max: number }>>;
|
|
characters?: Character[];
|
|
};
|
|
|
|
export const genshinGameData = gameData as GenshinGameDataContract;
|
|
|
|
export const slotNames = genshinGameData.slots;
|
|
export const statAliases = genshinGameData.aliases?.stats ?? {};
|
|
export const textReplacements = genshinGameData.aliases?.textReplacements ?? {};
|
|
export const slotAliases = genshinGameData.aliases?.slotAliases ?? {};
|
|
export const setAliases = genshinGameData.aliases?.setAliases ?? {};
|
|
export const pieceAliases = genshinGameData.aliases?.pieceAliases ?? {};
|
|
export const characterAliases = genshinGameData.aliases?.characterAliases ?? {};
|
|
export const knownSets = genshinGameData.artifactSets.map((set) => set.name);
|
|
export const knownCharacters = (genshinGameData.characters ?? []).map((character) => character.name);
|
|
export const sourceVersion = genshinGameData.sourceVersion ?? "unknown";
|
|
export const dataGeneratedAt = (genshinGameData as { generatedAt?: string }).generatedAt ?? "";
|
|
|
|
export const fixedMainStatBySlot: Record<string, string> = {
|
|
"Flower of Life": "HP",
|
|
"Plume of Death": "ATK",
|
|
};
|
|
|
|
const bundledMainStats = genshinGameData.stats?.main ?? genshinGameData.mainStats;
|
|
const bundledSubstats = genshinGameData.stats?.sub ?? genshinGameData.substats;
|
|
|
|
export const mainStatsBySlot: Record<string, string[]> = genshinGameData.stats?.mainBySlot ?? genshinGameData.mainStatsBySlot ?? {
|
|
"Flower of Life": ["HP"],
|
|
"Plume of Death": ["ATK"],
|
|
"Sands of Eon": ["HP%", "ATK%", "DEF%", "Energy Recharge", "Elemental Mastery"],
|
|
"Goblet of Eonothem": [
|
|
"HP%",
|
|
"ATK%",
|
|
"DEF%",
|
|
"Elemental Mastery",
|
|
"Hydro DMG Bonus",
|
|
"Pyro DMG Bonus",
|
|
"Electro DMG Bonus",
|
|
"Cryo DMG Bonus",
|
|
"Dendro DMG Bonus",
|
|
"Anemo DMG Bonus",
|
|
"Geo DMG Bonus",
|
|
"Physical DMG Bonus",
|
|
],
|
|
"Circlet of Logos": ["HP%", "ATK%", "DEF%", "Elemental Mastery", "CRIT Rate", "CRIT DMG", "Healing Bonus"],
|
|
};
|
|
|
|
const fallbackArtifactPieces = genshinGameData.artifactSets.flatMap((artifactSet) =>
|
|
artifactSet.pieces.map((piece) => ({
|
|
name: piece.name,
|
|
setName: artifactSet.name,
|
|
slot: slotFromRelicType(piece.relicType),
|
|
relicType: piece.relicType,
|
|
})),
|
|
);
|
|
|
|
export const artifactPieces: ArtifactPiece[] = (genshinGameData.artifactPieces ?? fallbackArtifactPieces)
|
|
.filter((piece) => piece.name && piece.setName && piece.slot);
|
|
|
|
export const pieceToSet = new Map(artifactPieces.map((piece) => [piece.name, piece.setName]));
|
|
export const pieceToSlot = new Map(artifactPieces.map((piece) => [piece.name, piece.slot]));
|
|
export const knownPieceNames = artifactPieces.map((piece) => piece.name);
|
|
|
|
export const globalMainStats = unique([
|
|
...bundledMainStats,
|
|
...Object.keys(statAliases),
|
|
]);
|
|
|
|
export const globalSubstats = unique([
|
|
...bundledSubstats,
|
|
...Object.keys(statAliases),
|
|
]);
|
|
|
|
export const mainStatValueReferences = genshinGameData.mainStatValueReferences ?? {};
|
|
|
|
export function allowedMainStatsForSlot(slot: string) {
|
|
return mainStatsBySlot[slot] ?? globalMainStats;
|
|
}
|
|
|
|
export function canonicalStatName(raw: string) {
|
|
return statAliases[raw] ?? raw;
|
|
}
|
|
|
|
export function normalizeSlotAlias(raw: string) {
|
|
const simplified = simplifyForMatch(raw);
|
|
const aliasMatch = Object.entries(slotAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
|
|
if (aliasMatch) return aliasMatch[1];
|
|
const direct = slotNames.find((slot) => simplifyForMatch(slot) === simplified);
|
|
return direct ?? "";
|
|
}
|
|
|
|
export function normalizeSetAlias(raw: string) {
|
|
const simplified = simplifyForMatch(raw);
|
|
const aliasMatch = Object.entries(setAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
|
|
if (aliasMatch) return aliasMatch[1];
|
|
const direct = knownSets.find((set) => simplifyForMatch(set) === simplified);
|
|
return direct ?? "";
|
|
}
|
|
|
|
export function normalizePieceAlias(raw: string) {
|
|
const simplified = simplifyForMatch(raw);
|
|
const aliasMatch = Object.entries(pieceAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
|
|
if (aliasMatch) return aliasMatch[1];
|
|
const direct = knownPieceNames.find((piece) => simplifyForMatch(piece) === simplified);
|
|
return direct ?? "";
|
|
}
|
|
|
|
export function normalizeCharacterAlias(raw: string) {
|
|
const simplified = simplifyForMatch(raw);
|
|
const aliasMatch = Object.entries(characterAliases).find(([alias]) => simplifyForMatch(alias) === simplified);
|
|
if (aliasMatch) return aliasMatch[1];
|
|
const direct = knownCharacters.find((character) => simplifyForMatch(character) === simplified);
|
|
return direct ?? "";
|
|
}
|
|
|
|
function unique(values: string[]) {
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function slotFromRelicType(relicType: string) {
|
|
switch (relicType) {
|
|
case "EQUIP_BRACER":
|
|
return "Flower of Life";
|
|
case "EQUIP_NECKLACE":
|
|
return "Plume of Death";
|
|
case "EQUIP_SHOES":
|
|
return "Sands of Eon";
|
|
case "EQUIP_RING":
|
|
return "Goblet of Eonothem";
|
|
case "EQUIP_DRESS":
|
|
return "Circlet of Logos";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|