e76d88e0c7
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
import presetsJson from "../../data/presets.json";
|
|
import type { StoredArtifactRecord } from "../types/storage";
|
|
import type { Artifact, ArtifactSlot, ScanSource, ArtifactSubstat } from "../types/domain";
|
|
|
|
const setNameToKey = new Map(
|
|
Object.entries(presetsJson.sets).map(([key, name]) => [simplify(String(name)), key]),
|
|
);
|
|
|
|
const slotMap: Record<string, ArtifactSlot> = {
|
|
[simplify("Flower of Life")]: "flower",
|
|
[simplify("Plume of Death")]: "plume",
|
|
[simplify("Sands of Eon")]: "sands",
|
|
[simplify("Goblet of Eonothem")]: "goblet",
|
|
[simplify("Circlet of Logos")]: "circlet",
|
|
};
|
|
|
|
const statNames = [
|
|
"CRIT Rate",
|
|
"CRIT DMG",
|
|
"Energy Recharge",
|
|
"Elemental Mastery",
|
|
"Physical DMG Bonus",
|
|
"Hydro DMG Bonus",
|
|
"Pyro DMG Bonus",
|
|
"Electro DMG Bonus",
|
|
"Cryo DMG Bonus",
|
|
"Dendro DMG Bonus",
|
|
"Anemo DMG Bonus",
|
|
"Geo DMG Bonus",
|
|
"Healing Bonus",
|
|
"ATK",
|
|
"HP",
|
|
"DEF",
|
|
"ATK%",
|
|
"HP%",
|
|
"DEF%",
|
|
];
|
|
|
|
export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifact[] {
|
|
const now = new Date().toISOString();
|
|
|
|
return records
|
|
.map((record): Artifact | null => {
|
|
const slot = toSlot(record.slot);
|
|
if (!slot) return null;
|
|
|
|
return {
|
|
id: record.id,
|
|
setKey: toSetKey(record.setName),
|
|
setName: record.setName || "Unknown set",
|
|
slot,
|
|
rarity: 5,
|
|
level: typeof record.level === "number" ? record.level : inferLevel(record),
|
|
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
|
|
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
|
|
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
|
|
locked: !record.needsReview && record.confidence >= 90,
|
|
source: toSource(record.source),
|
|
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
|
|
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
|
|
};
|
|
})
|
|
.filter(Boolean) as Artifact[];
|
|
}
|
|
|
|
function isUsefulEquippedName(value: string) {
|
|
return Boolean(value?.trim() && !/unknown|missing|not detected/i.test(value));
|
|
}
|
|
|
|
function toSlot(value: string): ArtifactSlot | null {
|
|
return slotMap[simplify(value)] ?? null;
|
|
}
|
|
|
|
function toSetKey(value: string) {
|
|
const simplified = simplify(value);
|
|
return setNameToKey.get(simplified) ?? slug(value || "unknown_set");
|
|
}
|
|
|
|
function normalizeMainStat(stat: string, value: string) {
|
|
const cleanStat = stat.replace(/\s+/g, " ").trim();
|
|
const cleanValue = value.trim();
|
|
if (/^(ATK|HP|DEF)$/i.test(cleanStat) && cleanValue.includes("%")) return `${cleanStat.toUpperCase()}%`;
|
|
return canonicalStatName(cleanStat, cleanValue) || cleanStat || "Unknown main stat";
|
|
}
|
|
|
|
function parseStoredSubstat(raw: string): ArtifactSubstat | null {
|
|
const text = raw.replace(/[•+]/g, " ").replace(/\s+/g, " ").trim();
|
|
if (!text) return null;
|
|
|
|
const stat = statNames.find((name) => simplify(text).includes(simplify(name.replace("%", ""))));
|
|
const valueMatch = /([0-9]+(?:\.[0-9])?)\s*%?/.exec(text);
|
|
if (!stat || !valueMatch) return null;
|
|
|
|
const value = Number(valueMatch[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
|
|
const unit: ArtifactSubstat["unit"] = text.includes("%") ? "%" : "flat";
|
|
return {
|
|
key: canonicalStatName(stat, unit === "%" ? `${value}%` : `${value}`) || stat,
|
|
value,
|
|
unit,
|
|
};
|
|
}
|
|
|
|
function canonicalStatName(stat: string, value: string) {
|
|
const simple = simplify(stat);
|
|
if (simple === "crit rate") return "CRIT Rate";
|
|
if (simple === "crit dmg" || simple === "crit damage") return "CRIT DMG";
|
|
if (simple === "energy recharge") return "Energy Recharge";
|
|
if (simple === "elemental mastery") return "Elemental Mastery";
|
|
if (simple === "atk" && value.includes("%")) return "ATK%";
|
|
if (simple === "hp" && value.includes("%")) return "HP%";
|
|
if (simple === "def" && value.includes("%")) return "DEF%";
|
|
if (simple === "atk") return "ATK";
|
|
if (simple === "hp") return "HP";
|
|
if (simple === "def") return "DEF";
|
|
return statNames.find((name) => simplify(name) === simple) ?? "";
|
|
}
|
|
|
|
function inferLevel(record: StoredArtifactRecord) {
|
|
// Backward compatibility for older OCR records written before level
|
|
// persistence landed in the local store.
|
|
return record.source === "manual-scan" || record.source === "auto-scan" ? 20 : 0;
|
|
}
|
|
|
|
function toSource(value: string): ScanSource {
|
|
if (value === "manual-scan") return "manual";
|
|
if (value === "overlay") return "overlay";
|
|
if (value === "good_import") return "good_import";
|
|
return "screen";
|
|
}
|
|
|
|
function simplify(value: string) {
|
|
return value.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim();
|
|
}
|
|
|
|
function slug(value: string) {
|
|
return simplify(value).replace(/%/g, "percent").replace(/\s+/g, "_") || "unknown_set";
|
|
}
|