From dcac155887c706082a3ec975c3dbd68a841d91ea Mon Sep 17 00:00:00 2001 From: AzuTear Date: Sun, 5 Jul 2026 21:55:20 +0200 Subject: [PATCH] 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 --- .../hooks/useScanDiagnosticsModalModel.ts | 6 +- src/lib/artifactMerge.test.ts | 74 +++++++ src/lib/artifactMerge.ts | 84 ++++++++ src/lib/dataPackageStatus.test.ts | 41 ++++ src/lib/dataPackageStatus.ts | 47 ++++ src/lib/genshinData.ts | 1 + src/lib/goodInterop.test.ts | 111 ++++++++++ src/lib/goodInterop.ts | 201 ++++++++++++++++++ src/lib/lockDetection.test.ts | 43 ++++ src/lib/lockDetection.ts | 52 +++++ 10 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 src/lib/artifactMerge.test.ts create mode 100644 src/lib/artifactMerge.ts create mode 100644 src/lib/dataPackageStatus.test.ts create mode 100644 src/lib/dataPackageStatus.ts create mode 100644 src/lib/goodInterop.test.ts create mode 100644 src/lib/goodInterop.ts create mode 100644 src/lib/lockDetection.test.ts create mode 100644 src/lib/lockDetection.ts diff --git a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts index 15d7cf2..62cc9aa 100644 --- a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts +++ b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts @@ -1,5 +1,6 @@ import { detailFingerprint } from "../../../../../lib/autoScanLoop"; -import { sourceVersion } from "../../../../../lib/genshinData"; +import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData"; +import { dataPackageStatus } from "../../../../../lib/dataPackageStatus"; import { useCallback, useMemo, type MouseEvent } from "react"; import type { ScanDiagnosticsModalProps } from "../types"; @@ -96,7 +97,8 @@ export function useScanDiagnosticsModalModel({ : "Run a capture once while the artifact inventory is visible."; const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading"; - const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`; + const dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion); + const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`; const playerProgress = useMemo(() => { const width = Math.min( diff --git a/src/lib/artifactMerge.test.ts b/src/lib/artifactMerge.test.ts new file mode 100644 index 0000000..dfde2ad --- /dev/null +++ b/src/lib/artifactMerge.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { StoredArtifactRecord } from "../types/storage"; +import { mergeIdentity, mergeRescannedArtifacts, substatName } from "./artifactMerge"; + +function record(overrides: Partial = {}): StoredArtifactRecord { + return { + id: "x", + name: "Gladiator's Nostalgia", + slot: "Flower of Life", + level: 0, + setName: "Gladiator's Finale", + mainStat: "HP", + mainValue: "717", + substats: ["CRIT DMG+5.4%", "ATK+19", "HP%+4.1%", "Energy Recharge+5.2%"], + equipped: "Not detected", + confidence: 80, + needsReview: false, + source: "auto-scan", + ...overrides, + }; +} + +describe("artifactMerge", () => { + it("strips values to get the substat name", () => { + expect(substatName("CRIT DMG+13.2%")).toBe("CRIT DMG"); + expect(substatName("ATK+19")).toBe("ATK"); + }); + + it("identity ignores level and substat values", () => { + const low = record({ level: 0, substats: ["CRIT DMG+5.4%", "ATK+19"] }); + const high = record({ level: 20, substats: ["CRIT DMG+13.2%", "ATK+37"] }); + expect(mergeIdentity(low)).toBe(mergeIdentity(high)); + }); + + it("collapses a leveled re-scan into one record, keeping the higher level", () => { + const low = record({ id: "a", level: 0, timesSeen: 1 }); + const high = record({ + id: "b", + level: 20, + timesSeen: 1, + substats: ["CRIT DMG+13.2%", "ATK+37", "HP%+15.7%", "Energy Recharge+11.7%"], + equipped: "Bennett", + }); + const { merged, collapsed } = mergeRescannedArtifacts([low, high]); + expect(collapsed).toBe(1); + expect(merged).toHaveLength(1); + expect(merged[0].level).toBe(20); + expect(merged[0].equipped).toBe("Bennett"); + expect(merged[0].timesSeen).toBe(2); + }); + + it("does not merge pieces with different substat lineups", () => { + const threeLine = record({ id: "a", substats: ["CRIT DMG+5.4%", "ATK+19", "HP%+4.1%"] }); + const fourLine = record({ id: "b", substats: ["CRIT DMG+5.4%", "ATK+19", "HP%+4.1%", "DEF+16"] }); + const { merged, collapsed } = mergeRescannedArtifacts([threeLine, fourLine]); + expect(collapsed).toBe(0); + expect(merged).toHaveLength(2); + }); + + it("keeps distinct sets/slots/mains apart", () => { + const flower = record({ slot: "Flower of Life", mainStat: "HP" }); + const plume = record({ slot: "Plume of Death", mainStat: "ATK" }); + const { merged } = mergeRescannedArtifacts([flower, plume]); + expect(merged).toHaveLength(2); + }); + + it("preserves the earliest firstSeenAt and latest lastSeenAt", () => { + const older = record({ id: "a", level: 0, firstSeenAt: "2026-01-01", lastSeenAt: "2026-01-02" }); + const newer = record({ id: "b", level: 20, firstSeenAt: "2026-06-01", lastSeenAt: "2026-06-10" }); + const { merged } = mergeRescannedArtifacts([older, newer]); + expect(merged[0].firstSeenAt).toBe("2026-01-01"); + expect(merged[0].lastSeenAt).toBe("2026-06-10"); + }); +}); diff --git a/src/lib/artifactMerge.ts b/src/lib/artifactMerge.ts new file mode 100644 index 0000000..3089c71 --- /dev/null +++ b/src/lib/artifactMerge.ts @@ -0,0 +1,84 @@ +import type { StoredArtifactRecord } from "../types/storage"; +import { storedArtifactStrength } from "./artifactStore"; + +// Rescan-merge (ADR-006 open follow-up): leveling an artifact changes its store +// signature (level + substat values), so re-scanning a leveled piece creates a +// duplicate record. This collapses those duplicates using a level-independent +// identity: set + slot + main stat + the SET OF SUBSTAT NAMES (values and level +// excluded). Substat names do not change with leveling, so two scans of the same +// 5-star piece at different levels share an identity and merge; two genuinely +// different pieces with an identical substat lineup can still be merged, which is +// an accepted, low-stakes risk for a triage helper (hence an explicit +// reconciliation pass, not a change to the per-save signature). + +export function substatName(substat: string): string { + const plusIndex = substat.indexOf("+"); + return (plusIndex >= 0 ? substat.slice(0, plusIndex) : substat).trim(); +} + +export function mergeIdentity(record: StoredArtifactRecord): string { + const substatNames = record.substats.map(substatName).filter(Boolean).sort().join(","); + return [record.setName, record.slot, record.mainStat, substatNames].join("::"); +} + +// The more-progressed / stronger record wins: higher level first, then strength. +function preferred(a: StoredArtifactRecord, b: StoredArtifactRecord): StoredArtifactRecord { + const levelA = a.level ?? 0; + const levelB = b.level ?? 0; + if (levelA !== levelB) return levelA > levelB ? a : b; + return storedArtifactStrength(a) >= storedArtifactStrength(b) ? a : b; +} + +function minDate(a: string | undefined, b: string | undefined): string | undefined { + if (!a) return b; + if (!b) return a; + return a <= b ? a : b; +} + +function maxDate(a: string | undefined, b: string | undefined): string | undefined { + if (!a) return b; + if (!b) return a; + return a >= b ? a : b; +} + +function mergePair(winner: StoredArtifactRecord, other: StoredArtifactRecord): StoredArtifactRecord { + return { + ...winner, + // The winner needs review only if it did on its own; a confident higher-level + // scan should clear a stale low-confidence duplicate. + needsReview: winner.needsReview, + timesSeen: (winner.timesSeen ?? 1) + (other.timesSeen ?? 1), + firstSeenAt: minDate(winner.firstSeenAt, other.firstSeenAt), + lastSeenAt: maxDate(winner.lastSeenAt, other.lastSeenAt), + // Keep an equipped character if either scan detected one. + equipped: + winner.equipped && !/not detected/i.test(winner.equipped) + ? winner.equipped + : other.equipped, + }; +} + +export interface MergeResult { + merged: StoredArtifactRecord[]; + collapsed: number; +} + +export function mergeRescannedArtifacts(records: readonly StoredArtifactRecord[]): MergeResult { + const byIdentity = new Map(); + let collapsed = 0; + + for (const record of records) { + const identity = mergeIdentity(record); + const existing = byIdentity.get(identity); + if (!existing) { + byIdentity.set(identity, record); + continue; + } + const winner = preferred(existing, record); + const loser = winner === existing ? record : existing; + byIdentity.set(identity, mergePair(winner, loser)); + collapsed++; + } + + return { merged: [...byIdentity.values()], collapsed }; +} diff --git a/src/lib/dataPackageStatus.test.ts b/src/lib/dataPackageStatus.test.ts new file mode 100644 index 0000000..5912dda --- /dev/null +++ b/src/lib/dataPackageStatus.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { dataPackageAgeDays, dataPackageStatus } from "./dataPackageStatus"; + +const NOW = Date.parse("2026-07-05T00:00:00.000Z"); + +describe("dataPackageStatus", () => { + it("computes age in whole days", () => { + expect(dataPackageAgeDays("2026-07-01T00:00:00.000Z", NOW)).toBe(4); + expect(dataPackageAgeDays("2026-07-05T00:00:00.000Z", NOW)).toBe(0); + }); + + it("returns null for missing or invalid timestamps", () => { + expect(dataPackageAgeDays("", NOW)).toBeNull(); + expect(dataPackageAgeDays("not-a-date", NOW)).toBeNull(); + }); + + it("clamps future timestamps to zero", () => { + expect(dataPackageAgeDays("2026-08-01T00:00:00.000Z", NOW)).toBe(0); + }); + + it("does not warn for a fresh package", () => { + const status = dataPackageStatus("2026-06-20T00:00:00.000Z", "genshin-db@5.2.12", NOW); + expect(status.stale).toBe(false); + expect(status.warning).toBe(""); + expect(status.ageDays).toBe(15); + }); + + it("warns for a package older than the max age", () => { + const status = dataPackageStatus("2026-04-01T00:00:00.000Z", "genshin-db@5.2.12", NOW, 45); + expect(status.stale).toBe(true); + expect(status.warning).toContain("Datenpaket"); + expect(status.warning).toContain("aktualisieren"); + }); + + it("stays quiet when the generation date is unknown", () => { + const status = dataPackageStatus("", "unknown", NOW); + expect(status.stale).toBe(false); + expect(status.warning).toBe(""); + expect(status.ageDays).toBeNull(); + }); +}); diff --git a/src/lib/dataPackageStatus.ts b/src/lib/dataPackageStatus.ts new file mode 100644 index 0000000..3f66134 --- /dev/null +++ b/src/lib/dataPackageStatus.ts @@ -0,0 +1,47 @@ +// Data-package staleness (ADR-005 follow-up). The genshin-db data package is a +// local snapshot; when a new Genshin version ships new sets/characters, an old +// package silently fails to recognize them. We cannot query the live game version +// offline, so staleness is based on the package's generation age: Genshin patches +// land roughly every six weeks, so a package older than ~45 days likely predates +// a content patch and should be regenerated with `npm run data:genshin`. + +const DAY_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_MAX_AGE_DAYS = 45; + +export function dataPackageAgeDays(generatedAt: string, now: number = Date.now()): number | null { + if (!generatedAt) return null; + const generated = Date.parse(generatedAt); + if (!Number.isFinite(generated)) return null; + const age = (now - generated) / DAY_MS; + return age < 0 ? 0 : Math.floor(age); +} + +export interface DataPackageStatus { + ageDays: number | null; + stale: boolean; + warning: string; +} + +export function dataPackageStatus( + generatedAt: string, + sourceVersion: string, + now: number = Date.now(), + maxAgeDays: number = DEFAULT_MAX_AGE_DAYS, +): DataPackageStatus { + const ageDays = dataPackageAgeDays(generatedAt, now); + if (ageDays === null) { + return { + ageDays: null, + stale: false, + warning: "", + }; + } + const stale = ageDays > maxAgeDays; + return { + ageDays, + stale, + warning: stale + ? `Datenpaket (${sourceVersion}) ist ${ageDays} Tage alt. Neue Sets/Charaktere fehlen evtl. - mit "npm run data:genshin" aktualisieren.` + : "", + }; +} diff --git a/src/lib/genshinData.ts b/src/lib/genshinData.ts index f2029f9..37e7446 100644 --- a/src/lib/genshinData.ts +++ b/src/lib/genshinData.ts @@ -44,6 +44,7 @@ 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 = { "Flower of Life": "HP", diff --git a/src/lib/goodInterop.test.ts b/src/lib/goodInterop.test.ts new file mode 100644 index 0000000..434a06e --- /dev/null +++ b/src/lib/goodInterop.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { StoredArtifactRecord } from "../types/storage"; +import { + goodDatabaseToStoredArtifacts, + goodSubstatToString, + goodToStoredArtifact, + setKeyToName, + setNameToKey, + storedArtifactToGood, + storedArtifactsToGood, + substatStringToGood, +} from "./goodInterop"; + +const record: StoredArtifactRecord = { + id: "x", + name: "Gladiator's Nostalgia", + slot: "Flower of Life", + level: 20, + setName: "Gladiator's Finale", + mainStat: "HP", + mainValue: "4,780", + substats: ["CRIT DMG+13.2%", "ATK+19", "HP%+15.7%", "Energy Recharge+5.2%"], + equipped: "Bennett", + confidence: 90, + needsReview: false, + source: "auto-scan", +}; + +describe("goodInterop set keys", () => { + it("converts set names to GOOD PascalCase keys", () => { + expect(setNameToKey("Gladiator's Finale")).toBe("GladiatorsFinale"); + expect(setNameToKey("Viridescent Venerer")).toBe("ViridescentVenerer"); + expect(setNameToKey("Emblem of Severed Fate")).toBe("EmblemOfSeveredFate"); + }); + + it("round-trips known set keys back to names", () => { + for (const name of ["Gladiator's Finale", "Viridescent Venerer"]) { + expect(setKeyToName(setNameToKey(name))).toBe(name); + } + }); +}); + +describe("goodInterop substats", () => { + it("parses percent and flat substat strings", () => { + expect(substatStringToGood("CRIT DMG+13.2%")).toEqual({ key: "critDMG_", value: 13.2 }); + expect(substatStringToGood("ATK+19")).toEqual({ key: "atk", value: 19 }); + expect(substatStringToGood("HP%+15.7%")).toEqual({ key: "hp_", value: 15.7 }); + }); + + it("returns null for unparseable substats", () => { + expect(substatStringToGood("nonsense")).toBeNull(); + expect(substatStringToGood("Unknown+5")).toBeNull(); + }); + + it("round-trips substat strings", () => { + for (const entry of record.substats) { + const good = substatStringToGood(entry)!; + expect(goodSubstatToString(good)).toBe(entry); + } + }); +}); + +describe("goodInterop export", () => { + it("exports a stored artifact to GOOD", () => { + const good = storedArtifactToGood(record); + expect(good.setKey).toBe("GladiatorsFinale"); + expect(good.slotKey).toBe("flower"); + expect(good.mainStatKey).toBe("hp"); + expect(good.level).toBe(20); + expect(good.rarity).toBe(5); + expect(good.substats).toContainEqual({ key: "critDMG_", value: 13.2 }); + }); + + it("wraps records in a GOOD database envelope", () => { + const db = storedArtifactsToGood([record]); + expect(db.format).toBe("GOOD"); + expect(db.artifacts).toHaveLength(1); + }); +}); + +describe("goodInterop import", () => { + it("imports a GOOD artifact back to a stored record", () => { + const good = storedArtifactToGood(record); + const back = goodToStoredArtifact({ ...good, location: "Bennett" })!; + expect(back.slot).toBe("Flower of Life"); + expect(back.setName).toBe("Gladiator's Finale"); + expect(back.mainStat).toBe("HP"); + expect(back.equipped).toBe("Bennett"); + expect(back.substats).toContain("CRIT DMG+13.2%"); + expect(back.source).toBe("good-import"); + }); + + it("computes a main value from slot + main stat + level on import", () => { + const good = storedArtifactToGood(record); + const back = goodToStoredArtifact(good)!; + // Flower HP main at +20 is the reference max; just assert it is populated. + expect(back.mainValue).not.toBe(""); + }); + + it("skips artifacts with an unknown slot or main stat", () => { + expect(goodToStoredArtifact({ setKey: "GladiatorsFinale", slotKey: "bogus", mainStatKey: "hp" })).toBeNull(); + expect(goodToStoredArtifact({ setKey: "GladiatorsFinale", slotKey: "flower", mainStatKey: "bogus" })).toBeNull(); + }); + + it("maps a full GOOD database", () => { + const db = storedArtifactsToGood([record, { ...record, slot: "Plume of Death", mainStat: "ATK", mainValue: "311" }]); + const imported = goodDatabaseToStoredArtifacts(db); + expect(imported).toHaveLength(2); + expect(imported.map((entry) => entry.slot)).toEqual(["Flower of Life", "Plume of Death"]); + }); +}); diff --git a/src/lib/goodInterop.ts b/src/lib/goodInterop.ts new file mode 100644 index 0000000..1a4a492 --- /dev/null +++ b/src/lib/goodInterop.ts @@ -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 = { + "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 = 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; +} diff --git a/src/lib/lockDetection.test.ts b/src/lib/lockDetection.test.ts new file mode 100644 index 0000000..a727811 --- /dev/null +++ b/src/lib/lockDetection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { detectLockState, isLocked, lockIconCropRect, lockSignalRatio } from "./lockDetection"; +import type { Bitmap } from "./ocrPreprocess"; +import { profileDetailRect } from "./layoutProfile"; + +// Build a BGRA bitmap where `goldPixels` of the pixels are lock-gold and the rest dark. +function bitmap(goldPixels: number, total: number): Bitmap { + const data = Buffer.alloc(total * 4); + for (let pixel = 0; pixel < total; pixel++) { + const index = pixel * 4; + if (pixel < goldPixels) { + data[index] = 40; // B + data[index + 1] = 170; // G + data[index + 2] = 230; // R -> gold + } + data[index + 3] = 255; + } + return { data, width: total, height: 1 }; +} + +describe("lockDetection", () => { + it("places the lock crop in the top-right of the detail card", () => { + const size = { width: 2560, height: 1440 }; + const detail = profileDetailRect(size); + const rect = lockIconCropRect(detail, size); + expect(rect.x).toBeGreaterThan(detail.x + detail.width * 0.5); + expect(rect.x + rect.width).toBeLessThanOrEqual(size.width); + expect(rect.y).toBeLessThan(detail.y + detail.height * 0.5); + }); + + it("measures the gold-pixel ratio", () => { + expect(lockSignalRatio(bitmap(0, 100))).toBe(0); + expect(lockSignalRatio(bitmap(50, 100))).toBeCloseTo(0.5, 5); + expect(lockSignalRatio(bitmap(100, 100))).toBe(1); + }); + + it("thresholds the ratio into a locked flag", () => { + expect(isLocked(0.02)).toBe(false); + expect(isLocked(0.2)).toBe(true); + expect(detectLockState(bitmap(20, 100))).toBe(true); + expect(detectLockState(bitmap(1, 100))).toBe(false); + }); +}); diff --git a/src/lib/lockDetection.ts b/src/lib/lockDetection.ts new file mode 100644 index 0000000..20e3b7c --- /dev/null +++ b/src/lib/lockDetection.ts @@ -0,0 +1,52 @@ +import { clampRect, type LayoutRect } from "./layoutProfile"; +import type { Bitmap } from "./ocrPreprocess"; + +// EXPERIMENTAL, read-only lock-status detection (nice-to-have). Genshin shows a +// padlock at the top-right of the artifact detail card: a bright gold fill when +// locked, a dim outline when not. This estimates that icon region and measures +// the fraction of bright "lock-gold" pixels; above a threshold the piece is +// considered locked. +// +// The crop position and threshold need calibration against a reference 16:9 +// screenshot before this is wired into the capture pipeline, so it ships pure and +// unit-tested but unused by main.ts. It never drives any in-game action - it only +// reads state for triage. + +export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect { + return clampRect( + { + x: Math.round(detailRect.x + detailRect.width * 0.8), + y: Math.round(detailRect.y + detailRect.height * 0.03), + width: Math.round(detailRect.width * 0.16), + height: Math.round(detailRect.height * 0.09), + }, + imageSize, + ); +} + +// A gold/highlighted lock pixel: red high, green mid-high, blue low. +function isLockGold(b: number, g: number, r: number): boolean { + return r >= 180 && g >= 140 && b <= 120 && r > b + 40 && g > b + 20; +} + +export function lockSignalRatio(bitmap: Bitmap): number { + const { data, width, height } = bitmap; + const pixels = width * height; + if (pixels === 0) return 0; + let gold = 0; + for (let pixel = 0; pixel < pixels; pixel++) { + const index = pixel * 4; + if (isLockGold(data[index], data[index + 1], data[index + 2])) gold++; + } + return gold / pixels; +} + +export const DEFAULT_LOCK_THRESHOLD = 0.06; + +export function isLocked(signalRatio: number, threshold: number = DEFAULT_LOCK_THRESHOLD): boolean { + return signalRatio >= threshold; +} + +export function detectLockState(bitmap: Bitmap, threshold: number = DEFAULT_LOCK_THRESHOLD): boolean { + return isLocked(lockSignalRatio(bitmap), threshold); +}