Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef65c3e6a0 | |||
| 7930e369a7 | |||
| b8309af377 | |||
| c8ae0dd7bf |
@@ -1,23 +0,0 @@
|
||||
@echo off
|
||||
rem Startet den Dev-Modus mit Administratorrechten (ein UAC-Prompt erscheint).
|
||||
rem Noetig, weil Genshin erhoeht laeuft: Windows (UIPI) verwirft sonst alle
|
||||
rem simulierten Maus-Eingaben an das Spiel - SendInput meldet trotzdem Erfolg.
|
||||
rem Der Punkt hinter %~dp0 verhindert, dass der abschliessende Backslash das
|
||||
rem schliessende Anfuehrungszeichen escaped.
|
||||
rem -NoExit haelt das erhoehte (innere) Fenster offen, selbst wenn das Skript
|
||||
rem einen Fehler wirft oder npm run dev sofort wieder beendet.
|
||||
rem
|
||||
rem Dieses AEUSSERE Fenster (das du beim Doppelklick oder ueber
|
||||
rem "npm run dev:admin" siehst) schloss sich frueher sofort, sobald
|
||||
rem Start-Process zurueckkehrte - auch wenn UAC abgelehnt wurde oder die
|
||||
rem Elevation ganz fehlschlug, ohne dass davon irgendetwas sichtbar war.
|
||||
rem try/catch + timeout zeigen jetzt den Fehler und halten das Fenster kurz offen.
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$script='%~dp0scripts\dev-admin-start.ps1'; $project='%~dp0.'; try { Start-Process powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File',$script,'-ProjectRoot',$project) -Verb RunAs -ErrorAction Stop; Write-Host ''; Write-Host 'UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev.' -ForegroundColor Green } catch { Write-Host ''; Write-Host 'Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:' -ForegroundColor Red; Write-Host $_.Exception.Message -ForegroundColor Red }"
|
||||
echo.
|
||||
echo Dieses Fenster kannst du jetzt schliessen (Taste druecken oder 15s warten). Das eigentliche Programm laeuft im neuen Administrator-Fenster.
|
||||
rem timeout statt pause/choice: pause und choice warten unter umgeleiteter
|
||||
rem Standardeingabe (z.B. beim Testen ueber ein Skript) fuer immer, weil sie
|
||||
rem auf ein echtes Konsolen-Handle angewiesen sind. timeout erkennt eine
|
||||
rem umgeleitete Eingabe explizit und bricht sofort ab statt zu haengen, waehrend
|
||||
rem es bei einem echten Doppelklick normal 15s wartet oder bei Tastendruck endet.
|
||||
timeout /t 15 >nul 2>&1
|
||||
+17
-2
@@ -49,7 +49,7 @@ flowchart LR
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, persistent PowerShell input/capture helper, JSON artifact store |
|
||||
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, input/capture sidecar orchestration, JSON artifact store, dev-only scanner control endpoints |
|
||||
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
||||
| `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store |
|
||||
| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview |
|
||||
@@ -111,9 +111,24 @@ sequenceDiagram
|
||||
|
||||
**Automatic grid scan** is user-triggered input automation limited to clicking detected inventory tiles and wheel-scrolling the inventory. Safety and reliability rules:
|
||||
|
||||
- All input goes through one persistent PowerShell helper process (`input-helper.ps1` in userData) that compiles the Win32 interop once and speaks JSON over stdin/stdout (ops: ping, focus, cursor, click, scroll, capture). Mouse movement is sent as iterated relative SendInput deltas (what a real mouse produces): Genshin tracks the cursor via raw input and snaps the OS cursor back to its own position every frame, so SetCursorPos/absolute moves silently stop working once the game owns the cursor. The helper verifies the cursor reached the target and refuses to click otherwise.
|
||||
- All input goes through the helper service boundary (currently a C# sidecar with
|
||||
fallback support behind the same JSON protocol). The helper owns focus, cursor
|
||||
movement, click, scroll, guard-state polling, elevation detection, and GDI
|
||||
capture. Mouse movement is sent as iterated relative input deltas instead of
|
||||
relying on a single absolute cursor jump. The helper verifies the cursor
|
||||
reached the target and refuses to click otherwise.
|
||||
- `npm run dev:admin` is the validated dev path for automation when elevated
|
||||
input is required. The elevated PowerShell startup is handled by
|
||||
`scripts/dev-admin.ps1` and logged to `outputs/admin-start/admin-dev.log`.
|
||||
The user must approve UAC manually; the app cannot approve the Secure Desktop
|
||||
prompt itself.
|
||||
- Failsafe: before every click and scroll the renderer polls cursor position and ESC state. Holding ESC or moving the mouse away from the last automated position aborts the scan immediately; the Stop button also aborts. Only the `GetAsyncKeyState` held-down bit (0x8000) is used - the "pressed since last call" bit fires for stale ESC presses from normal Genshin menu navigation and caused false aborts.
|
||||
- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing.
|
||||
- Dev-only probes under `http://127.0.0.1:17317` are used for live validation:
|
||||
`/automation/probe-click?index=N` tests one read-only tile selection, and
|
||||
`/scanner/start?limit=N` starts an auto-scan with a temporary limit payload.
|
||||
The live known-good result on 2026-07-07 is documented in
|
||||
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
|
||||
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
|
||||
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Automation Live Scan Runbook
|
||||
|
||||
This document is the durable reference for automatic artifact scanning, mouse
|
||||
movement, click input, elevation, and live validation status.
|
||||
|
||||
## Current Known-Good State
|
||||
|
||||
Validated live on 2026-07-07 with Genshin open in the artifact inventory at
|
||||
1920x1080, English UI:
|
||||
|
||||
- `npm run dev:admin` starts the app elevated after the user confirms UAC.
|
||||
- Runtime status reported `isElevated: true`, `genshinFound: true`, and
|
||||
`targetProcess: "GenshinImpact"`.
|
||||
- The safe probe endpoint `/automation/probe-click?index=1` focused Genshin,
|
||||
moved the cursor to the second visible inventory tile, clicked it, and changed
|
||||
the artifact detail panel fingerprint.
|
||||
- Probe result: `clicked: true`, `inputBlocked: false`,
|
||||
`foregroundProcess: "GenshinImpact"`, and `changed: true`.
|
||||
- A bounded live auto-scan via `/scanner/start?limit=2` completed with:
|
||||
`clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`,
|
||||
`review: 2`, `misses: 0`, `status: "done"`.
|
||||
|
||||
This proves that the current elevated app plus helper path can deliver mouse
|
||||
movement and click input to the focused Genshin client in this environment.
|
||||
|
||||
## Elevation And UAC
|
||||
|
||||
Use:
|
||||
|
||||
```powershell
|
||||
npm run dev:admin
|
||||
```
|
||||
|
||||
The command runs `scripts/dev-admin.ps1`, which launches a new elevated
|
||||
PowerShell window running `scripts/dev-admin-start.ps1`. The elevated start is
|
||||
logged to:
|
||||
|
||||
```text
|
||||
outputs/admin-start/admin-dev.log
|
||||
```
|
||||
|
||||
The user must confirm the Windows UAC prompt. The app cannot and must not click
|
||||
the Secure Desktop UAC prompt for itself. After confirmation, the app can verify
|
||||
its own runtime through the dev status endpoint.
|
||||
|
||||
Useful checks:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:17317/health
|
||||
Invoke-RestMethod http://127.0.0.1:17317/scanner/status
|
||||
```
|
||||
|
||||
Expected runtime facts before automatic scan:
|
||||
|
||||
- `isElevated: true`
|
||||
- `genshinFound: true`
|
||||
- `targetProcess: "GenshinImpact"`
|
||||
- hotkeys registered
|
||||
|
||||
## Mouse And Click Validation
|
||||
|
||||
Use the probe before broad auto-scan work:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" |
|
||||
ConvertTo-Json -Depth 12
|
||||
```
|
||||
|
||||
The probe performs one read-only inventory selection click. It does not delete,
|
||||
feed, enhance, lock, unlock, spend, or modify game resources.
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `click.ok: true`, `clicked: true`, `inputBlocked: false` means Windows did not
|
||||
block SendInput/UIPI in the current configuration.
|
||||
- `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was
|
||||
sent while Genshin was foreground.
|
||||
- `changed: true` means the detail panel changed after the click.
|
||||
- `changed: false` can be benign if the target tile was already selected or two
|
||||
neighboring artifacts render identically; retry with another `index`, `row`,
|
||||
or `col`.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
# Second visible tile
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1"
|
||||
|
||||
# Specific grid cell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?row=0&col=3"
|
||||
```
|
||||
|
||||
## Bounded Live Auto-Scan
|
||||
|
||||
For live validation, prefer a bounded scan first:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2"
|
||||
```
|
||||
|
||||
Then poll:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
|
||||
ConvertTo-Json -Depth 12
|
||||
```
|
||||
|
||||
The `/scanner/start?limit=N` endpoint sends a renderer command payload with a
|
||||
temporary scan limit. It does not change the normal UI setting. The normal
|
||||
hotkeys and buttons still use the UI's configured scan limit.
|
||||
|
||||
## Anti-Cheat And Safety Boundary
|
||||
|
||||
Do not describe the current implementation as bypassing anti-cheat. The app
|
||||
does not read memory, hook the process, inject code, modify game files, inspect
|
||||
packets, or interact with kernel drivers. It uses normal Windows screen capture,
|
||||
focus, cursor movement, wheel, and click input.
|
||||
|
||||
The practical finding is narrower:
|
||||
|
||||
- A non-elevated app can be blocked by Windows integrity/UIPI when the target
|
||||
process is elevated or protected.
|
||||
- Running the app elevated fixed input delivery in the tested environment.
|
||||
- Genshin's anti-cheat may still affect behavior on other machines, game modes,
|
||||
overlays, or future versions. Re-run the probe before trusting broad scans.
|
||||
|
||||
Never add automation that deletes, feeds, enhances, locks/unlocks, spends
|
||||
resources, reads memory, hooks, injects, or modifies Genshin.
|
||||
|
||||
## Live Layout Facts
|
||||
|
||||
The current 16:9 layout profile is calibrated from a 1920x1080 English
|
||||
artifact-inventory capture:
|
||||
|
||||
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
|
||||
- inventory grid: `8 x 5`
|
||||
- first tile center: `x=179`, `y=254`, `row=0`, `col=0`
|
||||
- second tile center: `x=325`, `y=254`, `row=0`, `col=1`
|
||||
- inventory count crop successfully read `2059/2400` in the live session
|
||||
|
||||
The profile is resolution-scaled for 16:9. Off-profile setups should be treated
|
||||
as higher risk and validated with Smart Capture plus the probe.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before marking an automation change done:
|
||||
|
||||
1. Run `npm run lint`.
|
||||
2. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
|
||||
3. Run `npm test`.
|
||||
4. Run `npm run build`.
|
||||
5. If Genshin is available, run `/automation/probe-click?index=1`.
|
||||
6. For scan-loop changes, run `/scanner/start?limit=2` before any broader scan.
|
||||
7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
||||
@@ -15,6 +15,7 @@ This document contains Architecture Decision Records.
|
||||
| ADR-007 | Measure OCR accuracy with a labeled eval harness before reworking the scanner | Accepted | 2026-07-05 |
|
||||
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
|
||||
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
|
||||
| ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
|
||||
|
||||
## ADR-001: Build A Local Electron App First
|
||||
|
||||
@@ -235,3 +236,53 @@ validated against the ADR-007 eval harness.
|
||||
insufficient.
|
||||
- Non-16:9 or non-borderless setups are explicitly unsupported for the auto
|
||||
scanner; the app should detect and warn rather than silently misread.
|
||||
|
||||
## ADR-010: Elevated Dev Runner And Bounded Live Automation Probes
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
Automatic grid scanning needs read-only mouse movement, click, and wheel input
|
||||
to reach the focused Genshin window. A lower-integrity app can fail to deliver
|
||||
input to an elevated or protected target because of Windows UIPI/integrity
|
||||
boundaries. During live testing, `npm run dev:admin` originally printed that a
|
||||
new Administrator window was started, but the elevated PowerShell received no
|
||||
arguments, so the intended dev process did not reliably start.
|
||||
|
||||
The project also needed a smaller live validation path than a full inventory
|
||||
scan. A full scan is too risky as the first proof of input delivery because it
|
||||
can click many tiles before a bad coordinate, focus issue, or blocked input is
|
||||
understood.
|
||||
|
||||
### Decision
|
||||
|
||||
Keep automatic scan input automation read-only and require an elevated runtime
|
||||
when Windows reports that automation would otherwise be blocked. Replace the
|
||||
old `dev-admin.cmd` entry with `scripts/dev-admin.ps1`, quote the elevated
|
||||
PowerShell arguments explicitly, and log elevated startup to
|
||||
`outputs/admin-start/admin-dev.log`.
|
||||
|
||||
Add dev-only HTTP checks:
|
||||
|
||||
- `/automation/probe-click?index=N` or `?row=R&col=C` performs one safe
|
||||
inventory selection click and verifies whether the detail panel changed.
|
||||
- `/scanner/start?limit=N` sends a temporary scan-limit payload to the renderer,
|
||||
so live auto-scan validation can start with two items instead of the UI
|
||||
default.
|
||||
|
||||
Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
|
||||
### Consequences
|
||||
|
||||
- The user still has to approve Windows UAC manually; the app must not try to
|
||||
click the Secure Desktop prompt.
|
||||
- We can distinguish input delivery from OCR/parser quality with a one-click
|
||||
probe before running any broader scan.
|
||||
- Live validation now has a low-risk path: check elevation and Genshin
|
||||
detection, run a single probe click, then run a bounded `limit=2` scan.
|
||||
- The implementation remains inside the allowed safety boundary: no memory
|
||||
reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
|
||||
locking/unlocking, or spending resources.
|
||||
|
||||
+9
-5
@@ -72,7 +72,7 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
||||
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
||||
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
||||
| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path |
|
||||
| Input automation | C# sidecar with elevated dev runner when needed | Live-validated for read-only inventory selection clicks; see `docs/AUTOMATION_LIVE_SCAN.md` |
|
||||
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
||||
| Packaging | electron-builder | Configured in `package.json` |
|
||||
|
||||
@@ -95,11 +95,15 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
||||
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
||||
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
||||
- Elevated live automation is validated in the current dev environment:
|
||||
`/automation/probe-click?index=1` changed the selected artifact and
|
||||
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
|
||||
|
||||
### What is still structurally weak
|
||||
|
||||
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
|
||||
- The current PowerShell input sidecar is serviceable for experimentation but not a strong production base for long-running, low-jitter auto-scan.
|
||||
- Broader scan soak testing still needs to increase the live limit gradually and
|
||||
validate scroll/page transitions beyond the first visible row.
|
||||
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
|
||||
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
||||
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
|
||||
@@ -196,7 +200,7 @@ Outcome:
|
||||
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
|
||||
|
||||
Status:
|
||||
- Planned
|
||||
- First live path validated; broader soak testing still needed
|
||||
|
||||
### Phase 5 - Learning loop that actually compounds
|
||||
|
||||
@@ -228,7 +232,7 @@ Status:
|
||||
1. Finish scan-page cleanup so the main operator view is no longer noisy.
|
||||
2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples.
|
||||
3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
|
||||
4. Replace or wrap the current PowerShell sidecar with a more stable long-lived automation process.
|
||||
4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions.
|
||||
5. Extend the learning system from text-only fixes into crop/UI profile tuning.
|
||||
6. Resume recommendation work only when scan accuracy is consistently trustworthy.
|
||||
|
||||
@@ -236,7 +240,7 @@ Status:
|
||||
|
||||
| Question | Status |
|
||||
| --- | --- |
|
||||
| Should the production input sidecar be Rust/C++ first, or a transitional Node native addon, for the next iteration? | Open |
|
||||
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
|
||||
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
||||
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
||||
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# 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.
|
||||
Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in
|
||||
[DECISIONS.md](DECISIONS.md) for the decisions behind these. For the current
|
||||
live automation runbook, see
|
||||
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
|
||||
## Done (implemented, unit-tested, build green)
|
||||
|
||||
@@ -12,34 +14,44 @@ Progress on the approved scanner/OCR rework. See ADR-007/008/009 in
|
||||
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.
|
||||
binarize). main.ts now uses calibrated 16:9 detail/count/grid coordinates
|
||||
first 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).
|
||||
scanned records), Electron file-picker import/export, and store merge.
|
||||
- **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.
|
||||
- **Lock detection (experimental)** — `src/lib/lockDetection.ts`, wired into
|
||||
live capture as a read-only `locked` flag and persisted with scanned records.
|
||||
- **Elevated live automation path** — `npm run dev:admin` now starts through
|
||||
`scripts/dev-admin.ps1` and logs to `outputs/admin-start/admin-dev.log`.
|
||||
Live status confirmed `isElevated: true`, `genshinFound: true`, and
|
||||
`targetProcess: "GenshinImpact"`.
|
||||
- **Read-only click probe** — `/automation/probe-click?index=1` verified that
|
||||
the app can focus Genshin, move to a visible inventory tile, click it, and
|
||||
observe a changed detail panel fingerprint (`clicked: true`,
|
||||
`inputBlocked: false`, `changed: true`).
|
||||
- **Bounded auto-scan validation** — `/scanner/start?limit=2` completed live
|
||||
with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records,
|
||||
2 review samples, and 0 misses.
|
||||
|
||||
## 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 +
|
||||
1. **Validate/tune OCR preprocessing** on more 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.
|
||||
2. **Validate locked=true** against a known locked artifact — unlocked/grey lock
|
||||
was live-checked; a gold locked icon still needs a positive sample.
|
||||
|
||||
3. **Broader scan soak test** — after the bounded two-item live scan passed,
|
||||
the next automation validation should increase the limit gradually and watch
|
||||
for repeated pages, scroll behavior, duplicate handling, and OCR review rate.
|
||||
|
||||
## Grow the eval corpus
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
SaveResultWithPath,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
ScannerStatusPayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type {
|
||||
@@ -51,6 +52,7 @@ interface PersistenceHandlersDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
interface CaptureHandlersDependencies {
|
||||
@@ -86,6 +88,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
||||
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
||||
exportGood: dependencies.exportGood,
|
||||
importGoodFile: dependencies.importGoodFile,
|
||||
});
|
||||
|
||||
registerCaptureHandlers({
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
} from "../src/types/global.js";
|
||||
|
||||
interface DevControlServerDependencies {
|
||||
registeredHotkeys: Record<string, boolean>;
|
||||
hasMainWindow: () => boolean;
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
id: string,
|
||||
delayMs?: number,
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function dataUrlBase64(dataUrl: string) {
|
||||
return dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
}
|
||||
|
||||
function devCaptureOutputDir() {
|
||||
return path.join(process.cwd(), "outputs", "live-capture");
|
||||
}
|
||||
|
||||
function safeDebugFilePart(value: string) {
|
||||
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
|
||||
}
|
||||
|
||||
function dataUrlFingerprint(dataUrl: string | undefined) {
|
||||
if (!dataUrl) return "";
|
||||
let hash = 2166136261;
|
||||
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
|
||||
for (let index = 0; index < dataUrl.length; index += stride) {
|
||||
hash ^= dataUrl.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) {
|
||||
if (!dataUrl) return null;
|
||||
await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64"));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function writeDevCaptureSnapshot(capture: CaptureResult) {
|
||||
const outputDir = devCaptureOutputDir();
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "");
|
||||
const prefix = safeDebugFilePart(`${stamp}-${capture.name}`);
|
||||
const files = {
|
||||
full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl),
|
||||
detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl),
|
||||
inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl),
|
||||
crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>,
|
||||
};
|
||||
|
||||
for (const crop of capture.crops ?? []) {
|
||||
const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`);
|
||||
const written = await writeDevCaptureImage(cropPath, crop.dataUrl);
|
||||
if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect });
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
capturedAt: capture.capturedAt,
|
||||
captureTarget: capture.captureTarget,
|
||||
ocrSkipped: capture.ocrSkipped,
|
||||
ocrTimedOut: capture.ocrTimedOut,
|
||||
layout: capture.layout,
|
||||
inventoryGrid: capture.inventoryGrid
|
||||
? {
|
||||
rows: capture.inventoryGrid.rows,
|
||||
cols: capture.inventoryGrid.cols,
|
||||
confidence: capture.inventoryGrid.confidence,
|
||||
source: capture.inventoryGrid.source,
|
||||
firstCenter: capture.inventoryGrid.centers[0] ?? null,
|
||||
lastCenter: capture.inventoryGrid.centers.at(-1) ?? null,
|
||||
}
|
||||
: null,
|
||||
inventoryCount: capture.inventoryCount ?? null,
|
||||
locked: capture.locked,
|
||||
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
|
||||
ocr: capture.ocr ?? [],
|
||||
files,
|
||||
};
|
||||
const summaryPath = path.join(outputDir, `${prefix}-summary.json`);
|
||||
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8");
|
||||
return { ...summary, summaryPath };
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) {
|
||||
return sourceId
|
||||
? sources.find((entry) => entry.id === sourceId)
|
||||
: sources.find((entry) => entry.isGenshinCandidate);
|
||||
}
|
||||
|
||||
function sourceListForError(sources: CaptureSourceInfo[]) {
|
||||
return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate }));
|
||||
}
|
||||
|
||||
export function createDevControlServer(deps: DevControlServerDependencies): Server {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
||||
writeDevJson(res, 403, { ok: false, error: "local only" });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||
? { type: "start-auto", scanLimit: limit }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/stop") {
|
||||
deps.sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/probe") {
|
||||
deps.sendScannerCommand("probe-click");
|
||||
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/click") {
|
||||
const x = Number(url.searchParams.get("x"));
|
||||
const y = Number(url.searchParams.get("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
||||
return;
|
||||
}
|
||||
deps.clickScreen(Math.round(x), Math.round(y))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/capture/smart") {
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const focus = url.searchParams.get("focus") !== "0";
|
||||
const skipOcr = url.searchParams.get("skipOcr") === "1";
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
const capture = await deps.captureSource(source.id, 250, focus, { skipOcr });
|
||||
const summary = await writeDevCaptureSnapshot(capture);
|
||||
writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary });
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/probe-click") {
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const requestedIndex = Number(url.searchParams.get("index") ?? "1");
|
||||
const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN);
|
||||
const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN);
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
|
||||
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true });
|
||||
const centers = before.inventoryGrid?.centers ?? [];
|
||||
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
|
||||
? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
|
||||
: centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))];
|
||||
if (!target) {
|
||||
writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null });
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl);
|
||||
const click = await deps.clickScreen(target.x, target.y);
|
||||
await wait(650);
|
||||
const after = await deps.captureSource(source.id, 0, true, { skipOcr: true });
|
||||
const afterFingerprint = dataUrlFingerprint(after.detailDataUrl);
|
||||
const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint);
|
||||
writeDevJson(res, 200, {
|
||||
ok: Boolean(click.ok && click.clicked && changed),
|
||||
changed,
|
||||
target,
|
||||
click,
|
||||
before: {
|
||||
captureTarget: before.captureTarget,
|
||||
grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null,
|
||||
detailFingerprint: beforeFingerprint,
|
||||
},
|
||||
after: {
|
||||
captureTarget: after.captureTarget,
|
||||
grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null,
|
||||
detailFingerprint: afterFingerprint,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
|
||||
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
||||
});
|
||||
|
||||
server.listen(17317, "127.0.0.1");
|
||||
return server;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SaveScannerLearningRulesResult,
|
||||
ScannerLearningRulePayload,
|
||||
SaveResultWithPath,
|
||||
GoodImportFileResult,
|
||||
} from "../../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||
|
||||
@@ -28,6 +29,7 @@ interface PersistenceDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export function registerPersistenceHandlers({
|
||||
@@ -39,6 +41,7 @@ export function registerPersistenceHandlers({
|
||||
loadScannerLearningRules,
|
||||
writeScannerLearningRules,
|
||||
exportGood,
|
||||
importGoodFile,
|
||||
}: PersistenceDependencies) {
|
||||
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
||||
try {
|
||||
@@ -81,4 +84,8 @@ export function registerPersistenceHandlers({
|
||||
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||
return exportGood(payload);
|
||||
});
|
||||
|
||||
ipcMain.handle("good:importFile", async () => {
|
||||
return importGoodFile();
|
||||
});
|
||||
}
|
||||
|
||||
+56
-65
@@ -1,19 +1,22 @@
|
||||
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||
import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import http, { type Server } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createWorker } from "tesseract.js";
|
||||
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
||||
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
||||
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
||||
import { createDevControlServer } from "./devControlServer.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
SaveResultWithPath,
|
||||
ScannerCommand,
|
||||
ScannerLearningRulePayload,
|
||||
ScannerStatusPayload,
|
||||
} from "../src/types/global.js";
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
profileDetailRect,
|
||||
} from "../src/lib/layoutProfile.js";
|
||||
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
|
||||
import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.js";
|
||||
|
||||
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
||||
// crash the GPU/renderer process) when the hosting process runs with a full
|
||||
@@ -416,7 +420,7 @@ function focusMainWindow() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function sendScannerCommand(command: "start-auto" | "stop" | "probe-click") {
|
||||
function sendScannerCommand(command: ScannerCommand | "probe-click") {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("scanner:command", command);
|
||||
}
|
||||
@@ -431,71 +435,18 @@ function registerScannerHotkeys() {
|
||||
};
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function startDevControlServer() {
|
||||
if (!isDev || devControlServer) return;
|
||||
|
||||
devControlServer = http.createServer((req, res) => {
|
||||
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
||||
writeDevJson(res, 403, { ok: false, error: "local only" });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: registeredHotkeys, hasWindow: Boolean(mainWindow && !mainWindow.isDestroyed()) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
sendScannerCommand("start-auto");
|
||||
writeDevJson(res, 200, { ok: true, command: "start-auto" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/stop") {
|
||||
sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/probe") {
|
||||
sendScannerCommand("probe-click");
|
||||
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/click") {
|
||||
const x = Number(url.searchParams.get("x"));
|
||||
const y = Number(url.searchParams.get("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
||||
return;
|
||||
}
|
||||
getInputHelperService()
|
||||
.clickScreen(Math.round(x), Math.round(y))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: scannerDevStatus });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
|
||||
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
||||
devControlServer = createDevControlServer({
|
||||
registeredHotkeys,
|
||||
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
sendScannerCommand,
|
||||
clickScreen: clickScreenCommand,
|
||||
scannerStatus: () => scannerDevStatus,
|
||||
loadReviewSamples,
|
||||
listCaptureSources,
|
||||
captureSource,
|
||||
});
|
||||
|
||||
devControlServer.listen(17317, "127.0.0.1");
|
||||
}
|
||||
|
||||
function createOverlayWindow() {
|
||||
@@ -802,6 +753,10 @@ function createCrops(
|
||||
}
|
||||
|
||||
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
||||
if (isSixteenNine(imageSize)) {
|
||||
return profileDetailRect(imageSize);
|
||||
}
|
||||
|
||||
const { width, height } = imageSize;
|
||||
const sampleStrideX = width > 2200 ? 4 : 3;
|
||||
const sampleStrideY = height > 1400 ? 4 : 3;
|
||||
@@ -869,6 +824,12 @@ async function buildCaptureResult(
|
||||
const detailRect = inferDetailRect(bitmap, size);
|
||||
const inventoryRect = inferInventoryRect(size, detailRect);
|
||||
const crops = createCrops(sourceImage, size, detailRect, inventoryRect);
|
||||
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
|
||||
const lockImage = sourceImage.crop(lockRect);
|
||||
const lockSize = lockImage.getSize();
|
||||
const locked = lockSize.width > 0 && lockSize.height > 0
|
||||
? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height })
|
||||
: undefined;
|
||||
const croppedPayload = crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
@@ -905,6 +866,7 @@ async function buildCaptureResult(
|
||||
})),
|
||||
inventoryGrid: inferInventoryGrid(size, detailRect),
|
||||
inventoryCount: count,
|
||||
locked,
|
||||
layout: {
|
||||
aspect: aspectRatioLabel(size),
|
||||
isSixteenNine: isSixteenNine(size),
|
||||
@@ -965,6 +927,34 @@ async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
|
||||
}
|
||||
}
|
||||
|
||||
async function importGoodFile(): Promise<GoodImportFileResult> {
|
||||
const dialogOptions = {
|
||||
title: "GOOD-Datei importieren",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
|
||||
} satisfies Electron.OpenDialogOptions;
|
||||
const dialogResult = mainWindow && !mainWindow.isDestroyed()
|
||||
? await dialog.showOpenDialog(mainWindow, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions);
|
||||
|
||||
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
|
||||
return { ok: false, canceled: true, path: "" };
|
||||
}
|
||||
|
||||
const filePath = dialogResult.filePaths[0];
|
||||
try {
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
canceled: false,
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAppLifecycle() {
|
||||
app.whenReady().then(() => {
|
||||
const userDataPath = app.getPath("userData");
|
||||
@@ -993,6 +983,7 @@ function initializeAppLifecycle() {
|
||||
loadScannerLearningRules: () => loadScannerLearningRules(),
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
|
||||
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
|
||||
importGoodFile: () => importGoodFile(),
|
||||
listSources: () => listCaptureSources(),
|
||||
captureSource: (
|
||||
id: string,
|
||||
|
||||
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
focusGenshinForScanStart: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
|
||||
saveReviewSample: (sample) => ipcRenderer.invoke("review:saveSample", sample),
|
||||
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
|
||||
@@ -19,6 +20,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
|
||||
+5
-3
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
|
||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
focusGenshinForScanStart: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
|
||||
saveReviewSample: (sample: ReviewSamplePayload) => ipcRenderer.invoke("review:saveSample", sample),
|
||||
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
|
||||
@@ -22,11 +23,12 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command);
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, command: ScannerCommand) => callback(command);
|
||||
ipcRenderer.on("scanner:command", listener);
|
||||
return () => ipcRenderer.removeListener("scanner:command", listener);
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
|
||||
lastSeenAt: now,
|
||||
timesSeen: (existing.timesSeen ?? 1) + 1,
|
||||
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
|
||||
locked: typeof record.locked === "boolean" ? record.locked : existing.locked,
|
||||
// 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),
|
||||
@@ -136,6 +137,7 @@ function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
|
||||
...record,
|
||||
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
|
||||
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
|
||||
locked: typeof record.locked === "boolean" ? record.locked : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +185,7 @@ function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredAr
|
||||
substats: [...(preferredSubstats ?? [])],
|
||||
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
|
||||
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
|
||||
locked: typeof incoming.locked === "boolean" ? incoming.locked : existing.locked,
|
||||
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
|
||||
source: resolveStoredArtifactSource(existing.source, incoming.source),
|
||||
firstSeenAt: existing.firstSeenAt ?? now,
|
||||
|
||||
@@ -39,6 +39,16 @@ public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
@@ -181,6 +191,44 @@ function Find-GenshinWindow {
|
||||
return $script:genshinHwnd
|
||||
}
|
||||
|
||||
# Plain SetForegroundWindow from this background helper process is silently
|
||||
# refused by Windows' foreground lock. Attach our thread's input queue to the
|
||||
# target (and current foreground) window thread and clear the lock timeout, so
|
||||
# the foreground change is honored - the same technique Inventory Kamera uses.
|
||||
function Force-Foreground {
|
||||
param([IntPtr]$hwnd)
|
||||
$current = [Native.InputHelper]::GetCurrentThreadId()
|
||||
$targetPid = [uint32]0
|
||||
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
|
||||
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
|
||||
$foreground = [uint32]0
|
||||
if ($fgWindow -ne [IntPtr]::Zero) {
|
||||
$fgPid = [uint32]0
|
||||
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
|
||||
}
|
||||
|
||||
$attachedTarget = $false
|
||||
$attachedForeground = $false
|
||||
$oldTimeout = [uint32]0
|
||||
$timeoutRead = $false
|
||||
try {
|
||||
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
|
||||
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
|
||||
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
|
||||
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
|
||||
# Inject a no-op input (0,0 mouse move) so this process is the last input
|
||||
# source, which Windows requires before it will honor a foreground change.
|
||||
Send-MouseInput -flags 0x0001 | Out-Null
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
|
||||
return [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
} finally {
|
||||
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
|
||||
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
|
||||
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Focus-GenshinWindow {
|
||||
$hwnd = Find-GenshinWindow
|
||||
$info = @{
|
||||
@@ -194,19 +242,7 @@ function Focus-GenshinWindow {
|
||||
|
||||
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||
if (-not $info.alreadyForeground) {
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
# A previous version tapped ALT (keybd_event) right before this call to
|
||||
# satisfy Windows' "who's allowed to change the foreground window"
|
||||
# eligibility check. That tap has a side effect in most Win32 apps: a
|
||||
# bare ALT press/release toggles menu-mnemonic navigation mode (verified
|
||||
# live - it left a real app's menu bar highlighted after just this call),
|
||||
# which then swallows the next several keyboard/mouse events as menu
|
||||
# navigation instead of routing them to the app - looking exactly like
|
||||
# "clicks/keys report success but do nothing". This app and Genshin run
|
||||
# at the same (elevated) integrity level, so plain SetForegroundWindow
|
||||
# already succeeds without the ALT tap - confirmed with a standalone
|
||||
# compiled test against a live target window.
|
||||
$info.setForegroundResult = [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
|
||||
Start-Sleep -Milliseconds 140
|
||||
}
|
||||
|
||||
|
||||
@@ -280,11 +280,7 @@ internal static class Program
|
||||
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
|
||||
if (!info.AlreadyForeground)
|
||||
{
|
||||
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
|
||||
// No ALT tap: this app and Genshin run at the same (elevated)
|
||||
// integrity level, so SetForegroundWindow succeeds on its own. An ALT
|
||||
// tap would toggle menu-mnemonic mode and swallow the next inputs.
|
||||
info.SetForegroundResult = Native.SetForegroundWindow(hwnd);
|
||||
info.SetForegroundResult = ForceForeground(hwnd);
|
||||
Thread.Sleep(140);
|
||||
}
|
||||
|
||||
@@ -294,6 +290,52 @@ internal static class Program
|
||||
return info;
|
||||
}
|
||||
|
||||
// Plain SetForegroundWindow from a background process is silently refused by
|
||||
// Windows' foreground lock. Inventory Kamera and other reliable automation
|
||||
// tools bypass it by attaching the calling thread's input queue to the target
|
||||
// (and current-foreground) window thread and clearing the lock timeout, so the
|
||||
// foreground change is honored. Without this the auto-scan aborts with
|
||||
// "Genshin konnte nicht in den Vordergrund geholt werden".
|
||||
private static bool ForceForeground(IntPtr hwnd)
|
||||
{
|
||||
var current = Native.GetCurrentThreadId();
|
||||
var target = Native.GetWindowThreadProcessId(hwnd, out _);
|
||||
var foregroundHwnd = Native.GetForegroundWindow();
|
||||
var foreground = foregroundHwnd != IntPtr.Zero ? Native.GetWindowThreadProcessId(foregroundHwnd, out _) : 0u;
|
||||
|
||||
var attachedTarget = false;
|
||||
var attachedForeground = false;
|
||||
uint oldTimeout = 0;
|
||||
var timeoutRead = false;
|
||||
try
|
||||
{
|
||||
if (target != 0 && target != current) attachedTarget = Native.AttachThreadInput(current, target, true);
|
||||
if (foreground != 0 && foreground != current && foreground != target)
|
||||
attachedForeground = Native.AttachThreadInput(current, foreground, true);
|
||||
|
||||
timeoutRead = Native.SystemParametersInfo(Native.SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ref oldTimeout, 0);
|
||||
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, Native.SPIF_SENDCHANGE);
|
||||
|
||||
// Inject a no-op input (0,0 relative mouse move) so this process counts
|
||||
// as the last input source - one of the conditions Windows requires to
|
||||
// allow a foreground change. This is what the removed ALT tap did, but
|
||||
// without the menu-mnemonic side effect.
|
||||
NudgeInput();
|
||||
|
||||
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
|
||||
Native.BringWindowToTop(hwnd);
|
||||
var ok = Native.SetForegroundWindow(hwnd);
|
||||
return ok;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (timeoutRead)
|
||||
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, new IntPtr((long)oldTimeout), Native.SPIF_SENDCHANGE);
|
||||
if (attachedForeground) Native.AttachThreadInput(current, foreground, false);
|
||||
if (attachedTarget) Native.AttachThreadInput(current, target, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr FindGenshinWindow()
|
||||
{
|
||||
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
|
||||
@@ -354,6 +396,14 @@ internal static class Program
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static void NudgeInput()
|
||||
{
|
||||
var move = new Native.INPUT[1];
|
||||
move[0].type = 0; // INPUT_MOUSE
|
||||
move[0].mi.dwFlags = Native.MOUSEEVENTF_MOVE; // dx=dy=0 -> no cursor movement
|
||||
Native.SendInput(1, move, Marshal.SizeOf<Native.INPUT>());
|
||||
}
|
||||
|
||||
private static uint SendMouseClickBatch()
|
||||
{
|
||||
var inputs = new Native.INPUT[2];
|
||||
@@ -391,9 +441,13 @@ internal static class Program
|
||||
|
||||
internal static class Native
|
||||
{
|
||||
public const uint MOUSEEVENTF_MOVE = 0x0001;
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
|
||||
public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
|
||||
public const uint SPIF_SENDCHANGE = 0x0002;
|
||||
public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new(-4);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
@@ -455,6 +509,21 @@ internal static class Native
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-electron\\electron\\preload.cjs",
|
||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
|
||||
"dev:web": "vite --host 127.0.0.1",
|
||||
"dev:admin": ".\\dev-admin.cmd",
|
||||
"dev:admin": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\dev-admin.ps1 -ProjectRoot .",
|
||||
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"start": "electron .",
|
||||
|
||||
@@ -9,8 +9,17 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
try {
|
||||
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||||
$logDir = Join-Path $project "outputs\admin-start"
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$logPath = Join-Path $logDir "admin-dev.log"
|
||||
try {
|
||||
Start-Transcript -Path $logPath -Append | Out-Null
|
||||
} catch {
|
||||
Write-Host "WARNUNG: Admin-Start-Log konnte nicht geschrieben werden: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "Projekt: $project"
|
||||
Write-Host "Admin-Log: $logPath"
|
||||
|
||||
# A UAC-elevated process gets its environment rebuilt fresh from the
|
||||
# registry; it does NOT inherit PATH edits that only exist in the calling
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
param(
|
||||
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Quote-ProcessArgument([string]$Value) {
|
||||
return '"' + $Value.Replace('"', '\"') + '"'
|
||||
}
|
||||
|
||||
try {
|
||||
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||||
$script = Join-Path $PSScriptRoot "dev-admin-start.ps1"
|
||||
$powershellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$arguments = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy Bypass",
|
||||
"-NoExit",
|
||||
"-File $(Quote-ProcessArgument $script)",
|
||||
"-ProjectRoot $(Quote-ProcessArgument $project)"
|
||||
) -join " "
|
||||
|
||||
Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev." -ForegroundColor Green
|
||||
Write-Host "Dieses Fenster kann geschlossen werden; das eigentliche Programm laeuft im neuen Administrator-Fenster."
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:" -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import type { ScanViewControllerResult } from "../types";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase } from "../../../lib/goodInterop";
|
||||
import { FieldConfidenceList } from "./ScanResultCards";
|
||||
import { useScanDiagnosticsModalModel } from "./modals/hooks/useScanDiagnosticsModalModel";
|
||||
import { useScanDetailsModalModel } from "./modals/hooks/useScanDetailsModalModel";
|
||||
@@ -59,7 +58,6 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
});
|
||||
|
||||
const [interopStatus, setInteropStatus] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExportGood = async () => {
|
||||
setInteropStatus("Exportiere GOOD...");
|
||||
@@ -71,27 +69,20 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportGood = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const database = JSON.parse(await file.text()) as GoodImportDatabase;
|
||||
const records = goodDatabaseToStoredArtifacts(database);
|
||||
if (records.length === 0) {
|
||||
setInteropStatus("Keine gueltigen Artifacts in der Datei gefunden.");
|
||||
return;
|
||||
}
|
||||
setInteropStatus(`Importiere ${records.length} Artifacts...`);
|
||||
const result = await controller.importGoodArtifacts(records);
|
||||
setInteropStatus(
|
||||
result.ok
|
||||
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert.`
|
||||
: "Import fehlgeschlagen (App im Electron-Fenster oeffnen).",
|
||||
);
|
||||
} catch {
|
||||
setInteropStatus("Datei ist kein gueltiges GOOD/JSON.");
|
||||
const handleImportGood = async () => {
|
||||
setInteropStatus("Waehle GOOD-Datei...");
|
||||
const result = await controller.importGoodFromFile();
|
||||
if (result.canceled) {
|
||||
setInteropStatus("GOOD-Import abgebrochen.");
|
||||
return;
|
||||
}
|
||||
setInteropStatus(
|
||||
result.ok
|
||||
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert (${result.count} gelesen).`
|
||||
: result.error === "No valid GOOD artifacts found."
|
||||
? "Keine gueltigen Artifacts in der Datei gefunden."
|
||||
: "Import fehlgeschlagen (Datei ist kein gueltiges GOOD/JSON oder Bridge fehlt).",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -182,11 +173,10 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
<Download size={15} />
|
||||
GOOD exportieren
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => fileInputRef.current?.click()} disabled={!controller.canGoodInterop}>
|
||||
<button className="ghost-button" onClick={handleImportGood} disabled={!controller.canGoodInterop}>
|
||||
<Upload size={15} />
|
||||
GOOD importieren
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="application/json,.json" hidden onChange={handleImportGood} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="scanner-subcopy">
|
||||
|
||||
@@ -244,7 +244,7 @@ export async function persistParsedArtifact(
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
|
||||
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview, capture?.locked)]);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
@@ -297,6 +297,7 @@ export async function saveReviewSample(
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
},
|
||||
parsed,
|
||||
|
||||
@@ -4,7 +4,16 @@ import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
|
||||
import type {
|
||||
AutomationGuard,
|
||||
BooleanResult,
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
ClickResult,
|
||||
FocusGenshinResult,
|
||||
RuntimeInfo,
|
||||
ScrollResult,
|
||||
} from "../../../types/global";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
@@ -36,6 +45,10 @@ export interface ScanActionContext {
|
||||
focusDashboard: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface VisibleGridScanOptions {
|
||||
scanLimit?: number;
|
||||
}
|
||||
|
||||
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
|
||||
}
|
||||
@@ -137,7 +150,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
|
||||
}
|
||||
|
||||
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
|
||||
export async function runVisibleGridScan(context: ScanActionContext, options: VisibleGridScanOptions = {}): Promise<void> {
|
||||
const {
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
@@ -158,13 +171,14 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
|
||||
persistParsedArtifact,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
scanLimit,
|
||||
scanLimit: configuredScanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
focusDashboard,
|
||||
} = context;
|
||||
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
|
||||
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||
|
||||
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||
if (requiresAdminForAutoScan) {
|
||||
@@ -208,13 +222,24 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
|
||||
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
|
||||
}
|
||||
|
||||
const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin;
|
||||
setReviewStatus("Genshin wird in den Vordergrund geholt...");
|
||||
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
|
||||
if (focusResult) {
|
||||
appendAutomationLog(
|
||||
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
|
||||
);
|
||||
let focusResult: FocusGenshinResult | null = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const current = await focusGenshinForScanStart().catch(() => null);
|
||||
if (!current) {
|
||||
appendAutomationLog(`focus attempt ${attempt}/3: exception`);
|
||||
} else {
|
||||
focusResult = current;
|
||||
appendAutomationLog(
|
||||
`focus attempt ${attempt}/3: ${current.focused ? "ok" : "failed"} found:${current.genshinFound ? "yes" : "no"} setForeground:${current.setForegroundResult ?? "n/a"} target:${current.targetProcess || "?"} fg:${current.foregroundProcess || "?"}`,
|
||||
);
|
||||
if (current.focused) break;
|
||||
if (!current.genshinFound) break;
|
||||
}
|
||||
if (attempt < 3) await wait(300);
|
||||
}
|
||||
|
||||
if (!focusResult?.focused) {
|
||||
setAutoScanRunning(false);
|
||||
const reason = !focusResult?.genshinFound
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { ScannerCommand } from "../../../types/global";
|
||||
import type { VisibleGridScanOptions } from "./scanViewScanActions";
|
||||
|
||||
interface ScanCommandListenerInput {
|
||||
automationRepo?: AutomationRepositoryPort;
|
||||
@@ -7,7 +9,7 @@ interface ScanCommandListenerInput {
|
||||
isScanning: boolean;
|
||||
selectedSourceId: string;
|
||||
requestScanStop: (reason: string) => void;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScanCommandListener({
|
||||
@@ -20,15 +22,16 @@ export function useScanCommandListener({
|
||||
}: ScanCommandListenerInput) {
|
||||
useEffect(() => {
|
||||
if (!automationRepo?.onCommand) return;
|
||||
return automationRepo.onCommand((command: "start-auto" | "stop") => {
|
||||
return automationRepo.onCommand((command: ScannerCommand) => {
|
||||
if (command === "stop") {
|
||||
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
|
||||
return;
|
||||
}
|
||||
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||
void runVisibleGridScan();
|
||||
const commandType = typeof command === "string" ? command : command.type;
|
||||
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||
const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit };
|
||||
void runVisibleGridScan(options);
|
||||
}
|
||||
});
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
|
||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
|
||||
import {
|
||||
initializeLearningState,
|
||||
loadReviewQueue as loadReviewQueueFromRepo,
|
||||
@@ -77,7 +77,7 @@ export interface ScanViewActionResult {
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
|
||||
@@ -262,11 +262,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
await runAutoReviewScanAction(scanActionContext);
|
||||
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
|
||||
|
||||
const runVisibleGridScan = useCallback(async () => {
|
||||
const runVisibleGridScan = useCallback(async (options: VisibleGridScanOptions = {}) => {
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
||||
return;
|
||||
}
|
||||
await runVisibleGridScanAction(scanActionContext);
|
||||
await runVisibleGridScanAction(scanActionContext, options);
|
||||
}, [
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
import { storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
|
||||
export function useScanViewController({
|
||||
@@ -174,6 +174,23 @@ export function useScanViewController({
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
|
||||
useScanViewStateSync({
|
||||
artifactRepo,
|
||||
latestCapture,
|
||||
@@ -253,6 +270,7 @@ export function useScanViewController({
|
||||
runVisibleGridScan,
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,5 +82,6 @@ export interface ScanViewControllerResult {
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
canGoodInterop: boolean;
|
||||
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
|
||||
importGoodFromFile: () => Promise<{ ok: boolean; added: number; updated: number; count: number; canceled?: boolean; path?: string; error?: string }>;
|
||||
importGoodArtifacts: (records: StoredArtifactRecord[]) => Promise<{ ok: boolean; added: number; updated: number }>;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
SaveScannerLearningRulesResult,
|
||||
GoodImportFileResult,
|
||||
} from "../../types/global";
|
||||
|
||||
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
|
||||
@@ -67,6 +68,12 @@ const EMPTY_SAVE_RULES_RESULT: SaveScannerLearningRulesResult = {
|
||||
rules: {},
|
||||
total: 0,
|
||||
};
|
||||
const EMPTY_GOOD_IMPORT_FILE_RESULT: GoodImportFileResult = {
|
||||
ok: false,
|
||||
canceled: false,
|
||||
path: "",
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
|
||||
async function createBridgeSafeCall<TResult>(
|
||||
callback: () => Promise<TResult> | TResult | null | undefined,
|
||||
@@ -179,6 +186,14 @@ export function createRendererRepositories(): RendererRepositories | null {
|
||||
() => bridge.getAutomationGuard(),
|
||||
emptyAutomationGuard(),
|
||||
),
|
||||
focusGenshinForScanStart: () =>
|
||||
createBridgeSafeCall(
|
||||
() =>
|
||||
typeof bridge.focusGenshinForScanStart === "function"
|
||||
? bridge.focusGenshinForScanStart()
|
||||
: bridge.focusGenshin(),
|
||||
emptyFocusGenshinResult(),
|
||||
),
|
||||
focusGenshin: () =>
|
||||
createBridgeSafeCall(
|
||||
() => bridge.focusGenshin(),
|
||||
@@ -197,6 +212,7 @@ export function createRendererRepositories(): RendererRepositories | null {
|
||||
|
||||
const exportRepo: ScanExportPort = {
|
||||
exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT),
|
||||
importGoodFile: () => createBridgeSafeCall(() => bridge.importGoodFile(), EMPTY_GOOD_IMPORT_FILE_RESULT),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,8 +8,10 @@ import type {
|
||||
ScannerStatusPayload,
|
||||
ReviewSamplePayload,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
FocusGenshinResult,
|
||||
RuntimeInfo,
|
||||
ScannerCommand,
|
||||
LoadScannerLearningRulesResult,
|
||||
SaveScannerLearningRulesResult,
|
||||
ArtifactStoreLoadResult,
|
||||
@@ -58,10 +60,11 @@ export interface SnapshotRepositoryPort {
|
||||
export interface AutomationRepositoryPort {
|
||||
getAutomationGuard(): Promise<AutomationGuard>;
|
||||
focusGenshin(): Promise<FocusGenshinResult>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
focusMainWindow(): Promise<BooleanResult>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
onCommand(callback: (command: "start-auto" | "stop") => void): () => void;
|
||||
onCommand(callback: (command: ScannerCommand) => void): () => void;
|
||||
}
|
||||
|
||||
export interface OverlayRepositoryPort {
|
||||
@@ -70,6 +73,7 @@ export interface OverlayRepositoryPort {
|
||||
|
||||
export interface ScanExportPort {
|
||||
exportGood(payload: GoodDatabase): Promise<SaveResultWithPath>;
|
||||
importGoodFile(): Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export interface RendererRepositories {
|
||||
|
||||
@@ -264,6 +264,27 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.fields.substats.confidence).toBe(96);
|
||||
});
|
||||
|
||||
it("parses the live calibrated 1080p Conductor circlet capture", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Conductor's Top Hat",
|
||||
"artifact-main-stat": "Circlet of Logos\nHP\n7. 0 % i",
|
||||
"artifact-substats": "a +\n+ Energy Recharge+4.5%\n+ ATK+14\n- Elemental Mastery+19\n- ATK+5.3% (unactivated)",
|
||||
"artifact-footer": "",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Conductor's Top Hat");
|
||||
expect(parsed?.slot).toBe("Circlet of Logos");
|
||||
expect(parsed?.setName).toBe("Wanderer's Troupe");
|
||||
expect(parsed?.mainStat).toBe("HP%");
|
||||
expect(parsed?.mainValue).toBe("7.0%");
|
||||
expect(parsed?.substats).toEqual([
|
||||
"Energy Recharge+4.5%",
|
||||
"ATK+14",
|
||||
"Elemental Mastery+19",
|
||||
"ATK%+5.3%",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a percent main value even when OCR misses the main stat label", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
textReplacements,
|
||||
} from "./genshinData.js";
|
||||
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
||||
import { implausibleSubstats } from "./substatRolls.js";
|
||||
|
||||
type MainStatValueReference = { stat: string; base: number; max: number };
|
||||
|
||||
@@ -110,6 +111,8 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
|
||||
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
|
||||
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
|
||||
const implausible = implausibleSubstats(substats);
|
||||
if (implausible.length) notes.push(`Substat value has no valid roll combination (likely OCR misread): ${implausible.join(", ")}.`);
|
||||
if (!setField.value) notes.push("Set name not confidently parsed.");
|
||||
|
||||
for (const [label, parsedField] of Object.entries({
|
||||
@@ -273,14 +276,15 @@ function findMainValue(text: string, mainStat: string, slot: string, level: numb
|
||||
}
|
||||
|
||||
function extractPercentValue(text: string) {
|
||||
const percentPattern = /([0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?)\s*%/;
|
||||
const lineMatches = text
|
||||
.split("\n")
|
||||
.map((line) => line.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/))
|
||||
.map((line) => line.match(percentPattern))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match));
|
||||
|
||||
const preferred = lineMatches[0] ?? text.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/);
|
||||
const preferred = lineMatches[0] ?? text.match(percentPattern);
|
||||
if (!preferred?.[1]) return "";
|
||||
return `${preferred[1].replace(/[:,\u00B7]/g, ".")}%`;
|
||||
return `${normalizeMainValue(preferred[1])}%`;
|
||||
}
|
||||
|
||||
function inferMainStat(slot: string, text: string): ParsedField {
|
||||
@@ -299,7 +303,7 @@ function inferMainStat(slot: string, text: string): ParsedField {
|
||||
|
||||
function findDirectMainStat(text: string) {
|
||||
const compact = simplifyForMatch(text);
|
||||
const hasPercentValue = /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text);
|
||||
const hasPercentValue = /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text);
|
||||
const priority = [
|
||||
"Physical DMG Bonus",
|
||||
"Elemental Mastery",
|
||||
@@ -478,7 +482,7 @@ function isPercentMainStat(stat: string) {
|
||||
}
|
||||
|
||||
function promotePercentVariant(stat: string, text: string) {
|
||||
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text)) return `${stat}%`;
|
||||
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text)) return `${stat}%`;
|
||||
return stat;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function hashId(value: string) {
|
||||
return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`;
|
||||
}
|
||||
|
||||
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean): StoredArtifactRecord {
|
||||
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean, locked?: boolean): StoredArtifactRecord {
|
||||
return {
|
||||
id: hashId(storeSignature(parsed)),
|
||||
name: parsed.name,
|
||||
@@ -58,6 +58,7 @@ export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string
|
||||
equipped: parsed.equipped,
|
||||
confidence: parsed.confidence,
|
||||
needsReview,
|
||||
locked,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detailFingerprint, fingerprintDataUrl, isRepeatedProcessedPageFingerprint, screenFingerprint } from "./autoScanLoop";
|
||||
import { runAutoScanLoop } from "./autoScanLoop";
|
||||
import type { AutoScanLoopDependencies } from "./autoScanLoop";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
|
||||
type ScanTestCapture = CaptureResult & { inventoryGrid: NonNullable<CaptureResult["inventoryGrid"]> };
|
||||
|
||||
const sampleGrid = {
|
||||
centers: [{ x: 80, y: 90, row: 0, col: 0 }],
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
confidence: 86,
|
||||
source: "detected" as const,
|
||||
};
|
||||
|
||||
const sampleParse = {
|
||||
name: "A Tiara of Torrents",
|
||||
slot: "Flower of Life",
|
||||
level: 16,
|
||||
mainStat: "HP",
|
||||
mainValue: "10.7%",
|
||||
substats: ["ATK+29", "DEF+10"],
|
||||
setName: "Tenacity of the Millelith",
|
||||
equipped: "Traveler",
|
||||
confidence: 84,
|
||||
notes: [],
|
||||
fields: {
|
||||
name: { value: "A Tiara of Torrents", confidence: 84, source: "ocr" as const },
|
||||
slot: { value: "Flower of Life", confidence: 84, source: "ocr" as const },
|
||||
level: { value: "16", confidence: 84, source: "ocr" as const },
|
||||
mainStat: { value: "HP", confidence: 84, source: "ocr" as const },
|
||||
mainValue: { value: "10.7%", confidence: 84, source: "ocr" as const },
|
||||
setName: { value: "Tenacity of the Millelith", confidence: 84, source: "ocr" as const },
|
||||
equipped: { value: "Traveler", confidence: 84, source: "ocr" as const },
|
||||
substats: { value: "ATK+29, DEF+10", confidence: 84, source: "ocr" as const },
|
||||
},
|
||||
};
|
||||
|
||||
function capture(overrides: Partial<ScanTestCapture> = {}): ScanTestCapture {
|
||||
return {
|
||||
id: "source",
|
||||
name: "screen-capture",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "data:image/png;base64,AA",
|
||||
capturedAt: "2026-01-01T00:00:00.000Z",
|
||||
captureTarget: "desktop-source",
|
||||
ocr: [],
|
||||
detailDataUrl: "data:image/png;base64,DETAIL-AAA",
|
||||
inventoryDataUrl: "data:image/png;base64,GRID-AAA",
|
||||
inventoryGrid: sampleGrid,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("autoScanLoop fingerprints", () => {
|
||||
it("distinguishes captures that share the same prefix but differ later", () => {
|
||||
@@ -47,4 +100,49 @@ describe("autoScanLoop fingerprints", () => {
|
||||
expect(isRepeatedProcessedPageFingerprint("abc", seen, 2)).toBe(true);
|
||||
expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not block before first click when start capture is from the primary screen", async () => {
|
||||
const startCapture = capture({
|
||||
captureTarget: "primary-screen",
|
||||
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
|
||||
});
|
||||
|
||||
let clicked = 0;
|
||||
const deps: AutoScanLoopDependencies = {
|
||||
api: {
|
||||
clickScreen: async () => {
|
||||
clicked += 1;
|
||||
return {
|
||||
ok: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
clicked: true,
|
||||
moved: true,
|
||||
focused: true,
|
||||
};
|
||||
},
|
||||
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
|
||||
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }),
|
||||
captureFastSelectedSource: async () => startCapture,
|
||||
parseArtifact: () => sampleParse,
|
||||
persistParsedArtifact: async () => false,
|
||||
saveReviewSample: async () => ({ ok: true }),
|
||||
getAutoReviewReason: () => "",
|
||||
shouldFlagArtifactForReview: () => false,
|
||||
appendAutomationLog: () => undefined,
|
||||
appendClickDiagnostics: () => undefined,
|
||||
setReviewStatus: () => undefined,
|
||||
setAutoScanStats: () => undefined,
|
||||
shouldStop: () => false,
|
||||
};
|
||||
|
||||
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
|
||||
expect(result.blockedReason).toBe("");
|
||||
expect(result.status).toBe("done");
|
||||
expect(clicked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-2
@@ -98,6 +98,8 @@ export async function runAutoScanLoop(
|
||||
let aborted = false;
|
||||
let consecutiveMisses = 0;
|
||||
let rowsQueued = 0;
|
||||
const primaryScreenStartWarning =
|
||||
"Start-Capture ist vom Primary-Screen, kein spezifischer Genshin-Client-Marker vorhanden - Auto-Scan wird mit Vorsicht fortgesetzt.";
|
||||
|
||||
function updateStats() {
|
||||
setAutoScanStats({ ...stats });
|
||||
@@ -153,7 +155,8 @@ export async function runAutoScanLoop(
|
||||
}
|
||||
|
||||
let currentCapture = await captureSelectedSource(0, true);
|
||||
const initialCaptureRejection = captureSourceRejectionReason(currentCapture);
|
||||
const isPrimaryCapture = currentCapture?.captureTarget === "primary-screen";
|
||||
const initialCaptureRejection = isPrimaryCapture ? "" : captureSourceRejectionReason(currentCapture);
|
||||
let gridModel = buildGridModel(currentCapture?.inventoryGrid);
|
||||
|
||||
if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) {
|
||||
@@ -162,6 +165,10 @@ export async function runAutoScanLoop(
|
||||
return { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets };
|
||||
}
|
||||
|
||||
if (isPrimaryCapture) {
|
||||
appendAutomationLog(primaryScreenStartWarning);
|
||||
}
|
||||
|
||||
let lastDetailSignature = "";
|
||||
const initialParsed = parseArtifact(currentCapture);
|
||||
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
||||
@@ -496,5 +503,6 @@ export function fingerprintDataUrl(dataUrl: string) {
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(() => resolve(), ms));
|
||||
const schedule = typeof window !== "undefined" && window.setTimeout ? window.setTimeout : setTimeout;
|
||||
return new Promise((resolve) => schedule(() => resolve(), ms));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GoodExportArtifact } from "../types/global";
|
||||
import type { StoredArtifactRecord } from "../types/storage";
|
||||
import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData";
|
||||
import { simplifyForMatch } from "./fuzzyMatch";
|
||||
import { inferRarity } from "./substatRolls";
|
||||
|
||||
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
|
||||
// local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha
|
||||
@@ -127,7 +128,7 @@ export function storedArtifactToGood(record: StoredArtifactRecord): GoodExportAr
|
||||
return {
|
||||
setKey: setNameToKey(record.setName),
|
||||
slotKey: SLOT_TO_GOOD[record.slot] ?? "",
|
||||
rarity: 5,
|
||||
rarity: inferRarity(record.level ?? 0, record.substats),
|
||||
level: record.level ?? 0,
|
||||
mainStatKey: statDisplayToGoodKey(record.mainStat),
|
||||
substats,
|
||||
|
||||
@@ -42,6 +42,10 @@ describe("layoutProfile", () => {
|
||||
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
|
||||
});
|
||||
|
||||
it("matches the calibrated 1080p artifact detail panel", () => {
|
||||
expect(profileDetailRect(HD)).toEqual({ x: 1308, y: 120, width: 492, height: 838 });
|
||||
});
|
||||
|
||||
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const crops = detailCropRects(detail, QHD);
|
||||
@@ -65,19 +69,28 @@ describe("layoutProfile", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const inv = inventoryRect(QHD, detail);
|
||||
const count = inventoryCountCropRect(inv, QHD);
|
||||
expect(count.x).toBeGreaterThanOrEqual(inv.x);
|
||||
expect(count.x).toBeGreaterThan(QHD.width * 0.75);
|
||||
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
|
||||
});
|
||||
|
||||
it("builds a 5-column inventory grid on the left", () => {
|
||||
it("builds the calibrated 8-column inventory grid on the left", () => {
|
||||
const detail = profileDetailRect(QHD);
|
||||
const grid = inventoryGrid(QHD, detail);
|
||||
expect(grid.cols).toBe(5);
|
||||
expect(grid.cols).toBe(8);
|
||||
expect(grid.rows).toBe(5);
|
||||
expect(grid.source).toBe("detected");
|
||||
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
|
||||
expect(grid.centers).toHaveLength(40);
|
||||
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the live 1080p artifact grid centers", () => {
|
||||
const detail = profileDetailRect(HD);
|
||||
const grid = inventoryGrid(HD, detail);
|
||||
expect(grid.centers[0]).toEqual({ x: 179, y: 254, row: 0, col: 0 });
|
||||
expect(grid.centers[7]).toEqual({ x: 1201, y: 254, row: 0, col: 7 });
|
||||
expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 958, row: 4, col: 7 });
|
||||
});
|
||||
|
||||
it("reports a missing grid when the inventory panel is too small", () => {
|
||||
const tiny = { width: 320, height: 180 };
|
||||
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
|
||||
|
||||
+36
-42
@@ -5,9 +5,9 @@
|
||||
// of that geometry; electron/main.ts consumes it for cropping and keeps a
|
||||
// colour-based detail-rect detector only as a fallback for off-profile setups.
|
||||
//
|
||||
// NOTE: the per-field detail crop fractions below are the current working values.
|
||||
// True IK-style fixed coordinates need calibration against a reference 16:9
|
||||
// screenshot; the structure here is what those calibrated numbers slot into.
|
||||
// Calibrated from a 1920x1080 English artifact-inventory screenshot and scaled
|
||||
// by client size. This follows Inventory Kamera's stable approach: fixed
|
||||
// 16:9-relative UI regions first, visual detection only as a fallback.
|
||||
|
||||
export interface LayoutRect {
|
||||
x: number;
|
||||
@@ -81,10 +81,10 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
|
||||
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
|
||||
return clampRect(
|
||||
{
|
||||
x: Math.round(width * 0.5),
|
||||
y: Math.round(height * 0.08),
|
||||
width: Math.round(width * 0.46),
|
||||
height: Math.round(height * 0.74),
|
||||
x: Math.round(width * 0.681),
|
||||
y: Math.round(height * 0.111),
|
||||
width: Math.round(width * 0.256),
|
||||
height: Math.round(height * 0.776),
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
@@ -97,10 +97,10 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
id: "artifact-title",
|
||||
label: "Artifact title",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.05),
|
||||
width: Math.round(detailRect.width * 0.82),
|
||||
height: Math.round(detailRect.height * 0.16),
|
||||
x: Math.round(detailRect.x),
|
||||
y: Math.round(detailRect.y),
|
||||
width: Math.round(detailRect.width),
|
||||
height: Math.round(detailRect.height * 0.07),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -108,9 +108,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
label: "Main stat",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.2),
|
||||
width: Math.round(detailRect.width * 0.82),
|
||||
height: Math.round(detailRect.height * 0.18),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.075),
|
||||
width: Math.round(detailRect.width * 0.58),
|
||||
height: Math.round(detailRect.height * 0.26),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -118,9 +118,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
label: "Substats",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.41),
|
||||
width: Math.round(detailRect.width * 0.82),
|
||||
height: Math.round(detailRect.height * 0.25),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.34),
|
||||
width: Math.round(detailRect.width * 0.86),
|
||||
height: Math.round(detailRect.height * 0.27),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -128,9 +128,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
label: "Footer",
|
||||
rect: {
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.78),
|
||||
width: Math.round(detailRect.width * 0.82),
|
||||
height: Math.round(detailRect.height * 0.16),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.82),
|
||||
width: Math.round(detailRect.width * 0.86),
|
||||
height: Math.round(detailRect.height * 0.14),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -139,12 +139,13 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
||||
}
|
||||
|
||||
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||
const { width, height } = imageSize;
|
||||
return clampRect(
|
||||
{
|
||||
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
|
||||
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
|
||||
width: Math.round(inventoryRect.width * 0.34),
|
||||
height: Math.round(inventoryRect.height * 0.09),
|
||||
x: Math.round(width * 0.795),
|
||||
y: Math.round(height * 0.02),
|
||||
width: Math.round(width * 0.145),
|
||||
height: Math.round(height * 0.055),
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
@@ -152,18 +153,12 @@ export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { w
|
||||
|
||||
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
|
||||
const { width, height } = imageSize;
|
||||
const preferredWidth = Math.max(140, Math.round(width * 0.48));
|
||||
const x = Math.round(width * 0.03);
|
||||
const y = Math.round(detailRect.y + detailRect.height * 0.09);
|
||||
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
|
||||
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
|
||||
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
|
||||
return clampRect(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
|
||||
height: Math.max(140, Math.round(height * 0.7)),
|
||||
x: Math.round(width * 0.055),
|
||||
y: Math.round(height * 0.155),
|
||||
width: Math.max(140, Math.round(Math.min(detailRect.x - width * 0.07, width * 0.63))),
|
||||
height: Math.max(140, Math.round(height * 0.74)),
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
@@ -171,18 +166,17 @@ export function inventoryRect(imageSize: { width: number; height: number }, deta
|
||||
|
||||
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
|
||||
const rect = inventoryRect(imageSize, detailRect);
|
||||
const cols = 5;
|
||||
if (rect.width < 160 || rect.height < 140) {
|
||||
const cols = 8;
|
||||
if (imageSize.width < 800 || imageSize.height < 450 || rect.width < 160 || rect.height < 140) {
|
||||
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
|
||||
}
|
||||
|
||||
const cellWidth = Math.max(56, Math.round(rect.width / cols));
|
||||
const stepX = Math.round(cellWidth * 0.96);
|
||||
const stepY = Math.round(cellWidth * 1.03);
|
||||
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
|
||||
const stepX = Math.round(imageSize.width * 0.076);
|
||||
const stepY = Math.round(imageSize.height * 0.163);
|
||||
const visibleRows = 5;
|
||||
|
||||
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
|
||||
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
|
||||
const startX = Math.round(imageSize.width * 0.093);
|
||||
const startY = Math.round(imageSize.height * 0.235);
|
||||
const centers: InventoryGridLayout["centers"] = [];
|
||||
for (let row = 0; row < visibleRows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
|
||||
@@ -19,13 +19,14 @@ function bitmap(goldPixels: number, total: number): Bitmap {
|
||||
}
|
||||
|
||||
describe("lockDetection", () => {
|
||||
it("places the lock crop in the top-right of the detail card", () => {
|
||||
it("places the lock crop on the lock button in the substat panel", () => {
|
||||
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);
|
||||
expect(rect.y).toBeGreaterThan(detail.y + detail.height * 0.3);
|
||||
expect(rect.y).toBeLessThan(detail.y + detail.height * 0.45);
|
||||
});
|
||||
|
||||
it("measures the gold-pixel ratio", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clampRect, type LayoutRect } from "./layoutProfile";
|
||||
import type { Bitmap } from "./ocrPreprocess";
|
||||
import { clampRect, type LayoutRect } from "./layoutProfile.js";
|
||||
import type { Bitmap } from "./ocrPreprocess.js";
|
||||
|
||||
// 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
|
||||
@@ -15,10 +15,10 @@ import type { Bitmap } from "./ocrPreprocess";
|
||||
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),
|
||||
x: Math.round(detailRect.x + detailRect.width * 0.735),
|
||||
y: Math.round(detailRect.y + detailRect.height * 0.355),
|
||||
width: Math.round(detailRect.width * 0.105),
|
||||
height: Math.round(detailRect.height * 0.07),
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
||||
import { simplifyForMatch } from "./fuzzyMatch";
|
||||
import { implausibleSubstats } from "./substatRolls";
|
||||
import type { CaptureResult, ReviewSampleRecord } from "../types/global";
|
||||
import type { ScannerLearningRulePayload } from "../types/global";
|
||||
|
||||
@@ -73,6 +74,10 @@ export function shouldFlagArtifactForReview(
|
||||
if (substatCount === 0) return true;
|
||||
if (substatCount < 3 && substatConfidence < 70) return true;
|
||||
|
||||
// A substat value that matches no legal roll combination is a guaranteed OCR
|
||||
// misread - never store it as fact, always review.
|
||||
if (implausibleSubstats(parsed.substats ?? []).length > 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function record(overrides: Partial<StoredArtifactRecord> = {}): StoredArtifactRe
|
||||
|
||||
describe("storedArtifactAdapter", () => {
|
||||
it("converts stored OCR artifacts into recommendation-domain artifacts", () => {
|
||||
const [artifact] = storedArtifactsToDomain([record()]);
|
||||
const [artifact] = storedArtifactsToDomain([record({ locked: true })]);
|
||||
|
||||
expect(artifact.slot).toBe("sands");
|
||||
expect(artifact.setKey).toBe("viridescent_venerer");
|
||||
@@ -32,6 +32,13 @@ describe("storedArtifactAdapter", () => {
|
||||
expect(artifact.equipped).toBe("Sucrose");
|
||||
expect(artifact.confidence).toBe(0.96);
|
||||
expect(artifact.source).toBe("screen");
|
||||
expect(artifact.locked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not invent lock state from confidence", () => {
|
||||
const [artifact] = storedArtifactsToDomain([record({ locked: undefined, confidence: 100 })]);
|
||||
|
||||
expect(artifact.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps flat and percent ATK substats distinct", () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifa
|
||||
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
|
||||
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
|
||||
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
|
||||
locked: !record.needsReview && record.confidence >= 90,
|
||||
locked: Boolean(record.locked),
|
||||
source: toSource(record.source),
|
||||
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
|
||||
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
implausibleSubstats,
|
||||
inferRarity,
|
||||
isPlausibleSubstat,
|
||||
isPlausibleSubstatValue,
|
||||
parseSubstatEntry,
|
||||
} from "./substatRolls";
|
||||
|
||||
describe("substatRolls parsing", () => {
|
||||
it("parses percent and flat entries", () => {
|
||||
expect(parseSubstatEntry("CRIT DMG+13.2%")).toEqual({ stat: "CRIT DMG", value: 13.2, percent: true });
|
||||
expect(parseSubstatEntry("HP+1,509")).toEqual({ stat: "HP", value: 1509, percent: false });
|
||||
expect(parseSubstatEntry("garbage")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("substatRolls plausibility", () => {
|
||||
it("accepts real single-roll values (5-star)", () => {
|
||||
expect(isPlausibleSubstatValue("CRIT DMG", 7.8)).toBe(true); // 7.77 high roll
|
||||
expect(isPlausibleSubstatValue("CRIT DMG", 5.4)).toBe(true); // 5.44 low roll
|
||||
expect(isPlausibleSubstatValue("CRIT Rate", 3.9)).toBe(true); // 3.89
|
||||
expect(isPlausibleSubstatValue("ATK", 19)).toBe(true); // 19.45
|
||||
expect(isPlausibleSubstatValue("HP", 269)).toBe(true); // 268.88
|
||||
});
|
||||
|
||||
it("accepts multi-roll sums", () => {
|
||||
expect(isPlausibleSubstat("CRIT DMG+13.2%")).toBe(true); // 5.44+7.77 or 6.22+6.99
|
||||
expect(isPlausibleSubstat("Elemental Mastery+68")).toBe(true);
|
||||
expect(isPlausibleSubstat("Energy Recharge+11.7%")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects values that fit no roll combination at either rarity (OCR misreads)", () => {
|
||||
// 8.1% CRIT DMG sits in the gap: single roll maxes at 7.8 (5-star), and the
|
||||
// smallest two-roll sum is 8.2 (4-star), so it is unreachable at both.
|
||||
expect(isPlausibleSubstatValue("CRIT DMG", 8.1)).toBe(false);
|
||||
// 3.5% CRIT DMG is below the lowest possible roll at either rarity.
|
||||
expect(isPlausibleSubstatValue("CRIT DMG", 3.5)).toBe(false);
|
||||
// A dropped digit on flat ATK.
|
||||
expect(isPlausibleSubstatValue("ATK", 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag unknown stats", () => {
|
||||
expect(isPlausibleSubstatValue("Mystery Stat", 12.3)).toBe(true);
|
||||
});
|
||||
|
||||
it("collects the implausible entries from a substat list", () => {
|
||||
const bad = implausibleSubstats(["CRIT DMG+13.2%", "CRIT DMG+8.1%", "ATK+19"]);
|
||||
expect(bad).toEqual(["CRIT DMG+8.1%"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("substatRolls rarity inference", () => {
|
||||
it("is 5-star for any artifact leveled past +16", () => {
|
||||
expect(inferRarity(20, ["ATK%+3.5%"])).toBe(5);
|
||||
expect(inferRarity(17, [])).toBe(5);
|
||||
});
|
||||
|
||||
it("detects 5-star from a substat that only fits the 5-star table", () => {
|
||||
// 7.8% CRIT DMG is a 5-star single high roll; not reachable at 4-star.
|
||||
expect(inferRarity(12, ["CRIT DMG+7.8%"])).toBe(5);
|
||||
});
|
||||
|
||||
it("detects 4-star from a substat that only fits the 4-star table", () => {
|
||||
// 4.1% CRIT DMG is a 4-star single low roll; below any 5-star roll.
|
||||
expect(inferRarity(12, ["CRIT DMG+4.1%"])).toBe(4);
|
||||
});
|
||||
|
||||
it("defaults to 5-star when ambiguous", () => {
|
||||
expect(inferRarity(8, [])).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Substat roll validation (the accuracy trick yas / Genshin Optimizer use).
|
||||
// Every artifact substat value is the SUM of discrete per-roll increments: a
|
||||
// substat rolls once when it appears and again at every +4 level, up to 6 rolls
|
||||
// total. So a displayed value is only legitimate if it equals round(sum of N
|
||||
// rolls) for some N in 1..6 from that stat's roll table. An OCR value that fits
|
||||
// no combination is a misread (e.g. a dropped/extra digit) and should go to
|
||||
// review instead of being stored as fact.
|
||||
//
|
||||
// Roll tables are keyed by the app's display stat names. 5-star values are the
|
||||
// full set; 4-star values are included for the stats used to tell rarities
|
||||
// apart. Flat 4-star tables are intentionally omitted (kept conservative).
|
||||
|
||||
const MAX_ROLLS = 6;
|
||||
|
||||
const ROLLS_5STAR: Record<string, number[]> = {
|
||||
HP: [209.13, 239.0, 268.88, 298.75],
|
||||
ATK: [13.62, 15.56, 17.51, 19.45],
|
||||
DEF: [16.2, 18.52, 20.83, 23.15],
|
||||
"HP%": [4.08, 4.66, 5.25, 5.83],
|
||||
"ATK%": [4.08, 4.66, 5.25, 5.83],
|
||||
"DEF%": [5.1, 5.83, 6.56, 7.29],
|
||||
"Elemental Mastery": [16.32, 18.65, 20.98, 23.31],
|
||||
"Energy Recharge": [4.53, 5.18, 5.83, 6.48],
|
||||
"CRIT Rate": [2.72, 3.11, 3.5, 3.89],
|
||||
"CRIT DMG": [5.44, 6.22, 6.99, 7.77],
|
||||
};
|
||||
|
||||
const ROLLS_4STAR: Record<string, number[]> = {
|
||||
"HP%": [3.06, 3.5, 3.93, 4.37],
|
||||
"ATK%": [3.06, 3.5, 3.93, 4.37],
|
||||
"DEF%": [3.83, 4.37, 4.92, 5.47],
|
||||
"Elemental Mastery": [12.25, 13.99, 15.74, 17.48],
|
||||
"Energy Recharge": [3.4, 3.89, 4.37, 4.86],
|
||||
"CRIT Rate": [2.04, 2.33, 2.62, 2.91],
|
||||
"CRIT DMG": [4.08, 4.66, 5.25, 5.83],
|
||||
};
|
||||
|
||||
const PERCENT_STATS = new Set([
|
||||
"HP%",
|
||||
"ATK%",
|
||||
"DEF%",
|
||||
"Energy Recharge",
|
||||
"CRIT Rate",
|
||||
"CRIT DMG",
|
||||
]);
|
||||
|
||||
function isPercent(stat: string) {
|
||||
return PERCENT_STATS.has(stat);
|
||||
}
|
||||
|
||||
function displayRound(value: number, percent: boolean) {
|
||||
return percent ? Math.round(value * 10) / 10 : Math.round(value);
|
||||
}
|
||||
|
||||
// All displayed values reachable by summing 1..MAX_ROLLS rolls from `rolls`.
|
||||
function buildValidSet(rolls: number[], percent: boolean): Set<number> {
|
||||
const results = new Set<number>();
|
||||
let sums = new Set<number>([0]);
|
||||
for (let n = 1; n <= MAX_ROLLS; n++) {
|
||||
const next = new Set<number>();
|
||||
for (const sum of sums) {
|
||||
for (const roll of rolls) next.add(Math.round((sum + roll) * 1000) / 1000);
|
||||
}
|
||||
sums = next;
|
||||
for (const sum of sums) results.add(displayRound(sum, percent));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const VALID_CACHE = new Map<string, Set<number>>();
|
||||
|
||||
function validSet(stat: string, table: Record<string, number[]>, tag: string): Set<number> | null {
|
||||
const rolls = table[stat];
|
||||
if (!rolls) return null;
|
||||
const key = `${tag}:${stat}`;
|
||||
let cached = VALID_CACHE.get(key);
|
||||
if (!cached) {
|
||||
cached = buildValidSet(rolls, isPercent(stat));
|
||||
VALID_CACHE.set(key, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function parseSubstatEntry(entry: string): { stat: string; value: number; percent: boolean } | null {
|
||||
const plusIndex = entry.indexOf("+");
|
||||
if (plusIndex <= 0) return null;
|
||||
const stat = entry.slice(0, plusIndex).trim();
|
||||
const raw = entry.slice(plusIndex + 1).replace(/,/g, "").replace("%", "").trim();
|
||||
const value = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return { stat, value, percent: entry.includes("%") };
|
||||
}
|
||||
|
||||
/** A displayed value is plausible if it matches a roll sum for 5-star or 4-star. */
|
||||
export function isPlausibleSubstatValue(stat: string, value: number): boolean {
|
||||
const rounded = displayRound(value, isPercent(stat));
|
||||
const five = validSet(stat, ROLLS_5STAR, "5");
|
||||
const four = validSet(stat, ROLLS_4STAR, "4");
|
||||
// Unknown stat (no table) -> do not claim it is implausible.
|
||||
if (!five && !four) return true;
|
||||
return Boolean(five?.has(rounded)) || Boolean(four?.has(rounded));
|
||||
}
|
||||
|
||||
export function isPlausibleSubstat(entry: string): boolean {
|
||||
const parsed = parseSubstatEntry(entry);
|
||||
if (!parsed) return true;
|
||||
return isPlausibleSubstatValue(parsed.stat, parsed.value);
|
||||
}
|
||||
|
||||
/** The substat entries whose value fits no legal roll combination. */
|
||||
export function implausibleSubstats(substats: readonly string[]): string[] {
|
||||
return substats.filter((entry) => !isPlausibleSubstat(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort rarity from level + which roll table the substats fit. Conservative:
|
||||
* only returns 4 when a substat clearly fits the 4-star table and not 5-star;
|
||||
* otherwise defaults to 5 (the common case and the previous hardcoded value).
|
||||
*/
|
||||
export function inferRarity(level: number, substats: readonly string[]): number {
|
||||
if (level > 16) return 5; // only 5-star artifacts level past +16
|
||||
let fitsFiveOnly = 0;
|
||||
let fitsFourOnly = 0;
|
||||
for (const entry of substats) {
|
||||
const parsed = parseSubstatEntry(entry);
|
||||
if (!parsed) continue;
|
||||
const rounded = displayRound(parsed.value, isPercent(parsed.stat));
|
||||
const five = validSet(parsed.stat, ROLLS_5STAR, "5");
|
||||
const four = validSet(parsed.stat, ROLLS_4STAR, "4");
|
||||
if (!five || !four) continue;
|
||||
const inFive = five.has(rounded);
|
||||
const inFour = four.has(rounded);
|
||||
if (inFive && !inFour) fitsFiveOnly++;
|
||||
else if (inFour && !inFive) fitsFourOnly++;
|
||||
}
|
||||
if (fitsFiveOnly > 0) return 5;
|
||||
if (fitsFourOnly > 0) return 4;
|
||||
return 5;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
SaveScannerLearningRulesResult,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
ScannerLearningRulePayload,
|
||||
} from "../types/global";
|
||||
@@ -39,6 +41,7 @@ export interface AssistantBridge {
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
showOverlay: () => Promise<BooleanResult>;
|
||||
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
||||
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
||||
@@ -51,9 +54,10 @@ export interface AssistantBridge {
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
focusMainWindow: () => Promise<BooleanResult>;
|
||||
focusGenshin: () => Promise<FocusGenshinResult>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
|
||||
}
|
||||
|
||||
function hasFunction(api: Record<string, unknown>, key: string): boolean {
|
||||
@@ -66,6 +70,7 @@ export function getAssistantBridge(): AssistantBridge | null {
|
||||
const apiRecord = api as unknown as Record<string, unknown>;
|
||||
const canAutoScan = hasFunction(apiRecord, "clickScreen") && hasFunction(apiRecord, "scrollScreen");
|
||||
const canReviewSamples = hasFunction(apiRecord, "loadReviewSamples") && hasFunction(apiRecord, "saveReviewSample");
|
||||
const hasFocusGenshinForScanStart = hasFunction(apiRecord, "focusGenshinForScanStart");
|
||||
|
||||
return {
|
||||
isAvailable: true,
|
||||
@@ -79,6 +84,7 @@ export function getAssistantBridge(): AssistantBridge | null {
|
||||
listCaptureSources: () => api.listCaptureSources(),
|
||||
captureSource: (sourceId, delayMs, focusGenshin, options) => api.captureSource(sourceId, delayMs, focusGenshin, options),
|
||||
exportGood: (payload) => api.exportGood(payload),
|
||||
importGoodFile: () => api.importGoodFile(),
|
||||
loadArtifacts: () => api.loadArtifacts(),
|
||||
saveArtifacts: (records) => api.saveArtifacts(records),
|
||||
loadReviewSamples: (limit = 50) => api.loadReviewSamples(limit),
|
||||
@@ -90,6 +96,7 @@ export function getAssistantBridge(): AssistantBridge | null {
|
||||
publishScannerStatus: (status) => api.publishScannerStatus(status),
|
||||
focusMainWindow: () => api.focusMainWindow(),
|
||||
focusGenshin: () => api.focusGenshin(),
|
||||
focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()),
|
||||
clickScreen: (x: number, y: number) => api.clickScreen(x, y),
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY),
|
||||
showOverlay: () => api.showOverlay(),
|
||||
|
||||
Vendored
+22
-1
@@ -56,6 +56,7 @@ export interface CaptureResult {
|
||||
source: "ocr" | "missing";
|
||||
text: string;
|
||||
};
|
||||
locked?: boolean;
|
||||
layout?: {
|
||||
aspect: string;
|
||||
isSixteenNine: boolean;
|
||||
@@ -125,6 +126,14 @@ export interface SaveResultWithPath {
|
||||
|
||||
export type SaveSnapshotResult = SaveResultWithPath;
|
||||
|
||||
export type ScannerCommand =
|
||||
| "start-auto"
|
||||
| "stop"
|
||||
| {
|
||||
type: "start-auto";
|
||||
scanLimit?: number;
|
||||
};
|
||||
|
||||
export interface ScannerLearningRulePayload {
|
||||
textReplacements?: Record<string, string>;
|
||||
}
|
||||
@@ -211,6 +220,7 @@ export interface ReviewSampleRecord {
|
||||
ocr?: OcrResult[];
|
||||
inventoryGrid?: CaptureResult["inventoryGrid"];
|
||||
inventoryCount?: CaptureResult["inventoryCount"];
|
||||
locked?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -239,6 +249,7 @@ export interface ReviewSamplePayload {
|
||||
ocr?: OcrResult[];
|
||||
inventoryGrid?: CaptureResult["inventoryGrid"];
|
||||
inventoryCount?: CaptureResult["inventoryCount"];
|
||||
locked?: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -280,6 +291,14 @@ export interface GoodDatabase {
|
||||
artifacts: GoodExportArtifact[];
|
||||
}
|
||||
|
||||
export interface GoodImportFileResult {
|
||||
ok: boolean;
|
||||
canceled: boolean;
|
||||
path: string;
|
||||
database?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
assistantApi?: {
|
||||
@@ -293,6 +312,7 @@ declare global {
|
||||
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||
focusMainWindow: () => Promise<BooleanResult>;
|
||||
focusGenshin: () => Promise<FocusGenshinResult>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
saveReviewSample: (sample: ReviewSamplePayload) => Promise<SaveResultWithPath>;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
@@ -301,10 +321,11 @@ declare global {
|
||||
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
||||
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
showOverlay: () => Promise<BooleanResult>;
|
||||
hideOverlay: () => Promise<BooleanResult>;
|
||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface StoredArtifactRecord {
|
||||
equipped: string;
|
||||
confidence: number;
|
||||
needsReview: boolean;
|
||||
locked?: boolean;
|
||||
source: string;
|
||||
firstSeenAt?: string;
|
||||
lastSeenAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user