import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { createHash } from "node:crypto"; import { loadNativeScannerDeletedResultIds } from "../electron/services/nativeScannerResultTombstones.js"; import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js"; import type { StoredScanResultEntry } from "../src/types/storage.js"; const options = parseArgs(process.argv.slice(2)); const scanRoot = path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "genshin-artifact-assistant", "native-scans"); const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot); const sourcePath = path.join(runDir, "scan-results.json"); const source = await fs.readFile(sourcePath, "utf8"); const parsed = JSON.parse(source); if (!Array.isArray(parsed) || parsed.length === 0) throw new Error(`No scan results found in ${sourcePath}`); const rawEntries = parsed.filter(isStoredScanResultEntry); if (rawEntries.length !== parsed.length) throw new Error(`Invalid scan-result entries: ${parsed.length - rawEntries.length}`); const deletedResultIds = await loadNativeScannerDeletedResultIds(runDir); const entries = rawEntries.filter((entry) => !deletedResultIds.has(entry.id)); if (entries.length === 0) throw new Error(`No visible scan results found in ${sourcePath}`); const report = replayNativeScanResults({ entries, repeats: options.repeats, runId: path.basename(runDir), sourcePath, sourceSha256: createHash("sha256").update(source).digest("hex"), }); const outputDir = path.resolve("outputs", "native-replay", report.runId); await fs.mkdir(outputDir, { recursive: true }); const reportPath = path.join(outputDir, "native-replay-report.json"); await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8"); console.log(`Native offline replay: ${report.runId}`); if (deletedResultIds.size > 0) console.log(`Locally removed results excluded: ${deletedResultIds.size}`); console.log( `Results: ${report.total}; evaluated=${report.values.evaluated}; excluded=${report.values.excluded}; ` + `review=${report.values.review}; unknown=${report.values.unknown}`, ); console.log(`Projection: available=${report.projections.available}; complete=${report.projections.complete}; unavailable=${report.projections.unavailable}`); console.log(`Score: min=${report.score.min}; avg=${report.score.average}; max=${report.score.max}`); console.log(`Deterministic: ${report.deterministic} across ${report.repeats} repeats`); console.log(`Report: ${reportPath}`); if (!report.deterministic) throw new Error("Offline replay produced different evaluation hashes."); if (report.cleanUnevaluated.length > 0) { console.error(`Clean but unevaluated results: ${report.cleanUnevaluated.map((entry) => `#${entry.sequence} ${entry.summary}`).join(" | ")}`); process.exitCode = 1; } function parseArgs(args: string[]) { const runDir = args.find((argument) => argument.startsWith("--run-dir="))?.slice("--run-dir=".length) ?? ""; const repeatValue = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 3); return { runDir, repeats: Number.isFinite(repeatValue) ? repeatValue : 3 }; } async function latestRunDir(root: string) { const entries = await fs.readdir(root, { withFileTypes: true }); const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse(); for (const candidate of candidates) { const runDir = path.join(root, candidate); try { await fs.access(path.join(runDir, "scan-results.json")); return runDir; } catch { // Continue to the next saved run; no live capture is started. } } throw new Error(`No saved native scan-results.json found below ${root}`); } function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry { if (!value || typeof value !== "object") return false; const entry = value as Partial; return typeof entry.id === "string" && typeof entry.runId === "string" && Number.isFinite(entry.sequence) && typeof entry.extractionStatus === "string" && typeof entry.valueStatus === "string" && Array.isArray(entry.notes); }