673 lines
26 KiB
TypeScript
673 lines
26 KiB
TypeScript
import type { CaptureResult } from "../types/global.js";
|
|
import {
|
|
allowedMainStatsForSlot,
|
|
canonicalStatName,
|
|
fixedMainStatBySlot,
|
|
globalMainStats,
|
|
globalSubstats,
|
|
knownCharacters,
|
|
knownPieceNames,
|
|
knownSets,
|
|
mainStatValueReferences,
|
|
normalizeCharacterAlias,
|
|
normalizePieceAlias,
|
|
normalizeSetAlias,
|
|
normalizeSlotAlias,
|
|
pieceToSet,
|
|
pieceToSlot,
|
|
slotNames,
|
|
textReplacements,
|
|
} from "./genshinData.js";
|
|
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
|
import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js";
|
|
import { implausibleSubstats } from "./substatRolls.js";
|
|
|
|
type MainStatValueReference = { stat: string; base: number; max: number };
|
|
|
|
export interface ParsedField {
|
|
value: string;
|
|
confidence: number;
|
|
source: "ocr" | "database" | "derived" | "fallback" | "missing";
|
|
}
|
|
|
|
export interface ParsedArtifactCandidate {
|
|
name: string;
|
|
slot: string;
|
|
level: number;
|
|
mainStat: string;
|
|
mainValue: string;
|
|
substats: string[];
|
|
setName: string;
|
|
equipped: string;
|
|
confidence: number;
|
|
notes: string[];
|
|
fields: {
|
|
name: ParsedField;
|
|
slot: ParsedField;
|
|
level?: ParsedField;
|
|
mainStat: ParsedField;
|
|
mainValue: ParsedField;
|
|
setName: ParsedField;
|
|
equipped: ParsedField;
|
|
substats: ParsedField;
|
|
};
|
|
}
|
|
|
|
const mainStatNames = sortLongestFirst(globalMainStats);
|
|
const substatNames = sortLongestFirst(globalSubstats);
|
|
|
|
export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArtifactCandidate | null {
|
|
if (!capture?.ocr?.length) return null;
|
|
|
|
const byId = new Map(
|
|
(capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]),
|
|
);
|
|
const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n"));
|
|
const nameText = byId.get("artifact-name") ?? "";
|
|
const slotOnlyText = byId.get("artifact-slot") ?? "";
|
|
const legacyTitleText = byId.get("artifact-title") ?? "";
|
|
const titleText = [nameText, slotOnlyText].filter(Boolean).join("\n") || legacyTitleText;
|
|
const mainLabelText = byId.get("artifact-main-stat-label") ?? "";
|
|
const mainValueText = byId.get("artifact-main-stat-value") ?? "";
|
|
const legacyMainText = byId.get("artifact-main-stat") ?? "";
|
|
const mainText = [mainLabelText, mainValueText].filter(Boolean).join("\n") || legacyMainText;
|
|
const levelText = byId.get("artifact-level") ?? "";
|
|
const substatText = byId.get("artifact-substats") ?? "";
|
|
const setText = byId.get("artifact-set-effects") ?? "";
|
|
const footerText = byId.get("artifact-footer") ?? "";
|
|
|
|
const nameField = parseArtifactName(titleText);
|
|
const slotField = parseSlot([slotOnlyText, titleText, allText].filter(Boolean).join("\n"), nameField);
|
|
const levelField = parseArtifactLevel([levelText, substatText, mainText, allText].filter(Boolean).join("\n"));
|
|
const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
|
|
const level = parsedLevel ?? 0;
|
|
let mainStatField = inferMainStat(slotField.value, mainText);
|
|
mainStatField = promoteSlotPercentMainStat(slotField.value, mainStatField);
|
|
let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
|
if (!mainStatField.value && mainValueField.value) {
|
|
const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel);
|
|
if (inferredFromValue.value) {
|
|
mainStatField = inferredFromValue;
|
|
mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
|
}
|
|
}
|
|
if (!mainStatField.value && mainValueField.value) {
|
|
const exactReferenceMatch = deriveMainStatFromExactReferenceValue(slotField.value, mainValueField.value, mainText, parsedLevel);
|
|
if (exactReferenceMatch.value) {
|
|
mainStatField = exactReferenceMatch;
|
|
mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
|
}
|
|
}
|
|
if ((!mainStatField.value || !mainValueField.value) && slotField.value) {
|
|
const noisyReferenceMatch = deriveMainStatAndValueFromNoisyReference(slotField.value, mainText, parsedLevel);
|
|
if (!mainStatField.value && noisyReferenceMatch.mainStat.value) {
|
|
mainStatField = noisyReferenceMatch.mainStat;
|
|
}
|
|
if (!mainValueField.value && noisyReferenceMatch.mainValue.value) {
|
|
mainValueField = noisyReferenceMatch.mainValue;
|
|
}
|
|
}
|
|
const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"));
|
|
const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing");
|
|
const setField = parseSetName(setText, nameField);
|
|
const equippedField = parseEquippedCharacter(footerText + "\n" + allText);
|
|
const notes: string[] = [];
|
|
|
|
if (!nameField.value) notes.push("Artifact name not confidently parsed.");
|
|
if (nameField.value && nameField.confidence < 84) notes.push("Artifact name was fuzzy-matched; review if this piece matters.");
|
|
if (!slotField.value) notes.push("Slot not confidently parsed.");
|
|
if (!levelField.value) notes.push("Artifact level not confidently parsed.");
|
|
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
|
|
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
|
|
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
|
|
const implausible = implausibleSubstats(substats);
|
|
if (implausible.length) notes.push(`Substat value has no valid roll combination (likely OCR misread): ${implausible.join(", ")}.`);
|
|
if (!setField.value) notes.push("Set name not confidently parsed.");
|
|
|
|
for (const [label, parsedField] of Object.entries({
|
|
name: nameField,
|
|
slot: slotField,
|
|
level: levelField,
|
|
mainStat: mainStatField,
|
|
mainValue: mainValueField,
|
|
set: setField,
|
|
equipped: equippedField,
|
|
}) as Array<[string, ParsedField]>) {
|
|
if (parsedField.value && parsedField.confidence < 70) notes.push(`${label} confidence is low; review before trusting it.`);
|
|
}
|
|
|
|
const fields = {
|
|
name: nameField,
|
|
slot: slotField,
|
|
level: levelField,
|
|
mainStat: mainStatField,
|
|
mainValue: mainValueField,
|
|
setName: setField,
|
|
equipped: equippedField,
|
|
substats: substatsField,
|
|
};
|
|
const confidence = Math.round(Object.values(fields).reduce((sum, parsedField) => sum + parsedField.confidence, 0) / Object.values(fields).length);
|
|
|
|
return {
|
|
name: nameField.value || "Unknown artifact",
|
|
slot: slotField.value || "Unknown slot",
|
|
level,
|
|
mainStat: mainStatField.value || "Unknown main stat",
|
|
mainValue: mainValueField.value || "?",
|
|
substats,
|
|
setName: setField.value || "Unknown set",
|
|
equipped: equippedField.value || "Not detected",
|
|
confidence,
|
|
notes: [...new Set(notes)],
|
|
fields,
|
|
};
|
|
}
|
|
|
|
function parseArtifactName(titleText: string): ParsedField {
|
|
const titleLines = titleText
|
|
.split("\n")
|
|
.map((line) => cleanupOcrLabel(line))
|
|
.filter(Boolean);
|
|
|
|
for (const line of titleLines) {
|
|
const match = matchPiece(line);
|
|
if (match.value) {
|
|
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
|
}
|
|
const alias = normalizePieceAlias(line);
|
|
if (alias) return field(alias, 96, "database");
|
|
}
|
|
|
|
const partialPiece = derivePieceFromDistinctivePartialName(titleText);
|
|
if (partialPiece) return field(partialPiece, 72, "fallback");
|
|
|
|
const knownPiece = fuzzyFindKnown(titleText, knownPieceNames, 0.72);
|
|
if (knownPiece) return field(knownPiece.value, Math.round(knownPiece.score * 100), knownPiece.score >= 0.98 ? "database" : "fallback");
|
|
|
|
const fallback = firstUsefulLine(titleText, slotNames);
|
|
return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing");
|
|
}
|
|
|
|
function parseSlot(text: string, artifactName: ParsedField): ParsedField {
|
|
const slotLines = text
|
|
.split("\n")
|
|
.map((line) => cleanupOcrLabel(line))
|
|
.filter(Boolean);
|
|
|
|
for (const line of slotLines) {
|
|
const match = matchSlot(line);
|
|
if (match.value) {
|
|
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "ocr");
|
|
}
|
|
const alias = normalizeSlotAlias(line);
|
|
if (alias) return field(alias, 96, "ocr");
|
|
}
|
|
|
|
const directSlot = fuzzyFindKnown(text, slotNames, 0.68);
|
|
if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback");
|
|
|
|
const derivedSlot = artifactName.value ? pieceToSlot.get(artifactName.value) ?? "" : "";
|
|
return derivedSlot ? field(derivedSlot, derivedConfidence(artifactName, 94), "derived") : field("", 0, "missing");
|
|
}
|
|
|
|
function parseSetName(setText: string, artifactName: ParsedField): ParsedField {
|
|
const setFromPiece = artifactName.value ? pieceToSet.get(artifactName.value) : undefined;
|
|
const candidateLines = setText
|
|
.split("\n")
|
|
.map((line) => line.trim().replace(/:$/, ""))
|
|
.filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line));
|
|
|
|
for (const line of candidateLines) {
|
|
const match = matchSet(line);
|
|
if (match.value) {
|
|
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
|
}
|
|
const alias = normalizeSetAlias(line);
|
|
if (alias) return field(alias, 96, "database");
|
|
}
|
|
|
|
const directLine = candidateLines.find((line) => line.length > 8);
|
|
|
|
const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64);
|
|
if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback");
|
|
if (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived");
|
|
const partialSetFromPiece = deriveSetFromPartialPieceName(artifactName.value);
|
|
if (partialSetFromPiece) return field(partialSetFromPiece, 72, "derived");
|
|
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
|
|
}
|
|
|
|
function derivedConfidence(sourceField: ParsedField, maxConfidence: number) {
|
|
if (sourceField.source === "database" || sourceField.confidence >= maxConfidence) return maxConfidence;
|
|
return Math.max(45, Math.min(maxConfidence, sourceField.confidence));
|
|
}
|
|
|
|
function parseArtifactLevel(text: string): ParsedField {
|
|
const lines = normalizeText(text)
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^\+\s*(20|1[0-9]|[0-9])\b/);
|
|
if (match?.[1]) return field(match[1], 96, "ocr");
|
|
}
|
|
|
|
const anywhere = normalizeText(text).match(/(?:^|\s)\+\s*(20|1[0-9]|[0-9])\b/);
|
|
return anywhere?.[1] ? field(anywhere[1], 84, "ocr") : field("", 0, "missing");
|
|
}
|
|
|
|
function normalizeText(text: string) {
|
|
return applyTextReplacements(text)
|
|
.replace(/[\u201c\u201d]/g, '"')
|
|
.replace(/[\u2019]/g, "'")
|
|
.replace(/[\u00B7]/g, ".")
|
|
.replace(/\r/g, "")
|
|
.replace(/[|]/g, "I")
|
|
.replace(/\s+\n/g, "\n")
|
|
.trim();
|
|
}
|
|
|
|
function applyTextReplacements(text: string) {
|
|
return Object.entries(textReplacements as Record<string, string>).reduce(
|
|
(current, [from, to]) => current.replace(new RegExp(escapeRegex(from), "gi"), to),
|
|
text,
|
|
);
|
|
}
|
|
|
|
function firstUsefulLine(text: string, rejectIncludes: string[]) {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? "";
|
|
}
|
|
|
|
function deriveSetFromPartialPieceName(text: string) {
|
|
const words = cleanupOcrLabel(text)
|
|
.split(/\s+/)
|
|
.map((word) => simplifyForMatch(word))
|
|
.filter((word) => word.length >= 5);
|
|
if (words.length < 2) return "";
|
|
|
|
const candidates = knownPieceNames.filter((piece) => {
|
|
const normalizedPiece = simplifyForMatch(piece);
|
|
const hits = words.filter((word) => normalizedPiece.includes(word)).length;
|
|
return hits >= 2;
|
|
});
|
|
const sets = [...new Set(candidates.map((piece) => pieceToSet.get(piece)).filter((set): set is string => Boolean(set)))];
|
|
return sets.length === 1 ? sets[0] : "";
|
|
}
|
|
|
|
function derivePieceFromDistinctivePartialName(text: string) {
|
|
const words = cleanupOcrLabel(text)
|
|
.split(/\s+/)
|
|
.map((word) => simplifyForMatch(word))
|
|
.filter((word) => word.length >= 8);
|
|
if (words.length === 0) return "";
|
|
|
|
const candidates = knownPieceNames.filter((piece) => {
|
|
const normalizedPiece = simplifyForMatch(piece);
|
|
return words.some((word) => normalizedPiece.includes(word));
|
|
});
|
|
return candidates.length === 1 ? candidates[0] : "";
|
|
}
|
|
|
|
function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField {
|
|
const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0");
|
|
const percentValue = extractPercentValue(cleaned);
|
|
let ocrField = field("", 0, "missing");
|
|
if (percentValue && !mainStat) ocrField = field(percentValue, 84, "ocr");
|
|
if (percentValue && isPercentMainStat(mainStat)) ocrField = field(percentValue, 96, "ocr");
|
|
const flat = /\b([0-9]{1,2},[0-9]{3}|[0-9]{2,4})\b/.exec(cleaned);
|
|
if (!ocrField.value && flat) ocrField = field(flat[1], 92, "ocr");
|
|
|
|
const derivedField = deriveMainValueFromLevel(slot, mainStat, level);
|
|
if (!ocrField.value) return derivedField;
|
|
if (!derivedField.value) return ocrField;
|
|
|
|
if (["HP", "ATK", "DEF", "Elemental Mastery"].includes(mainStat)) {
|
|
const ocrNumeric = parseNumericValue(ocrField.value);
|
|
const derivedNumeric = parseNumericValue(derivedField.value);
|
|
const derivedIntDigits = String(Math.round(derivedNumeric)).length;
|
|
const ocrIntDigits = String(Math.round(ocrNumeric)).length;
|
|
if (!Number.isFinite(ocrNumeric) || ocrNumeric < derivedNumeric * 0.5 || ocrIntDigits + 1 < derivedIntDigits) return derivedField;
|
|
if (Math.abs(ocrNumeric - derivedNumeric) >= Math.max(4, derivedNumeric * 0.22)) return derivedField;
|
|
}
|
|
|
|
return ocrField;
|
|
}
|
|
|
|
function extractPercentValue(text: string) {
|
|
const percentPattern = /([0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?)\s*%/;
|
|
const lineMatches = text
|
|
.split("\n")
|
|
.map((line) => line.match(percentPattern))
|
|
.filter((match): match is RegExpMatchArray => Boolean(match));
|
|
|
|
const preferred = lineMatches[0] ?? text.match(percentPattern);
|
|
if (!preferred?.[1]) return "";
|
|
return `${normalizeMainValue(preferred[1])}%`;
|
|
}
|
|
|
|
function inferMainStat(slot: string, text: string): ParsedField {
|
|
if (fixedMainStatBySlot[slot]) return field(fixedMainStatBySlot[slot], 100, "derived");
|
|
|
|
const direct = findDirectMainStat(text);
|
|
if (direct) return field(promotePercentVariant(direct, text), 94, "ocr");
|
|
|
|
const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot));
|
|
const lookup = matchStat(text);
|
|
if (lookup.value && allowedForSlot.includes(lookup.value)) {
|
|
return field(promotePercentVariant(lookup.value, text), lookup.confidence, lookup.source === "fuzzy" ? "fallback" : "ocr");
|
|
}
|
|
const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68);
|
|
if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback");
|
|
|
|
const fuzzy = fuzzyFindKnown(text, mainStatNames, 0.72);
|
|
return fuzzy ? field(promotePercentVariant(fuzzy.value, text), Math.round(fuzzy.score * 100), "fallback") : field("", 0, "missing");
|
|
}
|
|
|
|
function findDirectMainStat(text: string) {
|
|
const compact = simplifyForMatch(text);
|
|
const hasPercentValue = /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text);
|
|
const priority = [
|
|
"Physical DMG Bonus",
|
|
"Elemental Mastery",
|
|
"Energy Recharge",
|
|
"Healing Bonus",
|
|
"Hydro DMG Bonus",
|
|
"Pyro DMG Bonus",
|
|
"Electro DMG Bonus",
|
|
"Cryo DMG Bonus",
|
|
"Dendro DMG Bonus",
|
|
"Anemo DMG Bonus",
|
|
"Geo DMG Bonus",
|
|
"CRIT Rate",
|
|
"CRIT DMG",
|
|
"ATK%",
|
|
"HP%",
|
|
"DEF%",
|
|
"ATK",
|
|
"HP",
|
|
"DEF",
|
|
];
|
|
|
|
const direct = priority.find((stat) => compact.includes(simplifyForMatch(stat))) ?? "";
|
|
if (direct) return direct;
|
|
|
|
if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%";
|
|
if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%";
|
|
if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%";
|
|
if (hasPercentValue && /(^|\s)dlt(\s|$)/i.test(text)) return "DEF%";
|
|
|
|
return "";
|
|
}
|
|
|
|
function inferMainStatFromValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField {
|
|
const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", ""));
|
|
if (!Number.isFinite(numeric)) return field("", 0, "missing");
|
|
|
|
const candidates = getSlotMainStatValueReferences(slot)
|
|
.map((candidate) => {
|
|
const expected = expectedMainStatValue(candidate, level);
|
|
return {
|
|
...candidate,
|
|
expected,
|
|
delta: Math.abs(expected - numeric),
|
|
};
|
|
})
|
|
.sort((left, right) => left.delta - right.delta);
|
|
|
|
const best = candidates[0];
|
|
const tolerance = toleranceForMainStatValue(best?.stat ?? "", mainValue);
|
|
const competing = candidates.filter((candidate) => candidate.delta <= tolerance);
|
|
if (best && competing.length === 1) {
|
|
return field(promotePercentVariant(best.stat, text), Math.max(72, Math.round(92 - best.delta * 24)), "derived");
|
|
}
|
|
|
|
return field("", 0, "missing");
|
|
}
|
|
|
|
function deriveMainStatFromExactReferenceValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField {
|
|
const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", ""));
|
|
if (!Number.isFinite(numeric)) return field("", 0, "missing");
|
|
|
|
const matches = getSlotMainStatValueReferences(slot)
|
|
.filter((candidate) => Math.abs(expectedMainStatValue(candidate, level) - numeric) <= toleranceForMainStatValue(candidate.stat, mainValue))
|
|
.sort((left, right) => Math.abs(expectedMainStatValue(left, level) - numeric) - Math.abs(expectedMainStatValue(right, level) - numeric));
|
|
|
|
if (matches.length !== 1) return field("", 0, "missing");
|
|
return field(promotePercentVariant(matches[0].stat, text || mainValue), 88, "derived");
|
|
}
|
|
|
|
function deriveMainStatAndValueFromNoisyReference(slot: string, text: string, level: number | null) {
|
|
const fragment = text.replace(/[^\d]/g, "");
|
|
if (fragment.length < 2) {
|
|
return {
|
|
mainStat: field("", 0, "missing"),
|
|
mainValue: field("", 0, "missing"),
|
|
};
|
|
}
|
|
|
|
const candidates = getSlotMainStatValueReferences(slot)
|
|
.map((candidate) => {
|
|
const formattedValue = formatExpectedValue(candidate, level);
|
|
const digits = formattedValue.replace(/[^\d]/g, "");
|
|
return {
|
|
candidate,
|
|
formattedValue,
|
|
score: digitReferenceScore(fragment, digits),
|
|
};
|
|
})
|
|
.filter((entry) => entry.score > 0)
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
const best = candidates[0];
|
|
const second = candidates[1];
|
|
if (!best) {
|
|
return {
|
|
mainStat: field("", 0, "missing"),
|
|
mainValue: field("", 0, "missing"),
|
|
};
|
|
}
|
|
if (second && best.score - second.score < 0.2) {
|
|
return {
|
|
mainStat: field("", 0, "missing"),
|
|
mainValue: field("", 0, "missing"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
mainStat: field(promotePercentVariant(best.candidate.stat, text), Math.round(72 + best.score * 18), "derived"),
|
|
mainValue: field(best.formattedValue, Math.round(78 + best.score * 14), "derived"),
|
|
};
|
|
}
|
|
|
|
function parseSubstats(text: string) {
|
|
const normalized = normalizeText(text)
|
|
.replace(/CRIT\s*DMG/gi, "CRIT DMG")
|
|
.replace(/CRIT\s*Rate/gi, "CRIT Rate")
|
|
.replace(/Energy\s*Recharge/gi, "Energy Recharge")
|
|
.replace(/Elemental\s*Mastery/gi, "Elemental Mastery")
|
|
.replace(/([A-Z]{2,4})\s*\+/g, "$1+");
|
|
|
|
const statPattern = new RegExp(`(${substatNames.map(escapeRegex).join("|")})\\s*\\+\\s*([0-9]+(?:\\.[0-9])?%?)`, "gi");
|
|
const results: string[] = [];
|
|
let match: RegExpExecArray | null;
|
|
while ((match = statPattern.exec(normalized))) {
|
|
const stat = canonicalSubstatName(match[1], match[2]);
|
|
results.push(`${stat}+${match[2]}`);
|
|
}
|
|
return [...new Set(results)].slice(0, 4);
|
|
}
|
|
|
|
function leadingSetEffectText(text: string) {
|
|
const lines = normalizeText(text).split("\n");
|
|
const result: string[] = [];
|
|
for (const line of lines) {
|
|
if (/^\s*\d+\s*-\s*Piece Set/i.test(line) || /piece set/i.test(line)) break;
|
|
result.push(line);
|
|
}
|
|
return result.join("\n");
|
|
}
|
|
|
|
function canonicalSubstatName(rawStat: string, rawValue: string) {
|
|
const cleaned = rawStat.replace(/\s+/g, " ").trim();
|
|
const known = canonicalStatName(cleaned) || fuzzyFindKnown(cleaned, substatNames, 0.8)?.value || cleaned;
|
|
const canonical = canonicalStatName(known);
|
|
if (["ATK", "HP", "DEF"].includes(canonical) && rawValue.includes("%")) return `${canonical}%`;
|
|
return canonical;
|
|
}
|
|
|
|
function parseEquippedCharacter(text: string): ParsedField {
|
|
const equippedLine = text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find((line) => /equipped/i.test(line));
|
|
|
|
if (!equippedLine) return field("Not detected", 45, "missing");
|
|
|
|
const afterLabel = cleanupCharacterNoise(equippedLine);
|
|
const match = afterLabel ? matchCharacter(afterLabel) : null;
|
|
if (match?.value) {
|
|
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "database");
|
|
}
|
|
const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : "";
|
|
if (alias) return field(alias, 96, "database");
|
|
const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null;
|
|
if (known) return field(known.value, Math.round(known.score * 100), known.score >= 0.95 ? "ocr" : "fallback");
|
|
|
|
const fallbackSearch = cleanupCharacterNoise(text);
|
|
const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null;
|
|
if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback");
|
|
|
|
return field("Not detected", 45, "missing");
|
|
}
|
|
|
|
function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField {
|
|
return { value, confidence: Math.max(0, Math.min(100, confidence)), source };
|
|
}
|
|
|
|
function isPercentMainStat(stat: string) {
|
|
return /%|Rate|DMG|Bonus|Recharge/i.test(stat);
|
|
}
|
|
|
|
function promotePercentVariant(stat: string, text: string) {
|
|
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text)) return `${stat}%`;
|
|
return stat;
|
|
}
|
|
|
|
function promoteSlotPercentMainStat(slot: string, mainStat: ParsedField): ParsedField {
|
|
if (!["ATK", "HP", "DEF"].includes(mainStat.value)) return mainStat;
|
|
const references = getSlotMainStatValueReferences(slot);
|
|
const hasFlat = references.some((candidate) => candidate.stat === mainStat.value);
|
|
const hasPercent = references.some((candidate) => candidate.stat === `${mainStat.value}%`);
|
|
if (hasFlat || !hasPercent) return mainStat;
|
|
return {
|
|
...mainStat,
|
|
value: `${mainStat.value}%`,
|
|
confidence: Math.max(mainStat.confidence, 90),
|
|
source: "derived",
|
|
};
|
|
}
|
|
|
|
function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] {
|
|
const valueReferences = mainStatValueReferences[slot];
|
|
return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : [];
|
|
}
|
|
|
|
function normalizeMainValue(value: string) {
|
|
return value.replace(/[:,\u00B7]/g, ".").replace(/\s+/g, "");
|
|
}
|
|
|
|
function deriveMainValueFromLevel(slot: string, mainStat: string, level: number | null): ParsedField {
|
|
if (!mainStat) return field("", 0, "missing");
|
|
const reference = getSlotMainStatValueReferences(slot).find((candidate) => candidate.stat === mainStat);
|
|
if (!reference) return field("", 0, "missing");
|
|
if (level === null) {
|
|
if (slot === "Flower of Life" || slot === "Plume of Death") return field(formatExpectedValue(reference, null), 72, "derived");
|
|
return field("", 0, "missing");
|
|
}
|
|
if (level < 0 || level > 20) return field("", 0, "missing");
|
|
return field(formatExpectedValue(reference, level), 88, "derived");
|
|
}
|
|
|
|
function expectedMainStatValue(reference: { base: number; max: number }, level: number | null) {
|
|
if (level === null || !Number.isFinite(level)) return reference.max;
|
|
const clampedLevel = Math.max(0, Math.min(20, level));
|
|
return reference.base + (reference.max - reference.base) * (clampedLevel / 20);
|
|
}
|
|
|
|
function formatExpectedValue(reference: { stat: string; base: number; max: number }, level: number | null) {
|
|
const numeric = expectedMainStatValue(reference, level);
|
|
const rounded = isPercentMainStat(reference.stat)
|
|
? roundTo(numeric, 1)
|
|
: Math.round(numeric);
|
|
return isPercentMainStat(reference.stat) ? `${rounded.toFixed(1)}%` : rounded.toLocaleString("en-US");
|
|
}
|
|
|
|
function toleranceForMainStatValue(stat: string, mainValue: string) {
|
|
if (mainValue.includes("%") || isPercentMainStat(stat)) return 0.45;
|
|
if (stat === "Elemental Mastery") return 2.5;
|
|
return 6;
|
|
}
|
|
|
|
function parseNumericValue(value: string) {
|
|
return Number.parseFloat(
|
|
value
|
|
.replace(/,/g, "")
|
|
.replace("%", "")
|
|
.trim(),
|
|
);
|
|
}
|
|
|
|
function roundTo(value: number, digits: number) {
|
|
const factor = 10 ** digits;
|
|
return Math.round(value * factor) / factor;
|
|
}
|
|
|
|
function digitReferenceScore(fragment: string, referenceDigits: string) {
|
|
if (!fragment || !referenceDigits) return 0;
|
|
if (fragment === referenceDigits) return 1;
|
|
if (referenceDigits.startsWith(fragment)) {
|
|
return Math.max(0, 0.95 - (referenceDigits.length - fragment.length) * 0.08);
|
|
}
|
|
if (fragment.length >= 3 && isDigitSubsequence(fragment, referenceDigits)) {
|
|
return 0.72;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function isDigitSubsequence(fragment: string, referenceDigits: string) {
|
|
let index = 0;
|
|
for (const char of referenceDigits) {
|
|
if (char === fragment[index]) index++;
|
|
if (index >= fragment.length) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cleanupOcrLabel(line: string) {
|
|
return line
|
|
.replace(/^[^A-Za-z]+/, "")
|
|
.replace(/[^A-Za-z'\s]+$/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function cleanupCharacterNoise(text: string) {
|
|
return text
|
|
.replace(/^.*?equipped\s*:?\s*/i, "")
|
|
.replace(/^(?:by|to)\s+/i, "")
|
|
.replace(/[^A-Za-z'\-\s]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function sortLongestFirst(values: string[]) {
|
|
return [...values].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
function escapeRegex(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|