import fs from "node:fs/promises"; import path from "node:path"; import type { StoredArtifactRecord } from "../../src/types/storage.js"; import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js"; import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js"; interface ArtifactStoreFile { version?: number; artifacts?: StoredArtifactRecord[]; } export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort { private readonly filePath: string; constructor(userDataPath: string, fileName = "artifact-store.json") { this.filePath = path.join(userDataPath, fileName); } async loadAll(): Promise { const records = await this.loadMap(); const artifacts = [...records.values()].sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? "")); return { ok: true, artifacts, total: artifacts.length, path: this.filePath }; } async loadMap(): Promise> { try { const raw = JSON.parse(await fs.readFile(this.filePath, "utf8")) as ArtifactStoreFile; const records = Array.isArray(raw.artifacts) ? raw.artifacts : []; const cleaned = records .filter((record) => record?.id && !isObviousGarbageRecord(record)) .map((record) => normalizeStoredArtifactRecordForLoad(record)); const store = new Map(cleaned.map((record) => [record.id, record])); const storeChanged = cleaned.length !== records.length || cleaned.some((record, index) => JSON.stringify(record) !== JSON.stringify(records[index])); if (storeChanged) await this.writeRecords([...store.values()]); return store; } catch { return new Map(); } } async saveMany(records: StoredArtifactRecord[]): Promise { const store = await this.loadMap(); const now = new Date().toISOString(); let added = 0; let updated = 0; for (const record of records ?? []) { if (!record?.id || isObviousGarbageRecord(record)) continue; const existing = store.get(record.id); if (existing) { store.set(record.id, { ...existing, ...record, firstSeenAt: existing.firstSeenAt ?? now, lastSeenAt: now, timesSeen: (existing.timesSeen ?? 1) + 1, confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0), // A later confident scan clears the review flag; an uncertain rescan // must not downgrade an already confirmed artifact. needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview), source: resolveStoredArtifactSource(existing.source, record.source), }); updated++; } else { const mergeCandidate = [...store.values()].find((candidate) => shouldMergeArtifactRecords(candidate, record)); if (mergeCandidate) { const merged = mergeArtifactRecords(mergeCandidate, record, now); if (mergeCandidate.id !== merged.id) store.delete(mergeCandidate.id); store.set(merged.id, merged); updated++; } else { store.set(record.id, { ...record, firstSeenAt: now, lastSeenAt: now, timesSeen: 1 }); added++; } } } await this.writeRecords([...store.values()]); return { ok: true, added, updated, total: store.size, path: this.filePath }; } private async writeRecords(records: StoredArtifactRecord[]) { await fs.mkdir(path.dirname(this.filePath), { recursive: true }); await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8"); } } function artifactMergeKey(record: StoredArtifactRecord) { return [record.name, record.slot, record.setName, record.mainStat, record.mainValue, record.level ?? ""] .map((value) => `${value ?? ""}`.trim().toLowerCase()) .join("::"); } function artifactFamilyKey(record: StoredArtifactRecord) { return [record.name, record.slot, record.setName, record.mainStat] .map((value) => `${value ?? ""}`.trim().toLowerCase()) .join("::"); } function artifactQualityScore(record: StoredArtifactRecord) { const corePenalty = (record.name === "Unknown artifact" ? 40 : 0) + (record.slot === "Unknown slot" ? 35 : 0) + (record.setName === "Unknown set" ? 35 : 0) + (record.mainStat === "Unknown main stat" ? 40 : 0) + (record.mainValue === "?" ? 20 : 0); return (record.confidence ?? 0) + Math.min(20, (record.substats?.length ?? 0) * 5) + (record.needsReview ? -12 : 8) + (record.equipped && record.equipped !== "Not detected" ? 2 : 0) - corePenalty; } function isObviousGarbageRecord(record: StoredArtifactRecord) { return ( !record?.id || record.name === "Unknown artifact" || record.slot === "Unknown slot" || record.setName === "Unknown set" || record.mainStat === "Unknown main stat" || record.mainValue === "?" || (record.substats?.length ?? 0) === 0 || (record.confidence ?? 0) < 30 ); } function parseArtifactNumericValue(value: string) { const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, "").replace("%", "").trim()); return Number.isFinite(numeric) ? numeric : 0; } function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) { const normalizedTimesSeen = Math.max(1, Math.round(record.timesSeen ?? 1)); const reviewOnly = isReviewOnlyArtifactSource(record.source); return { ...record, timesSeen: reviewOnly ? 1 : normalizedTimesSeen, firstSeenAt: record.firstSeenAt ?? record.lastSeenAt, }; } function shouldMergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord) { if (existing.id === incoming.id) return true; const overlap = (incoming.substats ?? []).filter((substat: string) => (existing.substats ?? []).includes(substat)).length; const overlapThreshold = Math.min(2, Math.min(existing.substats?.length ?? 0, incoming.substats?.length ?? 0)); const qualityGap = Math.abs(artifactQualityScore(existing) - artifactQualityScore(incoming)) >= 8; if (artifactMergeKey(existing) === artifactMergeKey(incoming)) { return ( overlap >= overlapThreshold || existing.needsReview || incoming.needsReview || (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0) || qualityGap ); } if (artifactFamilyKey(existing) !== artifactFamilyKey(incoming)) return false; const sameOrBetterLevel = (incoming.level ?? 0) >= (existing.level ?? 0); const sameOrBetterMainValue = parseArtifactNumericValue(incoming.mainValue) >= parseArtifactNumericValue(existing.mainValue); return sameOrBetterLevel && sameOrBetterMainValue && ( overlap >= overlapThreshold || existing.needsReview || incoming.needsReview || (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0) || qualityGap ); } function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord, now: string): StoredArtifactRecord { const incomingPreferred = artifactQualityScore(incoming) >= artifactQualityScore(existing); const preferred = incomingPreferred ? incoming : existing; const secondary = incomingPreferred ? existing : incoming; const preferredSubstats = (preferred.substats?.length ?? 0) >= (secondary.substats?.length ?? 0) ? preferred.substats : secondary.substats; return { ...secondary, ...preferred, id: incomingPreferred ? incoming.id : existing.id, level: Math.max(existing.level ?? 0, incoming.level ?? 0), substats: [...(preferredSubstats ?? [])], equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped, confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0), needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview), source: resolveStoredArtifactSource(existing.source, incoming.source), firstSeenAt: existing.firstSeenAt ?? now, lastSeenAt: now, timesSeen: (existing.timesSeen ?? 1) + 1, }; }