chore: initialize repository baseline
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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<ArtifactStoreLoadResult> {
|
||||
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<Map<string, StoredArtifactRecord>> {
|
||||
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<ArtifactStoreSaveResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AutomationGuard,
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreSaveResult,
|
||||
ReviewSampleListResult,
|
||||
ReviewSamplePayload,
|
||||
LoadScannerLearningRulesResult,
|
||||
SaveScannerLearningRulesResult,
|
||||
SaveResultWithPath,
|
||||
FocusGenshinResult,
|
||||
RuntimeInfo,
|
||||
BooleanResult,
|
||||
ScrollResult,
|
||||
ScannerStatusPayload,
|
||||
ScannerLearningRulePayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||
|
||||
export type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreSaveResult,
|
||||
ReviewSampleListResult,
|
||||
ReviewSamplePayload,
|
||||
LoadScannerLearningRulesResult,
|
||||
SaveScannerLearningRulesResult,
|
||||
} from "../../src/types/global.js";
|
||||
|
||||
export interface ArtifactStoreRepositoryPort {
|
||||
loadAll(): Promise<ArtifactStoreLoadResult>;
|
||||
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
|
||||
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
|
||||
}
|
||||
|
||||
export interface ReviewSamplesRepositoryPort {
|
||||
list(limit?: number): Promise<ReviewSampleListResult>;
|
||||
append(sample: ReviewSamplePayload): Promise<SaveResultWithPath>;
|
||||
}
|
||||
|
||||
export type ScannerLearningRules = ScannerLearningRulePayload;
|
||||
export interface ScannerLearningLoadResult extends LoadScannerLearningRulesResult {}
|
||||
export interface ScannerLearningSaveResult extends SaveScannerLearningRulesResult {}
|
||||
|
||||
export interface ScannerLearningRepositoryPort {
|
||||
load(): Promise<ScannerLearningLoadResult>;
|
||||
save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult>;
|
||||
}
|
||||
|
||||
export interface SnapshotRepositoryPort {
|
||||
load(): Promise<AppSnapshot | null>;
|
||||
save(snapshot: AppSnapshot): Promise<SaveResultWithPath>;
|
||||
}
|
||||
|
||||
export interface RuntimeRepositoryPort {
|
||||
focusMainWindow(): Promise<BooleanResult>;
|
||||
moveMainWindowOffGenshin(): Promise<void>;
|
||||
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
|
||||
publishScannerStatus(status: ScannerStatusPayload): Promise<BooleanResult>;
|
||||
getRuntimeInfo(): Promise<RuntimeInfo>;
|
||||
showOverlay: () => Promise<BooleanResult>;
|
||||
hideOverlay: () => Promise<BooleanResult>;
|
||||
}
|
||||
|
||||
export interface AutomationRepositoryPort {
|
||||
getAutomationGuard(): Promise<AutomationGuard>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
}
|
||||
|
||||
export interface CaptureRepositoryPort {
|
||||
listSources(): Promise<CaptureSourceInfo[]>;
|
||||
captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise<CaptureResult>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./contracts.js";
|
||||
export { JsonArtifactStoreRepository } from "./artifactStoreRepository.js";
|
||||
export { ReviewSamplesRepository } from "./reviewSamplesRepository.js";
|
||||
export { ScannerLearningRepository } from "./scannerLearningRepository.js";
|
||||
export { JsonSnapshotRepository } from "./snapshotRepository.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
|
||||
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
|
||||
|
||||
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(userDataPath: string, fileName = "review-samples.jsonl") {
|
||||
this.filePath = path.join(userDataPath, fileName);
|
||||
}
|
||||
|
||||
async list(limit = 50): Promise<ReviewSampleListResult> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, "utf8");
|
||||
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||
const samples = lines
|
||||
.slice(-safeLimit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as ReviewSampleRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ReviewSampleRecord[];
|
||||
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath };
|
||||
} catch {
|
||||
return { ok: true, samples: [], total: 0, path: this.filePath };
|
||||
}
|
||||
}
|
||||
|
||||
async append(sample: ReviewSamplePayload): Promise<SaveResultWithPath> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.appendFile(this.filePath, `${JSON.stringify({ savedAt: new Date().toISOString(), sample })}\n`, "utf8");
|
||||
return { ok: true, path: this.filePath };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ScannerLearningLoadResult, ScannerLearningRepositoryPort, ScannerLearningRules, ScannerLearningSaveResult } from "./contracts.js";
|
||||
|
||||
export class ScannerLearningRepository implements ScannerLearningRepositoryPort {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(userDataPath: string, fileName = "scanner-learning.json") {
|
||||
this.filePath = path.join(userDataPath, fileName);
|
||||
}
|
||||
|
||||
async load(): Promise<ScannerLearningLoadResult> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ScannerLearningRules;
|
||||
return {
|
||||
ok: true,
|
||||
path: this.filePath,
|
||||
rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} },
|
||||
};
|
||||
} catch {
|
||||
return { ok: true, path: this.filePath, rules: { textReplacements: {} } };
|
||||
}
|
||||
}
|
||||
|
||||
async save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult> {
|
||||
const current = await this.load();
|
||||
const nextTextReplacements = {
|
||||
...((current.rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||
...((rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||
};
|
||||
const payload: ScannerLearningRules = { textReplacements: nextTextReplacements };
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { SnapshotRepositoryPort } from "./contracts.js";
|
||||
import type { SaveResultWithPath } from "../../src/types/global.js";
|
||||
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||
|
||||
export class JsonSnapshotRepository implements SnapshotRepositoryPort {
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(userDataPath: string, fileName = "snapshot.json") {
|
||||
this.filePath = path.join(userDataPath, fileName);
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(this.filePath, "utf8")) as AppSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async save(snapshot: AppSnapshot): Promise<SaveResultWithPath> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
||||
return { ok: true, path: this.filePath };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user