Compare commits
3 Commits
2c6c1a8b31
...
b8a38ba547
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a38ba547 | |||
| dcac155887 | |||
| 6ea3d9e714 |
@@ -0,0 +1,49 @@
|
|||||||
|
# Scanner rework status
|
||||||
|
|
||||||
|
Progress on the approved scanner/OCR rework. See ADR-007/008/009 in
|
||||||
|
[DECISIONS.md](DECISIONS.md) for the decisions behind these.
|
||||||
|
|
||||||
|
## Done (implemented, unit-tested, build green)
|
||||||
|
|
||||||
|
- **OCR eval harness** — `src/eval/`, `npm run eval`, gate in `npm test`. See
|
||||||
|
[ocr-eval.md](ocr-eval.md).
|
||||||
|
- **C# input/capture sidecar** — `native/input-helper/`, `npm run helper:build`.
|
||||||
|
Replaces the PowerShell helper on the same JSON protocol; PowerShell remains a
|
||||||
|
fallback. Verified end-to-end (spawn, runtime, base64 capture).
|
||||||
|
- **Layout profiles + OCR preprocessing** — `src/lib/layoutProfile.ts` (pure
|
||||||
|
geometry, 16:9 detection), `src/lib/ocrPreprocess.ts` (grayscale + Otsu
|
||||||
|
binarize). main.ts crops via the profile and OCRs an upscaled + binarized copy.
|
||||||
|
- **Card-ready gating** — `src/lib/cardReadyGate.ts` replaces the fixed 280 ms
|
||||||
|
settle with change+stability polling; robust to animation.
|
||||||
|
- **GOOD interop** — `src/lib/goodInterop.ts` (export + best-effort import for
|
||||||
|
scanned records).
|
||||||
|
- **Rescan-merge** — `src/lib/artifactMerge.ts` collapses leveled re-scan
|
||||||
|
duplicates by a level-independent identity.
|
||||||
|
- **Data staleness warning** — `src/lib/dataPackageStatus.ts`, surfaced in the
|
||||||
|
Scanner Diagnose data-package line.
|
||||||
|
- **Lock detection (experimental)** — `src/lib/lockDetection.ts`, pure heuristic,
|
||||||
|
not yet wired into capture.
|
||||||
|
|
||||||
|
## Remaining — needs the live environment or a UI pass
|
||||||
|
|
||||||
|
These cannot be finished/validated without Genshin running at the user's
|
||||||
|
resolution or without UI work best tested live:
|
||||||
|
|
||||||
|
1. **Calibrate IK-style fixed crop coordinates** (ADR-009). The layout module is
|
||||||
|
the structure; the exact per-field fractions still come from a
|
||||||
|
colour-detected/fallback detail rect. A reference 16:9 screenshot of the
|
||||||
|
artifact screen lets us pin exact client-relative crop coordinates.
|
||||||
|
2. **Validate/tune OCR preprocessing** on real captures — confirm invert +
|
||||||
|
threshold + upscale factor help (not hurt) actual Tesseract reads. The
|
||||||
|
text-level eval harness cannot measure image preprocessing.
|
||||||
|
3. **Wire GOOD import** — file-picker IPC + merge imported records into the store
|
||||||
|
(the conversion engine is done and tested).
|
||||||
|
4. **Wire live lock detection** — calibrate crop position/threshold against a
|
||||||
|
reference screenshot, then populate a `locked` flag during capture.
|
||||||
|
|
||||||
|
## Grow the eval corpus
|
||||||
|
|
||||||
|
Every low-confidence review sample already stores its crops + OCR. Confirm/correct
|
||||||
|
those via `reviewSampleToEvalCase` and commit them into `src/eval/corpus/` so the
|
||||||
|
harness keeps measuring real-world accuracy across patches. See
|
||||||
|
[ocr-eval.md](ocr-eval.md).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
|
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 { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
import type { ScanDiagnosticsModalProps } from "../types";
|
import type { ScanDiagnosticsModalProps } from "../types";
|
||||||
|
|
||||||
@@ -96,7 +97,8 @@ export function useScanDiagnosticsModalModel({
|
|||||||
: "Run a capture once while the artifact inventory is visible.";
|
: "Run a capture once while the artifact inventory is visible.";
|
||||||
|
|
||||||
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
|
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 playerProgress = useMemo(() => {
|
||||||
const width = Math.min(
|
const width = Math.min(
|
||||||
|
|||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, StoredArtifactRecord>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+29
-16
@@ -3,6 +3,7 @@ import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
|||||||
import { sessionSignature } from "./artifactStore";
|
import { sessionSignature } from "./artifactStore";
|
||||||
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
|
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
|
||||||
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
|
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
|
||||||
|
import { waitForCardReady } from "./cardReadyGate";
|
||||||
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
||||||
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
||||||
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
||||||
@@ -59,7 +60,11 @@ export type AutoScanLoopResult = {
|
|||||||
targetCount: number;
|
targetCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLICK_SETTLE_MS = 280;
|
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
|
||||||
|
// until it has changed and stabilized (or the budget is spent). See cardReadyGate.
|
||||||
|
const CARD_READY_MAX_MS = 900;
|
||||||
|
const CARD_READY_POLL_MS = 90;
|
||||||
|
const CARD_READY_STABLE_SAMPLES = 2;
|
||||||
const MISS_ABORT_THRESHOLD = 3;
|
const MISS_ABORT_THRESHOLD = 3;
|
||||||
const UNREADABLE_ABORT_THRESHOLD = 5;
|
const UNREADABLE_ABORT_THRESHOLD = 5;
|
||||||
|
|
||||||
@@ -162,6 +167,19 @@ export async function runAutoScanLoop(
|
|||||||
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
||||||
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
||||||
|
|
||||||
|
async function awaitCardReady() {
|
||||||
|
return waitForCardReady(
|
||||||
|
{
|
||||||
|
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, true)),
|
||||||
|
wait,
|
||||||
|
now: () => Date.now(),
|
||||||
|
checkAbort: checkGuard,
|
||||||
|
},
|
||||||
|
lastDetailViewFingerprint,
|
||||||
|
{ minStableSamples: CARD_READY_STABLE_SAMPLES, maxWaitMs: CARD_READY_MAX_MS, pollIntervalMs: CARD_READY_POLL_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
|
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) {
|
||||||
page++;
|
page++;
|
||||||
@@ -224,16 +242,13 @@ export async function runAutoScanLoop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
let ready = await awaitCardReady();
|
||||||
if (waitStop) {
|
if (ready.abortReason) {
|
||||||
blockedReason = waitStop;
|
blockedReason = ready.abortReason;
|
||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
let changedDetail = ready.changed;
|
||||||
let previewCapture = await captureFastSelectedSource(0, true);
|
|
||||||
let previewFingerprint = detailFingerprint(previewCapture);
|
|
||||||
let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
|
||||||
|
|
||||||
if (!changedDetail) {
|
if (!changedDetail) {
|
||||||
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
|
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
|
||||||
@@ -244,15 +259,13 @@ export async function runAutoScanLoop(
|
|||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
ready = await awaitCardReady();
|
||||||
if (waitStop) {
|
if (ready.abortReason) {
|
||||||
blockedReason = waitStop;
|
blockedReason = ready.abortReason;
|
||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
previewCapture = await captureFastSelectedSource(0, true);
|
changedDetail = ready.changed;
|
||||||
previewFingerprint = detailFingerprint(previewCapture);
|
|
||||||
changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changedDetail) {
|
if (!changedDetail) {
|
||||||
@@ -482,6 +495,6 @@ export function fingerprintDataUrl(dataUrl: string) {
|
|||||||
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number) {
|
function wait(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
return new Promise((resolve) => window.setTimeout(() => resolve(), ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { waitForCardReady, type CardReadyDeps } from "./cardReadyGate";
|
||||||
|
|
||||||
|
// Deterministic clock: each wait advances virtual time, each sample pops the
|
||||||
|
// next scripted fingerprint.
|
||||||
|
function harness(samples: string[], step = 90, checkAbort?: () => string) {
|
||||||
|
let clock = 0;
|
||||||
|
let index = 0;
|
||||||
|
const deps: CardReadyDeps = {
|
||||||
|
sampleFingerprint: async () => samples[Math.min(index++, samples.length - 1)],
|
||||||
|
wait: async (ms) => {
|
||||||
|
clock += ms;
|
||||||
|
},
|
||||||
|
now: () => clock,
|
||||||
|
checkAbort,
|
||||||
|
};
|
||||||
|
return { deps, sampleCount: () => index, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("waitForCardReady", () => {
|
||||||
|
it("returns ready once the card changed and stabilized", async () => {
|
||||||
|
const { deps } = harness(["old", "new", "new"]);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 900, pollIntervalMs: 90 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.stable).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
expect(result.polls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling while the detail still shows the previous artifact", async () => {
|
||||||
|
const { deps } = harness(["old", "old", "new", "new"]);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
expect(result.polls).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proceeds after the budget when content changed but never stabilizes (animation)", async () => {
|
||||||
|
// Always different (animated glow): changed but never two-in-a-row equal.
|
||||||
|
const animated = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10"];
|
||||||
|
const { deps } = harness(animated, 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 300, pollIntervalMs: 90 });
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.stable).toBe(false);
|
||||||
|
expect(result.ready).toBe(true); // budget spent, but content did change
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not-ready when the detail never changes within budget", async () => {
|
||||||
|
const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 });
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts immediately when checkAbort returns a reason", async () => {
|
||||||
|
const { deps } = harness(["new", "new"], 90, () => "ESC gehalten");
|
||||||
|
const result = await waitForCardReady(deps, "old", {});
|
||||||
|
expect(result.abortReason).toBe("ESC gehalten");
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
expect(result.polls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty fingerprints for stability", async () => {
|
||||||
|
const { deps } = harness(["", "", "new", "new"], 90);
|
||||||
|
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 900 });
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(result.fingerprint).toBe("new");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Card-ready gating for the auto-scan loop (ADR replaces the fixed 280ms settle
|
||||||
|
// delay). After clicking a tile, instead of waiting a hardcoded interval and
|
||||||
|
// hoping the detail card has rendered, poll a cheap detail fingerprint until it
|
||||||
|
// has both (a) changed from the previously-read artifact and (b) stabilized
|
||||||
|
// across consecutive samples. This is faster on quick machines and correct on
|
||||||
|
// slow ones, and it is robust to particle/hover-glow animation: if the card
|
||||||
|
// never fully stabilizes within the budget it still proceeds once the content
|
||||||
|
// has changed, rather than looping forever on an animated frame.
|
||||||
|
//
|
||||||
|
// Pure except for the injected async sampler/clock, so it is unit testable.
|
||||||
|
|
||||||
|
export interface CardReadyOptions {
|
||||||
|
/** Consecutive equal samples required to call the card stable. */
|
||||||
|
minStableSamples?: number;
|
||||||
|
/** Total time budget before giving up on full stability. */
|
||||||
|
maxWaitMs?: number;
|
||||||
|
/** Delay between samples. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardReadyDeps {
|
||||||
|
/** Fast capture -> detail fingerprint. */
|
||||||
|
sampleFingerprint: () => Promise<string>;
|
||||||
|
wait: (ms: number) => Promise<void>;
|
||||||
|
now: () => number;
|
||||||
|
/** Returns a non-empty reason to abort (ESC held, stop pressed, ...). */
|
||||||
|
checkAbort?: () => Promise<string> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardReadyResult {
|
||||||
|
/** Safe to read the full artifact: content changed (and stabilized or budget spent). */
|
||||||
|
ready: boolean;
|
||||||
|
/** The detail differs from the previously-read artifact. */
|
||||||
|
changed: boolean;
|
||||||
|
/** Reached the required number of consecutive equal samples. */
|
||||||
|
stable: boolean;
|
||||||
|
/** Latest sampled fingerprint. */
|
||||||
|
fingerprint: string;
|
||||||
|
/** Non-empty when aborted via checkAbort. */
|
||||||
|
abortReason: string;
|
||||||
|
polls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MIN_STABLE = 2;
|
||||||
|
const DEFAULT_MAX_WAIT_MS = 900;
|
||||||
|
const DEFAULT_POLL_MS = 90;
|
||||||
|
|
||||||
|
export async function waitForCardReady(
|
||||||
|
deps: CardReadyDeps,
|
||||||
|
previousFingerprint: string,
|
||||||
|
options: CardReadyOptions = {},
|
||||||
|
): Promise<CardReadyResult> {
|
||||||
|
const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE);
|
||||||
|
const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
|
||||||
|
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS);
|
||||||
|
|
||||||
|
const start = deps.now();
|
||||||
|
let previousSample = "";
|
||||||
|
let stableCount = 0;
|
||||||
|
let latest = "";
|
||||||
|
let polls = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (deps.checkAbort) {
|
||||||
|
const abortReason = await deps.checkAbort();
|
||||||
|
if (abortReason) {
|
||||||
|
return { ready: false, changed: false, stable: false, fingerprint: latest, abortReason, polls };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest = await deps.sampleFingerprint();
|
||||||
|
polls++;
|
||||||
|
|
||||||
|
stableCount = latest && latest === previousSample ? stableCount + 1 : 1;
|
||||||
|
previousSample = latest;
|
||||||
|
|
||||||
|
const changed = Boolean(latest) && latest !== previousFingerprint;
|
||||||
|
const stable = stableCount >= minStable;
|
||||||
|
|
||||||
|
if (changed && stable) {
|
||||||
|
return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.now() - start >= maxWaitMs) {
|
||||||
|
// Budget spent. Proceed if the content has at least changed, even if it is
|
||||||
|
// still animating (never fully stabilizes).
|
||||||
|
return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls };
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.wait(pollIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.`
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export const characterAliases = genshinGameData.aliases?.characterAliases ?? {};
|
|||||||
export const knownSets = genshinGameData.artifactSets.map((set) => set.name);
|
export const knownSets = genshinGameData.artifactSets.map((set) => set.name);
|
||||||
export const knownCharacters = (genshinGameData.characters ?? []).map((character) => character.name);
|
export const knownCharacters = (genshinGameData.characters ?? []).map((character) => character.name);
|
||||||
export const sourceVersion = genshinGameData.sourceVersion ?? "unknown";
|
export const sourceVersion = genshinGameData.sourceVersion ?? "unknown";
|
||||||
|
export const dataGeneratedAt = (genshinGameData as { generatedAt?: string }).generatedAt ?? "";
|
||||||
|
|
||||||
export const fixedMainStatBySlot: Record<string, string> = {
|
export const fixedMainStatBySlot: Record<string, string> = {
|
||||||
"Flower of Life": "HP",
|
"Flower of Life": "HP",
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
"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<string, string> = 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user