82 lines
4.2 KiB
TypeScript
82 lines
4.2 KiB
TypeScript
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<StoredScanResultEntry>;
|
|
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);
|
|
}
|