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 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-07-05 21:55:20 +02:00
parent 6ea3d9e714
commit dcac155887
10 changed files with 658 additions and 2 deletions
+43
View File
@@ -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);
});
});