Improve IK-style artifact scanner pipeline

This commit is contained in:
AzuTear
2026-07-07 22:02:24 +02:00
parent 8ebbe91c39
commit f791d1464c
70 changed files with 7408 additions and 445 deletions
+3
View File
@@ -5,6 +5,9 @@ node_modules/
dist/ dist/
dist-electron/ dist-electron/
outputs/dist/ outputs/dist/
outputs/admin-start/
outputs/live-capture/
outputs/live-soak/
# Logs # Logs
*.log *.log
+19 -1
View File
@@ -55,6 +55,9 @@ flowchart LR
| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview | | `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview |
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes | | `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data | | `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
| `src/lib/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
| `src/lib/autoScanEntry.ts` | Pure entry-mode planning and auto-scan preflight validation |
| `src/lib/cardReadyGate.ts` | Detail/page fingerprint readiness gate for scan timing |
| `src/lib/scoring.ts` | Recommendation and build scoring logic | | `src/lib/scoring.ts` | Recommendation and build scoring logic |
| `src/lib/demoData.ts` | Temporary local demo snapshot | | `src/lib/demoData.ts` | Temporary local demo snapshot |
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats | | `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
@@ -131,6 +134,17 @@ sequenceDiagram
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). [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. - 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". - 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".
- Artifact grid automation uses Inventory Kamera's 32-target full-page model
(`8 x 4` safe click targets). The apparent lower fifth row sits in the
bottom control band on 16:9 captures and is not clicked automatically.
- Guided auto-entry is state gated. The normal scan button first performs a
lightweight no-OCR preflight; OCR/store/review work starts only after the
artifact inventory grid and artifact detail card are visually confirmed.
- The entry path tries direct inventory (`B`) first and uses the IK-style
ESC/B fallback only when direct entry does not reach an artifact detail card.
- Card and page waits are fingerprint based. The scanner can proceed as soon as
the expected visual state changes and stabilizes, while still accepting IK-like
200 ms item and 100 ms scroll readiness points.
- 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). - 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).
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them. - The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them.
@@ -156,4 +170,8 @@ Parsed artifacts from both modes are persisted into `artifact-store.json` keyed
## Performance ## Performance
Current OCR is prototype-grade and may be slower than the target scanner. Two batch-scan bottlenecks were removed: input/capture no longer spawn a PowerShell process (and recompile Win32 interop) per action, and the Tesseract worker is created once and reused across captures. The eventual batch scanner should still move expensive capture/OCR/build work into workers or a native sidecar. Current OCR is still measured against the IK target rather than assumed good.
The app keeps a Tesseract.js worker pool, can use the Inventory-Kamera
`genshin_fast_09_04_21.traineddata` path for comparison, and reports capture,
OCR, card-ready, scroll-ready, active-scan, and projected-100 timings. A default
engine change requires a same-capture benchmark and a qualified live soak result.
+282 -1
View File
@@ -23,6 +23,12 @@ Validated live on 2026-07-07 with Genshin open in the artifact inventory at
This proves that the current elevated app plus helper path can deliver mouse 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. movement and click input to the focused Genshin client in this environment.
Latest-source timing is not proven while `/health.appBuild.signature` differs
from the `APP_RUNTIME_SIGNATURE` in `electron/main.ts`. On 2026-07-07 the port
was still owned by an older elevated runtime, so goal scans were intentionally
blocked by the stale-build gate. Restart the elevated app through
`npm run dev:admin` and confirm UAC before collecting new 100-artifact evidence.
## Elevation And UAC ## Elevation And UAC
Use: Use:
@@ -98,6 +104,66 @@ For live validation, prefer a bounded scan first:
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2" Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2"
``` ```
The visible-inventory path remains the safest first check. The normal guided
entry tries the read-only direct world path first:
`B -> artifact tab -> first artifact tile`. If that does not produce a visible
artifact detail card, it falls back to the Inventory Kamera-compatible sequence:
`ESC -> B -> artifact tab -> first artifact tile`.
The normal Auto-Scan button uses a guided start. It first takes one lightweight
preflight capture without OCR, full-frame payload, review scoring, or storing.
If an artifact detail card is already visible, it starts the visible-inventory
scan. Otherwise it runs the guided entry above. OCR/review/store work starts
only after the artifact-detail preflight passes.
Guided entry uses short state polling for the Inventory screen, artifact grid,
and first detail card instead of waiting the full fixed delay every time; if the
state never appears, the same timeout budget returns the last diagnostic capture.
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=paimon-menu&limit=2"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=auto-entry&limit=2"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2&engine=ik-traineddata"
```
Those paths send only read-only navigation. `ESC` is not a universal "go to
world" command: from the world it opens the Paimon menu, while from the
already-open Paimon menu it returns to the world. This is why the normal
`auto-entry` path first tries `B` directly and uses the IK-style `ESC -> B`
fallback only when direct entry did not reach an artifact detail card.
The scan starts only after a valid lookup package, supported 16:9 layout,
detected artifact grid, Genshin-client capture, and visual artifact-detail
markers are all present. If any preflight check fails, keep using the
visible-inventory path while tuning the entry step.
The visual preflight also classifies the Paimon menu. The Paimon profile/card
grid can look like an inventory grid if only fixed 16:9 coordinates are used, so
the scanner must reject `paimonMenu.present` before any artifact OCR, review
sample creation, store write, or grid scan starts. The guided entry may still
take lightweight skip-OCR captures while navigating, but those captures are only
state evidence.
The same guard also runs inside the scan loop. If the app is on the main game
screen, a Paimon/menu screen, a generic primary-screen capture, or any screen
without an artifact detail card, auto-scan must block instead of clicking tiles
or trying OCR.
After each click the loop polls the detail fingerprint with a short bounded
budget instead of sleeping blindly. The current budget is 420 ms with 60 ms
polls; if the card changes and stabilizes earlier, OCR starts earlier, and if it
does not change the loop retries or stops through the normal miss guards. If the
card changed but remains animated, the loop now proceeds after 200 ms, matching
Inventory Kamera's select-next-item wait more closely without removing the
detail-change guard.
The outer scan start focuses Genshin once; hot-loop fingerprint/OCR captures do
not re-run the focus helper before every tile, which avoids an OS focus ping on
each artifact while still relying on click readback, foreground checks, and the
detail-card guard for safety.
After a scroll, the loop now uses the same cheap fingerprint polling model for
the inventory pane: it proceeds as soon as the next page fingerprint changed and
stabilized instead of always sleeping the old fixed 760 ms settle delay. Changed
but still animated inventory pages may proceed after 100 ms, again matching IK's
fast-scroll wait while still blocking unchanged pages.
Then poll: Then poll:
```powershell ```powershell
@@ -105,10 +171,221 @@ Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
ConvertTo-Json -Depth 12 ConvertTo-Json -Depth 12
``` ```
Before live timing, verify that the endpoint is the current app instance:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/health" |
ConvertTo-Json -Depth 6
```
The response must include `appBuild.signature` and
`appBuild.expectedOcrWorkerPoolSize`. If `appBuild` is missing, or
`/scanner/status` still reports the old OCR warmup start time, the local port is
still owned by a stale elevated Electron process. Close the old Administrator
window/app and restart with `npm run dev:admin` before running scanner probes.
Use the status `stats` timing fields for IK comparisons: `elapsedMs`,
`activeScanMs`, `writeFlushMs`, `averageMsPerParsed`,
`activeAverageMsPerParsed`, `averageCaptureMs`, `averageOcrMs`,
`artifactsPerMinute`, and `projectedMsFor100`. `elapsedMs` is end-to-end
including queued writes; `activeScanMs` is the click/capture/OCR loop before
the final store/review flush. A run only counts as speed
evidence when `parsed`, `stored`, `review`, `duplicates`, and `misses` are read
together; raw click count alone is not scanner throughput. If `averageOcrMs`
dominates `averageMsPerParsed`, the next speed lever is an IK-style OCR worker
queue. If `averageCaptureMs` dominates, crop payload/capture work is the
bottleneck.
The `/scanner/start?limit=N` endpoint sends a renderer command payload with a 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 temporary scan limit. It does not change the normal UI setting. The normal
hotkeys and buttons still use the UI's configured scan limit. hotkeys and buttons still use the UI's configured scan limit.
Lookup and benchmark utility endpoints:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/status"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/regenerate"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/ocr/warmup"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/ocr/warmup?engine=ik-traineddata"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&engine=ik-traineddata"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&engine=compare"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&profile=full"
```
The benchmark endpoint measures the current Tesseract.js engine and the
Inventory-Kamera-traineddata Tesseract.js path against the artifact crop set and
returns timing/field counts, min/p50/p90/max timing, OCR p50/p90 timing,
20/45/100-artifact projections, skipped-OCR count, and the active OCR worker
pool size. It also returns per-field OCR timings under
`ocrFieldAverages`, which is the first place to look before changing crop or
parser behavior. Individual captures also report whether the artifact was
detected as `sanctified`; level/substat crops are shifted in that state to match
Inventory Kamera's crop model. By default it uses the auto-scan `fast` OCR profile,
which omits the low-value set-effect crop, the slot crop that can be derived
from the matched artifact piece name, and the main-stat-value crop that can be
derived from slot, main-stat label, and level. The fast profile also uses
Inventory Kamera's tighter substat crop height; full/manual captures keep the
larger recovery crop for debugging difficult samples. Auto-scan also omits per-crop diagnostic Base64 images from hot-loop OCR
captures while keeping the detail screenshot, OCR text, crop rect metadata, and
timings. OCR crops are passed to Tesseract as PNG buffers internally, not as
Base64 DataURLs, to avoid encode/decode overhead in batch scans. When
`skipOcrUnlessArtifactDetail` blocks OCR because no artifact detail card is
visible, OCR crop preprocessing is skipped too. Auto-scan readiness and scroll
checks use native detail/inventory fingerprints and omit preview DataURLs in
poll captures. Fast preflight/poll captures also omit crop list construction, crop images, and lock-state
detection unless a caller explicitly overrides that option; add
`profile=full` to OCR every artifact detail crop for debugging. It uses the same
artifact-detail guard as auto-scan: if the current screen is not a confirmed artifact detail view, OCR is
skipped and the response shows `skippedOcrCaptures` instead of burning time on
invalid crops.
For speed, the fast auto-scan profile also skips the optional Equipped footer
OCR. Name, level, main-stat label, and substats remain in the OCR hot path;
slot, set, and main-stat value are derived when the lookup/parser can validate
them. Use a full/manual capture when equipped ownership or every debug crop matters.
Local store/review writes are serialized through an internal queue but no longer
block the next inventory click. The scan still flushes the queue before it
returns its final summary, so `stored` and `review` counts remain final-state
numbers.
The app warms the default OCR worker pool in the background after startup; check
`/scanner/status` -> `ocrWarmup.current` before timing the first artifact. Use
`/scanner/ocr/warmup?engine=ik-traineddata` before comparing Inventory
Kamera-traineddata timings so the benchmark is not dominated by worker creation.
`engine=ik-traineddata` uses Inventory Kamera's local
`genshin_fast_09_04_21.traineddata` through Tesseract.js when the file is found
in `data/tessdata`, `IK_TESSDATA_DIR`, `work/Inventory_Kamera`, `work/refs`,
or the local `_ik_ref*` folders.
`engine=compare` runs `current` and `ik-traineddata` against the same visible
artifact detail state. The auto-scan default must stay `current` until the IK
traineddata path wins on the same captures. For a controlled live comparison,
start the scanner with `engine=ik-traineddata`; this only changes the OCR
worker language for that run and leaves the default UI/hotkey path on
`current`.
The OCR pool defaults to four workers because the fast artifact crop set has
four useful OCR parameter groups; set `GAA_OCR_WORKERS=1..8` before startup to
benchmark a different worker count. Inventory Kamera's native engine pool is
still the reference design, but the current app path remains Tesseract.js until
native OCR is integrated and measured. Crops are scheduled across the whole
worker pool and each worker caches its last Tesseract parameter profile; this is
closer to Inventory Kamera's multi-engine field OCR than the earlier
parameter-group-serial scheduler.
## Diagnostic Evidence
The Diagnose page contains a compact evidence timeline for scanner work. It logs
runtime pings, focus attempts, key presses, entry captures, artifact-tab clicks,
preflight failures, grid/count metadata, detail fingerprints, and detail/inventory
screenshots. The same last events are also published through:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
ConvertTo-Json -Depth 18
```
Use this before changing scanner behavior: run the smallest failing action, read
the evidence timeline, then decide whether the failure is focus/input, entry
navigation, grid detection, capture quality, OCR, or parser validation.
If Paimon entry shows `entry key ESC` or `entry key B` with `eventsSent: 0`, the
running `InputHelper.exe` probably predates keyboard support or is blocked. Stop
the elevated app/helper, run `npm run helper:build`, then restart with
`npm run dev:admin` so the app loads the rebuilt helper.
## Soak-Test Helper
After the elevated app is running and Genshin is open on the artifact inventory,
the non-elevated terminal can drive the local dev-control endpoints and save a
full evidence bundle:
```powershell
npm run scan:soak
```
The helper writes timestamped JSON snapshots and a transcript to:
```text
outputs/live-soak/<timestamp>/
```
Default sequence:
1. `/health`
2. `/scanner/status`
3. `/capture/smart?skipOcr=1`
4. `/automation/probe-click?index=1`
5. `/automation/probe-click?index=3`
6. `/scanner/start?limit=2`
7. `/scanner/start?limit=5`
8. `/scanner/start?limit=10`
9. `/scanner/start?limit=20`
10. `/review/samples?limit=30`
For the actual Inventory-Kamera speed target, use the explicit goal run after
`/health` shows the current `appBuild`:
```powershell
npm run scan:goal
npm run scan:goal:current
npm run scan:goal:ik
npm run scan:goal:compare
```
That run first warms/benchmarks `current` vs. `ik-traineddata`, then scans
limits `2, 5, 20, 45, 100` with the selected scan engine, and writes
`scan-run-summary.json` plus `scan-run-summary.csv`. `npm run scan:goal`
uses the default `current` scan engine; use `scan:goal:ik` for a native
IK-traineddata scan pass. Use `scan:goal:compare` to run both scan engines
back-to-back with the same limits and one combined CSV. The CSV is the quickest evidence for
`averageMsPerParsed`, `activeAverageMsPerParsed`, `averageCaptureMs`,
`captureP50Ms`, `captureP90Ms`, `averageOcrMs`, `ocrP50Ms`, `ocrP90Ms`,
`averageCardReadyMs`, `averageScrollReadyMs`, `artifactsPerMinute`, and
`projectedMsFor100`.
The run also writes `scan-performance-assessment.json`, which groups results by
limit, picks the best qualified engine, and labels the dominant bottleneck as
OCR, capture, card-ready, or scroll-ready. A qualified winner must finish the
run, parse the requested count, keep miss rate under 2%, and keep review rate
at or below 15%; review and miss rates are penalized before active average speed
is used as the tie-breaker.
The assessment ranking can be verified without Genshin or the Electron app:
```powershell
npm run scan:assessment:test
```
This self-test rejects synthetic runs that are fast but have too many misses or
too many review samples, so the final IK comparison cannot be won by speed alone.
For the current implementation summary and IK comparison rationale, see
[scanner-ik-progress-report.md](scanner-ik-progress-report.md).
Use the readiness timings to compare against Inventory Kamera's fixed waits:
IK waits about 200 ms after selecting the next inventory item and about 100 ms
after fast scrolls. If `averageCardReadyMs` or `averageScrollReadyMs` dominates
the active average while OCR is already low, tune the fingerprint gate before
touching OCR again.
The runner reads `APP_RUNTIME_SIGNATURE` from `electron/main.ts` and refuses
to run against a stale Electron process when `/health.appBuild.signature` does
not match the current source. Use `-AllowStaleBuild` only for deliberate
debugging of an older instance.
Current dev builds also expose `/dev/shutdown` on localhost. The start cleanup
script calls it before falling back to `Stop-Process`, so a previous elevated
app can shut itself down cleanly even when the caller cannot terminate an
administrator process directly. Older builds without that endpoint still need
manual close or a confirmed `npm run dev:admin` restart.
Review samples are saved as a compact summary by default so Vite does not try to
watch large Base64 payloads under `outputs/`. Full review payloads can be saved
with `-SaveFullReviewSamples` when needed.
It stops on a failed probe, blocked scan, stopped scan, or timeout unless
`-ContinueAfterBlocked` is supplied directly:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\live-soak.ps1 -Limits 2,5 -ContinueAfterBlocked
```
## Anti-Cheat And Safety Boundary ## Anti-Cheat And Safety Boundary
Do not describe the current implementation as bypassing anti-cheat. The app Do not describe the current implementation as bypassing anti-cheat. The app
@@ -133,7 +410,9 @@ The current 16:9 layout profile is calibrated from a 1920x1080 English
artifact-inventory capture: artifact-inventory capture:
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838` - detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
- inventory grid: `8 x 5` - inventory grid: `8 x 4` safe automated targets, matching Inventory Kamera's
32-artifact full-page model. The apparent lower fifth row is in the bottom
control band and is intentionally not clicked during auto-scan.
- first tile center: `x=179`, `y=254`, `row=0`, `col=0` - first tile center: `x=179`, `y=254`, `row=0`, `col=0`
- second tile center: `x=325`, `y=254`, `row=0`, `col=1` - second tile center: `x=325`, `y=254`, `row=0`, `col=1`
- inventory count crop successfully read `2059/2400` in the live session - inventory count crop successfully read `2059/2400` in the live session
@@ -152,3 +431,5 @@ Before marking an automation change done:
5. If Genshin is available, run `/automation/probe-click?index=1`. 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. 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`. 7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
8. For IK-target claims, attach or cite `scan-performance-assessment.json` from
a non-stale `npm run scan:goal:compare` run.
+13
View File
@@ -4,6 +4,7 @@
- [ ] The expected crop or capture behavior is clear. - [ ] The expected crop or capture behavior is clear.
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files. - [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
- [ ] Auto-scan starts only from confirmed artifact inventory plus visible detail card, or blocks with a reason.
- [ ] Capture failures are shown to the user. - [ ] Capture failures are shown to the user.
- [ ] OCR uncertainty remains inspectable in Details. - [ ] OCR uncertainty remains inspectable in Details.
- [ ] Parser output does not silently trust low-confidence text. - [ ] Parser output does not silently trust low-confidence text.
@@ -12,6 +13,18 @@
- [ ] `npm run build` passes. - [ ] `npm run build` passes.
- [ ] Manual Smart Capture is tested when possible. - [ ] Manual Smart Capture is tested when possible.
## IK-Speed Or OCR-Engine Claim
- [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`.
- [ ] `npm run scan:assessment:test` passes.
- [ ] The run includes `scan-performance-assessment.json`.
- [ ] The 100-artifact run finishes cleanly.
- [ ] Parsed count is at least the requested count.
- [ ] Miss rate is at or below 2%.
- [ ] Review rate is at or below 15%.
- [ ] Speed comparison uses active scan timing plus quality, not click count alone.
- [ ] The default OCR engine is changed only after same-capture benchmark evidence.
## UI Change ## UI Change
- [ ] The main workflow remains visible without unnecessary scrolling. - [ ] The main workflow remains visible without unnecessary scrolling.
+47
View File
@@ -16,6 +16,7 @@ This document contains Architecture Decision Records.
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | 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-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-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
| ADR-011 | Quality-gated Inventory Kamera comparison before OCR default changes | Accepted | 2026-07-07 |
## ADR-001: Build A Local Electron App First ## ADR-001: Build A Local Electron App First
@@ -286,3 +287,49 @@ Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
- The implementation remains inside the allowed safety boundary: no memory - The implementation remains inside the allowed safety boundary: no memory
reads, hooks, injection, game-file modification, deleting, feeding, enhancing, reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
locking/unlocking, or spending resources. locking/unlocking, or spending resources.
## ADR-011: Quality-Gated Inventory Kamera Comparison Before OCR Default Changes
### Status
Accepted
### Context
The product target is not merely to click through 100 artifacts quickly. It is
to scan the first 100 artifacts with accuracy at least as good as Inventory
Kamera and speed equal to or better than Inventory Kamera. A faster scan that
creates too many misses, review samples, or false positives is worse than a
slower qualified run.
The app can now compare the current OCR path with Inventory Kamera's
`genshin_fast_09_04_21.traineddata` through the same visible crop set. It also
has hot-loop timing fields for capture, OCR, card readiness, scroll readiness,
active scan time, and projected 100-artifact time.
### Decision
Use a quality-gated live soak and benchmark before changing the default OCR
engine or claiming IK parity. A qualified scan result must:
- finish cleanly,
- parse at least the requested count,
- keep miss rate at or below 2%,
- keep review rate at or below 15%,
- report active scan timing and bottlenecks,
- come from a runtime whose `/health.appBuild.signature` matches the current
source.
`scripts/live-soak.ps1` writes the evidence bundle and
`scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that
the ranking logic rejects fast but low-quality synthetic runs without needing
Genshin.
### Consequences
- Speed claims cannot be based on click count or elapsed time alone.
- A new OCR engine cannot become the default just because it is theoretically
closer to IK; it must win the same-capture benchmark and a qualified live run.
- Stale elevated Electron instances are treated as invalid evidence, not as a
harmless warning.
- The goal remains open until the 100-artifact qualified comparison is captured.
+14 -2
View File
@@ -3,6 +3,8 @@
This document is the source of truth for project intent, scope, runtime facts, and operational expectations. This document is the source of truth for project intent, scope, runtime facts, and operational expectations.
For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md). For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md).
For the latest Inventory-Kamera comparison work, see
[scanner-ik-progress-report.md](scanner-ik-progress-report.md).
## Project Identity ## Project Identity
@@ -58,6 +60,7 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
| --- | --- | --- | | --- | --- | --- |
| Safety | Never perform irreversible in-game actions. | Code review and manual test | | Safety | Never perform irreversible in-game actions. | Code review and manual test |
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification | | Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
| IK target | First 100 artifacts should scan with accuracy at least as good as Inventory Kamera and equal or better speed. | `npm run scan:goal:compare` quality-gated report |
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path | | Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
| Reliability | Uncertain OCR must be visible to the user. | Confidence and details view | | Reliability | Uncertain OCR must be visible to the user. | Confidence and details view |
| Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` | | Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` |
@@ -95,6 +98,9 @@ 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. - 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. - 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. - The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
- The scanner now has an Inventory-Kamera comparison path: 32 safe artifact
targets per page, lookup-derived fields, fast OCR crop profile, current vs.
IK-traineddata benchmark endpoint, and a quality-gated live soak runner.
- Elevated live automation is validated in the current dev environment: - Elevated live automation is validated in the current dev environment:
`/automation/probe-click?index=1` changed the selected artifact and `/automation/probe-click?index=1` changed the selected artifact and
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses. `/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
@@ -108,6 +114,9 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
- 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. - 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. - The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
- Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus. - Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus.
- The latest source has not yet completed the final 100-artifact live comparison
because the current dev-control port is still owned by a stale elevated
Electron process. Live timing must wait for a UAC-approved restart.
### Current product conclusion ### Current product conclusion
@@ -233,8 +242,11 @@ Status:
2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples. 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. 3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions. 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. 5. Run `npm run scan:goal:compare` after `/health.appBuild.signature` matches
6. Resume recommendation work only when scan accuracy is consistently trustworthy. the current source and use the quality-gated 100-artifact report as the IK
target evidence.
6. Extend the learning system from text-only fixes into crop/UI profile tuning.
7. Resume recommendation work only when scan accuracy is consistently trustworthy.
## Open Questions ## Open Questions
+3
View File
@@ -2,12 +2,15 @@
Field-level accuracy measurement for the artifact OCR parser. This is the gate Field-level accuracy measurement for the artifact OCR parser. This is the gate
every OCR, crop, layout, or parser change runs against (see ADR-007). every OCR, crop, layout, or parser change runs against (see ADR-007).
It is necessary but not sufficient for the IK target: live scan speed and
review/miss rates are measured by `npm run scan:goal:compare`.
## Run it ## Run it
```powershell ```powershell
npm run eval # full accuracy report for the seed corpus npm run eval # full accuracy report for the seed corpus
npm test # runs the eval gate alongside the rest of the suite npm test # runs the eval gate alongside the rest of the suite
npm run scan:assessment:test # verifies quality-first scan ranking logic
``` ```
The report prints exact-match rate, overall field accuracy, a per-field The report prints exact-match rate, overall field accuracy, a per-field
+301
View File
@@ -0,0 +1,301 @@
# Scanner IK Progress Report - 2026-07-07
This report summarizes the scanner/OCR work toward the current target:
scan the first 100 artifacts with accuracy at least as good as Inventory Kamera
and speed equal to or better than Inventory Kamera, without memory reads, hooks,
injection, game-file modification, or unsafe in-game actions.
## Executive Summary
The scanner has moved from a fragile OCR-first prototype toward an
Inventory-Kamera-style artifact scanner:
- Artifact scan is now the first-class path.
- Auto-scan starts only after a validated artifact inventory/detail preflight.
- Main-game, Paimon-menu, primary-screen, unsupported-layout, missing-grid, and
missing-detail states block before OCR/store/review work.
- OCR uses a fast artifact profile that skips low-value fields and derives
slot, set, and main-stat value through lookup constraints when safe.
- The OCR worker pool, field crop split, page model, scroll model, and readiness
waits now mirror the relevant IK design choices more closely.
- Diagnostics now preserve state evidence, timings, screenshots where useful,
entry events, focus/input events, preflight failures, and scan-loop reasons.
- A live soak runner now measures throughput and quality, compares current vs.
IK-traineddata engines, and refuses to run against stale Electron builds.
The requested final goal is not proven complete yet. The current live dev port is
still owned by an older elevated Electron instance, so the latest code cannot be
truthfully benchmarked against IK until the app is restarted with UAC approval
and `npm run scan:goal:compare` completes a qualified 100-artifact run.
## What Changed
### Lookup and validation
- `scripts/generate-genshin-data.cjs` was extended into a stricter lookup
package generator.
- `src/lib/genshinLookup.ts` provides pure matching and validation for sets,
pieces, slots, stats, characters, aliases, GOOD keys, source version, and
validation summaries.
- Auto-scan preflight blocks if the lookup package is invalid.
Why this matters:
IK succeeds partly because raw OCR is not trusted by itself. The app now follows
the same principle: OCR text is normalized, matched, constrained, and derived
against a canonical package before it is accepted.
### OCR and parser pipeline
- Artifact detail crops are split into field-specific regions:
name, slot, main-stat label, main-stat value, level, substats, set effects,
equipped/footer, lock, and rarity.
- Fast auto-scan profile skips lower-value OCR work:
set effects, slot crop, main-stat value crop, equipped footer, crop images,
full-frame payloads, and inventory preview payloads.
- Slot, set, and main-stat value are derived when lookup, slot rules, and level
constraints make that safe.
- Field-specific Tesseract PSM/whitelist cleanup and preprocessing are used.
- OCR crops are passed as PNG buffers internally instead of Base64 DataURLs.
- Exact visual duplicates are skipped before OCR.
Why this matters:
The fast path spends OCR only on fields that materially change the artifact
identity or review decision. That is closer to IK's queued crop model than a
manual-debug capture that OCRs every visible thing.
### Engine comparison and benchmark path
- `/scanner/ocr/warmup?engine=current|ik-traineddata` warms OCR workers.
- `/scanner/benchmark-ocr?engine=current|ik-traineddata|compare` benchmarks the
same visible artifact crops.
- Auto-scan accepts `ocrEngine: "current" | "ik-traineddata"`.
- `scripts/live-soak.ps1` supports:
- `npm run scan:goal`
- `npm run scan:goal:current`
- `npm run scan:goal:ik`
- `npm run scan:goal:compare`
- `scan-performance-assessment.json` ranks runs by quality first and speed
second.
Important rule:
A fast engine cannot win if it has too many misses or too much review. A
qualified winner must finish cleanly, parse the requested count, keep miss rate
at or below 2%, and keep review rate at or below 15%.
### Auto-scan entry and safety
- The normal auto button runs a guided start:
1. focus Genshin,
2. run a lightweight no-OCR preflight,
3. if artifact detail is visible, use visible-inventory mode,
4. otherwise try direct `B -> artifact tab -> first artifact tile`,
5. if needed, fall back to the IK-style ESC/B inventory sequence,
6. start OCR only after artifact grid and detail card pass preflight.
- Entry captures are state evidence only. They do not create review samples,
store artifacts, or run artifact OCR before the detail preflight passes.
- Entry waits now poll for state readiness instead of always sleeping the full
fixed delay.
- Scan loop also rechecks the same safety boundary after each click and scroll.
Why this matters:
The previous failure mode was dangerous from a product-quality point of view:
when the game was not in artifact inventory, the scanner could still take
screenshots and try to read artifacts. The current path is explicitly blocked
outside the artifact inventory/detail state.
### Scan loop and speed
- Grid model uses IK's 32-artifact visible page concept (`8 x 4`) instead of
clicking the risky lower band.
- Last/partial page planning bottom-aligns like IK, avoiding unnecessary
duplicate reads after scroll.
- Card readiness uses detail fingerprint polling:
max 420 ms, 60 ms polls, changed cards may proceed after 200 ms.
- Scroll readiness uses inventory fingerprint polling:
max 760 ms, 80 ms polls, changed pages may proceed after 100 ms.
- Store/review writes are queued so the next tile can be clicked before disk
writes finish. The queue is still flushed before final summary.
- Focus is done once at scan start; hot-loop captures do not refocus every tile.
Why this matters:
IK uses fixed waits around 200 ms after selecting inventory items and 100 ms
after fast scrolls. The app now keeps those as safety ceilings/acceptance points
while allowing earlier continuation when visual evidence is ready.
### Diagnostics and logging
- Scanner diagnostics now capture timeline events for runtime, focus, keypress,
entry captures, tab clicks, first-tile clicks, preflight, OCR/skips, grid,
counts, detail/page fingerprints, and failure reasons.
- `/scanner/status` publishes recent diagnostic evidence.
- Review sample output is compact by default so Vite does not watch large Base64
payloads during live soak runs.
- The live runner writes timestamped JSON snapshots, CSV summaries, transcript,
benchmark data, and performance assessment files under `outputs/live-soak/`.
Why this matters:
Future scanner bugs can be debugged from captured evidence instead of relying
only on a human description of what appeared on screen.
## Vorgehensweise
1. Read the local Inventory Kamera reference under `work/Inventory_Kamera`.
2. Copy the proven concepts, not the entire implementation:
32 artifact targets per page, fixed coordinate ratios, queued OCR work,
short item/scroll waits, read-only inventory navigation, and Tesseract
traineddata comparison.
3. Harden the app's own architecture around those concepts:
pure lookup API, parser derivation, renderer scan orchestration,
Electron capture/OCR boundary, sidecar input helper, and diagnostics.
4. Add tests before trusting behavior:
lookup validation, parser derivation, auto-entry planning/preflight,
card-ready gates, page planning, scan-loop blocking, OCR eval corpus.
5. Add live tooling before claiming performance:
bounded probes, stale-build gate, benchmark endpoint, soak runner, CSV/JSON
assessment, and quality-first comparison.
## Tests and Evidence
Latest repo validation after the recent changes:
| Check | Result |
| --- | --- |
| PowerShell parse for `scripts/live-soak.ps1` | Passed |
| `npm run scan:assessment:test` | Passed |
| `npm run lint` | Passed |
| Focused scanner tests | Passed |
| `npm test` | Passed, 171 tests |
| OCR eval seed corpus | 100% exact match, 100% field accuracy, 100% critical fields |
| `npm run build` | Passed |
| `git diff --check` | Passed |
Live evidence already collected earlier on 2026-07-07:
- Probe click changed artifact detail successfully.
- Limit 2 live auto-scan completed with 2/2 parsed and 0 misses.
- Limit 20 live soak completed on the first visible page.
- Limit 45 live soak crossed into a scrolled page.
Current live limitation:
- `/health` still reports an older elevated build:
`2026-07-07-ocr-pool4-hotloop-no-refocus`.
- Current source expects:
`2026-07-07-ik32-fastsubstats-active-timing`.
- The live soak runner correctly refuses to benchmark the stale runtime.
- A UAC restart attempt was canceled, so the latest code is not yet live.
## Inventory Kamera Comparison
| Area | Inventory Kamera | Current app status |
| --- | --- | --- |
| Safe scope | Reads inventory through screen/click automation | Same safety boundary: screen capture and read-only input only |
| Entry | ESC/B inventory navigation and tab click | Direct `B` path plus IK-style fallback, with preflight guards |
| Page model | 32 artifact items per page | 32 safe targets (`8 x 4`) implemented |
| Last page | Bottom-aligned partial page after scroll | Implemented in page planner |
| Item wait | About 200 ms fixed wait | Fingerprint polling, accepts changed card after 200 ms |
| Scroll wait | About 100 ms fast wait after scroll | Fingerprint polling, accepts changed page after 100 ms |
| OCR model | Native Tesseract worker queue and custom traineddata | Tesseract.js pool with current and IK-traineddata comparison path |
| Field parsing | OCR plus game-data lookup | OCR plus generated lookup, GOOD keys, aliases, slot/stat constraints |
| Quality gate | Mature behavior by design and user history | Explicit benchmark/soak quality gates added |
| Diagnostics | Logs/screenshots in IK flow | Diagnostics timeline plus JSON evidence bundle |
| 100-artifact proof | Reference target | Not yet proven on latest app build |
What is theoretically better than before:
- The app no longer spends OCR on invalid screens.
- It no longer treats click count as scanner success.
- It can prove whether `current` or `ik-traineddata` wins on the same capture
set instead of changing engines blindly.
- It can reject fast-but-wrong results automatically.
- It can identify whether the bottleneck is OCR, capture, card readiness, or
scroll readiness.
What is not yet proven better than IK:
- Native Tesseract speed is not integrated as the default.
- The latest code has not completed the 100-artifact live run.
- Review rate and miss rate on the user's real inventory still need the new
live report.
## Theoretical Runtime Flow
For the intended 100-artifact comparison:
1. Start current elevated app with `npm run dev:admin` and confirm UAC.
2. Verify `/health.appBuild.signature` matches `electron/main.ts`.
3. Warm current and IK-traineddata OCR workers.
4. Run a small bounded probe from the artifact inventory.
5. Run `npm run scan:goal:compare`.
6. For each engine and limit (`2, 5, 20, 45, 100`):
- focus Genshin once,
- verify lookup and layout,
- verify artifact grid and detail card,
- click one safe grid target,
- poll detail fingerprint,
- skip duplicate visuals,
- OCR only the fast artifact crop set,
- parse through lookup constraints,
- queue store/review writes,
- scroll with inventory fingerprint polling,
- stop on repeated pages, invalid surfaces, blocked input, OCR timeout, or
repeated misses.
7. Write CSV, JSON snapshots, transcript, benchmark report, and performance
assessment.
8. Declare a winner only if the 100-artifact run is qualified by quality.
Expected bottleneck sequence:
- If OCR dominates, compare `current` vs `ik-traineddata`, crop count, and
worker pool size.
- If capture dominates, reduce payload construction and preview/crop image work.
- If card-ready dominates, tune the detail fingerprint gate.
- If scroll-ready dominates, tune page fingerprint polling and scroll notches.
## What Is Better Than Before
- Auto-scan is artifact-detail gated; no more blind OCR from main gameplay or
menu screens.
- Paimon/menu detection blocks before scan-loop OCR or writes.
- The normal button is one coherent guided flow instead of a separate "get to
inventory first, then scan" workflow.
- The app has a real lookup layer instead of raw OCR plus scattered hardcoded
assumptions.
- The scanner can compare OCR engines without changing the default blindly.
- Performance reports now include quality decisions, not just elapsed time.
- Diagnostics are concrete enough for later self-troubleshooting.
- Stale elevated runtime is detected before live soak, avoiding false evidence.
## Risks and Remaining Work
1. Restart with UAC and run the latest build live.
2. Run `npm run scan:goal:compare` from a visible artifact inventory.
3. If the 100-artifact winner is not qualified, inspect:
`scan-performance-assessment.json`, review samples, diagnostic timeline, and
field timings.
4. If `ik-traineddata` wins but Tesseract.js is still slow, evaluate native
Tesseract integration.
5. Grow the eval corpus with confirmed real review samples before tightening
parser thresholds further.
6. Validate a positive locked-artifact sample.
7. Keep recommendations secondary until scanner quality is proven.
## Definition of Done for the IK Target
The goal is complete only when current evidence proves all of these:
- The app is running the latest runtime signature.
- The 100-artifact scan finishes cleanly.
- Parsed count is at least 100.
- Miss rate is at or below 2%.
- Review rate is at or below 15%.
- The run is equal to or faster than the recorded IK reference or the selected
IK-traineddata/native baseline on the same machine and inventory setup.
- The evidence bundle is saved under `outputs/live-soak/`.
- Any chosen default OCR engine is backed by the same-capture benchmark.
+89 -2
View File
@@ -5,6 +5,22 @@ Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in
live automation runbook, see live automation runbook, see
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
## Current IK-Speed Target Status
See [scanner-ik-progress-report.md](scanner-ik-progress-report.md) for the full
report.
Current status:
- The scanner architecture now follows the relevant Inventory Kamera model:
32 artifact targets per page, lookup-derived fields, fast artifact OCR profile,
short readiness gates, page-overlap planning, and queued OCR/store work.
- The live runner can compare `current` and `ik-traineddata` engines and rejects
runs that are fast but fail miss/review quality thresholds.
- The final 100-artifact IK target is not proven yet. The dev-control port is
currently owned by an older elevated Electron build, and the runner correctly
refuses stale timing evidence until the app is restarted with UAC approval.
## Done (implemented, unit-tested, build green) ## Done (implemented, unit-tested, build green)
- **OCR eval harness** — `src/eval/`, `npm run eval`, gate in `npm test`. See - **OCR eval harness** — `src/eval/`, `npm run eval`, gate in `npm test`. See
@@ -37,6 +53,62 @@ live automation runbook, see
- **Bounded auto-scan validation** — `/scanner/start?limit=2` completed live - **Bounded auto-scan validation** — `/scanner/start?limit=2` completed live
with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records, with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records,
2 review samples, and 0 misses. 2 review samples, and 0 misses.
- **Auto-scan OCR performance pass** - auto-scan captures now use an artifact
OCR mode that skips inventory-count OCR on each tile, keeps equipped-character
OCR, raises the substat crop to catch artifact level, stores automatic review
samples without full-screen/inventory screenshots, reads only the tail of large
JSONL files, avoids review noise when only level/equipped is missing, starts
the scan with an OCR-free preflight capture, skips exact visual duplicates
before OCR, prevents repeated startup review reprocessing, omits full-frame
and inventory-preview Base64 payloads from tile captures, and applies
crop-specific Tesseract page-segmentation/whitelist parameters.
- **Visible-page live soak helper** - `scripts/live-soak.ps1` now drives the
dev-control health/status, smart-capture, probe-click, bounded scan, and
review-tail endpoints and writes evidence to `outputs/live-soak/`. On
2026-07-07 it completed probes at indices 1 and 3 plus scan limits 2, 5, 10,
and 20 against the elevated running app. The limit 20 run finished `done` with
20 attempted, 20 verified, 18 parsed, 18 stored, 1 review, 1 duplicate, 1
miss, and 1 page.
- **Scroll/page-transition live soak** - after the helper and loop fixes,
`scripts/live-soak.ps1 -Limits 45 -ProbeIndices 1 -SkipSmartCapture`
completed `done` on 2026-07-07 with 45 attempted, 45 verified, 35 parsed, 35
stored, 9 review, 1 duplicate, 9 misses, and 2 pages. This validates that the
scanner can cross from the first visible page into a scrolled page in the live
1920x1080 setup.
- **Lookup package layer** - `scripts/generate-genshin-data.cjs` now emits
normalized lookup keys, GOOD keys, piece/set/slot links, aliases, source
version metadata, generated time, and validation summary. `src/lib/genshinLookup.ts`
provides pure matching and validation APIs, and the scanner status/dev-control
path exposes lookup validity. Auto-scan preflight blocks when the lookup package
is invalid.
- **Inventory-Kamera-style field split** - artifact detail crops now separate
name, slot, main-stat label, main-stat value, level, substats, set effects, and
footer. OCR uses field-specific PSM/whitelist cleanup, and the parser derives
slot/set/main-stat through lookup constraints before falling back to review.
- **Paimon-menu auto-entry scaffold** - auto-scan supports
`scanEntryMode: "paimon-menu"` and `/scanner/start?entry=paimon-menu&limit=N`.
The entry sends only read-only navigation (`ESC`, `B`, artifact-tab click),
then requires a valid lookup, supported layout, and detected artifact grid
before the scan loop starts. The existing visible-inventory start remains the
fallback/debug path.
- **OCR benchmark endpoint scaffold** - `/scanner/benchmark-ocr?limit=N` captures
identical artifact crops with the current engine and returns timing/field counts.
`/scanner/benchmark-ocr?engine=compare` can also compare the local
Inventory-Kamera-traineddata Tesseract.js path when
`genshin_fast_09_04_21.traineddata` is present in `data/tessdata`, `work/`, or
`IK_TESSDATA_DIR`. The OCR worker pool defaults to four workers and can be
tuned with `GAA_OCR_WORKERS=1..8`. Native Tesseract is still not the default
and should only replace `tesseract.js` after the benchmark proves it faster
and more accurate on the same crops.
- **Quality-gated live comparison** - `scripts/live-soak.ps1` now supports
goal runs for `current`, `ik-traineddata`, and `compare`, writes CSV/JSON
summaries, groups results by limit, identifies timing bottlenecks, and rejects
winners that miss the requested count, exceed 2% misses, or exceed 15% review.
`npm run scan:assessment:test` verifies this ranking logic without Genshin.
- **State-polled guided entry** - the guided auto-entry waits for Inventory,
artifact grid, and first detail card evidence instead of sleeping the full
fixed delay every time. OCR/review/store work still starts only after artifact
detail preflight passes.
## Remaining — needs the live environment or a UI pass ## Remaining — needs the live environment or a UI pass
@@ -46,12 +118,27 @@ resolution or without UI work best tested live:
1. **Validate/tune OCR preprocessing** on more 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 threshold + upscale factor help (not hurt) actual Tesseract reads. The
text-level eval harness cannot measure image preprocessing. text-level eval harness cannot measure image preprocessing.
2. **Validate locked=true** against a known locked artifact — unlocked/grey lock 2. **Wire and benchmark native IK-traineddata OCR** against the same crop set.
The current benchmark can use IK-traineddata through Tesseract.js; native
Tesseract integration remains the next implementation step before any engine
default changes.
3. **Validate guided entry live** from world, visible inventory, and Paimon/menu
states with limits 2, 20, and 45. Confirm the artifact-tab coordinate in the
user's current 16:9 layout and keep `visible-inventory` as fallback if the
menu path is blocked.
4. **Validate locked=true** against a known locked artifact — unlocked/grey lock
was live-checked; a gold locked icon still needs a positive sample. 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, 5. **Broader scan soak test** — after the bounded two-item live scan passed,
the next automation validation should increase the limit gradually and watch the next automation validation should increase the limit gradually and watch
for repeated pages, scroll behavior, duplicate handling, and OCR review rate. for repeated pages, scroll behavior, duplicate handling, and OCR review rate.
6. **100-artifact IK comparison** — after `/health.appBuild.signature` matches
current source, run `npm run scan:goal:compare` and compare qualified
100-artifact results.
Visible-page limits up to 20 and a scroll/page-transition limit of 45 have
passed. The remaining soak work is now OCR accuracy, review-rate reduction, and
larger runs after the review corpus has grown.
## Grow the eval corpus ## Grow the eval corpus
+3
View File
@@ -14,6 +14,7 @@ import type {
ClickResult, ClickResult,
AutomationGuard, AutomationGuard,
ScrollResult, ScrollResult,
KeyPressResult,
SaveResultWithPath, SaveResultWithPath,
SaveSnapshotResult, SaveSnapshotResult,
GoodDatabase, GoodDatabase,
@@ -60,6 +61,7 @@ interface CaptureHandlersDependencies {
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>; captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
keyPress: (key: string) => Promise<KeyPressResult>;
getAutomationGuard: () => Promise<AutomationGuard>; getAutomationGuard: () => Promise<AutomationGuard>;
} }
@@ -96,6 +98,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
captureSource: dependencies.captureSource, captureSource: dependencies.captureSource,
clickScreen: dependencies.clickScreen, clickScreen: dependencies.clickScreen,
scrollScreen: dependencies.scrollScreen, scrollScreen: dependencies.scrollScreen,
keyPress: dependencies.keyPress,
getAutomationGuard: dependencies.getAutomationGuard, getAutomationGuard: dependencies.getAutomationGuard,
}); });
} }
+177 -3
View File
@@ -1,6 +1,8 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import http, { type Server } from "node:http"; import http, { type Server } from "node:http";
import path from "node:path"; import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { import type {
CaptureOptions, CaptureOptions,
CaptureResult, CaptureResult,
@@ -9,14 +11,20 @@ import type {
ReviewSampleListResult, ReviewSampleListResult,
ScannerCommand, ScannerCommand,
ScannerStatusPayload, ScannerStatusPayload,
AppRuntimeInfo,
} from "../src/types/global.js"; } from "../src/types/global.js";
import { validateLookupPackage } from "../src/lib/genshinLookup.js";
const execFileAsync = promisify(execFile);
interface DevControlServerDependencies { interface DevControlServerDependencies {
registeredHotkeys: Record<string, boolean>; registeredHotkeys: Record<string, boolean>;
appBuild: AppRuntimeInfo;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
sendScannerCommand: (command: ScannerCommand | "probe-click") => void; sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scannerStatus: () => ScannerStatusPayload; scannerStatus: () => ScannerStatusPayload;
warmOcr: (engine: "current" | "ik-traineddata") => Promise<unknown>;
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>; loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
listCaptureSources: () => Promise<CaptureSourceInfo[]>; listCaptureSources: () => Promise<CaptureSourceInfo[]>;
captureSource: ( captureSource: (
@@ -25,6 +33,7 @@ interface DevControlServerDependencies {
focusGenshin?: boolean, focusGenshin?: boolean,
options?: CaptureOptions, options?: CaptureOptions,
) => Promise<CaptureResult>; ) => Promise<CaptureResult>;
requestShutdown?: (reason: string) => void;
} }
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) { function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
@@ -136,13 +145,30 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
const url = new URL(req.url ?? "/", "http://127.0.0.1"); const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/health") { if (url.pathname === "/health") {
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() }); writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild });
return;
}
if (url.pathname === "/dev/shutdown") {
if (!deps.requestShutdown) {
writeDevJson(res, 501, { ok: false, error: "shutdown not supported" });
return;
}
const reason = url.searchParams.get("reason") || "dev-control shutdown requested";
writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason });
setTimeout(() => deps.requestShutdown?.(reason), 50);
return; return;
} }
if (url.pathname === "/scanner/start") { if (url.pathname === "/scanner/start") {
const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
const command: ScannerCommand = Number.isFinite(limit) && limit > 0 const entry = url.searchParams.get("entry");
? { type: "start-auto", scanLimit: limit } const engine = url.searchParams.get("engine");
const scanEntryMode = entry === "paimon-menu" || entry === "visible-inventory" || entry === "direct-inventory" || entry === "auto-entry"
? entry
: undefined;
const ocrEngine = engine === "ik-traineddata" ? "ik-traineddata" : engine === "current" ? "current" : undefined;
const hasLimit = Number.isFinite(limit) && limit > 0;
const command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine
? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine }
: "start-auto"; : "start-auto";
deps.sendScannerCommand(command); deps.sendScannerCommand(command);
writeDevJson(res, 200, { ok: true, command }); writeDevJson(res, 200, { ok: true, command });
@@ -174,6 +200,154 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }); writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
return; return;
} }
if (url.pathname === "/scanner/ocr/warmup") {
const engineParam = url.searchParams.get("engine");
const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current";
deps.warmOcr(engine)
.then((status) => writeDevJson(res, 200, { ok: true, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/lookup/status") {
const status = validateLookupPackage();
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status });
return;
}
if (url.pathname === "/scanner/lookup/regenerate") {
execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 })
.then(({ stdout, stderr }) => {
const status = validateLookupPackage();
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr });
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/benchmark-ocr") {
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
const sourceId = url.searchParams.get("sourceId");
const engineParam = url.searchParams.get("engine");
const profileParam = url.searchParams.get("profile");
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
const engines: Array<"current" | "ik-traineddata"> = engineParam === "compare"
? ["current", "ik-traineddata"]
: engineParam === "ik-traineddata"
? ["ik-traineddata"]
: ["current"];
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 benchmarkSource = source;
async function runEngineBenchmark(engine: "current" | "ik-traineddata") {
const startedAt = Date.now();
const captures: Array<{
index: number;
elapsedMs: number;
ocrFields: number;
timedOut: boolean;
ocrSkipped: boolean;
artifactDetailConfidence: number;
sanctified: boolean;
prepareMs: number;
ocrMs: number;
totalMs: number;
ocrProfile?: "full" | "fast";
ocrWorkerPoolSize?: number;
ocrFieldMs?: Record<string, number>;
}> = [];
for (let index = 0; index < limit; index += 1) {
const captureStartedAt = Date.now();
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
ocrMode: "artifact",
ocrProfile,
ocrEngine: engine,
omitFullFrame: true,
omitInventoryPreview: true,
skipOcrUnlessArtifactDetail: true,
});
captures.push({
index,
elapsedMs: Date.now() - captureStartedAt,
ocrFields: capture.ocr?.length ?? 0,
timedOut: Boolean(capture.ocrTimedOut),
ocrSkipped: Boolean(capture.ocrSkipped),
artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0,
sanctified: Boolean(capture.sanctified),
prepareMs: capture.timings?.prepareMs ?? 0,
ocrMs: capture.timings?.ocrMs ?? 0,
totalMs: capture.timings?.totalMs ?? 0,
ocrProfile: capture.timings?.ocrProfile,
ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize,
ocrFieldMs: capture.timings?.ocrFieldMs,
});
}
const elapsedMs = Date.now() - startedAt;
const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right);
const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right);
const averageMs = Math.round(elapsedMs / limit);
const averageOcrMs = ocrTimings.length > 0
? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length)
: 0;
const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0;
const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0;
const ocrFieldTotals = captures.reduce<Record<string, { totalMs: number; count: number; maxMs: number }>>((fields, capture) => {
for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) {
const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 };
current.totalMs += elapsed;
current.count += 1;
current.maxMs = Math.max(current.maxMs, elapsed);
fields[field] = current;
}
return fields;
}, {});
const ocrFieldAverages = Object.fromEntries(
Object.entries(ocrFieldTotals).map(([field, timing]) => [
field,
{
averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)),
maxMs: timing.maxMs,
count: timing.count,
},
]),
);
return {
engine,
nativeTesseract: "not-enabled",
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
ocrProfile,
limit,
elapsedMs,
averageMs,
averageOcrMs,
minMs: timings[0] ?? 0,
p50Ms: percentile(0.5),
p90Ms: percentile(0.9),
maxMs: timings[timings.length - 1] ?? 0,
ocrP50Ms: ocrPercentile(0.5),
ocrP90Ms: ocrPercentile(0.9),
ocrFieldAverages,
projectedMs: {
artifacts20: averageMs * 20,
artifacts45: averageMs * 45,
artifacts100: averageMs * 100,
},
skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length,
captures,
};
}
const summaries = await Promise.all(engines.map((engine) => runEngineBenchmark(engine)));
writeDevJson(res, 200, {
ok: true,
summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries },
});
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/review/samples") { if (url.pathname === "/review/samples") {
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20)) deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
.then((payload: unknown) => writeDevJson(res, 200, payload)) .then((payload: unknown) => writeDevJson(res, 200, payload))
+4 -1
View File
@@ -1,11 +1,12 @@
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } from "../../src/types/global.js"; import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard, KeyPressResult } from "../../src/types/global.js";
interface CaptureCommandDependencies { interface CaptureCommandDependencies {
listSources: () => Promise<CaptureSourceInfo[]>; listSources: () => Promise<CaptureSourceInfo[]>;
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>; captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
keyPress: (key: string) => Promise<KeyPressResult>;
getAutomationGuard: () => Promise<AutomationGuard>; getAutomationGuard: () => Promise<AutomationGuard>;
} }
@@ -14,6 +15,7 @@ export function registerCaptureHandlers({
captureSource, captureSource,
clickScreen, clickScreen,
scrollScreen, scrollScreen,
keyPress,
getAutomationGuard, getAutomationGuard,
}: CaptureCommandDependencies) { }: CaptureCommandDependencies) {
ipcMain.handle("capture:listSources", async () => listSources()); ipcMain.handle("capture:listSources", async () => listSources());
@@ -22,5 +24,6 @@ export function registerCaptureHandlers({
}); });
ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y)); ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y));
ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY)); ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY));
ipcMain.handle("automation:keyPress", async (_event, key: string) => keyPress(key));
ipcMain.handle("automation:getGuard", async () => getAutomationGuard()); ipcMain.handle("automation:getGuard", async () => getAutomationGuard());
} }
+593 -61
View File
@@ -2,9 +2,10 @@ import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nati
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import type { Server } from "node:http"; import type { Server } from "node:http";
import { cpus } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createWorker } from "tesseract.js"; import { createWorker, PSM } from "tesseract.js";
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js"; import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
@@ -15,6 +16,8 @@ import type {
CaptureResult, CaptureResult,
GoodDatabase, GoodDatabase,
GoodImportFileResult, GoodImportFileResult,
OcrResult,
AppRuntimeInfo,
SaveResultWithPath, SaveResultWithPath,
ScannerCommand, ScannerCommand,
ScannerLearningRulePayload, ScannerLearningRulePayload,
@@ -50,6 +53,8 @@ app.commandLine.appendSwitch("disable-gpu-sandbox");
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
const APP_RUNTIME_SIGNATURE = "2026-07-07-ik32-fastsubstats-active-timing";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let overlayWindow: BrowserWindow | null = null; let overlayWindow: BrowserWindow | null = null;
@@ -211,6 +216,7 @@ async function readRuntimeInfo() {
ok: true, ok: true,
isElevated: result.isElevated, isElevated: result.isElevated,
platform: result.platform, platform: result.platform,
appBuild: appRuntimeInfo(),
hotkeys: registeredHotkeys, hotkeys: registeredHotkeys,
genshinFound: result.genshinFound, genshinFound: result.genshinFound,
genshinHwnd: result.genshinHwnd ?? undefined, genshinHwnd: result.genshinHwnd ?? undefined,
@@ -220,7 +226,7 @@ async function readRuntimeInfo() {
helperPid: result.helperPid, helperPid: result.helperPid,
}; };
} catch { } catch {
return { ok: false, isElevated: false, platform: process.platform, hotkeys: registeredHotkeys }; return { ok: false, isElevated: false, platform: process.platform, appBuild: appRuntimeInfo(), hotkeys: registeredHotkeys };
} }
} }
@@ -326,12 +332,40 @@ async function listCaptureSources() {
})); }));
} }
async function toScreenPoint(x: number, y: number) {
const point = { x: Math.round(x), y: Math.round(y) };
const bounds = await getGenshinWindowBounds();
if (!bounds) return point;
const looksClientRelative =
point.x >= 0 &&
point.y >= 0 &&
point.x <= bounds.width &&
point.y <= bounds.height &&
(bounds.x !== 0 || bounds.y !== 0);
if (!looksClientRelative) return point;
return {
x: bounds.x + point.x,
y: bounds.y + point.y,
};
}
async function clickScreenCommand(x: number, y: number) { async function clickScreenCommand(x: number, y: number) {
return getInputHelperService().clickScreen(Math.round(x), Math.round(y)); const point = await toScreenPoint(x, y);
return getInputHelperService().clickScreen(point.x, point.y);
} }
async function scrollScreenCommand(notches: number, anchorX?: number, anchorY?: number) { async function scrollScreenCommand(notches: number, anchorX?: number, anchorY?: number) {
return getInputHelperService().scrollScreen(notches, anchorX, anchorY); if (typeof anchorX === "number" && typeof anchorY === "number") {
const point = await toScreenPoint(anchorX, anchorY);
return getInputHelperService().scrollScreen(notches, point.x, point.y);
}
return getInputHelperService().scrollScreen(notches);
}
async function keyPressCommand(key: string) {
return getInputHelperService().keyPress(key);
} }
async function getAutomationGuardCommand() { async function getAutomationGuardCommand() {
@@ -439,13 +473,19 @@ function startDevControlServer() {
if (!isDev || devControlServer) return; if (!isDev || devControlServer) return;
devControlServer = createDevControlServer({ devControlServer = createDevControlServer({
registeredHotkeys, registeredHotkeys,
appBuild: appRuntimeInfo(),
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()), hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
sendScannerCommand, sendScannerCommand,
clickScreen: clickScreenCommand, clickScreen: clickScreenCommand,
scannerStatus: () => scannerDevStatus, scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }),
warmOcr: (engine) => warmOcrWorkerPool(engine),
loadReviewSamples, loadReviewSamples,
listCaptureSources, listCaptureSources,
captureSource, captureSource,
requestShutdown: (reason) => {
console.log(`[dev-control] shutdown requested: ${reason}`);
app.quit();
},
}); });
} }
@@ -489,60 +529,284 @@ function createOverlayWindow() {
}); });
} }
function dataUrlToBuffer(dataUrl: string) { // Inventory Kamera keeps a pool of native Tesseract engines and scans artifact
const base64 = dataUrl.replace(/^data:image\/png;base64,/, ""); // fields concurrently. Our fast artifact profile has four useful OCR parameter
return Buffer.from(base64, "base64"); // groups, so the default pool is four workers unless the machine is smaller or
// GAA_OCR_WORKERS explicitly overrides it.
const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize();
const IK_TRAINEDDATA_LANG = "genshin_fast_09_04_21";
type OcrWorker = Awaited<ReturnType<typeof createWorker>>;
type OcrWorkerEngine = "current" | "ik-traineddata";
type OcrCropPayload = { id: string; label: string; image: Buffer };
type OcrWarmupStatus = {
engine: OcrWorkerEngine;
status: "cold" | "warming" | "ready" | "error";
workerPoolSize: number;
startedAt?: string;
readyAt?: string;
elapsedMs?: number;
error?: string;
};
type OcrWorkerSlot = {
worker: OcrWorker;
parametersKey: string;
};
type OcrWorkerPoolState = {
poolPromise: Promise<OcrWorkerSlot[]> | null;
runQueue: Promise<unknown>;
};
const ocrWorkerPools: Record<OcrWorkerEngine, OcrWorkerPoolState> = {
current: { poolPromise: null, runQueue: Promise.resolve() },
"ik-traineddata": { poolPromise: null, runQueue: Promise.resolve() },
};
const ocrWarmupStatuses: Record<OcrWorkerEngine, OcrWarmupStatus> = {
current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
"ik-traineddata": { engine: "ik-traineddata", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
};
function resolveOcrWorkerPoolSize() {
const requested = Number(process.env.GAA_OCR_WORKERS ?? Number.NaN);
if (Number.isFinite(requested) && requested > 0) {
return Math.max(1, Math.min(8, Math.floor(requested)));
}
const logicalCores = cpus().length || 4;
return Math.max(2, Math.min(4, logicalCores - 1));
} }
// One shared OCR worker. Creating a Tesseract worker per capture added ~1s function appRuntimeInfo(): AppRuntimeInfo {
// to every artifact during batch scans. return {
let ocrWorkerPromise: ReturnType<typeof createWorker> | null = null; signature: APP_RUNTIME_SIGNATURE,
pid: process.pid,
function getOcrWorker() { startedAt: APP_RUNTIME_STARTED_AT,
if (!ocrWorkerPromise) { cwd: process.cwd(),
ocrWorkerPromise = createWorker("eng"); isDev,
} expectedOcrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
return ocrWorkerPromise; };
} }
async function resetOcrWorker() { function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine {
const broken = ocrWorkerPromise; return options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
ocrWorkerPromise = null; }
function ikTessdataCandidates() {
const envDir = process.env.IK_TESSDATA_DIR;
return [
envDir,
path.resolve(process.cwd(), "data", "tessdata"),
path.resolve(process.cwd(), "work", "Inventory_Kamera", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "work", "refs", "Inventory_Kamera", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "..", "_ik_ref_fork", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "..", "_ik_ref", "InventoryKamera", "tessdata"),
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref_fork", "InventoryKamera", "tessdata"),
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref", "InventoryKamera", "tessdata"),
process.resourcesPath ? path.resolve(process.resourcesPath, "tessdata") : "",
].filter(Boolean) as string[];
}
function findIkTessdataDir() {
return ikTessdataCandidates().find((candidate) => existsSync(path.join(candidate, `${IK_TRAINEDDATA_LANG}.traineddata`))) ?? "";
}
function getOcrWorkerOptions(engine: OcrWorkerEngine) {
if (engine !== "ik-traineddata") return { lang: "eng", options: undefined };
const langPath = findIkTessdataDir();
if (!langPath) {
throw new Error(`IK traineddata not found. Set IK_TESSDATA_DIR or place ${IK_TRAINEDDATA_LANG}.traineddata in data/tessdata.`);
}
return {
lang: IK_TRAINEDDATA_LANG,
options: {
langPath,
gzip: false,
cachePath: app.isReady() ? path.join(app.getPath("userData"), "tessdata-cache") : path.resolve(process.cwd(), "outputs", "tessdata-cache"),
},
};
}
function getOcrWorkerPool(engine: OcrWorkerEngine) {
const state = ocrWorkerPools[engine];
if (!state.poolPromise) {
const { lang, options } = getOcrWorkerOptions(engine);
state.poolPromise = Promise.all(
Array.from({ length: OCR_WORKER_POOL_SIZE }, async () => ({
worker: await createWorker(lang, 1, options),
parametersKey: "",
})),
);
}
return state.poolPromise;
}
async function resetOcrWorker(engine?: OcrWorkerEngine) {
const engines: OcrWorkerEngine[] = engine ? [engine] : ["current", "ik-traineddata"];
await Promise.all(engines.map(async (engineId) => {
const state = ocrWorkerPools[engineId];
const broken = state.poolPromise;
state.poolPromise = null;
state.runQueue = Promise.resolve();
ocrWarmupStatuses[engineId] = { engine: engineId, status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE };
if (broken) { if (broken) {
try { try {
const worker = await broken; const pool = await broken;
await worker.terminate(); await Promise.all(pool.map((slot) => slot.worker.terminate().catch(() => undefined)));
} catch { } catch {
// Worker never initialized; nothing to clean up. // Workers never initialized; nothing to clean up.
} }
} }
}));
}
function ocrParametersForCrop(cropId: string) {
switch (cropId) {
case "artifact-level":
return {
tessedit_pageseg_mode: PSM.SINGLE_WORD,
tessedit_char_whitelist: "0123456789+",
};
case "artifact-main-stat-value":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "0123456789.,%+",
};
case "inventory-count":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "0123456789/",
};
case "artifact-name":
case "artifact-slot":
case "artifact-main-stat-label":
case "artifact-footer":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .:'%-",
};
case "artifact-main-stat":
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,%+",
};
case "artifact-substats":
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,%+-",
};
default:
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "",
};
}
} }
async function runOcrOnCrops(crops: Array<{ id: string; label: string; dataUrl: string }>) { function ocrParametersKey(cropId: string) {
try { const params = ocrParametersForCrop(cropId);
const worker = await getOcrWorker(); return `${params.tessedit_pageseg_mode}:${params.tessedit_char_whitelist}`;
const results = []; }
for (const crop of crops) {
const recognized = await worker.recognize(dataUrlToBuffer(crop.dataUrl)); async function applyOcrParameters(slot: OcrWorkerSlot, cropId: string) {
results.push({ const params = ocrParametersForCrop(cropId);
id: crop.id, const key = ocrParametersKey(cropId);
label: crop.label, if (key === slot.parametersKey) return;
text: cleanOcrText(crop.id, recognized.data.text), await slot.worker.setParameters(params);
confidence: Math.round(recognized.data.confidence), slot.parametersKey = key;
}
function warmOcrWorkerPool(engine: OcrWorkerEngine = "current") {
const current = ocrWarmupStatuses[engine];
if (current.status === "warming" || current.status === "ready") return Promise.resolve(current);
const started = Date.now();
ocrWarmupStatuses[engine] = {
engine,
status: "warming",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: new Date(started).toISOString(),
};
return getOcrWorkerPool(engine)
.then(async (pool) => {
await Promise.all(pool.map((slot) => applyOcrParameters(slot, "artifact-name")));
const readyAt = Date.now();
ocrWarmupStatuses[engine] = {
engine,
status: "ready",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: ocrWarmupStatuses[engine].startedAt,
readyAt: new Date(readyAt).toISOString(),
elapsedMs: readyAt - started,
};
return ocrWarmupStatuses[engine];
})
.catch((error: unknown) => {
ocrWarmupStatuses[engine] = {
engine,
status: "error",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: ocrWarmupStatuses[engine].startedAt,
elapsedMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
};
return ocrWarmupStatuses[engine];
}); });
} }
function getOcrWarmupStatus() {
return {
current: ocrWarmupStatuses.current,
"ik-traineddata": ocrWarmupStatuses["ik-traineddata"],
};
}
async function runOcrOnCrops(crops: OcrCropPayload[], engine: OcrWorkerEngine) {
try {
const pool = await getOcrWorkerPool(engine);
const results: OcrResult[] = new Array(crops.length);
let nextCropIndex = 0;
const workers = pool.slice(0, Math.min(pool.length, crops.length || 1));
await Promise.all(workers.map(async (slot) => {
while (nextCropIndex < crops.length) {
const index = nextCropIndex++;
const crop = crops[index];
if (!crop) continue;
results[index] = await recognizeCropWithSlot(slot, crop);
}
}));
return results; return results;
} catch (error) { } catch (error) {
await resetOcrWorker(); await resetOcrWorker(engine);
throw error; throw error;
} }
} }
async function runOcrOnCropsWithTimeout(crops: Array<{ id: string; label: string; dataUrl: string }>, timeoutMs = 6500) { async function recognizeCropWithSlot(slot: OcrWorkerSlot, crop: OcrCropPayload): Promise<OcrResult> {
const startedAt = Date.now();
await applyOcrParameters(slot, crop.id);
const recognized = await slot.worker.recognize(crop.image);
return {
id: crop.id,
label: crop.label,
text: cleanOcrText(crop.id, recognized.data.text),
confidence: Math.round(recognized.data.confidence),
elapsedMs: Date.now() - startedAt,
};
}
function runQueuedOcrOnCrops(crops: OcrCropPayload[], engine: OcrWorkerEngine) {
const state = ocrWorkerPools[engine];
const run = state.runQueue.catch(() => undefined).then(() => runOcrOnCrops(crops, engine));
state.runQueue = run.catch(() => undefined);
return run;
}
async function runOcrOnCropsWithTimeout(crops: OcrCropPayload[], engine: OcrWorkerEngine, timeoutMs = 6500) {
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
try { try {
return await Promise.race([ return await Promise.race([
runOcrOnCrops(crops).then((ocr) => ({ ocr, timedOut: false })), runQueuedOcrOnCrops(crops, engine).then((ocr) => ({ ocr, timedOut: false })),
new Promise<{ ocr: Awaited<ReturnType<typeof runOcrOnCrops>>; timedOut: boolean }>((resolve) => { new Promise<{ ocr: Awaited<ReturnType<typeof runOcrOnCrops>>; timedOut: boolean }>((resolve) => {
timeout = setTimeout(() => resolve({ ocr: [], timedOut: true }), timeoutMs); timeout = setTimeout(() => resolve({ ocr: [], timedOut: true }), timeoutMs);
}), }),
@@ -569,7 +833,7 @@ function cleanOcrText(cropId: string, text: string) {
return match ? `Equipped: ${match[1].replace(/[^A-Za-z'\-\s]/g, "").trim()}` : equipped; return match ? `Equipped: ${match[1].replace(/[^A-Za-z'\-\s]/g, "").trim()}` : equipped;
} }
if (cropId === "artifact-title") { if (cropId === "artifact-title" || cropId === "artifact-name" || cropId === "artifact-slot" || cropId === "artifact-main-stat-label") {
return normalized return normalized
.filter((line) => /[A-Za-z]/.test(line)) .filter((line) => /[A-Za-z]/.test(line))
.slice(0, 2) .slice(0, 2)
@@ -583,10 +847,23 @@ function cleanOcrText(cropId: string, text: string) {
.join("\n"); .join("\n");
} }
if (cropId === "artifact-main-stat-value") {
return normalized
.map((line) => line.replace(/[^0-9.,%]/g, ""))
.find((line) => /[0-9]/.test(line)) ?? "";
}
if (cropId === "artifact-level") {
const value = normalized
.map((line) => line.replace(/[^0-9+]/g, ""))
.find((line) => /[0-9]/.test(line)) ?? "";
return value.startsWith("+") || value === "" ? value : `+${value}`;
}
if (cropId === "artifact-substats") { if (cropId === "artifact-substats") {
return normalized return normalized
.filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line)) .filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line))
.slice(0, 5) .slice(0, 6)
.join("\n"); .join("\n");
} }
@@ -660,6 +937,169 @@ function isArtifactTextColor(bitmap: Buffer, index: number) {
return green >= 180 && red >= 140 && blue <= 95 && green > blue + 35 && red > blue + 10; return green >= 180 && red >= 140 && blue <= 95 && green > blue + 35 && red > blue + 10;
} }
function hasEquippedFooterMarker(bitmap: Buffer, imageSize: { width: number; height: number }, rect: Electron.Rectangle) {
const safeRect = clampCaptureRect(rect, imageSize);
const strideX = Math.max(1, Math.floor(safeRect.width / 48));
const strideY = Math.max(1, Math.floor(safeRect.height / 18));
let hits = 0;
for (let y = safeRect.y; y < safeRect.y + safeRect.height; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = safeRect.x; x < safeRect.x + safeRect.width; x += strideX) {
if (isEquippedFooterYellow(bitmap, rowOffset + x * 4)) {
hits++;
if (hits >= 10) return true;
}
}
}
return false;
}
function isSanctifiedArtifactPurple(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return blue >= 220 && red >= 180 && red <= 245 && green >= 155 && green <= 220 && blue > red + 10;
}
function detectSanctifiedArtifactDetail(bitmap: Buffer, imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const safeRect = clampCaptureRect(detailRect, imageSize);
const x0 = Math.max(safeRect.x, Math.round(safeRect.x + safeRect.width * 0.0));
const x1 = Math.min(imageSize.width - 1, Math.round(safeRect.x + safeRect.width * 0.0606));
const y0 = Math.max(safeRect.y, Math.round(safeRect.y + safeRect.height * 0.3333));
const y1 = Math.min(imageSize.height - 1, Math.round(y0 + safeRect.height * 0.0526));
const strideX = Math.max(1, Math.floor(Math.max(1, x1 - x0) / 12));
const strideY = Math.max(1, Math.floor(Math.max(1, y1 - y0) / 10));
let hits = 0;
for (let y = y0; y <= y1; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = x0; x <= x1; x += strideX) {
if (isSanctifiedArtifactPurple(bitmap, rowOffset + x * 4)) {
hits++;
if (hits >= 3) return true;
}
}
}
return false;
}
function analyzeArtifactDetailPanel(bitmap: Buffer, imageSize: { width: number; height: number }, rect: Electron.Rectangle) {
const safeRect = clampCaptureRect(rect, imageSize);
const strideX = Math.max(1, Math.floor(safeRect.width / 96));
const strideY = Math.max(1, Math.floor(safeRect.height / 160));
let orangeHits = 0;
let greenHits = 0;
let textHits = 0;
let titleOrangeHits = 0;
let upperTextHits = 0;
let lowerGreenHits = 0;
for (let y = safeRect.y; y < safeRect.y + safeRect.height; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = safeRect.x; x < safeRect.x + safeRect.width; x += strideX) {
const index = rowOffset + x * 4;
const relativeY = (y - safeRect.y) / safeRect.height;
const isOrange = isArtifactTitleOrange(bitmap, index);
const isGreen = isSetTitleGreen(bitmap, index);
const isText = isArtifactTextColor(bitmap, index);
if (isOrange) {
orangeHits++;
if (relativeY <= 0.085) titleOrangeHits++;
}
if (isGreen) {
greenHits++;
if (relativeY >= 0.37 && relativeY <= 0.84) lowerGreenHits++;
}
if (isText) {
textHits++;
if (relativeY >= 0.08 && relativeY <= 0.37) upperTextHits++;
}
}
}
const confidence = Math.max(
0,
Math.min(100, Math.round(titleOrangeHits * 1.6 + upperTextHits * 1.2 + lowerGreenHits * 0.7)),
);
return {
present: titleOrangeHits >= 40 && (upperTextHits >= 20 || lowerGreenHits >= 40) && confidence >= 58,
confidence,
orangeHits,
greenHits,
textHits,
titleOrangeHits,
upperTextHits,
lowerGreenHits,
};
}
function isPaimonProfileLight(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red > 150 && green > 150 && blue > 150;
}
function isPaimonProfileCream(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red >= 195 && green >= 185 && blue >= 155;
}
function isPaimonMenuTileDark(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red >= 45 && red <= 115 && green >= 55 && green <= 125 && blue >= 70 && blue <= 150;
}
function sampleScreenZone(
bitmap: Buffer,
imageSize: { width: number; height: number },
zone: { x0: number; x1: number; y0: number; y1: number },
predicate: (bitmap: Buffer, index: number) => boolean,
) {
const x0 = Math.max(0, Math.floor(imageSize.width * zone.x0));
const x1 = Math.min(imageSize.width, Math.ceil(imageSize.width * zone.x1));
const y0 = Math.max(0, Math.floor(imageSize.height * zone.y0));
const y1 = Math.min(imageSize.height, Math.ceil(imageSize.height * zone.y1));
const strideX = Math.max(1, Math.floor((x1 - x0) / 135));
const strideY = Math.max(1, Math.floor((y1 - y0) / 90));
let hits = 0;
let samples = 0;
for (let y = y0; y < y1; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = x0; x < x1; x += strideX) {
samples++;
if (predicate(bitmap, rowOffset + x * 4)) hits++;
}
}
return samples > 0 ? (hits / samples) * 100 : 0;
}
function analyzePaimonMenu(bitmap: Buffer, imageSize: { width: number; height: number }) {
const profileZone = { x0: 0.05, x1: 0.40, y0: 0, y1: 0.30 };
const menuTileZone = { x0: 0.06, x1: 0.39, y0: 0.32, y1: 0.98 };
const profileLightPct = sampleScreenZone(bitmap, imageSize, profileZone, isPaimonProfileLight);
const profileCreamPct = sampleScreenZone(bitmap, imageSize, profileZone, isPaimonProfileCream);
const menuTileDarkPct = sampleScreenZone(bitmap, imageSize, menuTileZone, isPaimonMenuTileDark);
const confidence = Math.max(0, Math.min(100, Math.round((profileLightPct - 20) * 1.2 + (profileCreamPct - 12) * 1.4 + (menuTileDarkPct - 25) * 1.1)));
return {
present: profileLightPct >= 35 && profileCreamPct >= 20 && menuTileDarkPct >= 40,
confidence,
profileLightPct: Math.round(profileLightPct * 10) / 10,
profileCreamPct: Math.round(profileCreamPct * 10) / 10,
menuTileDarkPct: Math.round(menuTileDarkPct * 10) / 10,
};
}
function waitDelay(ms: number) { function waitDelay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Math.floor(ms)))); return new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Math.floor(ms))));
} }
@@ -708,18 +1148,40 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
return sourceImage.crop(safeRect).toDataURL(); return sourceImage.crop(safeRect).toDataURL();
} }
function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const bitmap = sourceImage.crop(safeRect).getBitmap();
let hash = 2166136261;
const stride = Math.max(4, Math.floor(bitmap.length / 4096) * 4);
for (let index = 0; index < bitmap.length; index += stride) {
hash ^= bitmap[index] ?? 0;
hash = Math.imul(hash, 16777619);
hash ^= bitmap[index + 1] ?? 0;
hash = Math.imul(hash, 16777619);
hash ^= bitmap[index + 2] ?? 0;
hash = Math.imul(hash, 16777619);
}
return `${safeRect.width}x${safeRect.height}:${(hash >>> 0).toString(16)}`;
}
// Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then // Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then
// grayscale + Otsu-binarize with inversion (artifact text is the bright // grayscale + Otsu-binarize with inversion (artifact text is the bright
// foreground). The original crop is kept separately for the diagnostics UI. // foreground). The original crop is kept separately for the diagnostics UI.
function preprocessedCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) { // OCR receives a PNG buffer directly to avoid DataURL encode/decode churn in
// the auto-scan hot path.
function preprocessedCropPngBuffer(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }, cropId = "") {
const safeRect = clampCaptureRect(rect, imageSize); const safeRect = clampCaptureRect(rect, imageSize);
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * 2), quality: "best" }); const scale = cropId === "artifact-level" ? 3 : 2;
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * scale), quality: "best" });
const size = upscaled.getSize(); const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toDataURL(); if (!size.width || !size.height) return upscaled.toPNG();
const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height }); const binarized = binarizeForOcr(
{ data: upscaled.getBitmap(), width: size.width, height: size.height },
cropId === "artifact-level" ? { contrast: 80 } : {},
);
return nativeImage return nativeImage
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height }) .createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
.toDataURL(); .toPNG();
} }
function createCrops( function createCrops(
@@ -727,10 +1189,27 @@ function createCrops(
imageSize: { width: number; height: number }, imageSize: { width: number; height: number },
detailRect: Electron.Rectangle, detailRect: Electron.Rectangle,
inventoryRect: Electron.Rectangle, inventoryRect: Electron.Rectangle,
options: CaptureOptions = {},
bitmap?: Buffer,
cropOptions: { sanctified?: boolean; skipOcr?: boolean } = {},
) { ) {
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize); const isArtifactScanMode = options.ocrMode === "artifact";
const fastArtifactProfile = isArtifactScanMode && options.ocrProfile === "fast";
const omitCropImages = Boolean(options.omitCropImages);
const skipCropOcr = Boolean(cropOptions.skipOcr);
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
.filter((template) => {
if (fastArtifactProfile && (
template.id === "artifact-set-effects" ||
template.id === "artifact-slot" ||
template.id === "artifact-main-stat-value"
)) return false;
if (template.id === "artifact-footer" && (options.omitEquippedOcr || fastArtifactProfile)) return false;
if (!isArtifactScanMode || template.id !== "artifact-footer" || !bitmap) return true;
return hasEquippedFooterMarker(bitmap, imageSize, template.rect);
});
if (inventoryRect.width > 120 && inventoryRect.height > 80) { if (!isArtifactScanMode && inventoryRect.width > 120 && inventoryRect.height > 80) {
templates.push({ templates.push({
id: "inventory-count", id: "inventory-count",
label: "Inventory count", label: "Inventory count",
@@ -741,12 +1220,14 @@ function createCrops(
return templates return templates
.map((template) => { .map((template) => {
const rect = clampCaptureRect(template.rect, imageSize); const rect = clampCaptureRect(template.rect, imageSize);
const ocrEnabled = !skipCropOcr;
return { return {
id: template.id, id: template.id,
label: template.label, label: template.label,
rect, rect,
dataUrl: imageCropDataUrl(sourceImage, rect, imageSize), dataUrl: omitCropImages ? undefined : imageCropDataUrl(sourceImage, rect, imageSize),
ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize), ocrImage: ocrEnabled ? preprocessedCropPngBuffer(sourceImage, rect, imageSize, template.id) : undefined,
ocrEnabled,
}; };
}) })
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0); .filter((crop) => crop.rect.width > 0 && crop.rect.height > 0);
@@ -815,6 +1296,7 @@ async function buildCaptureResult(
captureTarget: CaptureResult["captureTarget"], captureTarget: CaptureResult["captureTarget"],
options: CaptureOptions = {}, options: CaptureOptions = {},
) { ) {
const buildStartedAt = Date.now();
const size = sourceImage.getSize(); const size = sourceImage.getSize();
if (!size.width || !size.height) { if (!size.width || !size.height) {
throw new Error("Capture produced an empty image."); throw new Error("Capture produced an empty image.");
@@ -822,22 +1304,52 @@ async function buildCaptureResult(
const bitmap = sourceImage.getBitmap(); const bitmap = sourceImage.getBitmap();
const detailRect = inferDetailRect(bitmap, size); const detailRect = inferDetailRect(bitmap, size);
const artifactDetail = analyzeArtifactDetailPanel(bitmap, size, detailRect);
const paimonMenu = analyzePaimonMenu(bitmap, size);
const sanctified = detectSanctifiedArtifactDetail(bitmap, size, detailRect);
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail.present);
const shouldSkipOcr = Boolean(options.skipOcr || skipOcrForMissingDetail);
const inventoryRect = inferInventoryRect(size, detailRect); const inventoryRect = inferInventoryRect(size, detailRect);
const crops = createCrops(sourceImage, size, detailRect, inventoryRect); const omitCrops = Boolean(options.omitCrops);
const crops = omitCrops
? []
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
const locked = options.omitLockState
? undefined
: (() => {
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size); const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
const lockImage = sourceImage.crop(lockRect); const lockImage = sourceImage.crop(lockRect);
const lockSize = lockImage.getSize(); const lockSize = lockImage.getSize();
const locked = lockSize.width > 0 && lockSize.height > 0 return lockSize.width > 0 && lockSize.height > 0
? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height }) ? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height })
: undefined; : undefined;
const croppedPayload = crops.map((crop) => ({ })();
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact");
const omitDetailPreview = Boolean(options.omitDetailPreview);
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact");
const detailFingerprint = imageCropFingerprint(sourceImage, detailRect, size);
const inventoryFingerprint = imageCropFingerprint(sourceImage, inventoryRect, size);
const croppedPayload = crops
.filter((crop) => crop.ocrEnabled !== false)
.map((crop) => {
return crop.ocrImage
? {
id: crop.id, id: crop.id,
label: crop.label, label: crop.label,
// OCR reads the preprocessed (upscaled + binarized) crop; the original is image: crop.ocrImage,
// kept below for the diagnostics UI. }
dataUrl: crop.ocrDataUrl ?? crop.dataUrl, : null;
})); })
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload); .filter((crop): crop is OcrCropPayload => Boolean(crop));
const prepareMs = Date.now() - buildStartedAt;
const ocrEngine = ocrEngineFromOptions(options);
const ocrStartedAt = Date.now();
const recognized = shouldSkipOcr
? { ocr: [], timedOut: false }
: await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine);
const ocrMs = Date.now() - ocrStartedAt;
const totalMs = Date.now() - buildStartedAt;
const captureOcrEngine: CaptureOptions["ocrEngine"] = ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
const count = parseInventoryCount(recognized.ocr); const count = parseInventoryCount(recognized.ocr);
return { return {
@@ -845,14 +1357,16 @@ async function buildCaptureResult(
name: sourceName, name: sourceName,
width: size.width, width: size.width,
height: size.height, height: size.height,
dataUrl: sourceImage.toDataURL(), dataUrl: omitFullFrame ? "" : sourceImage.toDataURL(),
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
captureTarget, captureTarget,
detailDataUrl: imageCropDataUrl(sourceImage, detailRect, size), detailDataUrl: omitDetailPreview ? undefined : imageCropDataUrl(sourceImage, detailRect, size),
inventoryDataUrl: imageCropDataUrl(sourceImage, inventoryRect, size), inventoryDataUrl: omitInventoryPreview ? undefined : imageCropDataUrl(sourceImage, inventoryRect, size),
detailFingerprint,
inventoryFingerprint,
ocr: recognized.ocr, ocr: recognized.ocr,
ocrTimedOut: recognized.timedOut, ocrTimedOut: recognized.timedOut,
ocrSkipped: Boolean(options.skipOcr), ocrSkipped: shouldSkipOcr,
crops: crops.map((crop) => ({ crops: crops.map((crop) => ({
id: crop.id, id: crop.id,
label: crop.label, label: crop.label,
@@ -865,13 +1379,30 @@ async function buildCaptureResult(
dataUrl: crop.dataUrl, dataUrl: crop.dataUrl,
})), })),
inventoryGrid: inferInventoryGrid(size, detailRect), inventoryGrid: inferInventoryGrid(size, detailRect),
artifactDetail,
paimonMenu,
inventoryCount: count, inventoryCount: count,
locked, locked,
sanctified,
layout: { layout: {
aspect: aspectRatioLabel(size), aspect: aspectRatioLabel(size),
isSixteenNine: isSixteenNine(size), isSixteenNine: isSixteenNine(size),
warning: layoutSupportWarning(size), warning: layoutSupportWarning(size),
}, },
timings: {
totalMs,
prepareMs,
ocrMs: shouldSkipOcr ? 0 : ocrMs,
cropCount: croppedPayload.length,
ocrEngine: captureOcrEngine,
ocrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
ocrProfile: options.ocrProfile ?? "full",
ocrFieldMs: recognized.ocr.reduce<Record<string, number>>((fields, result) => {
if (typeof result.elapsedMs === "number") fields[result.id] = result.elapsedMs;
return fields;
}, {}),
ocrSkipped: shouldSkipOcr,
},
}; };
} }
@@ -993,12 +1524,14 @@ function initializeAppLifecycle() {
) => captureSource(id, delayMs, focus, captureOptions), ) => captureSource(id, delayMs, focus, captureOptions),
clickScreen: (x: number, y: number) => clickScreenCommand(x, y), clickScreen: (x: number, y: number) => clickScreenCommand(x, y),
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => scrollScreenCommand(notches, anchorX, anchorY), scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => scrollScreenCommand(notches, anchorX, anchorY),
keyPress: (key: string) => keyPressCommand(key),
getAutomationGuard: () => getAutomationGuardCommand(), getAutomationGuard: () => getAutomationGuardCommand(),
}); });
createMainWindow(); createMainWindow();
registerScannerHotkeys(); registerScannerHotkeys();
startDevControlServer(); startDevControlServer();
void warmOcrWorkerPool("current");
}); });
app.on("activate", () => { app.on("activate", () => {
@@ -1025,4 +1558,3 @@ function initializeAppLifecycle() {
} }
initializeAppLifecycle(); initializeAppLifecycle();
+1
View File
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options), captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y), clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y),
scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY), scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
keyPress: (key) => ipcRenderer.invoke("automation:keyPress", key),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"), getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
+1
View File
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options), captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y), clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y),
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY), scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
keyPress: (key: string) => ipcRenderer.invoke("automation:keyPress", key),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"), getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
@@ -3,6 +3,9 @@ import path from "node:path";
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js"; import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js"; import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
const SMALL_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
const TAIL_READ_LIMIT_BYTES = 32 * 1024 * 1024;
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort { export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
private readonly filePath: string; private readonly filePath: string;
@@ -12,9 +15,12 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
async list(limit = 50): Promise<ReviewSampleListResult> { async list(limit = 50): Promise<ReviewSampleListResult> {
try { try {
const raw = await fs.readFile(this.filePath, "utf8");
const lines = raw.split(/\r?\n/).filter(Boolean);
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
const stats = await fs.stat(this.filePath);
const raw = stats.size <= SMALL_FILE_LIMIT_BYTES
? await fs.readFile(this.filePath, "utf8")
: await readTailText(this.filePath, stats.size, TAIL_READ_LIMIT_BYTES);
const lines = raw.split(/\r?\n/).filter(Boolean);
const samples = lines const samples = lines
.slice(-safeLimit) .slice(-safeLimit)
.map((line) => { .map((line) => {
@@ -25,7 +31,8 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
} }
}) })
.filter(Boolean) as ReviewSampleRecord[]; .filter(Boolean) as ReviewSampleRecord[];
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath }; const total = stats.size <= SMALL_FILE_LIMIT_BYTES ? lines.length : Math.max(samples.length, lines.length);
return { ok: true, samples: samples.reverse(), total, path: this.filePath };
} catch { } catch {
return { ok: true, samples: [], total: 0, path: this.filePath }; return { ok: true, samples: [], total: 0, path: this.filePath };
} }
@@ -37,3 +44,17 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
return { ok: true, path: this.filePath }; return { ok: true, path: this.filePath };
} }
} }
async function readTailText(filePath: string, fileSize: number, maxBytes: number) {
const bytesToRead = Math.min(fileSize, maxBytes);
const handle = await fs.open(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, fileSize - bytesToRead);
const text = buffer.toString("utf8");
const firstNewline = text.indexOf("\n");
return fileSize > bytesToRead && firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
} finally {
await handle.close();
}
}
+58
View File
@@ -7,6 +7,7 @@ import type {
FocusGenshinResult, FocusGenshinResult,
GdiCaptureResult, GdiCaptureResult,
HelperOperationResponse, HelperOperationResponse,
KeyPressResult,
WindowBounds, WindowBounds,
RuntimeInfo, RuntimeInfo,
ScrollResult, ScrollResult,
@@ -115,6 +116,32 @@ function Send-MouseClickBatch {
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize) return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
} }
function Send-KeyPressBatch {
param([int]$virtualKey)
$down = New-Object Native.InputHelper+INPUT
$down.type = 1
$down.mi.dx = $virtualKey
$up = New-Object Native.InputHelper+INPUT
$up.type = 1
$up.mi.dx = $virtualKey
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
$up.mi.dy = 0x0002
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
}
function Resolve-VirtualKey {
param([string]$key)
switch ($key.ToUpperInvariant()) {
"ESC" { return 27 }
"ESCAPE" { return 27 }
"ENTER" { return 13 }
"B" { return 66 }
"C" { return 67 }
"1" { return 49 }
default { throw "unsupported key: $key" }
}
}
function Get-CursorPoint { function Get-CursorPoint {
$pt = New-Object Native.InputHelper+POINT $pt = New-Object Native.InputHelper+POINT
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
@@ -358,6 +385,21 @@ while ($true) {
$response.notchesSent = $sentTotal $response.notchesSent = $sentTotal
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0)) $response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
} }
"key" {
$focusInfo = Focus-GenshinWindow
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
Start-Sleep -Milliseconds 120
}
$vk = Resolve-VirtualKey -key "$($cmd.key)"
$sent = Send-KeyPressBatch -virtualKey $vk
$response.key = "$($cmd.key)"
$response.focused = $focusInfo.focused
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.targetProcess = $focusInfo.targetProcess
$response.isElevated = Get-CurrentProcessElevation
$response.eventsSent = $sent
$response.inputBlocked = ($sent -lt 2)
}
"bounds" { "bounds" {
$clientBounds = Get-GenshinClientBounds $clientBounds = Get-GenshinClientBounds
if ($null -eq $clientBounds) { if ($null -eq $clientBounds) {
@@ -545,6 +587,7 @@ export interface InputHelperService {
getGenshinWindowBounds(): Promise<WindowBounds | null>; getGenshinWindowBounds(): Promise<WindowBounds | null>;
clickScreen(x: number, y: number): Promise<ClickResult>; clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
keyPress(key: string): Promise<KeyPressResult>;
getAutomationGuard(): Promise<AutomationGuard>; getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>; capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
dispose(): void; dispose(): void;
@@ -641,6 +684,20 @@ export function createInputHelperService(options: { userDataPath: string; exePat
}; };
} }
async function keyPress(key: string) {
const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
return {
ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
key,
focused: Boolean(result.focused),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
inputBlocked: Boolean(result.inputBlocked),
eventsSent: Number(result.eventsSent ?? 0),
};
}
async function getAutomationGuard() { async function getAutomationGuard() {
const result = await request("cursor", {}, 4000); const result = await request("cursor", {}, 4000);
return { return {
@@ -688,6 +745,7 @@ export function createInputHelperService(options: { userDataPath: string; exePat
getGenshinWindowBounds, getGenshinWindowBounds,
clickScreen, clickScreen,
scrollScreen, scrollScreen,
keyPress,
getAutomationGuard, getAutomationGuard,
capturePrimaryScreenViaGdi, capturePrimaryScreenViaGdi,
dispose: () => inputHelper.dispose(), dispose: () => inputHelper.dispose(),
+42
View File
@@ -169,6 +169,23 @@ internal static class Program
break; break;
} }
case "key":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
var key = GetString(root, "key");
var sent = SendKeyPressBatch(ResolveVirtualKey(key));
response["key"] = key;
response["focused"] = info.Focused;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["isElevated"] = IsElevated();
response["eventsSent"] = sent;
response["inputBlocked"] = sent < 2;
break;
}
case "bounds": case "bounds":
{ {
var bounds = GetGenshinClientBounds(); var bounds = GetGenshinClientBounds();
@@ -423,6 +440,30 @@ internal static class Program
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>()); return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
} }
private static uint SendKeyPressBatch(int virtualKey)
{
var inputs = new Native.INPUT[2];
inputs[0].type = 1; // INPUT_KEYBOARD
inputs[0].mi.dx = virtualKey; // same union bytes as KEYBDINPUT.wVk
inputs[1].type = 1;
inputs[1].mi.dx = virtualKey;
inputs[1].mi.dy = Native.KEYEVENTF_KEYUP; // same union bytes as KEYBDINPUT.dwFlags
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
}
private static int ResolveVirtualKey(string key)
{
return key.Trim().ToUpperInvariant() switch
{
"ESC" or "ESCAPE" => 0x1B,
"ENTER" => 0x0D,
"B" => 0x42,
"C" => 0x43,
"1" => 0x31,
_ => throw new ArgumentOutOfRangeException(nameof(key), $"unsupported key: {key}")
};
}
private static string GetString(JsonElement root, string name) private static string GetString(JsonElement root, string name)
=> root.TryGetProperty(name, out var value) ? value.ToString() : ""; => root.TryGetProperty(name, out var value) ? value.ToString() : "";
@@ -445,6 +486,7 @@ internal static class Native
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002; public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
public const uint MOUSEEVENTF_LEFTUP = 0x0004; public const uint MOUSEEVENTF_LEFTUP = 0x0004;
public const uint MOUSEEVENTF_WHEEL = 0x0800; public const uint MOUSEEVENTF_WHEEL = 0x0800;
public const int KEYEVENTF_KEYUP = 0x0002;
public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000; public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001; public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
public const uint SPIF_SENDCHANGE = 0x0002; public const uint SPIF_SENDCHANGE = 0x0002;
+6
View File
@@ -16,6 +16,12 @@
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"eval": "vitest run src/eval/ocrEval.test.ts", "eval": "vitest run src/eval/ocrEval.test.ts",
"scan:soak": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1",
"scan:goal": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun",
"scan:goal:current": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine current",
"scan:goal:ik": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine ik-traineddata",
"scan:goal:compare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine compare",
"scan:assessment:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment",
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish", "helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
"data:genshin": "node scripts/generate-genshin-data.cjs" "data:genshin": "node scripts/generate-genshin-data.cjs"
}, },
+16
View File
@@ -56,6 +56,22 @@ try {
& (Join-Path $PSScriptRoot "kill-stale-instances.ps1") & (Join-Path $PSScriptRoot "kill-stale-instances.ps1")
$devPort = 5173 $devPort = 5173
$devControlPort = 17317
$devControlInUse = $null
try {
$devControlInUse = Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop | Select-Object -First 1
} catch {
# Get-NetTCPConnection kann auf manchen Systemen fehlen; das ist kein Fehler.
$devControlInUse = $null
}
if ($devControlInUse) {
$portOwner = Get-Process -Id $devControlInUse.OwningProcess -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "FEHLER: Dev-Control-Port $devControlPort ist noch belegt (Prozess: $($portOwner.ProcessName), PID $($devControlInUse.OwningProcess))." -ForegroundColor Red
Write-Host "Das wuerde Live-Tests gegen eine alte App-Instanz laufen lassen. Schliesse die alte App oder beende diesen Prozess und starte npm run dev:admin erneut." -ForegroundColor Red
throw "Dev-Control-Port $devControlPort ist durch PID $($devControlInUse.OwningProcess) belegt."
}
$portInUse = $null $portInUse = $null
try { try {
$portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1 $portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1
+106
View File
@@ -44,6 +44,9 @@ const artifactPieces = artifacts
const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot])); const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot]));
const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName])); const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName]));
const setToPieces = Object.fromEntries(
artifacts.map((set) => [set.name, artifactPieces.filter((piece) => piece.setName === set.name).map((piece) => piece.name)]),
);
const mainStats = [ const mainStats = [
'Elemental Mastery', 'Elemental Mastery',
@@ -198,6 +201,23 @@ const data = {
'Qiqi ': 'Qiqi', 'Qiqi ': 'Qiqi',
}, },
}, },
lookup: {
normalizedKeys: {
sets: normalizedMap(artifacts.map((set) => set.name)),
pieces: normalizedMap(artifactPieces.map((piece) => piece.name)),
slots: normalizedMap(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']),
stats: normalizedMap([...mainStats, ...substats]),
characters: normalizedMap(characters.map((character) => character.name)),
},
goodKeys: {
sets: Object.fromEntries(artifacts.map((set) => [set.name, goodKey(set.name)])),
pieces: Object.fromEntries(artifactPieces.map((piece) => [piece.name, goodKey(piece.name)])),
stats: Object.fromEntries([...mainStats, ...substats].map((stat) => [stat, goodStatKey(stat)])),
characters: Object.fromEntries(characters.map((character) => [character.name, goodKey(character.name)])),
},
setToPieces,
validation: validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }),
},
uiProfiles: { uiProfiles: {
artifactDetailEn: { artifactDetailEn: {
language: 'English', language: 'English',
@@ -231,3 +251,89 @@ function slotFromRelicType(relicType) {
return ''; return '';
} }
} }
function normalizeLookupKey(value) {
return String(value)
.toLowerCase()
.normalize('NFKD')
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function normalizedMap(values) {
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
}
function goodKey(value) {
return String(value)
.replace(/[']/g, '')
.replace(/[^A-Za-z0-9]+(.)/g, (_match, next) => String(next).toUpperCase())
.replace(/^[a-z]/, (first) => first.toUpperCase())
.replace(/[^A-Za-z0-9]/g, '');
}
function goodStatKey(stat) {
switch (stat) {
case 'HP':
return 'hp';
case 'HP%':
return 'hp_';
case 'ATK':
return 'atk';
case 'ATK%':
return 'atk_';
case 'DEF':
return 'def';
case 'DEF%':
return 'def_';
case 'Elemental Mastery':
return 'eleMas';
case 'Energy Recharge':
return 'enerRech_';
case 'CRIT Rate':
return 'critRate_';
case 'CRIT DMG':
return 'critDMG_';
case 'Healing Bonus':
return 'heal_';
case 'Physical DMG Bonus':
return 'physical_dmg_';
default:
return stat.toLowerCase().replace(' dmg bonus', '_dmg_').replace(/\s+/g, '');
}
}
function validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }) {
const errors = [];
const warnings = [];
const setNames = new Set(artifacts.map((set) => set.name));
const slotNames = new Set(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']);
const goodSetKeys = new Set();
for (const set of artifacts) {
const key = goodKey(set.name);
if (goodSetKeys.has(key)) errors.push(`Duplicate GOOD set key: ${key}`);
goodSetKeys.add(key);
if ((setToPieces[set.name] ?? []).length === 0) warnings.push(`Set has no pieces: ${set.name}`);
}
for (const piece of artifactPieces) {
if (!setNames.has(piece.setName)) errors.push(`Piece ${piece.name} references missing set ${piece.setName}`);
if (!slotNames.has(piece.slot)) errors.push(`Piece ${piece.name} references missing slot ${piece.slot}`);
}
if (!characters.length) warnings.push('No characters generated.');
if (!mainStats.length || !substats.length) errors.push('Stats were not generated.');
return {
valid: errors.length === 0,
errors,
warnings,
summary: {
artifactSets: artifacts.length,
artifactPieces: artifactPieces.length,
characters: characters.length,
stats: mainStats.length + substats.length,
},
};
}
+53 -4
View File
@@ -16,8 +16,25 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path $project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe" $electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
$devControlPort = 17317
$killed = 0 $killed = 0
$candidatePids = @{}
try {
$health = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/health" -TimeoutSec 2 -ErrorAction Stop
if ($health.appBuild -and $health.appBuild.cwd -eq $project) {
Write-Host "Dev-Control-Port $devControlPort gehoert zu diesem Projekt (PID $($health.appBuild.pid), Signatur $($health.appBuild.signature)). Versuche Self-Shutdown..."
try {
Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/dev/shutdown?reason=restart" -TimeoutSec 2 -ErrorAction Stop | Out-Null
Start-Sleep -Milliseconds 900
} catch {
Write-Host "Self-Shutdown nicht verfuegbar oder fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} catch {
# No dev-control server or an older/stuck process; continue with process scan.
}
Get-CimInstance Win32_Process | Get-CimInstance Win32_Process |
Where-Object { Where-Object {
@@ -26,15 +43,47 @@ Get-CimInstance Win32_Process |
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*") ($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
} | } |
ForEach-Object { ForEach-Object {
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))" $candidatePids[[int]$_.ProcessId] = $_.Name
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
try {
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
ForEach-Object {
if ($_.OwningProcess -gt 0) {
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
}
}
} catch {
# Get-NetTCPConnection can be unavailable on some machines; process matching
# above still handles the normal non-elevated path.
}
$failed = 0
foreach ($entry in $candidatePids.GetEnumerator()) {
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
try {
Stop-Process -Id $entry.Key -Force -ErrorAction Stop
$killed++ $killed++
} catch {
Write-Host "WARNUNG: Konnte PID $($entry.Key) nicht beenden: $($_.Exception.Message)" -ForegroundColor Yellow
$failed++
}
} }
if ($killed -gt 0) { if ($killed -gt 0) {
# Windows braucht einen Moment, um Ports/Handles wirklich freizugeben. # Windows braucht einen Moment, um Ports/Handles wirklich freizugeben.
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
Write-Host "$killed alte Prozess(e) beendet." Write-Host "$killed alte Prozess(e) beendet."
} else { }
Write-Host "Keine alten Instanzen gefunden."
if ($failed -gt 0) {
Write-Host "$failed alte Prozess(e) konnten nicht beendet werden. Wenn das ein Admin-Prozess ist, starte npm run dev:admin und bestaetige UAC oder schliesse die alte App manuell." -ForegroundColor Yellow
throw "$failed alte Prozess(e) konnten nicht beendet werden."
}
if ($killed -eq 0 -and $failed -eq 0) {
Write-Host "Keine alten Instanzen gefunden."
} else {
Start-Sleep -Milliseconds 300
} }
+685
View File
@@ -0,0 +1,685 @@
param(
[string]$BaseUrl = "http://127.0.0.1:17317",
[int[]]$ProbeIndices = @(1, 3),
[int[]]$Limits = @(2, 5, 10, 20),
[int]$PollIntervalSeconds = 2,
[int]$TimeoutSeconds = 600,
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"),
[switch]$GoalRun,
[ValidateSet("current", "ik-traineddata", "compare")]
[string]$ScanEngine = "current",
[switch]$BenchmarkOcr,
[int]$BenchmarkLimit = 5,
[ValidateSet("current", "ik-traineddata", "compare")]
[string]$BenchmarkEngine = "compare",
[ValidateSet("fast", "full")]
[string]$BenchmarkProfile = "fast",
[switch]$SkipSmartCapture,
[switch]$SaveFullReviewSamples,
[switch]$AllowStaleBuild,
[string]$ExpectedAppSignature,
[switch]$ContinueAfterBlocked,
[switch]$SelfTestAssessment
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($ExpectedAppSignature)) {
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
if (Test-Path -LiteralPath $mainPath) {
$mainSource = Get-Content -LiteralPath $mainPath -Raw
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
if ($match.Success) {
$ExpectedAppSignature = $match.Groups[1].Value
}
}
}
function ConvertTo-SafeFilePart([string]$Value) {
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
$safe = $safe.Trim("-")
if ($safe.Length -eq 0) { return "item" }
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
return $safe
}
function Invoke-DevJson([string]$Path) {
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 60
}
function Save-Json([string]$Name, [object]$Payload) {
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
$Payload | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Assert-CurrentAppBuild([object]$HealthPayload) {
if ($AllowStaleBuild) { return }
if ($null -eq $HealthPayload.appBuild) {
throw "Dev endpoint is stale: /health has no appBuild signature. Close the old elevated Genshin Artifact Assistant/Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
if ([string]::IsNullOrWhiteSpace([string]$HealthPayload.appBuild.signature)) {
throw "Dev endpoint is stale: appBuild.signature is empty. Restart the elevated app before live scanner timing."
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedAppSignature) -and [string]$HealthPayload.appBuild.signature -ne $ExpectedAppSignature) {
throw "Dev endpoint is stale: appBuild.signature='$($HealthPayload.appBuild.signature)' but current source expects '$ExpectedAppSignature'. Close the old Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
}
function Get-ScannerStatus {
Invoke-DevJson "/scanner/status"
}
function Test-ProbeSucceeded([object]$ProbePayload) {
if ($ProbePayload.ok) { return $true }
if ($ProbePayload.changed) { return $true }
return $false
}
function Get-CompletedScanSummary([object]$StatusPayload, [int]$Limit) {
if ($null -ne $StatusPayload.status.summary) {
return $StatusPayload.status.summary
}
$reviewStatus = [string]$StatusPayload.status.reviewStatus
$status = "done"
if ($reviewStatus -match "blockiert") {
$status = "blocked"
} elseif ($reviewStatus -match "gestoppt") {
$status = "stopped"
}
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
mode = "Automatischer Scan"
status = $status
clicked = [int]$stats.clicked
attempted = [int]$stats.attempted
verified = [int]$stats.verified
parsed = [int]$stats.parsed
stored = [int]$stats.stored
review = [int]$stats.review
duplicates = [int]$stats.duplicates
misses = [int]$stats.misses
pages = [int]$stats.pages
targetCount = $Limit
gridLabel = $reviewStatus
}
}
function Convert-ScanTimingSummary([object]$StatusPayload, [int]$Limit, [string]$Engine) {
$summary = Get-CompletedScanSummary $StatusPayload $Limit
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
engine = $Engine
limit = $Limit
status = $summary.status
clicked = $summary.clicked
attempted = $summary.attempted
verified = $summary.verified
parsed = $summary.parsed
stored = $summary.stored
review = $summary.review
duplicates = $summary.duplicates
misses = $summary.misses
pages = $summary.pages
elapsedMs = [int]$stats.elapsedMs
activeScanMs = [int]$stats.activeScanMs
writeFlushMs = [int]$stats.writeFlushMs
averageMsPerParsed = [int]$stats.averageMsPerParsed
activeAverageMsPerParsed = [int]$stats.activeAverageMsPerParsed
artifactsPerMinute = [double]$stats.artifactsPerMinute
activeArtifactsPerMinute = [double]$stats.activeArtifactsPerMinute
projectedMsFor100 = [int]$stats.projectedMsFor100
activeProjectedMsFor100 = [int]$stats.activeProjectedMsFor100
averageCaptureMs = [int]$stats.averageCaptureMs
averageOcrMs = [int]$stats.averageOcrMs
captureP50Ms = [int]$stats.captureP50Ms
captureP90Ms = [int]$stats.captureP90Ms
ocrP50Ms = [int]$stats.ocrP50Ms
ocrP90Ms = [int]$stats.ocrP90Ms
cardReadyMs = [int]$stats.cardReadyMs
cardReadyCount = [int]$stats.cardReadyCount
averageCardReadyMs = [int]$stats.averageCardReadyMs
scrollReadyMs = [int]$stats.scrollReadyMs
scrollReadyCount = [int]$stats.scrollReadyCount
averageScrollReadyMs = [int]$stats.averageScrollReadyMs
captureMs = [int]$stats.captureMs
ocrMs = [int]$stats.ocrMs
reviewStatus = [string]$StatusPayload.status.reviewStatus
}
}
function Write-ScanTimingLine([object]$Timing) {
Write-Host ("engine={0} limit={1} status={2} parsed={3} review={4} miss={5} elapsed={6}ms active={7}ms flush={8}ms avg={9}ms activeAvg={10}ms captureAvg={11}ms captureP50={12}ms captureP90={13}ms ocrAvg={14}ms ocrP50={15}ms ocrP90={16}ms cardReadyAvg={17}ms scrollReadyAvg={18}ms ppm={19} activePpm={20} projected100={21}ms activeProjected100={22}ms" -f `
$Timing.engine,
$Timing.limit,
$Timing.status,
$Timing.parsed,
$Timing.review,
$Timing.misses,
$Timing.elapsedMs,
$Timing.activeScanMs,
$Timing.writeFlushMs,
$Timing.averageMsPerParsed,
$Timing.activeAverageMsPerParsed,
$Timing.averageCaptureMs,
$Timing.captureP50Ms,
$Timing.captureP90Ms,
$Timing.averageOcrMs,
$Timing.ocrP50Ms,
$Timing.ocrP90Ms,
$Timing.averageCardReadyMs,
$Timing.averageScrollReadyMs,
$Timing.artifactsPerMinute,
$Timing.activeArtifactsPerMinute,
$Timing.projectedMsFor100,
$Timing.activeProjectedMsFor100)
}
function Get-TimingBottleneck([object]$Timing) {
$parts = @(
[pscustomobject]@{ name = "ocr"; value = [int]$Timing.averageOcrMs },
[pscustomobject]@{ name = "capture"; value = [int]$Timing.averageCaptureMs },
[pscustomobject]@{ name = "card-ready"; value = [int]$Timing.averageCardReadyMs },
[pscustomobject]@{ name = "scroll-ready"; value = [int]$Timing.averageScrollReadyMs }
) | Sort-Object -Property value -Descending
if ($parts.Count -eq 0 -or $parts[0].value -le 0) { return "unknown" }
return $parts[0].name
}
function Get-TimingRecommendation([object]$Timing) {
$bottleneck = Get-TimingBottleneck $Timing
switch ($bottleneck) {
"ocr" { return "OCR dominates; compare engine, crop count, worker pool, and parser-derived fields first." }
"capture" { return "Capture dominates; reduce payloads/crops and avoid full-frame or Base64 work in the hot loop." }
"card-ready" { return "Card-ready dominates; tune detail fingerprint polling against IK's 200ms item wait." }
"scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling against IK's 100ms fast-scroll wait." }
default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." }
}
}
function Get-ScanQuality([object]$Timing) {
$parsed = [int]$Timing.parsed
$misses = [int]$Timing.misses
$review = [int]$Timing.review
$target = [int]$Timing.limit
$processed = [Math]::Max(1, $parsed + $misses)
$parseCoverage = if ($target -gt 0) { [Math]::Round($parsed / $target, 4) } else { 0 }
$missRate = [Math]::Round($misses / $processed, 4)
$reviewRate = if ($parsed -gt 0) { [Math]::Round($review / $parsed, 4) } else { 1 }
$statusOk = [string]$Timing.status -eq "done"
$coverageOk = $parsed -ge $target
$missOk = $missRate -le 0.02
$reviewOk = $reviewRate -le 0.15
$qualityPenalty = [Math]::Round(($missRate * 1000000) + ($reviewRate * 250000) + ((1 - $parseCoverage) * 1000000), 0)
$qualityDecision = if (-not $statusOk) {
"not-qualified: scan did not finish cleanly"
} elseif (-not $coverageOk) {
"not-qualified: parsed fewer artifacts than requested"
} elseif (-not $missOk) {
"not-qualified: miss rate above 2%"
} elseif (-not $reviewOk) {
"not-qualified: review rate above 15%"
} else {
"qualified"
}
return [pscustomobject]@{
parseCoverage = $parseCoverage
missRate = $missRate
reviewRate = $reviewRate
qualityDecision = $qualityDecision
qualityPenalty = $qualityPenalty
qualified = ($statusOk -and $coverageOk -and $missOk -and $reviewOk)
}
}
function New-PerformanceAssessment([object[]]$Summaries) {
$limitReports = @()
foreach ($group in ($Summaries | Group-Object -Property limit | Sort-Object { [int]$_.Name })) {
$entries = @($group.Group | ForEach-Object {
$quality = Get-ScanQuality $_
$_ | Add-Member -NotePropertyName parseCoverage -NotePropertyValue $quality.parseCoverage -Force
$_ | Add-Member -NotePropertyName missRate -NotePropertyValue $quality.missRate -Force
$_ | Add-Member -NotePropertyName reviewRate -NotePropertyValue $quality.reviewRate -Force
$_ | Add-Member -NotePropertyName qualityDecision -NotePropertyValue $quality.qualityDecision -Force
$_ | Add-Member -NotePropertyName qualityPenalty -NotePropertyValue $quality.qualityPenalty -Force
$_ | Add-Member -NotePropertyName qualified -NotePropertyValue $quality.qualified -Force
$_
} | Sort-Object -Property @{ Expression = "qualified"; Descending = $true }, qualityPenalty, activeAverageMsPerParsed, averageMsPerParsed)
if ($entries.Count -eq 0) { continue }
$winner = $entries[0]
$engineReports = @()
foreach ($entry in $entries) {
$engineReports += [pscustomobject]@{
engine = $entry.engine
status = $entry.status
parsed = $entry.parsed
review = $entry.review
misses = $entry.misses
parseCoverage = $entry.parseCoverage
missRate = $entry.missRate
reviewRate = $entry.reviewRate
qualified = $entry.qualified
qualityDecision = $entry.qualityDecision
qualityPenalty = $entry.qualityPenalty
activeAverageMsPerParsed = $entry.activeAverageMsPerParsed
activeProjectedMsFor100 = $entry.activeProjectedMsFor100
averageOcrMs = $entry.averageOcrMs
averageCaptureMs = $entry.averageCaptureMs
averageCardReadyMs = $entry.averageCardReadyMs
averageScrollReadyMs = $entry.averageScrollReadyMs
bottleneck = Get-TimingBottleneck $entry
recommendation = Get-TimingRecommendation $entry
}
}
$limitReports += [pscustomobject]@{
limit = [int]$group.Name
winnerEngine = $winner.engine
winnerQualified = $winner.qualified
winnerMissRate = $winner.missRate
winnerReviewRate = $winner.reviewRate
winnerActiveAverageMsPerParsed = $winner.activeAverageMsPerParsed
winnerActiveProjectedMsFor100 = $winner.activeProjectedMsFor100
engines = $engineReports
}
}
$goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
return [pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
limits = $limitReports
}
}
function Write-PerformanceAssessment([object]$Assessment) {
foreach ($limit in @($Assessment.limits)) {
Write-Host ("assessment limit={0}: winner={1} qualified={2} missRate={3:P1} reviewRate={4:P1} activeAvg={5}ms projected100={6}ms" -f `
$limit.limit,
$limit.winnerEngine,
$limit.winnerQualified,
$limit.winnerMissRate,
$limit.winnerReviewRate,
$limit.winnerActiveAverageMsPerParsed,
$limit.winnerActiveProjectedMsFor100)
foreach ($engine in @($limit.engines)) {
Write-Host (" {0}: qualified={1}, missRate={2:P1}, reviewRate={3:P1}, bottleneck={4}, activeAvg={5}ms, ocr={6}ms, capture={7}ms, cardReady={8}ms, scrollReady={9}ms, decision={10}" -f `
$engine.engine,
$engine.qualified,
$engine.missRate,
$engine.reviewRate,
$engine.bottleneck,
$engine.activeAverageMsPerParsed,
$engine.averageOcrMs,
$engine.averageCaptureMs,
$engine.averageCardReadyMs,
$engine.averageScrollReadyMs,
$engine.qualityDecision)
}
}
}
function Invoke-AssessmentSelfTest {
$synthetic = @(
[pscustomobject]@{
engine = "current"
limit = 100
status = "done"
parsed = 100
review = 22
misses = 0
activeAverageMsPerParsed = 700
averageMsPerParsed = 760
activeProjectedMsFor100 = 70000
averageOcrMs = 260
averageCaptureMs = 160
averageCardReadyMs = 190
averageScrollReadyMs = 60
},
[pscustomobject]@{
engine = "ik-traineddata"
limit = 100
status = "done"
parsed = 100
review = 4
misses = 0
activeAverageMsPerParsed = 820
averageMsPerParsed = 870
activeProjectedMsFor100 = 82000
averageOcrMs = 210
averageCaptureMs = 170
averageCardReadyMs = 205
averageScrollReadyMs = 80
},
[pscustomobject]@{
engine = "broken-fast"
limit = 45
status = "done"
parsed = 45
review = 0
misses = 3
activeAverageMsPerParsed = 300
averageMsPerParsed = 330
activeProjectedMsFor100 = 30000
averageOcrMs = 100
averageCaptureMs = 90
averageCardReadyMs = 40
averageScrollReadyMs = 20
},
[pscustomobject]@{
engine = "current"
limit = 45
status = "done"
parsed = 45
review = 1
misses = 0
activeAverageMsPerParsed = 600
averageMsPerParsed = 650
activeProjectedMsFor100 = 60000
averageOcrMs = 230
averageCaptureMs = 130
averageCardReadyMs = 160
averageScrollReadyMs = 50
}
)
$assessment = New-PerformanceAssessment -Summaries $synthetic
$goal100 = $assessment.goal100
$limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]
if ($goal100.winnerEngine -ne "ik-traineddata") {
throw "Assessment self-test failed: expected ik-traineddata to win limit=100, got '$($goal100.winnerEngine)'."
}
if (-not $goal100.winnerQualified) {
throw "Assessment self-test failed: expected limit=100 winner to be qualified."
}
if ($limit45.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
}
if (@($goal100.engines | Where-Object { $_.engine -eq "current" })[0].qualityDecision -ne "not-qualified: review rate above 15%") {
throw "Assessment self-test failed: expected high-review current run to be rejected."
}
if (@($limit45.engines | Where-Object { $_.engine -eq "broken-fast" })[0].qualityDecision -ne "not-qualified: miss rate above 2%") {
throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
}
Write-PerformanceAssessment $assessment
Write-Host "Assessment self-test passed." -ForegroundColor Green
return $assessment
}
function Invoke-OcrBenchmark {
param(
[int]$Limit,
[string]$Engine,
[string]$Profile
)
Write-Host "Warming current OCR workers..."
$warmCurrent = Invoke-DevJson "/scanner/ocr/warmup?engine=current"
Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null
if ($Engine -eq "compare" -or $Engine -eq "ik-traineddata") {
Write-Host "Warming IK-traineddata OCR workers..."
$warmIk = Invoke-DevJson "/scanner/ocr/warmup?engine=ik-traineddata"
Save-Json "benchmark-warmup-ik-traineddata" $warmIk | Out-Null
}
Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit"
$benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&engine=$Engine&profile=$Profile"
Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null
if ($benchmark.summary.mode -eq "compare") {
foreach ($engineSummary in @($benchmark.summary.engines)) {
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$engineSummary.engine,
$engineSummary.averageMs,
$engineSummary.averageOcrMs,
$engineSummary.p50Ms,
$engineSummary.p90Ms,
$engineSummary.projectedMs.artifacts100,
$engineSummary.skippedOcrCaptures,
$engineSummary.workerPoolSize)
}
} else {
$summary = $benchmark.summary
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$summary.engine,
$summary.averageMs,
$summary.averageOcrMs,
$summary.p50Ms,
$summary.p90Ms,
$summary.projectedMs.artifacts100,
$summary.skippedOcrCaptures,
$summary.workerPoolSize)
}
return $benchmark
}
function Convert-ReviewSamplesSummary([object]$ReviewPayload) {
$samples = @()
foreach ($record in @($ReviewPayload.samples)) {
$parsed = $record.sample.parsed
$capture = $record.sample.capture
$ocr = @()
foreach ($entry in @($capture.ocr)) {
$ocr += [pscustomobject]@{
id = $entry.id
label = $entry.label
text = $entry.text
confidence = $entry.confidence
}
}
$samples += [pscustomobject]@{
savedAt = $record.savedAt
reason = $record.sample.reason
parsed = [pscustomobject]@{
name = $parsed.name
slot = $parsed.slot
level = $parsed.level
mainStat = $parsed.mainStat
mainValue = $parsed.mainValue
setName = $parsed.setName
equipped = $parsed.equipped
confidence = $parsed.confidence
notes = $parsed.notes
fields = $parsed.fields
}
capture = [pscustomobject]@{
name = $capture.name
width = $capture.width
height = $capture.height
captureTarget = $capture.captureTarget
capturedAt = $capture.capturedAt
inventoryGrid = if ($capture.inventoryGrid) {
[pscustomobject]@{
rows = $capture.inventoryGrid.rows
cols = $capture.inventoryGrid.cols
confidence = $capture.inventoryGrid.confidence
source = $capture.inventoryGrid.source
}
} else { $null }
inventoryCount = $capture.inventoryCount
locked = $capture.locked
ocr = $ocr
}
}
}
return [pscustomobject]@{
ok = $ReviewPayload.ok
total = $ReviewPayload.total
path = $ReviewPayload.path
samples = $samples
}
}
function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
$startedAt = Get-Date
$pollIndex = 0
$lastStatus = $null
while ($true) {
Start-Sleep -Seconds $PollIntervalSeconds
$pollIndex += 1
$statusPayload = Get-ScannerStatus
$lastStatus = $statusPayload
Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null
$running = [bool]$statusPayload.status.running
if (-not $running) {
return $statusPayload
}
$elapsed = ((Get-Date) - $startedAt).TotalSeconds
if ($elapsed -gt $TimeoutSeconds) {
$stop = Invoke-DevJson "/scanner/stop"
Save-Json "scan-$Engine-limit-$Limit-timeout-stop" $stop | Out-Null
throw "Scanner timed out after $TimeoutSeconds seconds for limit=$Limit engine=$Engine. Stop command was sent."
}
}
}
if ($SelfTestAssessment) {
Invoke-AssessmentSelfTest | Out-Null
exit 0
}
$stamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$RunDir = Join-Path $OutputRoot $stamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
if ($GoalRun) {
$Limits = @(2, 5, 20, 45, 100)
$BenchmarkOcr = $true
}
$ScanEngines = if ($ScanEngine -eq "compare") { @("current", "ik-traineddata") } else { @($ScanEngine) }
$RunSummaries = @()
$transcriptPath = Join-Path $RunDir "transcript.log"
try {
Start-Transcript -Path $transcriptPath -Append | Out-Null
} catch {
Write-Warning "Could not start transcript: $($_.Exception.Message)"
}
try {
Write-Host "Live soak output: $RunDir"
Write-Host "Checking dev control server at $BaseUrl"
$health = Invoke-DevJson "/health"
Save-Json "00-health" $health | Out-Null
Assert-CurrentAppBuild $health
if ($health.appBuild) {
Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)"
if ($health.appBuild.expectedOcrWorkerPoolSize -lt 4) {
Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for IK-speed comparison." -ForegroundColor Yellow
}
}
$initialStatus = Get-ScannerStatus
Save-Json "01-status-before" $initialStatus | Out-Null
if ($initialStatus.status.runtimeInfo) {
$runtime = $initialStatus.status.runtimeInfo
Write-Host "Runtime: elevated=$($runtime.isElevated), genshinFound=$($runtime.genshinFound), target=$($runtime.targetProcess)"
} else {
Write-Host "Runtime info is not published yet. Open the app scanner view before running broad scans." -ForegroundColor Yellow
}
if ($BenchmarkOcr) {
Invoke-OcrBenchmark -Limit $BenchmarkLimit -Engine $BenchmarkEngine -Profile $BenchmarkProfile | Out-Null
}
if (-not $SkipSmartCapture) {
Write-Host "Capturing smart preflight snapshot without OCR..."
$capture = Invoke-DevJson "/capture/smart?skipOcr=1"
Save-Json "02-smart-capture-skip-ocr" $capture | Out-Null
}
foreach ($index in $ProbeIndices) {
Write-Host "Running probe click index=$index"
$probe = Invoke-DevJson "/automation/probe-click?index=$index"
Save-Json "probe-index-$index" $probe | Out-Null
if (-not (Test-ProbeSucceeded $probe)) {
Write-Host "Probe index=$index did not fully pass. Review the saved JSON before broader scans." -ForegroundColor Yellow
if (-not $ContinueAfterBlocked) {
throw "Stopping after failed probe index=$index. Re-run with -ContinueAfterBlocked only if you deliberately want to continue."
}
} elseif (-not $probe.ok -and $probe.changed) {
Write-Host "Probe index=$index changed the detail panel even though helper cursor/click readback was not clean; continuing." -ForegroundColor Yellow
}
}
foreach ($engine in $ScanEngines) {
foreach ($limit in $Limits) {
if ($limit -lt 1) { continue }
Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
$start = Invoke-DevJson "/scanner/start?limit=$limit&engine=$engine"
Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null
$finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
Save-Json "scan-$engine-limit-$limit-final" $finalStatus | Out-Null
$summary = Get-CompletedScanSummary $finalStatus $limit
Write-Host "engine=$engine limit=$limit summary: status=$($summary.status), attempted=$($summary.attempted), verified=$($summary.verified), parsed=$($summary.parsed), stored=$($summary.stored), review=$($summary.review), misses=$($summary.misses), pages=$($summary.pages)"
$timing = Convert-ScanTimingSummary $finalStatus $limit $engine
$RunSummaries += $timing
Write-ScanTimingLine $timing
if (($summary.status -eq "blocked" -or $summary.status -eq "stopped") -and -not $ContinueAfterBlocked) {
throw "Stopping after scan status '$($summary.status)' for limit=$limit engine=$engine."
}
}
}
$review = Invoke-DevJson "/review/samples?limit=30"
Save-Json "review-samples-tail-summary" (Convert-ReviewSamplesSummary $review) | Out-Null
if ($SaveFullReviewSamples) {
Save-Json "review-samples-tail-full" $review | Out-Null
}
$afterStatus = Get-ScannerStatus
Save-Json "99-status-after" $afterStatus | Out-Null
Save-Json "scan-run-summary" ([pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goalRun = [bool]$GoalRun
scanEngine = $ScanEngine
scanEngines = $ScanEngines
benchmarkOcr = [bool]$BenchmarkOcr
limits = $Limits
summaries = $RunSummaries
}) | Out-Null
if ($RunSummaries.Count -gt 0) {
$assessment = New-PerformanceAssessment -Summaries $RunSummaries
Save-Json "scan-performance-assessment" $assessment | Out-Null
Write-PerformanceAssessment $assessment
$csvPath = Join-Path $RunDir "scan-run-summary.csv"
$RunSummaries | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
Write-Host "Timing summary CSV: $csvPath"
}
Write-Host "Live soak complete. Output: $RunDir" -ForegroundColor Green
} catch {
Write-Host "Live soak failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Output so far: $RunDir" -ForegroundColor Yellow
exit 1
} finally {
try {
Stop-Transcript | Out-Null
} catch {
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react"; import { AlertTriangle, BadgeCheck, Camera, ClipboardList, Download, Gauge, Play, Target, Upload, Wrench } from "lucide-react";
import type { CaptureResult } from "../../../types/global"; import type { CaptureResult } from "../../../types/global";
import type { ScanViewControllerResult } from "../types"; import type { ScanViewControllerResult } from "../types";
import { FieldConfidenceList } from "./ScanResultCards"; import { FieldConfidenceList } from "./ScanResultCards";
@@ -14,6 +14,53 @@ interface DiagnosticsViewProps {
canDemoScan?: boolean; canDemoScan?: boolean;
} }
const appDiagnosisSections = [
{
title: "Was die App kann",
tone: "ok",
icon: BadgeCheck,
items: [
"Genshin-Fenster erkennen, Smart Capture ausfuehren und fokussierte Artifact-Crops erzeugen.",
"Artifact-Felder deterministisch gegen das lokale Genshin-Datenpaket parsen.",
"Auto-Scan read-only aus der sichtbaren Inventory-Seite starten, inklusive Grid, Verifikation, Dedupe und Store.",
"Review-Samples, lokale Text-Lernregeln, GOOD Import/Export und Lock-Status im Store nutzen.",
],
},
{
title: "Was noch fehlt",
tone: "warn",
icon: ClipboardList,
items: [
"Paimon-Menue-Einstieg ist gebaut, aber live noch nicht mit 2/20/45 Limits validiert.",
"Native/IK-Tesseract ist nur als Benchmark-Pfad vorbereitet, noch nicht Standard.",
"Positive locked=true Probe an einem sicher gesperrten Artifact fehlt.",
"Empfehlungen bleiben Nebenfunktion, bis Scanner-Vertrauen und Review-Rate stabil genug sind.",
],
},
{
title: "Wo es Probleme macht",
tone: "risk",
icon: AlertTriangle,
items: [
"OCR ist weiterhin der Haupt-Risikofaktor; einige Felder landen noch in Fallback, Ableitung oder Review.",
"Bild-Preprocessing kann nur an echten Captures bewertet werden, nicht allein mit Text-Eval.",
"Auto-Scan braucht bei erhoehtem Genshin auch eine erhoehte App-Laufzeit.",
"Groessere Runs brauchen weiter Beobachtung auf Scroll-Uebergaenge, Wiederholseiten und Review-Quote.",
],
},
{
title: "Naechste Verbesserungen",
tone: "next",
icon: Target,
items: [
"Review-Corpus aus echten Samples vergroessern und mit `npm run eval` messbar halten.",
"OCR-Benchmark gegen identische Crops fahren und erst danach Engine-Standard wechseln.",
"Paimon-Menue-Pfad live pruefen und bei Blockade sichtbar auf visible-inventory zurueckfallen.",
"Diagnose weiter als Operator-Cockpit halten: Live-Status, Evidenz und naechster sicherer Schritt.",
],
},
];
// All developer / diagnostic surfaces live here, separated from the Scan // All developer / diagnostic surfaces live here, separated from the Scan
// workspace: runtime + rights, grid detection, learning + data-package status, // workspace: runtime + rights, grid detection, learning + data-package status,
// fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/ // fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/
@@ -39,6 +86,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
playerProgress, playerProgress,
reviewStatus, reviewStatus,
automationLogLines, automationLogLines,
diagnosticEvents,
canSaveReviewSample, canSaveReviewSample,
handleSaveReviewSample, handleSaveReviewSample,
} = useScanDiagnosticsModalModel({ } = useScanDiagnosticsModalModel({
@@ -106,6 +154,37 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
</div> </div>
</div> </div>
<div className="diagnose-card app-diagnosis-card">
<div className="diagnose-card-heading">
<div>
<p className="eyebrow">Aktueller App-Stand</p>
<h3>Scanner zuerst, Empfehlungen danach</h3>
</div>
<span className="diagnosis-source">
<Gauge size={14} />
Quelle: Docs + Live-Status
</span>
</div>
<div className="app-diagnosis-grid">
{appDiagnosisSections.map((section) => {
const Icon = section.icon;
return (
<article className={`app-diagnosis-section ${section.tone}`} key={section.title}>
<div className="app-diagnosis-title">
<Icon size={16} />
<strong>{section.title}</strong>
</div>
<ul>
{section.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
);
})}
</div>
</div>
<div className="diagnose-grid"> <div className="diagnose-grid">
<div className="diagnose-card"> <div className="diagnose-card">
<p className="eyebrow">Status</p> <p className="eyebrow">Status</p>
@@ -196,6 +275,67 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
</div> </div>
</div> </div>
<div className="diagnose-card">
<div className="diagnose-card-heading">
<div>
<p className="eyebrow">Evidence timeline</p>
<h3>Scan-Flugschreiber</h3>
</div>
<span className="diagnosis-source">
<Camera size={14} />
letzte {diagnosticEvents.length}
</span>
</div>
{diagnosticEvents.length > 0 ? (
<div className="scan-evidence-timeline">
{diagnosticEvents.slice().reverse().map((event) => (
<article className={`scan-evidence-event ${event.severity}`} key={event.id}>
<div className="scan-evidence-header">
<span>{new Date(event.at).toLocaleTimeString()}</span>
<strong>{event.phase}</strong>
<em>{event.severity}</em>
</div>
<p>{event.message}</p>
{event.details && (
<div className="scan-evidence-details">
{Object.entries(event.details).map(([key, value]) => (
<span key={key}>{key}: {String(value ?? "-")}</span>
))}
</div>
)}
{event.capture && (
<div className="scan-evidence-capture">
<div>
<strong>{event.capture.name}</strong>
<span>{event.capture.width}x{event.capture.height} · {event.capture.target ?? "capture"} · fp {event.capture.fingerprint}</span>
{event.capture.grid && (
<span>grid {event.capture.grid.cols}x{event.capture.grid.rows} · {event.capture.grid.targets} targets · {event.capture.grid.confidence}% · {event.capture.grid.source}</span>
)}
{event.capture.count && (
<span>count {event.capture.count.current}/{event.capture.count.total || "?"} · {event.capture.count.confidence}% · {event.capture.count.text || "-"}</span>
)}
{event.capture.artifactDetail && (
<span>detail {event.capture.artifactDetail.present ? "yes" : "no"} · {event.capture.artifactDetail.confidence}% · orange {event.capture.artifactDetail.orangeHits} · text {event.capture.artifactDetail.textHits}</span>
)}
{event.capture.paimonMenu && (
<span>paimon {event.capture.paimonMenu.present ? "yes" : "no"} · {event.capture.paimonMenu.confidence}%</span>
)}
{event.capture.layoutWarning && <span className="evidence-warning">{event.capture.layoutWarning}</span>}
</div>
<div className="scan-evidence-images">
{event.capture.screenshots?.inventory && <img src={event.capture.screenshots.inventory} alt={`${event.phase} inventory`} />}
{event.capture.screenshots?.detail && <img src={event.capture.screenshots.detail} alt={`${event.phase} detail`} />}
</div>
</div>
)}
</article>
))}
</div>
) : (
<p className="result-empty">Noch keine Evidence-Events. Starte einen Capture oder Auto-Scan, dann erscheinen hier Schritte mit Screenshots.</p>
)}
</div>
<div className="diagnose-card"> <div className="diagnose-card">
<div className="diagnose-card-heading"> <div className="diagnose-card-heading">
<p className="eyebrow">Crops, OCR &amp; Confidence</p> <p className="eyebrow">Crops, OCR &amp; Confidence</p>
@@ -7,14 +7,9 @@ export function ScanMainSection({
latestCapture, latestCapture,
captureStatus, captureStatus,
parsedArtifact, parsedArtifact,
sourceLabel,
gridLabel,
inventoryLabel,
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen, setDetailsOpen,
autoScanRunning, autoScanRunning,
canOpenReviewQueue, canOpenReviewQueue,
@@ -27,29 +22,25 @@ export function ScanMainSection({
captureImageSrc, captureImageSrc,
captureImageAlt, captureImageAlt,
hasCapture, hasCapture,
captureModeText,
resultHeading, resultHeading,
noArtifactText, noArtifactText,
noCaptureMessage, noCaptureMessage,
targetLabel, targetLabel,
dbLabel, dbLabel,
reviewLabel, reviewLabel,
rulesLabel,
} = useScanMainSectionModel({ } = useScanMainSectionModel({
latestCapture, latestCapture,
parsedArtifact, parsedArtifact,
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen, setDetailsOpen,
openReviewQueue, openReviewQueue,
}); });
return ( return (
<div className="scanner-main-grid"> <div className="scanner-main-grid">
<div className="capture-stage-shell"> <div className={`capture-stage-shell ${hasCapture ? "has-capture" : "is-empty"}`}>
<div className="capture-stage"> <div className="capture-stage">
{hasCapture ? ( {hasCapture ? (
<img src={captureImageSrc} alt={captureImageAlt} /> <img src={captureImageSrc} alt={captureImageAlt} />
@@ -61,10 +52,6 @@ export function ScanMainSection({
</div> </div>
)} )}
</div> </div>
<div className="capture-stage-meta">
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
</div>
</div> </div>
<aside className="scanner-result-panel"> <aside className="scanner-result-panel">
@@ -81,9 +68,12 @@ export function ScanMainSection({
<span>{reviewLabel}</span> <span>{reviewLabel}</span>
</div> </div>
<div className="scanner-result-actions"> <div className="scanner-result-actions">
<button className="ghost-button" onClick={handleOpenDetails} disabled={!canOpenDetails}>
Details
</button>
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}> <button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
<AlertTriangle size={15} /> <AlertTriangle size={15} />
Review Queue Review
</button> </button>
</div> </div>
<p className="scanner-result-caption">{captureStatus}</p> <p className="scanner-result-caption">{captureStatus}</p>
@@ -32,7 +32,7 @@ export function ScanTopControlsSection({
openDiagnostics, openDiagnostics,
captureSingleArtifact, captureSingleArtifact,
stopScan, stopScan,
runVisibleGridScan, runGuidedAutoScan,
runAutoReviewScan, runAutoReviewScan,
bridgeStatusText, bridgeStatusText,
bridgePillClass, bridgePillClass,
@@ -46,7 +46,6 @@ export function ScanTopControlsSection({
refreshCaptureSourcesTitle, refreshCaptureSourcesTitle,
diagnosticsButtonTitle, diagnosticsButtonTitle,
scanSetupButtonTitle, scanSetupButtonTitle,
showPlayerProgress,
progressWidth, progressWidth,
progressStats, progressStats,
} = useScanTopControlsModel({ } = useScanTopControlsModel({
@@ -64,8 +63,7 @@ export function ScanTopControlsSection({
<div className="scanner-header"> <div className="scanner-header">
<div> <div>
<p className="eyebrow">Scanner</p> <p className="eyebrow">Scanner</p>
<h2>Artifact capture workspace</h2> <h2>Artifact Scan</h2>
<p className="scanner-subcopy">Quelle waehlen, Auto-Scan starten, Ergebnis rechts pruefen. Dev-Details im Diagnose-Tab.</p>
</div> </div>
<div className="scanner-header-pills"> <div className="scanner-header-pills">
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span> <span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
@@ -119,10 +117,11 @@ export function ScanTopControlsSection({
</button> </button>
</div> </div>
<div className="player-scan-lower">
<div className="player-scan-actions"> <div className="player-scan-actions">
<button <button
className="primary-button scan-cta" className="primary-button scan-cta"
onClick={runVisibleGridScan} onClick={runGuidedAutoScan}
disabled={!canStartAutoScan} disabled={!canStartAutoScan}
title={autoScanButtonTitle} title={autoScanButtonTitle}
> >
@@ -145,7 +144,7 @@ export function ScanTopControlsSection({
title={captureSingleButtonTitle} title={captureSingleButtonTitle}
> >
<Camera size={15} /> <Camera size={15} />
Einzelnes Artifact lesen Einzelnes Artifact
</button> </button>
{autoScanRunning && ( {autoScanRunning && (
<button className="stop-button" onClick={stopScan}> <button className="stop-button" onClick={stopScan}>
@@ -154,11 +153,6 @@ export function ScanTopControlsSection({
)} )}
</div> </div>
<p className="player-status">
{playerStatusText}
</p>
{showPlayerProgress && (
<div className="player-progress"> <div className="player-progress">
<div className="player-progress-bar"> <div className="player-progress-bar">
<div style={{ width: `${progressWidth}%` }} /> <div style={{ width: `${progressWidth}%` }} />
@@ -171,7 +165,11 @@ export function ScanTopControlsSection({
))} ))}
</div> </div>
</div> </div>
)} </div>
<p className="player-status">
{playerStatusText}
</p>
</div> </div>
</> </>
); );
@@ -8,14 +8,12 @@ export interface ScanMainSectionModel {
captureImageSrc: string; captureImageSrc: string;
captureImageAlt: string; captureImageAlt: string;
hasCapture: boolean; hasCapture: boolean;
captureModeText: string;
resultHeading: string; resultHeading: string;
noArtifactText: string; noArtifactText: string;
noCaptureMessage: string; noCaptureMessage: string;
targetLabel: string; targetLabel: string;
dbLabel: string; dbLabel: string;
reviewLabel: string; reviewLabel: string;
rulesLabel: string;
} }
type UseScanMainSectionModelProps = Pick< type UseScanMainSectionModelProps = Pick<
@@ -25,8 +23,6 @@ type UseScanMainSectionModelProps = Pick<
| "activeTargetCount" | "activeTargetCount"
| "storedTotal" | "storedTotal"
| "reviewSampleTotal" | "reviewSampleTotal"
| "learningRulesLoaded"
| "learningRuleCount"
| "setDetailsOpen" | "setDetailsOpen"
| "openReviewQueue" | "openReviewQueue"
>; >;
@@ -37,8 +33,6 @@ export function useScanMainSectionModel({
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen, setDetailsOpen,
openReviewQueue, openReviewQueue,
}: UseScanMainSectionModelProps): ScanMainSectionModel { }: UseScanMainSectionModelProps): ScanMainSectionModel {
@@ -53,14 +47,12 @@ export function useScanMainSectionModel({
const captureImageAlt = latestCapture const captureImageAlt = latestCapture
? `Latest capture from ${latestCapture.name}` ? `Latest capture from ${latestCapture.name}`
: "Latest capture is not available yet"; : "Latest capture is not available yet";
const captureModeText = latestCapture ? "Erkannt" : "Warte";
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact"; const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan."; const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
const noCaptureMessage = noArtifactText; const noCaptureMessage = noArtifactText;
const targetLabel = `Ziel ${activeTargetCount}`; const targetLabel = `Ziel ${activeTargetCount}`;
const dbLabel = `DB ${storedTotal ?? "-"}`; const dbLabel = `DB ${storedTotal ?? "-"}`;
const reviewLabel = `Review ${reviewSampleTotal}`; const reviewLabel = `Review ${reviewSampleTotal}`;
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
return { return {
canOpenDetails, canOpenDetails,
@@ -69,13 +61,11 @@ export function useScanMainSectionModel({
captureImageSrc, captureImageSrc,
captureImageAlt, captureImageAlt,
hasCapture: Boolean(latestCapture), hasCapture: Boolean(latestCapture),
captureModeText,
resultHeading, resultHeading,
noArtifactText, noArtifactText,
noCaptureMessage, noCaptureMessage,
targetLabel, targetLabel,
dbLabel, dbLabel,
reviewLabel, reviewLabel,
rulesLabel,
}; };
} }
@@ -1,4 +1,12 @@
import type { ScanSummaryFooterProps } from "../types"; import type { ScanSummaryFooterProps } from "../types";
function formatDuration(ms: number) {
if (ms <= 0) return "0s";
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
}
export interface ScanSummaryFooterModel { export interface ScanSummaryFooterModel {
summaryCopy: string; summaryCopy: string;
@@ -12,10 +20,10 @@ export function useScanSummaryFooterModel({
}: ScanSummaryFooterProps): ScanSummaryFooterModel { }: ScanSummaryFooterProps): ScanSummaryFooterModel {
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan" const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben." ? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`; : `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen in ${formatDuration(scanSummary.elapsedMs)} (${scanSummary.averageMsPerParsed || 0} ms/Artifact). Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
const devCopy = devMode const devCopy = devMode
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}` ? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
: null; : null;
return { return {
@@ -15,7 +15,7 @@ export interface ScanTopControlsModel {
openDiagnostics: () => void; openDiagnostics: () => void;
captureSingleArtifact: () => void; captureSingleArtifact: () => void;
stopScan: () => void; stopScan: () => void;
runVisibleGridScan: () => void; runGuidedAutoScan: () => void;
runAutoReviewScan: () => void; runAutoReviewScan: () => void;
bridgeStatusText: string; bridgeStatusText: string;
bridgePillClass: string; bridgePillClass: string;
@@ -47,7 +47,7 @@ export function useScanTopControlsModel({
setSettingsOpen, setSettingsOpen,
setDiagnosticsOpen, setDiagnosticsOpen,
requestScanStop, requestScanStop,
runVisibleGridScan, runGuidedAutoScan,
runAutoReviewScan, runAutoReviewScan,
autoScanRunning, autoScanRunning,
canCaptureSource, canCaptureSource,
@@ -77,17 +77,20 @@ export function useScanTopControlsModel({
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]); const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]); const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]); const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
const startGuidedAutoScan = useCallback(() => {
void runGuidedAutoScan();
}, [runGuidedAutoScan]);
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt"; const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
const bridgePillClass = bridgeReady ? "elevated" : "standard"; const bridgePillClass = bridgeReady ? "elevated" : "standard";
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard"; const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard"; const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
const playerStatusText = reviewStatus const playerStatusText = reviewStatus
|| (runtimeInfo?.isElevated || (runtimeInfo?.isElevated
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan." ? "App laeuft als Administrator. Auto-Scan prueft den Screen ohne OCR und startet erst, wenn eine Artifact-Detailkarte offen ist."
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten."); : "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
const autoScanButtonTitle = requiresAdminForAutoScan const autoScanButtonTitle = requiresAdminForAutoScan
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten." ? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar."; : "Prueft zuerst ohne OCR den Screen, oeffnet bei Bedarf per Inventory-Kamera-Sequenz das Artifact-Inventar und scannt erst mit sichtbarer Detailkarte.";
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten"; const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen."; const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig."; const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
@@ -104,11 +107,20 @@ export function useScanTopControlsModel({
{ label: "Klicks", value: autoScanStats.clicked }, { label: "Klicks", value: autoScanStats.clicked },
{ label: "Positionen", value: autoScanStats.attempted }, { label: "Positionen", value: autoScanStats.attempted },
{ label: "Verifiziert", value: autoScanStats.verified }, { label: "Verifiziert", value: autoScanStats.verified },
{ label: "ms/Artifact", value: autoScanStats.averageMsPerParsed || "-" },
{ label: "Gespeichert", value: autoScanStats.stored }, { label: "Gespeichert", value: autoScanStats.stored },
{ label: "Review", value: autoScanStats.review }, { label: "Review", value: autoScanStats.review },
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" }, { label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
], ],
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal], [
autoScanStats.clicked,
autoScanStats.attempted,
autoScanStats.verified,
autoScanStats.averageMsPerParsed,
autoScanStats.stored,
autoScanStats.review,
storedTotal,
],
); );
return { return {
@@ -124,7 +136,7 @@ export function useScanTopControlsModel({
openDiagnostics, openDiagnostics,
captureSingleArtifact, captureSingleArtifact,
stopScan, stopScan,
runVisibleGridScan, runGuidedAutoScan: startGuidedAutoScan,
runAutoReviewScan, runAutoReviewScan,
bridgeStatusText, bridgeStatusText,
bridgePillClass, bridgePillClass,
@@ -1,5 +1,6 @@
import type { ScanSettingsModalProps } from "./types"; import type { ScanSettingsModalProps } from "./types";
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel"; import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
import type { StepperControlModel } from "./hooks/useScanSettingsModalModel";
export function ScanSettingsModal({ export function ScanSettingsModal({
open, open,
@@ -16,9 +17,9 @@ export function ScanSettingsModal({
const { const {
closeSettings, closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation, stopPropagation,
scanLimitControl,
skipRowsControl,
inventoryCountText, inventoryCountText,
inventoryClassName, inventoryClassName,
scanLimitClassName, scanLimitClassName,
@@ -50,33 +51,17 @@ export function ScanSettingsModal({
<button className="ghost-button" onClick={closeSettings}>Schliessen</button> <button className="ghost-button" onClick={closeSettings}>Schliessen</button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="scan-config-strip"> <div className="scan-settings-layout">
<label> <div className="scan-settings-controls">
<span>Anzahl Artifacts</span> <StepperControl control={scanLimitControl} />
<input <StepperControl control={skipRowsControl} />
type="number"
min={1}
max={1800}
value={scanLimit}
onChange={handleScanLimitChange}
/>
</label>
<label>
<span>Zeilen ueberspringen</span>
<input
type="number"
min={0}
max={8}
value={skipRows}
onChange={handleSkipRowsChange}
/>
</label>
<p>
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
</p>
</div> </div>
<div className="scanner-preflight diagnostics-preflight"> <div className="scan-settings-note">
<strong>Manuelle Werte bleiben erhalten.</strong>
<span>Die erkannte Inventar-Anzahl ist nur ein Vorschlag und oberer Deckel. Startzeilen brauchst du nur, wenn du nicht oben beginnst.</span>
</div>
</div>
<div className="scanner-preflight settings-preflight">
<div className={inventoryClassName}> <div className={inventoryClassName}>
<span>Inventarzaehler</span> <span>Inventarzaehler</span>
<strong>{inventoryCountText}</strong> <strong>{inventoryCountText}</strong>
@@ -97,3 +82,42 @@ export function ScanSettingsModal({
</div> </div>
); );
} }
function StepperControl({ control }: { control: StepperControlModel }) {
return (
<section className="settings-stepper" aria-label={control.label}>
<div className="settings-stepper-head">
<div>
<span>{control.label}</span>
<small>{control.helper}</small>
</div>
</div>
<div className="settings-stepper-row">
<button type="button" className="stepper-button" onClick={control.decrement} aria-label={`${control.label} verringern`}>
</button>
<input
inputMode="numeric"
pattern="[0-9]*"
min={control.min}
max={control.max}
value={control.value}
onChange={control.onChange}
onBlur={control.onBlur}
onKeyDown={control.onKeyDown}
aria-label={control.label}
/>
<button type="button" className="stepper-button" onClick={control.increment} aria-label={`${control.label} erhoehen`}>
+
</button>
</div>
<div className="settings-presets" aria-label={`${control.label} Presets`}>
{control.presets.map((value) => (
<button type="button" key={value} onClick={() => control.applyPreset(value)}>
{value}
</button>
))}
</div>
</section>
);
}
@@ -33,21 +33,24 @@ export function useScanDetailsModalModel({
const crops = latestCapture?.crops ?? []; const crops = latestCapture?.crops ?? [];
const ocr = latestCapture?.ocr ?? []; const ocr = latestCapture?.ocr ?? [];
const cropRows = crops
.filter((crop) => Boolean(crop.dataUrl))
.map((crop) => ({
id: crop.id,
dataUrl: crop.dataUrl ?? "",
label: crop.label,
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
}));
return { return {
closeDetails, closeDetails,
stopPropagation, stopPropagation,
parsedNotes, parsedNotes,
showParsedNotes: parsedNotes.length > 0, showParsedNotes: parsedNotes.length > 0,
cropRows: crops.map((crop) => ({ cropRows,
id: crop.id,
dataUrl: crop.dataUrl,
label: crop.label,
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
})),
ocrRows: ocr.map((entry) => ({ ocrRows: ocr.map((entry) => ({
id: entry.id, id: entry.id,
label: entry.label, label: entry.label,
@@ -55,7 +58,7 @@ export function useScanDetailsModalModel({
text: entry.text || "No text detected", text: entry.text || "No text detected",
})), })),
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`, debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
showCrops: crops.length > 0, showCrops: cropRows.length > 0,
showOcr: ocr.length > 0, showOcr: ocr.length > 0,
}; };
} }
@@ -1,8 +1,10 @@
import { detailFingerprint } from "../../../../../lib/autoScanLoop"; import { detailFingerprint } from "../../../../../lib/autoScanLoop";
import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData"; import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData";
import { dataPackageStatus } from "../../../../../lib/dataPackageStatus"; import { dataPackageStatus } from "../../../../../lib/dataPackageStatus";
import { validateLookupPackage } from "../../../../../lib/genshinLookup";
import { useCallback, useMemo, type MouseEvent } from "react"; import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types"; import type { ScanDiagnosticsModalProps } from "../types";
import type { ScanDiagnosticEvent } from "../../../../../lib/scanDiagnosticsLog";
export interface ScanDiagnosticsModelProgress { export interface ScanDiagnosticsModelProgress {
width: number; width: number;
@@ -37,6 +39,7 @@ export interface ScanDiagnosticsModalModel {
showDevRows: boolean; showDevRows: boolean;
reviewStatus: string; reviewStatus: string;
automationLogLines: string[]; automationLogLines: string[];
diagnosticEvents: ScanDiagnosticEvent[];
canSaveReviewSample: boolean; canSaveReviewSample: boolean;
} }
@@ -98,7 +101,11 @@ export function useScanDiagnosticsModalModel({
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading"; const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
const dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion); const dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion);
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`; const lookupStatus = validateLookupPackage();
const lookupText = lookupStatus.valid
? `Lookup OK: ${lookupStatus.summary.artifactSets} sets / ${lookupStatus.summary.artifactPieces} pieces`
: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown error"}`;
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. ${lookupText}. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
const playerProgress = useMemo(() => { const playerProgress = useMemo(() => {
const width = Math.min( const width = Math.min(
@@ -128,6 +135,25 @@ export function useScanDiagnosticsModalModel({
{ label: "duplicates", value: controller.autoScanStats.duplicates }, { label: "duplicates", value: controller.autoScanStats.duplicates },
{ label: "misses", value: controller.autoScanStats.misses }, { label: "misses", value: controller.autoScanStats.misses },
{ label: "pages", value: controller.autoScanStats.pages }, { label: "pages", value: controller.autoScanStats.pages },
{ label: "elapsedMs", value: controller.autoScanStats.elapsedMs },
{ label: "activeScanMs", value: controller.autoScanStats.activeScanMs },
{ label: "writeFlushMs", value: controller.autoScanStats.writeFlushMs },
{ label: "avgMs", value: controller.autoScanStats.averageMsPerParsed },
{ label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed },
{ label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs },
{ label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms },
{ label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms },
{ label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs },
{ label: "ocrP50Ms", value: controller.autoScanStats.ocrP50Ms },
{ label: "ocrP90Ms", value: controller.autoScanStats.ocrP90Ms },
{ label: "cardReadyAvgMs", value: controller.autoScanStats.averageCardReadyMs },
{ label: "cardReadyCount", value: controller.autoScanStats.cardReadyCount },
{ label: "scrollReadyAvgMs", value: controller.autoScanStats.averageScrollReadyMs },
{ label: "scrollReadyCount", value: controller.autoScanStats.scrollReadyCount },
{ label: "perMin x10", value: Math.round(controller.autoScanStats.artifactsPerMinute * 10) },
{ label: "activePerMin x10", value: Math.round(controller.autoScanStats.activeArtifactsPerMinute * 10) },
{ label: "projected100Ms", value: controller.autoScanStats.projectedMsFor100 },
{ label: "activeProjected100Ms", value: controller.autoScanStats.activeProjectedMsFor100 },
], ],
[ [
controller.autoScanStats.clicked, controller.autoScanStats.clicked,
@@ -139,6 +165,25 @@ export function useScanDiagnosticsModalModel({
controller.autoScanStats.duplicates, controller.autoScanStats.duplicates,
controller.autoScanStats.misses, controller.autoScanStats.misses,
controller.autoScanStats.pages, controller.autoScanStats.pages,
controller.autoScanStats.elapsedMs,
controller.autoScanStats.activeScanMs,
controller.autoScanStats.writeFlushMs,
controller.autoScanStats.averageMsPerParsed,
controller.autoScanStats.activeAverageMsPerParsed,
controller.autoScanStats.averageCaptureMs,
controller.autoScanStats.captureP50Ms,
controller.autoScanStats.captureP90Ms,
controller.autoScanStats.averageOcrMs,
controller.autoScanStats.ocrP50Ms,
controller.autoScanStats.ocrP90Ms,
controller.autoScanStats.averageCardReadyMs,
controller.autoScanStats.cardReadyCount,
controller.autoScanStats.averageScrollReadyMs,
controller.autoScanStats.scrollReadyCount,
controller.autoScanStats.artifactsPerMinute,
controller.autoScanStats.activeArtifactsPerMinute,
controller.autoScanStats.projectedMsFor100,
controller.autoScanStats.activeProjectedMsFor100,
], ],
); );
@@ -186,6 +231,7 @@ export function useScanDiagnosticsModalModel({
showDevRows: controller.devMode, showDevRows: controller.devMode,
reviewStatus: controller.reviewStatus, reviewStatus: controller.reviewStatus,
automationLogLines: controller.automationLog, automationLogLines: controller.automationLog,
diagnosticEvents: controller.diagnosticEvents,
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact), canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
}; };
} }
@@ -1,12 +1,27 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react"; import { useCallback, useEffect, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global"; import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession"; import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
export interface StepperControlModel {
label: string;
value: string;
helper: string;
min: number;
max: number;
presets: number[];
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onBlur: (event: FocusEvent<HTMLInputElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
decrement: () => void;
increment: () => void;
applyPreset: (value: number) => void;
}
export interface ScanSettingsModalModel { export interface ScanSettingsModalModel {
closeSettings: () => void; closeSettings: () => void;
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void; stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
scanLimitControl: StepperControlModel;
skipRowsControl: StepperControlModel;
inventoryCountText: string; inventoryCountText: string;
inventoryClassName: string; inventoryClassName: string;
scanLimitClassName: string; scanLimitClassName: string;
@@ -36,17 +51,71 @@ export function useScanSettingsModalModel({
setScanLimitTouched: (touched: boolean) => void; setScanLimitTouched: (touched: boolean) => void;
setSkipRows: (rows: number) => void; setSkipRows: (rows: number) => void;
}): ScanSettingsModalModel { }): ScanSettingsModalModel {
const [scanLimitText, setScanLimitText] = useState(String(scanLimit));
const [skipRowsText, setSkipRowsText] = useState(String(skipRows));
useEffect(() => {
setScanLimitText(String(scanLimit));
}, [scanLimit]);
useEffect(() => {
setSkipRowsText(String(skipRows));
}, [skipRows]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]); const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setScanLimitTouched(true);
setScanLimit(clampScanLimit(Number(event.target.value)));
}, [setScanLimit, setScanLimitTouched]);
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSkipRows(clampSkipRows(Number(event.target.value)));
}, [setSkipRows]);
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => { const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
}, []); }, []);
const applyScanLimit = useCallback((value: number) => {
const next = clampScanLimit(value);
setScanLimitTouched(true);
setScanLimit(next);
setScanLimitText(String(next));
}, [setScanLimit, setScanLimitTouched]);
const applySkipRows = useCallback((value: number) => {
const next = clampSkipRows(value);
setSkipRows(next);
setSkipRowsText(String(next));
}, [setSkipRows]);
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setScanLimitText(event.target.value.replace(/\D/g, "").slice(0, 4));
}, []);
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSkipRowsText(event.target.value.replace(/\D/g, "").slice(0, 2));
}, []);
const commitScanLimit = useCallback((rawValue: string) => {
applyScanLimit(rawValue.trim() === "" ? scanLimit : Number(rawValue));
}, [applyScanLimit, scanLimit]);
const commitSkipRows = useCallback((rawValue: string) => {
applySkipRows(rawValue.trim() === "" ? skipRows : Number(rawValue));
}, [applySkipRows, skipRows]);
const handleScanLimitBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
commitScanLimit(event.target.value);
}, [commitScanLimit]);
const handleSkipRowsBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
commitSkipRows(event.target.value);
}, [commitSkipRows]);
const handleScanLimitKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
commitScanLimit(event.currentTarget.value);
event.currentTarget.blur();
}, [commitScanLimit]);
const handleSkipRowsKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
commitSkipRows(event.currentTarget.value);
event.currentTarget.blur();
}, [commitSkipRows]);
const detectedInventoryCount = latestCapture?.inventoryCount?.current; const detectedInventoryCount = latestCapture?.inventoryCount?.current;
const detectedInventoryTotal = latestCapture?.inventoryCount?.total; const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral"; const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
@@ -56,14 +125,40 @@ export function useScanSettingsModalModel({
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard"; const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
const skipRowsClassName = skipRows > 0 ? "ok" : "standard"; const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard"; const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`; const activeTargetText = `${activeTargetCount} Ziele · ${focusModeText}`;
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt."; const scanSummaryText = "Auto-Scan zaehlt Positionen, verifizierte Ansichten und gespeicherte Artifacts getrennt.";
return { return {
closeSettings, closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation, stopPropagation,
scanLimitControl: {
label: "Scan-Ziel",
value: scanLimitText,
helper: "Wie viele sichtbare Positionen verarbeitet werden.",
min: 1,
max: 1800,
presets: [16, 20, 50, 100],
onChange: handleScanLimitChange,
onBlur: handleScanLimitBlur,
onKeyDown: handleScanLimitKeyDown,
decrement: () => applyScanLimit(scanLimit - 1),
increment: () => applyScanLimit(scanLimit + 1),
applyPreset: applyScanLimit,
},
skipRowsControl: {
label: "Startzeilen ueberspringen",
value: skipRowsText,
helper: "Nur nutzen, wenn du mitten in der Liste beginnst.",
min: 0,
max: 8,
presets: [0, 1, 2, 3],
onChange: handleSkipRowsChange,
onBlur: handleSkipRowsBlur,
onKeyDown: handleSkipRowsKeyDown,
decrement: () => applySkipRows(skipRows - 1),
increment: () => applySkipRows(skipRows + 1),
applyPreset: applySkipRows,
},
inventoryCountText, inventoryCountText,
inventoryClassName, inventoryClassName,
scanLimitClassName, scanLimitClassName,
@@ -3,6 +3,7 @@ import type { ScannerLearningRules } from "../../../lib/scannerLearning";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { ScanActionContext } from "./scanViewScanActions"; import type { ScanActionContext } from "./scanViewScanActions";
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { import type {
ReviewStateContext, ReviewStateContext,
} from "./scanViewReviewHelpers"; } from "./scanViewReviewHelpers";
@@ -42,6 +43,7 @@ export interface ScanActionContextInput {
setReviewStatus: Dispatch<SetStateAction<string>>; setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void; appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: ( persistParsedArtifact: (
capture: CaptureResult | null, capture: CaptureResult | null,
@@ -60,7 +62,11 @@ export interface ScanActionContextInput {
focusGenshin?: boolean, focusGenshin?: boolean,
options?: CaptureOptions, options?: CaptureOptions,
) => Promise<CaptureResult | null>; ) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>; captureFastSelectedSource: (
delayMs?: number,
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult | null>;
} }
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext { export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
@@ -98,6 +104,7 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
setReviewStatus: input.setReviewStatus, setReviewStatus: input.setReviewStatus,
appendAutomationLog: input.appendAutomationLog, appendAutomationLog: input.appendAutomationLog,
appendClickDiagnostics: input.appendClickDiagnostics, appendClickDiagnostics: input.appendClickDiagnostics,
appendDiagnosticEvent: input.appendDiagnosticEvent,
parseArtifact: input.parseArtifact, parseArtifact: input.parseArtifact,
persistParsedArtifact: input.persistParsedArtifact, persistParsedArtifact: input.persistParsedArtifact,
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false), shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
@@ -277,9 +277,28 @@ export async function saveReviewSample(
} }
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false }; if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
const result = await reviewSamplesRepo.saveSample({ const compactAutomaticSample = /^automatic:/i.test(reason);
reason, const sampleCapture = compactAutomaticSample
capture: { ? {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
detailDataUrl: capture.detailDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
locked: capture.locked,
ocr: capture.ocr,
}
: {
id: capture.id, id: capture.id,
name: capture.name, name: capture.name,
width: capture.width, width: capture.width,
@@ -299,7 +318,11 @@ export async function saveReviewSample(
inventoryCount: capture.inventoryCount, inventoryCount: capture.inventoryCount,
locked: capture.locked, locked: capture.locked,
ocr: capture.ocr, ocr: capture.ocr,
}, };
const result = await reviewSamplesRepo.saveSample({
reason,
capture: sampleCapture,
parsed, parsed,
}); });
+346 -7
View File
@@ -1,8 +1,10 @@
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner"; import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
import { artifactTabClickTarget, keyPressBlocked, validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality"; import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
import { runAutoScanLoop } from "../../../lib/autoScanLoop"; import { runAutoScanLoop } from "../../../lib/autoScanLoop";
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils"; import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
import { summarizeClickResult, summarizeKeyPressResult, type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories"; import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { import type {
AutomationGuard, AutomationGuard,
@@ -36,8 +38,9 @@ export interface ScanActionContext {
setReviewStatus: Dispatch<SetStateAction<string>>; setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void; appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>; captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>; persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>; saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
@@ -47,6 +50,9 @@ export interface ScanActionContext {
export interface VisibleGridScanOptions { export interface VisibleGridScanOptions {
scanLimit?: number; scanLimit?: number;
scanEntryMode?: ScanEntryMode;
processInitialSelection?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
} }
function buildScanSignature(parsed: ParsedArtifactCandidate) { function buildScanSignature(parsed: ParsedArtifactCandidate) {
@@ -83,6 +89,11 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
const seen = new Set<string>(); const seen = new Set<string>();
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 }; const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
const startedAt = Date.now();
const updateManualStats = () => {
updateScanTiming(stats, startedAt);
setAutoScanStats({ ...stats });
};
let idleTicks = 0; let idleTicks = 0;
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount); const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
const maxIdleTicks = 90; const maxIdleTicks = 90;
@@ -96,7 +107,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
if (capture && rejection) { if (capture && rejection) {
await saveReviewSample(capture, parsed, `manual:capture-rejected`); await saveReviewSample(capture, parsed, `manual:capture-rejected`);
stats.review++; stats.review++;
setAutoScanStats({ ...stats }); updateManualStats();
} }
idleTicks++; idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`); setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
@@ -117,6 +128,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
stats.attempted++; stats.attempted++;
stats.verified++; stats.verified++;
stats.parsed++; stats.parsed++;
addCaptureTiming(stats, capture.timings);
const reason = getAutoReviewReason(capture, parsed); const reason = getAutoReviewReason(capture, parsed);
const needsReview = shouldFlagArtifactForReview(parsed); const needsReview = shouldFlagArtifactForReview(parsed);
@@ -127,12 +139,13 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) { if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
stats.stored++; stats.stored++;
} }
setAutoScanStats({ ...stats }); updateManualStats();
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`); setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
await wait(700); await wait(700);
} }
setAutoScanRunning(false); setAutoScanRunning(false);
updateScanTiming(stats, startedAt);
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done"; const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : ""; const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
await focusDashboard(); await focusDashboard();
@@ -165,6 +178,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
setReviewStatus, setReviewStatus,
appendAutomationLog, appendAutomationLog,
appendClickDiagnostics, appendClickDiagnostics,
appendDiagnosticEvent,
captureSelectedSource, captureSelectedSource,
captureFastSelectedSource, captureFastSelectedSource,
parseArtifact, parseArtifact,
@@ -177,8 +191,14 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
focusDashboard, focusDashboard,
} = context; } = context;
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit; const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
const scanEntryMode = options.scanEntryMode ?? "visible-inventory";
const ocrEngine = options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return; if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) {
setReviewStatus("Auto-Scan-Einstieg ist nicht verfuegbar: Keypress-Bridge fehlt.");
return;
}
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo); const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
if (requiresAdminForAutoScan) { if (requiresAdminForAutoScan) {
@@ -200,6 +220,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
setScanSummary(null); setScanSummary(null);
setAutoScanStats(emptyAutoScanStats); setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort."); setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
appendDiagnosticEvent({
phase: "scan-start",
severity: "info",
message: `Auto-scan start requested (${scanEntryMode}, ${ocrEngine})`,
details: { scanLimit, skipRows, detectedInventoryCount, ocrEngine },
});
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null); const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
const adminBlockReason = automationBlockReason(freshRuntime); const adminBlockReason = automationBlockReason(freshRuntime);
@@ -207,6 +233,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
setAutoScanRunning(false); setAutoScanRunning(false);
setReviewStatus(adminBlockReason); setReviewStatus(adminBlockReason);
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt"); appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
appendDiagnosticEvent({
phase: "preflight",
severity: "error",
message: adminBlockReason,
details: { elevated: freshRuntime?.isElevated, genshinFound: freshRuntime?.genshinFound },
});
setScanSummary({ setScanSummary({
mode: "Automatischer Scan", mode: "Automatischer Scan",
status: "blocked", status: "blocked",
@@ -220,6 +252,17 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
if (freshRuntime) { if (freshRuntime) {
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found"; const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`); appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
appendDiagnosticEvent({
phase: "runtime",
severity: freshRuntime.genshinFound ? "ok" : "warn",
message: `Runtime ping: ${required}`,
details: {
elevated: freshRuntime.isElevated,
foreground: freshRuntime.foregroundProcess,
target: freshRuntime.targetProcess,
helperPid: freshRuntime.helperPid,
},
});
} }
const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin; const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin;
@@ -234,6 +277,18 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
appendAutomationLog( 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 || "?"}`, `focus attempt ${attempt}/3: ${current.focused ? "ok" : "failed"} found:${current.genshinFound ? "yes" : "no"} setForeground:${current.setForegroundResult ?? "n/a"} target:${current.targetProcess || "?"} fg:${current.foregroundProcess || "?"}`,
); );
appendDiagnosticEvent({
phase: "focus",
severity: current.focused ? "ok" : "warn",
message: `Focus attempt ${attempt}/3 ${current.focused ? "succeeded" : "failed"}`,
details: {
found: current.genshinFound,
setForeground: current.setForegroundResult,
target: current.targetProcess,
foreground: current.foregroundProcess,
alreadyForeground: current.alreadyForeground,
},
});
if (current.focused) break; if (current.focused) break;
if (!current.genshinFound) break; if (!current.genshinFound) break;
} }
@@ -246,6 +301,16 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten." ? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten."; : "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
setReviewStatus(reason); setReviewStatus(reason);
appendDiagnosticEvent({
phase: "focus",
severity: "error",
message: reason,
details: {
found: focusResult?.genshinFound,
target: focusResult?.targetProcess,
foreground: focusResult?.foregroundProcess,
},
});
setScanSummary({ setScanSummary({
mode: "Automatischer Scan", mode: "Automatischer Scan",
status: "blocked", status: "blocked",
@@ -256,7 +321,46 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
return; return;
} }
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort."); setReviewStatus(scanEntryMode !== "visible-inventory"
? "Genshin ist im Vordergrund. Oeffne Artifact-Inventar und warte auf Detailkarte..."
: "Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const entryCapture = await prepareAutoScanEntry({
mode: scanEntryMode,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
const entryPreflight = validateAutoScanEntryPreflight(entryCapture);
if (!entryPreflight.ok) {
setAutoScanRunning(false);
setReviewStatus(entryPreflight.reason);
appendAutomationLog(`entry blocked: ${entryPreflight.reason}`);
appendDiagnosticEvent({
phase: "entry-preflight",
severity: "error",
message: entryPreflight.reason,
capture: entryCapture,
});
setScanSummary({
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: entryPreflight.reason,
});
await focusDashboard();
return;
}
setReviewStatus("Artifact-Inventar ist bereit. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
appendDiagnosticEvent({
phase: "entry-preflight",
severity: "ok",
message: "Artifact inventory preflight passed.",
capture: entryCapture,
});
const result = await runAutoScanLoop( const result = await runAutoScanLoop(
{ {
@@ -285,7 +389,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
automationRepo?.getAutomationGuard?.() ?? automationRepo?.getAutomationGuard?.() ??
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }), Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
}, },
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin), captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, options),
captureFastSelectedSource, captureFastSelectedSource,
parseArtifact, parseArtifact,
persistParsedArtifact, persistParsedArtifact,
@@ -302,6 +406,9 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
scanLimit, scanLimit,
skipRows, skipRows,
detectedInventoryCount, detectedInventoryCount,
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
skipInitialGridTarget: scanEntryMode !== "visible-inventory",
ocrEngine,
}, },
); );
@@ -310,10 +417,242 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
await focusDashboard(); await focusDashboard();
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`); setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
setScanSummary({ setScanSummary({
mode: "Automatischer Scan", mode: scanEntryMode === "visible-inventory" ? `Automatischer Scan [${ocrEngine}]` : `Automatischer Scan (${scanEntryMode}) [${ocrEngine}]`,
status: result.status, status: result.status,
...result.stats, ...result.stats,
targetCount: result.targetCount, targetCount: result.targetCount,
gridLabel: result.gridLabel, gridLabel: result.gridLabel,
}); });
} }
async function prepareAutoScanEntry({
mode,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
}: {
mode: ScanEntryMode;
automationRepo: AutomationRepositoryPort;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
appendAutomationLog: (line: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
}) {
if (mode === "visible-inventory") {
const capture = await captureFastSelectedSource(0, true);
appendDiagnosticEvent({
phase: "entry-visible",
severity: capture ? "ok" : "error",
message: capture ? "Visible inventory preflight capture ready." : "Visible inventory preflight capture failed.",
capture,
});
return capture;
}
if (mode === "direct-inventory") {
return tryInventoryEntrySequence({
label: "direct",
sendEscapeFirst: false,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
}
if (mode === "auto-entry") {
const directCapture = await tryInventoryEntrySequence({
label: "auto-direct",
sendEscapeFirst: false,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
const directPreflight = validateAutoScanEntryPreflight(directCapture);
if (directPreflight.ok) return directCapture;
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
appendDiagnosticEvent({
phase: "entry-fallback",
severity: "info",
message: `Direct inventory entry did not reach an artifact detail card. Trying IK fallback. ${directPreflight.reason}`,
capture: directCapture,
});
}
return tryInventoryEntrySequence({
label: "paimon",
sendEscapeFirst: true,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
}
async function tryInventoryEntrySequence({
label,
sendEscapeFirst,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
}: {
label: string;
sendEscapeFirst: boolean;
automationRepo: AutomationRepositoryPort;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
appendAutomationLog: (line: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
}) {
if (sendEscapeFirst) {
const escapeResult = await automationRepo.keyPress?.("ESC");
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
message: `${label} entry key ESC`,
details: summarizeKeyPressResult(escapeResult),
});
if (keyPressBlocked(escapeResult)) return null;
const menuProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 750,
predicate: (capture) => Boolean(capture),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: menuProbe ? "info" : "warn",
message: `${label} capture after ESC step.`,
capture: menuProbe,
});
if (menuProbe?.paimonMenu?.present) {
const closeMenuResult = await automationRepo.keyPress?.("ESC");
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
message: `${label} entry key ESC close menu`,
details: summarizeKeyPressResult(closeMenuResult),
});
if (keyPressBlocked(closeMenuResult)) return menuProbe;
const worldProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 750,
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: worldProbe ? "info" : "warn",
message: `${label} capture after closing Paimon menu.`,
capture: worldProbe,
});
if (worldProbe?.paimonMenu?.present) {
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
return worldProbe;
}
}
}
const inventoryResult = await automationRepo.keyPress?.("B");
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
message: `${label} entry key B`,
details: summarizeKeyPressResult(inventoryResult),
});
if (keyPressBlocked(inventoryResult)) return null;
const tabProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 1200,
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: tabProbe ? "info" : "warn",
message: `${label} capture after Inventory-key step.`,
capture: tabProbe,
});
if (tabProbe?.paimonMenu?.present) {
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
appendDiagnosticEvent({
phase: "entry-capture",
severity: "info",
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
capture: tabProbe,
});
return tabProbe;
}
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
const target = artifactTabClickTarget(tabProbe);
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
const click = await automationRepo.clickScreen(target.x, target.y);
appendDiagnosticEvent({
phase: "entry-click",
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
message: `${label} artifact tab click at ${target.x},${target.y}`,
details: summarizeClickResult(click),
capture: tabProbe,
});
if (click.inputBlocked || click.clicked === false) return null;
const gridProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 900,
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: gridProbe ? "info" : "warn",
message: `${label} capture after artifact-tab click before first tile selection.`,
capture: gridProbe,
});
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
if (!firstTarget) return gridProbe;
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
appendDiagnosticEvent({
phase: "entry-click",
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
details: summarizeClickResult(firstTileClick),
capture: gridProbe,
});
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
const finalCapture = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 900,
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: finalCapture ? "info" : "warn",
message: `${label} final capture after first artifact selection.`,
capture: finalCapture,
});
return finalCapture;
}
async function waitForEntryCapture({
captureFastSelectedSource,
timeoutMs,
pollMs = 150,
predicate,
}: {
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
timeoutMs: number;
pollMs?: number;
predicate: (capture: CaptureResult | null) => boolean;
}) {
const startedAt = Date.now();
let latest: CaptureResult | null = null;
while (Date.now() - startedAt <= timeoutMs) {
latest = await captureFastSelectedSource(0, true);
if (predicate(latest)) return latest;
const remaining = timeoutMs - (Date.now() - startedAt);
if (remaining <= 0) break;
await wait(Math.min(pollMs, remaining));
}
return latest;
}
@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { ScannerCommand } from "../../../types/global"; import type { CaptureOptions, ScannerCommand } from "../../../types/global";
import type { VisibleGridScanOptions } from "./scanViewScanActions"; import type { VisibleGridScanOptions } from "./scanViewScanActions";
interface ScanCommandListenerInput { interface ScanCommandListenerInput {
@@ -9,6 +9,7 @@ interface ScanCommandListenerInput {
isScanning: boolean; isScanning: boolean;
selectedSourceId: string; selectedSourceId: string;
requestScanStop: (reason: string) => void; requestScanStop: (reason: string) => void;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
} }
@@ -18,6 +19,7 @@ export function useScanCommandListener({
isScanning, isScanning,
selectedSourceId, selectedSourceId,
requestScanStop, requestScanStop,
runGuidedAutoScan,
runVisibleGridScan, runVisibleGridScan,
}: ScanCommandListenerInput) { }: ScanCommandListenerInput) {
useEffect(() => { useEffect(() => {
@@ -29,9 +31,12 @@ export function useScanCommandListener({
} }
const commandType = typeof command === "string" ? command : command.type; const commandType = typeof command === "string" ? command : command.type;
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) { if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit }; if (typeof command === "string" || !command.scanEntryMode) {
void runVisibleGridScan(options); void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit, ocrEngine: command.ocrEngine });
return;
}
void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode, ocrEngine: command.ocrEngine });
} }
}); });
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]); }, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]);
} }
@@ -4,6 +4,8 @@ import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSessio
import type { RuntimeInfo } from "../../../types/global"; import type { RuntimeInfo } from "../../../types/global";
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { CaptureResult } from "../../../types/global"; import type { CaptureResult } from "../../../types/global";
import { validateLookupPackage } from "../../../lib/genshinLookup";
import type { ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>; type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
@@ -17,6 +19,7 @@ interface ScanSnapshotPublisherInput {
snapshot: AppSnapshot; snapshot: AppSnapshot;
latestInventoryGrid: InventoryGrid | null | undefined; latestInventoryGrid: InventoryGrid | null | undefined;
automationLog: string[]; automationLog: string[];
diagnosticEvents: ScanDiagnosticEvent[];
runtimeInfo: RuntimeInfo | null; runtimeInfo: RuntimeInfo | null;
storedTotal: number | null; storedTotal: number | null;
learningRuleCount: number; learningRuleCount: number;
@@ -33,6 +36,7 @@ export function useScanSnapshotPublisher({
snapshot, snapshot,
latestInventoryGrid, latestInventoryGrid,
automationLog, automationLog,
diagnosticEvents,
runtimeInfo, runtimeInfo,
storedTotal, storedTotal,
learningRuleCount, learningRuleCount,
@@ -52,9 +56,33 @@ export function useScanSnapshotPublisher({
snapshotBuilds: snapshot.builds.length, snapshotBuilds: snapshot.builds.length,
grid: latestInventoryGrid ?? null, grid: latestInventoryGrid ?? null,
automationLog: automationLog.slice(-12), automationLog: automationLog.slice(-12),
diagnosticEvents: diagnosticEvents.slice(-8).map((event) => ({
...event,
capture: event.capture
? {
...event.capture,
screenshots: event.capture.screenshots
? {
detail: event.capture.screenshots.detail ? "[detail screenshot available in Diagnose]" : undefined,
inventory: event.capture.screenshots.inventory ? "[inventory screenshot available in Diagnose]" : undefined,
full: event.capture.screenshots.full ? "[full screenshot omitted]" : undefined,
}
: undefined,
}
: undefined,
})),
runtimeInfo, runtimeInfo,
storedTotal, storedTotal,
learningRuleCount, learningRuleCount,
lookupStatus: validateLookupPackage(),
ocrEngine: scanSummary?.mode.includes("ik-traineddata") ? "ik-traineddata" : "current",
entryMode: scanSummary?.mode.includes("auto-entry")
? "auto-entry"
: scanSummary?.mode.includes("direct-inventory")
? "direct-inventory"
: scanSummary?.mode.includes("paimon-menu")
? "paimon-menu"
: "visible-inventory",
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}).catch(() => undefined); }).catch(() => undefined);
}, [ }, [
@@ -67,6 +95,7 @@ export function useScanSnapshotPublisher({
snapshot, snapshot,
latestInventoryGrid, latestInventoryGrid,
automationLog, automationLog,
diagnosticEvents,
runtimeInfo, runtimeInfo,
storedTotal, storedTotal,
learningRuleCount, learningRuleCount,
+68 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions"; import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
import { import {
@@ -21,6 +21,8 @@ import type {
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { RuntimeInfo } from "../../../types/global"; import type { RuntimeInfo } from "../../../types/global";
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry";
type BooleanSetter = Dispatch<SetStateAction<boolean>>; type BooleanSetter = Dispatch<SetStateAction<boolean>>;
type NumberSetter = Dispatch<SetStateAction<number>>; type NumberSetter = Dispatch<SetStateAction<number>>;
@@ -49,6 +51,7 @@ interface ScanViewActionInput {
setReviewStatus: StringSetter; setReviewStatus: StringSetter;
appendAutomationLog: (line: string) => void; appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
artifactRepo?: ArtifactRepositoryPort; artifactRepo?: ArtifactRepositoryPort;
reviewSamplesRepo?: ReviewSampleRepositoryPort; reviewSamplesRepo?: ReviewSampleRepositoryPort;
@@ -77,6 +80,7 @@ export interface ScanViewActionResult {
loadReviewQueue: () => Promise<void>; loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>; openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>; runAutoReviewScan: () => Promise<void>;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
} }
@@ -99,6 +103,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus, setReviewStatus,
appendAutomationLog, appendAutomationLog,
appendClickDiagnostics, appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact, parseArtifact,
artifactRepo, artifactRepo,
reviewSamplesRepo, reviewSamplesRepo,
@@ -116,6 +121,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
canCaptureSource, canCaptureSource,
setReviewQueueOpen, setReviewQueueOpen,
} = input; } = input;
const learningInitializedRef = useRef(false);
const requestScanStop = useCallback((reason = "Stop angefordert.") => { const requestScanStop = useCallback((reason = "Stop angefordert.") => {
stopVisibleScanRef.current = true; stopVisibleScanRef.current = true;
@@ -154,8 +160,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
); );
useEffect(() => { useEffect(() => {
if (learningInitializedRef.current) return;
if (!learningRepo && !reviewSamplesRepo && !artifactRepo) return;
learningInitializedRef.current = true;
void initializeLearningState(reviewContext); void initializeLearningState(reviewContext);
}, [reviewContext]); }, [artifactRepo, learningRepo, reviewContext, reviewSamplesRepo]);
const focusDashboard = useCallback(async () => { const focusDashboard = useCallback(async () => {
try { try {
@@ -215,12 +224,25 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus, setReviewStatus,
appendAutomationLog, appendAutomationLog,
appendClickDiagnostics, appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact, parseArtifact,
persistParsedArtifact: parseArtifactAndPersist, persistParsedArtifact: parseArtifactAndPersist,
saveReviewSample: handleSaveReviewSample, saveReviewSample: handleSaveReviewSample,
focusDashboard, focusDashboard,
captureSelectedSource, captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }), ocrMode: "artifact",
omitFullFrame: true,
omitInventoryPreview: true,
...options,
}),
captureFastSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
skipOcr: true,
omitFullFrame: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
...options,
}),
}), }),
[ [
autoScanRunning, autoScanRunning,
@@ -240,6 +262,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setReviewStatus, setReviewStatus,
appendAutomationLog, appendAutomationLog,
appendClickDiagnostics, appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact, parseArtifact,
parseArtifactAndPersist, parseArtifactAndPersist,
handleSaveReviewSample, handleSaveReviewSample,
@@ -276,12 +299,52 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
scanActionContext, scanActionContext,
]); ]);
const runGuidedAutoScan = useCallback(async (options: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] } = {}) => {
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
return;
}
setReviewStatus("Auto-Scan prueft den Startzustand ohne OCR...");
const preflightCapture = await captureSelectedSource(0, true, {
skipOcr: true,
omitFullFrame: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
});
const visibleInventoryReady = validateAutoScanEntryPreflight(preflightCapture).ok;
appendDiagnosticEvent({
phase: "guided-start",
severity: visibleInventoryReady ? "ok" : "info",
message: visibleInventoryReady
? "Artifact inventory detail view already visible; starting scan directly."
: "Artifact detail view is not ready; trying direct inventory entry, then Inventory Kamera fallback.",
capture: preflightCapture,
});
await runVisibleGridScanAction(scanActionContext, {
scanLimit: options.scanLimit,
scanEntryMode: visibleInventoryReady ? "visible-inventory" : "auto-entry",
processInitialSelection: visibleInventoryReady,
ocrEngine: options.ocrEngine,
});
}, [
autoScanRunning,
bridgeReady,
selectedSourceId,
automationRepo?.clickScreen,
automationRepo?.scrollScreen,
setReviewStatus,
captureSelectedSource,
appendDiagnosticEvent,
scanActionContext,
]);
useScanCommandListener({ useScanCommandListener({
automationRepo, automationRepo,
autoScanRunning, autoScanRunning,
isScanning, isScanning,
selectedSourceId, selectedSourceId,
requestScanStop, requestScanStop,
runGuidedAutoScan,
runVisibleGridScan, runVisibleGridScan,
}); });
@@ -291,6 +354,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
loadReviewQueue: loadReviewQueueAction, loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal, openReviewQueue: openReviewQueueModal,
runAutoReviewScan, runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan, runVisibleGridScan,
}; };
} }
@@ -14,6 +14,7 @@ import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
import { useScanViewActions } from "./useScanViewActions"; import { useScanViewActions } from "./useScanViewActions";
import { useScanViewStateSync } from "./useScanViewStateSync"; import { useScanViewStateSync } from "./useScanViewStateSync";
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { ScanViewProps, ScanViewControllerResult } from "../types"; import type { ScanViewProps, ScanViewControllerResult } from "../types";
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage"; import type { StoredArtifactRecord } from "../../../types/storage";
@@ -56,6 +57,7 @@ export function useScanViewController({
const [scanLimitTouched, setScanLimitTouched] = useState(false); const [scanLimitTouched, setScanLimitTouched] = useState(false);
const [skipRows, setSkipRows] = useState(0); const [skipRows, setSkipRows] = useState(0);
const [automationLog, setAutomationLog] = useState<string[]>([]); const [automationLog, setAutomationLog] = useState<string[]>([]);
const [diagnosticEvents, setDiagnosticEvents] = useState<ScanDiagnosticEvent[]>([]);
const [storedTotal, setStoredTotal] = useState<number | null>(null); const [storedTotal, setStoredTotal] = useState<number | null>(null);
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1"); const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} }); const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
@@ -92,12 +94,28 @@ export function useScanViewController({
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]); setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
}, []); }, []);
const appendDiagnosticEvent = useCallback((event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => {
setDiagnosticEvents((previous) => [
...previous.slice(-23),
createScanDiagnosticEvent({
...event,
includeFullScreenshot: false,
}),
]);
}, []);
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => { const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`; const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`; const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
const blocked = result.inputBlocked ? " input:blocked" : ""; const blocked = result.inputBlocked ? " input:blocked" : "";
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`); appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
}, [appendAutomationLog]); appendDiagnosticEvent({
phase: "input",
severity: result.inputBlocked || result.clicked === false || result.moved === false ? "warn" : "ok",
message: `${prefix}: click ${result.clicked ? "sent" : "not sent"}`,
details: summarizeClickResult(result),
});
}, [appendAutomationLog, appendDiagnosticEvent]);
const toggleDevMode = useCallback(() => { const toggleDevMode = useCallback(() => {
setDevMode((previous) => { setDevMode((previous) => {
@@ -118,6 +136,7 @@ export function useScanViewController({
loadReviewQueue: loadReviewQueueAction, loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal, openReviewQueue: openReviewQueueModal,
runAutoReviewScan, runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan, runVisibleGridScan,
} = useScanViewActions({ } = useScanViewActions({
autoScanRunning, autoScanRunning,
@@ -137,6 +156,7 @@ export function useScanViewController({
setReviewStatus, setReviewStatus,
appendAutomationLog, appendAutomationLog,
appendClickDiagnostics, appendClickDiagnostics,
appendDiagnosticEvent,
parseArtifact, parseArtifact,
artifactRepo, artifactRepo,
reviewSamplesRepo, reviewSamplesRepo,
@@ -209,6 +229,7 @@ export function useScanViewController({
snapshot, snapshot,
latestInventoryGrid: latestCapture?.inventoryGrid ?? null, latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
automationLog, automationLog,
diagnosticEvents,
runtimeInfo, runtimeInfo,
storedTotal, storedTotal,
learningRuleCount, learningRuleCount,
@@ -230,6 +251,7 @@ export function useScanViewController({
scanLimitTouched, scanLimitTouched,
skipRows, skipRows,
automationLog, automationLog,
diagnosticEvents,
storedTotal, storedTotal,
devMode, devMode,
scannerLearningRules, scannerLearningRules,
@@ -267,6 +289,7 @@ export function useScanViewController({
loadReviewQueue: loadReviewQueueAction, loadReviewQueue: loadReviewQueueAction,
openReviewQueue: openReviewQueueModal, openReviewQueue: openReviewQueueModal,
runAutoReviewScan, runAutoReviewScan,
runGuidedAutoScan,
runVisibleGridScan, runVisibleGridScan,
canGoodInterop, canGoodInterop,
exportGoodFromStore, exportGoodFromStore,
+3
View File
@@ -3,6 +3,7 @@ import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, R
import type { StoredArtifactRecord } from "../../types/storage"; import type { StoredArtifactRecord } from "../../types/storage";
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession"; import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser"; import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog";
import type { ScannerLearningRules } from "../../lib/scannerLearning"; import type { ScannerLearningRules } from "../../lib/scannerLearning";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis"; import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
@@ -36,6 +37,7 @@ export interface ScanViewControllerResult {
scanLimitTouched: boolean; scanLimitTouched: boolean;
skipRows: number; skipRows: number;
automationLog: string[]; automationLog: string[];
diagnosticEvents: ScanDiagnosticEvent[];
storedTotal: number | null; storedTotal: number | null;
devMode: boolean; devMode: boolean;
scannerLearningRules: ScannerLearningRules; scannerLearningRules: ScannerLearningRules;
@@ -79,6 +81,7 @@ export interface ScanViewControllerResult {
loadReviewQueue: () => Promise<void>; loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>; openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>; runAutoReviewScan: () => Promise<void>;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>;
runVisibleGridScan: () => Promise<void>; runVisibleGridScan: () => Promise<void>;
canGoodInterop: boolean; canGoodInterop: boolean;
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>; exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
@@ -22,6 +22,7 @@ import type {
RuntimeInfo, RuntimeInfo,
SaveResultWithPath, SaveResultWithPath,
ScrollResult, ScrollResult,
KeyPressResult,
AutomationGuard, AutomationGuard,
ClickResult, ClickResult,
ReviewSampleListResult, ReviewSampleListResult,
@@ -111,6 +112,10 @@ function emptyScrollResult(): ScrollResult {
return { ok: false, notchesSent: 0, inputBlocked: false }; return { ok: false, notchesSent: 0, inputBlocked: false };
} }
function emptyKeyPressResult(key = ""): KeyPressResult {
return { ok: false, key, inputBlocked: false, eventsSent: 0 };
}
function emptyBooleanResult(): BooleanResult { function emptyBooleanResult(): BooleanResult {
return { ok: false }; return { ok: false };
} }
@@ -203,6 +208,7 @@ export function createRendererRepositories(): RendererRepositories | null {
clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()), clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()),
scrollScreen: (notches, anchorX, anchorY) => scrollScreen: (notches, anchorX, anchorY) =>
createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()), createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()),
keyPress: (key) => createBridgeSafeCall(() => bridge.keyPress(key), emptyKeyPressResult(key)),
onCommand: bridge.onScannerCommand, onCommand: bridge.onScannerCommand,
}; };
@@ -20,6 +20,7 @@ import type {
SaveResultWithPath, SaveResultWithPath,
ScrollResult, ScrollResult,
ScannerLearningRulePayload, ScannerLearningRulePayload,
KeyPressResult,
} from "../../types/global"; } from "../../types/global";
import type { AppSnapshot } from "../../types/domain"; import type { AppSnapshot } from "../../types/domain";
import type { StoredArtifactRecord } from "../../types/storage"; import type { StoredArtifactRecord } from "../../types/storage";
@@ -64,6 +65,7 @@ export interface AutomationRepositoryPort {
focusMainWindow(): Promise<BooleanResult>; focusMainWindow(): Promise<BooleanResult>;
clickScreen(x: number, y: number): Promise<ClickResult>; clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
keyPress(key: string): Promise<KeyPressResult>;
onCommand(callback: (command: ScannerCommand) => void): () => void; onCommand(callback: (command: ScannerCommand) => void): () => void;
} }
+91
View File
@@ -95,6 +95,83 @@ describe("parseArtifactCandidate", () => {
expect(parsed?.mainValue).toBe("46.6%"); expect(parsed?.mainValue).toBe("46.6%");
}); });
it("derives main stat value when the fast OCR profile skips the value crop", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-name": "Maidens Fading Beauty",
"artifact-slot": "Goblet of Eonothem",
"artifact-main-stat-label": "Cryo DMG Bonus",
"artifact-level": "+20",
"artifact-substats": "+ CRIT Rate+6.6%\n+ CRIT DMG+12.4%\n+ HP+269\n+ ATK+16.3%",
"artifact-footer": "Equipped: Skirk",
}));
expect(parsed?.mainStat).toBe("Cryo DMG Bonus");
expect(parsed?.mainValue).toBe("46.6%");
expect(parsed?.fields.mainValue.source).toBe("derived");
});
it("uses split main stat value OCR when level OCR is missing", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-name": "Heldenepos's Unspoken Tale",
"artifact-slot": "Goblet of Eonothem",
"artifact-main-stat-label": "Pyro DMG Bonus",
"artifact-main-stat-value": "46.6%",
"artifact-level": "",
"artifact-substats": "+ Energy Recharge+13.0%\n+ ATK+5.8%\n+ Elemental Mastery+58\n+ CRIT DMG+14.0%",
}));
expect(parsed?.slot).toBe("Goblet of Eonothem");
expect(parsed?.mainStat).toBe("Pyro DMG Bonus");
expect(parsed?.mainValue).toBe("46.6%");
expect(parsed?.fields.mainValue.source).toBe("ocr");
});
it("derives fixed flower and plume values without a value crop", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-name": "Gladiator's Nostalgia",
"artifact-slot": "Flower of Life",
"artifact-main-stat-label": "HP",
"artifact-level": "+20",
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
}));
expect(parsed?.mainStat).toBe("HP");
expect(parsed?.mainValue).toBe("4,780");
expect(parsed?.fields.mainValue.source).toBe("derived");
});
it("derives slot and set from the piece name when fast auto-scan skips slot OCR", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-name": "Gladiator's Nostalgia",
"artifact-main-stat-label": "HP",
"artifact-level": "+20",
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
}));
expect(parsed?.slot).toBe("Flower of Life");
expect(parsed?.fields.slot.source).toBe("derived");
expect(parsed?.setName).toBe("Gladiator's Finale");
expect(parsed?.fields.setName.source).toBe("derived");
expect(parsed?.mainStat).toBe("HP");
expect(parsed?.mainValue).toBe("4,780");
});
it("caps derived slot and set confidence when the piece name is fuzzy", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-name": "Gladiator Nostalg",
"artifact-main-stat-label": "HP",
"artifact-level": "+20",
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
}));
expect(parsed?.name).toBe("Gladiator's Nostalgia");
expect(parsed?.fields.name.confidence).toBeLessThan(94);
expect(parsed?.slot).toBe("Flower of Life");
expect(parsed?.fields.slot.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
expect(parsed?.setName).toBe("Gladiator's Finale");
expect(parsed?.fields.setName.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
});
it("does not let substats override circlet crit main stats", () => { it("does not let substats override circlet crit main stats", () => {
const parsed = parseArtifactCandidate(captureFromOcr({ const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Holy Crown of the Believer\nCirclet of Logos", "artifact-title": "Holy Crown of the Believer\nCirclet of Logos",
@@ -342,4 +419,18 @@ describe("parseArtifactCandidate", () => {
expect(sands?.mainValue).toBe("46.6%"); expect(sands?.mainValue).toBe("46.6%");
expect(sands?.mainStat).toBe("Unknown main stat"); expect(sands?.mainStat).toBe("Unknown main stat");
}); });
it("derives DEF percent for goblet max-value reads when the OCR stat label is garbled", () => {
const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Vessel of Plenty",
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
"artifact-footer": "Equipped: I -",
}));
expect(parsed?.slot).toBe("Goblet of Eonothem");
expect(parsed?.mainValue).toBe("58.3%");
expect(parsed?.mainStat).toBe("DEF%");
expect(parsed?.setName).toBe("Night of the Sky's Unveiling");
});
}); });
+45 -12
View File
@@ -19,6 +19,7 @@ import {
textReplacements, textReplacements,
} from "./genshinData.js"; } from "./genshinData.js";
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js"; import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js";
import { implausibleSubstats } from "./substatRolls.js"; import { implausibleSubstats } from "./substatRolls.js";
type MainStatValueReference = { stat: string; base: number; max: number }; type MainStatValueReference = { stat: string; base: number; max: number };
@@ -62,15 +63,22 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
(capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]), (capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]),
); );
const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n")); const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n"));
const titleText = byId.get("artifact-title") ?? ""; const nameText = byId.get("artifact-name") ?? "";
const mainText = byId.get("artifact-main-stat") ?? ""; const slotOnlyText = byId.get("artifact-slot") ?? "";
const legacyTitleText = byId.get("artifact-title") ?? "";
const titleText = [nameText, slotOnlyText].filter(Boolean).join("\n") || legacyTitleText;
const mainLabelText = byId.get("artifact-main-stat-label") ?? "";
const mainValueText = byId.get("artifact-main-stat-value") ?? "";
const legacyMainText = byId.get("artifact-main-stat") ?? "";
const mainText = [mainLabelText, mainValueText].filter(Boolean).join("\n") || legacyMainText;
const levelText = byId.get("artifact-level") ?? "";
const substatText = byId.get("artifact-substats") ?? ""; const substatText = byId.get("artifact-substats") ?? "";
const setText = byId.get("artifact-set-effects") ?? ""; const setText = byId.get("artifact-set-effects") ?? "";
const footerText = byId.get("artifact-footer") ?? ""; const footerText = byId.get("artifact-footer") ?? "";
const nameField = parseArtifactName(titleText); const nameField = parseArtifactName(titleText);
const slotField = parseSlot(titleText + "\n" + allText, nameField.value); const slotField = parseSlot([slotOnlyText, titleText, allText].filter(Boolean).join("\n"), nameField);
const levelField = parseArtifactLevel(substatText + "\n" + mainText + "\n" + allText); const levelField = parseArtifactLevel([levelText, substatText, mainText, allText].filter(Boolean).join("\n"));
const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null; const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
const level = parsedLevel ?? 0; const level = parsedLevel ?? 0;
let mainStatField = inferMainStat(slotField.value, mainText); let mainStatField = inferMainStat(slotField.value, mainText);
@@ -100,7 +108,7 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
} }
const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n")); const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"));
const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing"); const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing");
const setField = parseSetName(setText, nameField.value); const setField = parseSetName(setText, nameField);
const equippedField = parseEquippedCharacter(footerText + "\n" + allText); const equippedField = parseEquippedCharacter(footerText + "\n" + allText);
const notes: string[] = []; const notes: string[] = [];
@@ -161,6 +169,10 @@ function parseArtifactName(titleText: string): ParsedField {
.filter(Boolean); .filter(Boolean);
for (const line of titleLines) { for (const line of titleLines) {
const match = matchPiece(line);
if (match.value) {
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
}
const alias = normalizePieceAlias(line); const alias = normalizePieceAlias(line);
if (alias) return field(alias, 96, "database"); if (alias) return field(alias, 96, "database");
} }
@@ -172,13 +184,17 @@ function parseArtifactName(titleText: string): ParsedField {
return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing"); return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing");
} }
function parseSlot(text: string, artifactName: string): ParsedField { function parseSlot(text: string, artifactName: ParsedField): ParsedField {
const slotLines = text const slotLines = text
.split("\n") .split("\n")
.map((line) => cleanupOcrLabel(line)) .map((line) => cleanupOcrLabel(line))
.filter(Boolean); .filter(Boolean);
for (const line of slotLines) { for (const line of slotLines) {
const match = matchSlot(line);
if (match.value) {
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "ocr");
}
const alias = normalizeSlotAlias(line); const alias = normalizeSlotAlias(line);
if (alias) return field(alias, 96, "ocr"); if (alias) return field(alias, 96, "ocr");
} }
@@ -186,18 +202,22 @@ function parseSlot(text: string, artifactName: string): ParsedField {
const directSlot = fuzzyFindKnown(text, slotNames, 0.68); const directSlot = fuzzyFindKnown(text, slotNames, 0.68);
if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback"); if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback");
const derivedSlot = artifactName ? pieceToSlot.get(artifactName) ?? "" : ""; const derivedSlot = artifactName.value ? pieceToSlot.get(artifactName.value) ?? "" : "";
return derivedSlot ? field(derivedSlot, 94, "derived") : field("", 0, "missing"); return derivedSlot ? field(derivedSlot, derivedConfidence(artifactName, 94), "derived") : field("", 0, "missing");
} }
function parseSetName(setText: string, artifactName: string): ParsedField { function parseSetName(setText: string, artifactName: ParsedField): ParsedField {
const setFromPiece = artifactName ? pieceToSet.get(artifactName) : undefined; const setFromPiece = artifactName.value ? pieceToSet.get(artifactName.value) : undefined;
const candidateLines = setText const candidateLines = setText
.split("\n") .split("\n")
.map((line) => line.trim().replace(/:$/, "")) .map((line) => line.trim().replace(/:$/, ""))
.filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line)); .filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line));
for (const line of candidateLines) { for (const line of candidateLines) {
const match = matchSet(line);
if (match.value) {
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
}
const alias = normalizeSetAlias(line); const alias = normalizeSetAlias(line);
if (alias) return field(alias, 96, "database"); if (alias) return field(alias, 96, "database");
} }
@@ -206,10 +226,15 @@ function parseSetName(setText: string, artifactName: string): ParsedField {
const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64); const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64);
if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback"); if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback");
if (setFromPiece) return field(setFromPiece, 92, "derived"); if (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived");
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing"); return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
} }
function derivedConfidence(sourceField: ParsedField, maxConfidence: number) {
if (sourceField.source === "database" || sourceField.confidence >= maxConfidence) return maxConfidence;
return Math.max(45, Math.min(maxConfidence, sourceField.confidence));
}
function parseArtifactLevel(text: string): ParsedField { function parseArtifactLevel(text: string): ParsedField {
const lines = normalizeText(text) const lines = normalizeText(text)
.split("\n") .split("\n")
@@ -294,6 +319,10 @@ function inferMainStat(slot: string, text: string): ParsedField {
if (direct) return field(promotePercentVariant(direct, text), 94, "ocr"); if (direct) return field(promotePercentVariant(direct, text), 94, "ocr");
const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot)); const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot));
const lookup = matchStat(text);
if (lookup.value && allowedForSlot.includes(lookup.value)) {
return field(promotePercentVariant(lookup.value, text), lookup.confidence, lookup.source === "fuzzy" ? "fallback" : "ocr");
}
const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68); const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68);
if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback"); if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback");
@@ -332,6 +361,7 @@ function findDirectMainStat(text: string) {
if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%"; if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%";
if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%"; if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%";
if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%"; if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%";
if (hasPercentValue && /(^|\s)dlt(\s|$)/i.test(text)) return "DEF%";
return ""; return "";
} }
@@ -461,6 +491,10 @@ function parseEquippedCharacter(text: string): ParsedField {
if (!equippedLine) return field("Not detected", 45, "missing"); if (!equippedLine) return field("Not detected", 45, "missing");
const afterLabel = cleanupCharacterNoise(equippedLine); const afterLabel = cleanupCharacterNoise(equippedLine);
const match = afterLabel ? matchCharacter(afterLabel) : null;
if (match?.value) {
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "database");
}
const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : ""; const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : "";
if (alias) return field(alias, 96, "database"); if (alias) return field(alias, 96, "database");
const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null; const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null;
@@ -585,4 +619,3 @@ function sortLongestFirst(values: string[]) {
function escapeRegex(value: string) { function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { CaptureResult } from "../types/global";
import { artifactTabClickTarget, buildAutoScanEntryPlan, keyPressBlocked, validateAutoScanEntryPreflight } from "./autoScanEntry";
function capture(overrides: Partial<CaptureResult> = {}): CaptureResult {
return {
id: "test",
name: "test",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
inventoryGrid: {
centers: [{ x: 179, y: 254, row: 0, col: 0 }],
rows: 4,
cols: 8,
confidence: 76,
source: "detected",
},
captureTarget: "genshin-client",
artifactDetail: { present: true, confidence: 84, orangeHits: 14, greenHits: 8, textHits: 30 },
layout: { aspect: "1.78:1", isSixteenNine: true, warning: "" },
...overrides,
};
}
describe("autoScanEntry", () => {
it("plans only read-only keys/clicks for the Paimon entry", () => {
expect(buildAutoScanEntryPlan("paimon-menu")).toEqual([
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" },
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" },
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" },
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
]);
});
it("plans a guided auto entry with direct world path before IK fallback", () => {
expect(buildAutoScanEntryPlan("auto-entry")).toEqual([
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" },
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
]);
});
it("uses Inventory Kamera's artifact-tab coordinate ratio", () => {
expect(artifactTabClickTarget(capture())).toEqual({ x: 672, y: 47 });
});
it("blocks invalid lookup and unsupported layouts before auto-scan", () => {
expect(validateAutoScanEntryPreflight(capture(), { valid: false, errors: ["bad"], warnings: [], summary: { artifactSets: 0, artifactPieces: 0, characters: 0, stats: 0, generatedAt: "", sourceVersion: "" } }).reason).toContain("Lookup invalid");
expect(validateAutoScanEntryPreflight(capture({ layout: { aspect: "2.39:1", isSixteenNine: false, warning: "nicht 16:9" } })).reason).toContain("nicht 16:9");
});
it("blocks primary-screen and missing detail-card starts", () => {
expect(validateAutoScanEntryPreflight(capture({ captureTarget: "primary-screen" })).reason).toContain("Primary Screen");
expect(validateAutoScanEntryPreflight(capture({ artifactDetail: { present: false, confidence: 12, orangeHits: 0, greenHits: 1, textHits: 4 } })).reason).toContain("Keine Artifact-Detailansicht");
});
it("blocks the Paimon menu before grid clicks or OCR", () => {
expect(validateAutoScanEntryPreflight(capture({
artifactDetail: {
present: false,
confidence: 8,
orangeHits: 12,
greenHits: 154,
textHits: 20,
titleOrangeHits: 4,
upperTextHits: 11,
lowerGreenHits: 0,
},
paimonMenu: {
present: true,
confidence: 81,
profileLightPct: 56,
profileCreamPct: 31.1,
menuTileDarkPct: 67.7,
},
})).reason).toContain("Paimon-Menue erkannt");
});
it("detects blocked key input", () => {
expect(keyPressBlocked({ ok: false, key: "B", inputBlocked: true })).toBe(true);
expect(keyPressBlocked({ ok: true, key: "B", inputBlocked: false })).toBe(false);
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { CaptureResult, KeyPressResult } from "../types/global";
import { validateLookupPackage, type LookupValidationStatus } from "./genshinLookup";
export type ScanEntryMode = "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
export interface AutoScanEntryAction {
type: "key" | "click-artifact-tab" | "click-first-artifact" | "capture-preflight";
key?: "ESC" | "B";
label: string;
}
export interface AutoScanEntryPreflight {
ok: boolean;
reason: string;
}
export function buildAutoScanEntryPlan(mode: ScanEntryMode): AutoScanEntryAction[] {
if (mode === "visible-inventory") {
return [{ type: "capture-preflight", label: "Capture visible artifact inventory" }];
}
if (mode === "direct-inventory") {
return [
{ type: "key", key: "B", label: "Open inventory from the current world state" },
{ type: "click-artifact-tab", label: "Select artifact inventory tab" },
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
];
}
if (mode === "auto-entry") {
return [
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" },
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
];
}
return [
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" },
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" },
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" },
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
];
}
export function artifactTabClickTarget(capture: CaptureResult): { x: number; y: number } {
return {
x: Math.round(capture.width * (448 / 1280)),
y: Math.round(capture.height * (31 / 720)),
};
}
export function keyPressBlocked(result: KeyPressResult | null | undefined) {
return !result?.ok || Boolean(result.inputBlocked);
}
export function validateAutoScanEntryPreflight(
capture: CaptureResult | null,
lookupStatus: LookupValidationStatus = validateLookupPackage(),
): AutoScanEntryPreflight {
if (!lookupStatus.valid) {
return {
ok: false,
reason: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown lookup error"}`,
};
}
if (!capture) return { ok: false, reason: "Keine Capture-Daten fuer den Auto-Scan-Start." };
if (capture.captureTarget === "primary-screen") {
return { ok: false, reason: "Auto-Scan blockiert: Capture stammt vom Primary Screen statt vom Genshin-Client." };
}
if (capture.layout?.warning) return { ok: false, reason: capture.layout.warning };
if (capture.paimonMenu?.present) {
return { ok: false, reason: `Paimon-Menue erkannt (${capture.paimonMenu.confidence}%). Auto-Scan startet erst im Artifact-Inventar mit sichtbarer Detailkarte.` };
}
if (!capture.inventoryGrid || capture.inventoryGrid.source === "missing" || capture.inventoryGrid.centers.length === 0) {
return { ok: false, reason: "Kein verlaessliches Artifact-Grid erkannt. Artifact-Inventar sichtbar lassen." };
}
if (!capture.artifactDetail?.present) {
const confidence = capture.artifactDetail ? ` (${capture.artifactDetail.confidence}% Detail-Marker)` : "";
return { ok: false, reason: `Keine Artifact-Detailansicht erkannt${confidence}. Artifact-Inventar mit sichtbarer Detailkarte oeffnen.` };
}
return { ok: true, reason: "" };
}
+460 -2
View File
@@ -50,6 +50,7 @@ function capture(overrides: Partial<ScanTestCapture> = {}): ScanTestCapture {
detailDataUrl: "data:image/png;base64,DETAIL-AAA", detailDataUrl: "data:image/png;base64,DETAIL-AAA",
inventoryDataUrl: "data:image/png;base64,GRID-AAA", inventoryDataUrl: "data:image/png;base64,GRID-AAA",
inventoryGrid: sampleGrid, inventoryGrid: sampleGrid,
artifactDetail: { present: true, confidence: 84, orangeHits: 12, greenHits: 8, textHits: 28 },
...overrides, ...overrides,
}; };
} }
@@ -81,6 +82,11 @@ describe("autoScanLoop fingerprints", () => {
expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never)); expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never));
}); });
it("prefers native detail fingerprints when preview images are omitted", () => {
expect(detailFingerprint({ detailFingerprint: "native-detail", dataUrl: "data:image/png;base64,FULL" } as never)).toBe("native-detail");
expect(screenFingerprint({ inventoryFingerprint: "native-inventory", dataUrl: "data:image/png;base64,FULL" } as never)).toBe("native-inventory");
});
it("uses the inventory preview for scroll verification when available", () => { it("uses the inventory preview for scroll verification when available", () => {
const captureA = { const captureA = {
inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64), inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64),
@@ -101,7 +107,7 @@ describe("autoScanLoop fingerprints", () => {
expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false); expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false);
}); });
it("does not block before first click when start capture is from the primary screen", async () => { it("blocks before first click when start capture is from the primary screen", async () => {
const startCapture = capture({ const startCapture = capture({
captureTarget: "primary-screen", captureTarget: "primary-screen",
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`, detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
@@ -127,7 +133,7 @@ describe("autoScanLoop fingerprints", () => {
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }), getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
}, },
captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }), captureSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }),
captureFastSelectedSource: async () => startCapture, captureFastSelectedSource: async () => (clicked > 0 ? capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }) : startCapture),
parseArtifact: () => sampleParse, parseArtifact: () => sampleParse,
persistParsedArtifact: async () => false, persistParsedArtifact: async () => false,
saveReviewSample: async () => ({ ok: true }), saveReviewSample: async () => ({ ok: true }),
@@ -141,8 +147,460 @@ describe("autoScanLoop fingerprints", () => {
}; };
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null }); const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
expect(result.blockedReason).toContain("Primary Screen");
expect(result.status).toBe("blocked");
expect(clicked).toBe(0);
});
it("blocks before first click when no artifact detail card is visible", async () => {
let clicked = 0;
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async () => {
clicked += 1;
return {
ok: true,
x: 0,
y: 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(),
captureFastSelectedSource: async () => capture({ artifactDetail: { present: false, confidence: 10, orangeHits: 0, greenHits: 0, textHits: 3 } }),
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, ocrEngine: "ik-traineddata" });
expect(result.blockedReason).toContain("Keine Artifact-Detailansicht");
expect(result.status).toBe("blocked");
expect(clicked).toBe(0);
});
it("continues when detail verification proves a helper-reported click miss still changed selection", async () => {
const startCapture = capture({
detailDataUrl: `data:image/png;base64,${"D".repeat(600)}`,
});
let clicked = 0;
const selectedCaptureOptions: unknown[] = [];
const selectedFocusFlags: boolean[] = [];
const fastFocusFlags: boolean[] = [];
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async () => {
clicked += 1;
if (clicked === 1) {
return {
ok: true,
x: 80,
y: 90,
cursorX: 960,
cursorY: 539,
clicked: false,
moved: false,
focused: true,
isElevated: true,
};
}
return {
ok: true,
x: 80,
y: 90,
cursorX: 80,
cursorY: 90,
clicked: true,
moved: true,
focused: true,
isElevated: true,
};
},
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: async (_delayMs, focusGenshin, options) => {
selectedFocusFlags.push(Boolean(focusGenshin));
selectedCaptureOptions.push(options);
return capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` });
},
captureFastSelectedSource: async (_delayMs, focusGenshin) => {
fastFocusFlags.push(Boolean(focusGenshin));
return clicked > 0 ? capture({ detailDataUrl: `data:image/png;base64,${"E".repeat(600)}` }) : startCapture;
},
parseArtifact: () => sampleParse,
persistParsedArtifact: async () => true,
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, ocrEngine: "ik-traineddata" });
expect(result.blockedReason).toBe(""); expect(result.blockedReason).toBe("");
expect(result.status).toBe("done"); expect(result.status).toBe("done");
expect(clicked).toBe(1); expect(clicked).toBe(1);
expect(result.stats.verified).toBe(1);
expect(result.stats.parsed).toBe(1);
expect(selectedFocusFlags).toEqual([false]);
expect(fastFocusFlags.every((flag) => flag === false)).toBe(true);
expect(selectedCaptureOptions).toContainEqual({
ocrMode: "artifact",
ocrProfile: "fast",
ocrEngine: "ik-traineddata",
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
});
it("processes an initial Paimon-selected artifact before clicking the next tile", async () => {
let clicked = 0;
let persisted = 0;
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async () => {
clicked += 1;
return {
ok: true,
x: 80,
y: 90,
cursorX: 80,
cursorY: 90,
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,${"I".repeat(600)}` }),
captureFastSelectedSource: async () => capture({ detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
parseArtifact: () => sampleParse,
persistParsedArtifact: async () => {
persisted += 1;
return true;
},
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,
processInitialSelection: true,
});
expect(result.status).toBe("done");
expect(result.stats.parsed).toBe(1);
expect(result.stats.stored).toBe(1);
expect(clicked).toBe(0);
expect(persisted).toBe(1);
});
it("starts after the already selected initial tile", async () => {
let clicked = 0;
const clickedTargets: Array<{ x: number; y: number }> = [];
const logs: string[] = [];
const twoTileGrid = {
centers: [
{ x: 179, y: 254, row: 0, col: 0 },
{ x: 325, y: 254, row: 0, col: 1 },
],
rows: 1,
cols: 2,
confidence: 86,
source: "detected" as const,
};
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async (x, y) => {
clicked += 1;
clickedTargets.push({ x, y });
return {
ok: true,
x: 80,
y: 90,
cursorX: 80,
cursorY: 90,
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({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
captureFastSelectedSource: async () => capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` }),
parseArtifact: () => sampleParse,
persistParsedArtifact: async () => true,
saveReviewSample: async () => ({ ok: true }),
getAutoReviewReason: () => "",
shouldFlagArtifactForReview: () => false,
appendAutomationLog: (line) => logs.push(line),
appendClickDiagnostics: () => undefined,
setReviewStatus: () => undefined,
setAutoScanStats: () => undefined,
shouldStop: () => false,
};
await runAutoScanLoop(deps, {
scanLimit: 2,
skipRows: 0,
detectedInventoryCount: null,
processInitialSelection: true,
skipInitialGridTarget: true,
});
expect(clicked).toBe(1);
expect(clickedTargets[0]).toEqual({ x: 325, y: 254 });
expect(logs.some((line) => line.includes("r0 c0"))).toBe(false);
});
it("does not skip the first grid tile when the visible inventory selection was user-made", async () => {
let clicked = 0;
const clickedTargets: Array<{ x: number; y: number }> = [];
const twoTileGrid = {
centers: [
{ x: 179, y: 254, row: 0, col: 0 },
{ x: 325, y: 254, row: 0, col: 1 },
],
rows: 1,
cols: 2,
confidence: 86,
source: "detected" as const,
};
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async (x, y) => {
clicked += 1;
clickedTargets.push({ x, y });
return {
ok: true,
x,
y,
cursorX: x,
cursorY: y,
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({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"A".repeat(600)}` }),
captureFastSelectedSource: async () => (clicked > 0
? capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"A".repeat(600)}` })
: capture({ inventoryGrid: twoTileGrid, detailDataUrl: `data:image/png;base64,${"I".repeat(600)}` })),
parseArtifact: () => sampleParse,
persistParsedArtifact: async () => true,
saveReviewSample: async () => ({ ok: true }),
getAutoReviewReason: () => "",
shouldFlagArtifactForReview: () => false,
appendAutomationLog: () => undefined,
appendClickDiagnostics: () => undefined,
setReviewStatus: () => undefined,
setAutoScanStats: () => undefined,
shouldStop: () => false,
};
await runAutoScanLoop(deps, {
scanLimit: 2,
skipRows: 0,
detectedInventoryCount: null,
processInitialSelection: true,
skipInitialGridTarget: false,
});
expect(clickedTargets[0]).toEqual({ x: 179, y: 254 });
});
it("polls for the next inventory page after scroll instead of sleeping a fixed settle delay", async () => {
const started = Date.now();
let clicked = 0;
let scrolled = false;
let parsedIndex = 0;
const waits: string[] = [];
const singleTileGrid = {
centers: [{ x: 179, y: 254, row: 0, col: 0 }],
rows: 1,
cols: 1,
confidence: 86,
source: "detected" as const,
};
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async (x, y) => {
clicked += 1;
return {
ok: true,
x,
y,
cursorX: x,
cursorY: y,
clicked: true,
moved: true,
focused: true,
};
},
scrollScreen: async () => {
scrolled = true;
waits.push("scroll");
return { ok: true, notchesSent: -9 };
},
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: async () => capture({
inventoryGrid: singleTileGrid,
inventoryFingerprint: scrolled ? "page-2" : "page-1",
detailFingerprint: clicked > 1 ? "detail-2-selected" : "detail-1-selected",
}),
captureFastSelectedSource: async () => {
const pageFingerprint = scrolled ? "page-2" : "page-1";
const detail = clicked > 1 ? "detail-2" : clicked > 0 ? "detail-1" : "detail-start";
return capture({
inventoryGrid: singleTileGrid,
inventoryFingerprint: pageFingerprint,
detailFingerprint: `${detail}:${pageFingerprint}`,
});
},
parseArtifact: () => ({
...sampleParse,
name: `Artifact ${++parsedIndex}`,
fields: {
...sampleParse.fields,
name: { value: `Artifact ${parsedIndex}`, confidence: 84, source: "ocr" as const },
},
}),
persistParsedArtifact: async () => true,
saveReviewSample: async () => ({ ok: true }),
getAutoReviewReason: () => "",
shouldFlagArtifactForReview: () => false,
appendAutomationLog: () => undefined,
appendClickDiagnostics: () => undefined,
setReviewStatus: () => undefined,
setAutoScanStats: () => undefined,
shouldStop: () => false,
};
const result = await runAutoScanLoop(deps, { scanLimit: 2, skipRows: 0, detectedInventoryCount: null });
expect(result.status).toBe("done");
expect(result.stats.parsed).toBe(2);
expect(waits).toEqual(["scroll"]);
expect(Date.now() - started).toBeLessThan(700);
});
it("queues store writes so the next tile can be clicked before persistence finishes", async () => {
let clicked = 0;
let selectedReads = 0;
const persistResolvers: Array<(value: boolean) => void> = [];
const twoTileGrid = {
centers: [
{ x: 179, y: 254, row: 0, col: 0 },
{ x: 325, y: 254, row: 0, col: 1 },
],
rows: 1,
cols: 2,
confidence: 86,
source: "detected" as const,
};
const deps: AutoScanLoopDependencies = {
api: {
clickScreen: async (x, y) => {
clicked += 1;
return {
ok: true,
x,
y,
cursorX: x,
cursorY: y,
clicked: true,
moved: true,
focused: true,
};
},
scrollScreen: async () => ({ ok: true, notchesSent: 0 }),
getAutomationGuard: async () => ({ ok: true, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: async () => {
selectedReads += 1;
return capture({
inventoryGrid: twoTileGrid,
detailFingerprint: `detail-read-${selectedReads}`,
});
},
captureFastSelectedSource: async () => capture({
inventoryGrid: twoTileGrid,
detailFingerprint: clicked === 0 ? "detail-start" : `detail-click-${clicked}`,
}),
parseArtifact: () => ({
...sampleParse,
name: `Artifact ${selectedReads}`,
fields: {
...sampleParse.fields,
name: { value: `Artifact ${selectedReads}`, confidence: 84, source: "ocr" as const },
},
}),
persistParsedArtifact: async () => new Promise<boolean>((resolve) => {
persistResolvers.push(resolve);
}),
saveReviewSample: async () => ({ ok: true }),
getAutoReviewReason: () => "",
shouldFlagArtifactForReview: () => false,
appendAutomationLog: () => undefined,
appendClickDiagnostics: () => undefined,
setReviewStatus: () => undefined,
setAutoScanStats: () => undefined,
shouldStop: () => false,
};
const runPromise = runAutoScanLoop(deps, { scanLimit: 2, skipRows: 0, detectedInventoryCount: null });
await new Promise((resolve) => setTimeout(resolve, 250));
expect(clicked).toBe(2);
expect(persistResolvers).toHaveLength(1);
persistResolvers[0](true);
for (let attempt = 0; attempt < 10 && persistResolvers.length < 2; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
expect(persistResolvers).toHaveLength(2);
persistResolvers[1](true);
const result = await runPromise;
expect(result.status).toBe("done");
expect(result.stats.parsed).toBe(2);
expect(result.stats.stored).toBe(2);
expect(result.stats.activeScanMs).toBeGreaterThan(0);
expect(result.stats.writeFlushMs).toBeGreaterThan(0);
expect(result.stats.activeScanMs).toBeLessThanOrEqual(result.stats.elapsedMs);
}); });
}); });
+310 -53
View File
@@ -1,4 +1,4 @@
import type { BooleanResult, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global"; import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global";
import type { ParsedArtifactCandidate } from "./artifactOcrParser"; import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { sessionSignature } from "./artifactStore"; import { sessionSignature } from "./artifactStore";
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner"; import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
@@ -6,7 +6,8 @@ import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./au
import { waitForCardReady } from "./cardReadyGate"; import { waitForCardReady } from "./cardReadyGate";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality"; import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
import type { AutoScanStats, ScanSummary } from "./scannerSession"; import type { AutoScanStats, ScanSummary } from "./scannerSession";
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession"; import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
import { validateAutoScanEntryPreflight } from "./autoScanEntry";
// Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md // Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md
// ADR-007): one click per tile, a fixed settle delay, one retry if the detail // ADR-007): one click per tile, a fixed settle delay, one retry if the detail
@@ -22,8 +23,8 @@ type AutoScanApi = {
export type AutoScanLoopDependencies = { export type AutoScanLoopDependencies = {
api: AutoScanApi; api: AutoScanApi;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>; captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: ( persistParsedArtifact: (
capture: CaptureResult | null, capture: CaptureResult | null,
@@ -49,6 +50,9 @@ export type AutoScanLoopOptions = {
scanLimit: number; scanLimit: number;
skipRows: number; skipRows: number;
detectedInventoryCount?: number | null; detectedInventoryCount?: number | null;
processInitialSelection?: boolean;
skipInitialGridTarget?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
}; };
export type AutoScanLoopResult = { export type AutoScanLoopResult = {
@@ -62,9 +66,14 @@ export type AutoScanLoopResult = {
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint // Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
// until it has changed and stabilized (or the budget is spent). See cardReadyGate. // until it has changed and stabilized (or the budget is spent). See cardReadyGate.
const CARD_READY_MAX_MS = 900; const CARD_READY_MAX_MS = 420;
const CARD_READY_POLL_MS = 90; const CARD_READY_POLL_MS = 60;
const CARD_READY_STABLE_SAMPLES = 2; const CARD_READY_STABLE_SAMPLES = 2;
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 200;
const SCROLL_READY_MAX_MS = 760;
const SCROLL_READY_POLL_MS = 80;
const SCROLL_READY_STABLE_SAMPLES = 2;
const SCROLL_READY_ACCEPT_CHANGED_AFTER_MS = 100;
const MISS_ABORT_THRESHOLD = 3; const MISS_ABORT_THRESHOLD = 3;
const UNREADABLE_ABORT_THRESHOLD = 5; const UNREADABLE_ABORT_THRESHOLD = 5;
@@ -89,28 +98,66 @@ export async function runAutoScanLoop(
} = deps; } = deps;
const stats: AutoScanStats = { ...emptyAutoScanStats }; const stats: AutoScanStats = { ...emptyAutoScanStats };
const startedAt = Date.now();
const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount); const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
const rowsToSkip = clampSkipRows(options.skipRows); const rowsToSkip = clampSkipRows(options.skipRows);
const seen = new Set<string>(); const seen = new Set<string>();
const seenDetailFingerprints = new Set<string>();
const seenPageFingerprints = new Set<string>(); const seenPageFingerprints = new Set<string>();
let page = 0; let page = 0;
let blockedReason = ""; let blockedReason = "";
let aborted = false; let aborted = false;
let consecutiveMisses = 0; let consecutiveMisses = 0;
let rowsQueued = 0; let rowsQueued = 0;
const primaryScreenStartWarning = let writeQueue: Promise<void> = Promise.resolve();
"Start-Capture ist vom Primary-Screen, kein spezifischer Genshin-Client-Marker vorhanden - Auto-Scan wird mit Vorsicht fortgesetzt."; function updateStats(preserveActiveScanMs = false) {
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs });
function updateStats() {
setAutoScanStats({ ...stats }); setAutoScanStats({ ...stats });
} }
async function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) { function enqueueWrite(label: string, task: () => Promise<void>) {
writeQueue = writeQueue
.catch(() => undefined)
.then(async () => {
try {
await task();
} catch (error) {
appendAutomationLog(`write failed ${label}: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
async function flushWrites() {
await writeQueue.catch(() => undefined);
updateStats(true);
}
async function finish(result: AutoScanLoopResult) {
const flushStartedAt = Date.now();
stats.activeScanMs = Math.max(0, flushStartedAt - startedAt);
await flushWrites();
stats.writeFlushMs += Math.max(0, Date.now() - flushStartedAt);
updateStats(true);
return { ...result, stats: { ...stats } };
}
function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) {
enqueueWrite(`review:${reason}`, async () => {
const saved = await saveReviewSample(capture, parsed, reason); const saved = await saveReviewSample(capture, parsed, reason);
if (saved?.ok) { if (saved?.ok) {
stats.review++; stats.review++;
updateStats(); updateStats();
} }
});
}
function persistArtifactLater(capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) {
enqueueWrite(`persist:${source}:${parsed.name}`, async () => {
if (await persistParsedArtifact(capture, parsed, source, needsReview)) {
stats.stored++;
updateStats();
}
});
} }
async function checkGuard() { async function checkGuard() {
@@ -154,41 +201,183 @@ export async function runAutoScanLoop(
return clickResult; return clickResult;
} }
let currentCapture = await captureSelectedSource(0, true); function reportedClickDeliveryFailure(result: ClickResult) {
const isPrimaryCapture = currentCapture?.captureTarget === "primary-screen"; return result.moved === false || result.clicked === false;
const initialCaptureRejection = isPrimaryCapture ? "" : captureSourceRejectionReason(currentCapture); }
function clickDeliveryFailureReason(target: GridTarget, clickResult: ClickResult) {
if (clickResult.inputBlocked) {
return "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten').";
}
if (clickResult.isElevated === false) {
return `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
}
const cursor = `${clickResult.cursorX ?? "?"},${clickResult.cursorY ?? "?"}`;
return `Cursor kam nicht am Klick-Ziel ${target.x},${target.y} an (Cursor ${cursor}). Genshin im Vordergrund lassen und erneut versuchen.`;
}
let currentCapture = await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
});
const initialSurfaceRejection = validateAutoScanEntryPreflight(currentCapture);
const initialCaptureRejection = initialSurfaceRejection.ok ? captureSourceRejectionReason(currentCapture) : initialSurfaceRejection.reason;
let gridModel = buildGridModel(currentCapture?.inventoryGrid); let gridModel = buildGridModel(currentCapture?.inventoryGrid);
if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) { if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) {
const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren."; const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren.";
setReviewStatus(reason); setReviewStatus(reason);
return { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets }; return finish({ status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets });
}
if (isPrimaryCapture) {
appendAutomationLog(primaryScreenStartWarning);
} }
let lastDetailSignature = ""; let lastDetailSignature = "";
const initialParsed = parseArtifact(currentCapture);
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
let lastDetailViewFingerprint = detailFingerprint(currentCapture); let lastDetailViewFingerprint = detailFingerprint(currentCapture);
if (lastDetailViewFingerprint) seenDetailFingerprints.add(lastDetailViewFingerprint);
const shouldSkipInitialGridTarget = Boolean(options.processInitialSelection && options.skipInitialGridTarget);
let initialProcessedOffset = 0;
let initialSelectionDuplicateSkipped = false;
if (options.processInitialSelection) {
const initialCapture = await captureSelectedSource(0, false, {
ocrMode: "artifact",
ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
const initialSurfaceRejection = validateAutoScanEntryPreflight(initialCapture);
if (!initialSurfaceRejection.ok) {
return finish({
status: "blocked",
stats,
blockedReason: initialSurfaceRejection.reason,
pageCount: 0,
gridLabel: initialSurfaceRejection.reason,
targetCount: maxTargets,
});
}
if (!initialCapture) {
return finish({
status: "blocked",
stats,
blockedReason: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
pageCount: 0,
gridLabel: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
targetCount: maxTargets,
});
}
const parsed = parseArtifact(initialCapture);
const rejection = captureRejectionReason(initialCapture, parsed);
if (rejection || !parsed) {
saveAutomaticReviewSample(initialCapture, parsed, `automatic:initial-selection-rejected`);
stats.verified++;
stats.misses++;
addCaptureTiming(stats, initialCapture.timings);
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
lastDetailViewFingerprint = detailFingerprint(initialCapture);
updateStats();
appendAutomationLog(`initial selection review: ${rejection || "kein Artifact lesbar"}; scan continues with next tile`);
} else {
stats.verified++;
stats.parsed++;
addCaptureTiming(stats, initialCapture.timings);
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
const signature = sessionSignature(parsed);
seen.add(signature);
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(initialCapture);
const reason = getAutoReviewReason(initialCapture, parsed);
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
if (reason) saveAutomaticReviewSample(initialCapture, parsed, `automatic:${reason}:initial-selection`);
persistArtifactLater(initialCapture, parsed, "auto-scan-initial", needsReview);
updateStats();
appendAutomationLog(`initial selection parsed: ${parsed.name}`);
if (stats.parsed >= maxTargets) {
return finish({
status: "done",
stats,
blockedReason: "",
pageCount: 1,
gridLabel: `Initial ausgewaehltes Artifact verarbeitet, Ziel ${maxTargets} Artifacts`,
targetCount: maxTargets,
});
}
}
}
async function awaitCardReady() { async function awaitCardReady() {
return waitForCardReady( const startedAt = Date.now();
const result = await waitForCardReady(
{ {
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, true)), sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
})),
wait, wait,
now: () => Date.now(), now: () => Date.now(),
checkAbort: checkGuard, checkAbort: checkGuard,
}, },
lastDetailViewFingerprint, lastDetailViewFingerprint,
{ minStableSamples: CARD_READY_STABLE_SAMPLES, maxWaitMs: CARD_READY_MAX_MS, pollIntervalMs: CARD_READY_POLL_MS }, {
minStableSamples: CARD_READY_STABLE_SAMPLES,
maxWaitMs: CARD_READY_MAX_MS,
pollIntervalMs: CARD_READY_POLL_MS,
acceptChangedAfterMs: CARD_READY_ACCEPT_CHANGED_AFTER_MS,
},
); );
addCardReadyTiming(stats, Date.now() - startedAt);
return result;
}
async function awaitInventoryPageReady(previousFingerprint: string): Promise<{
ready: Awaited<ReturnType<typeof waitForCardReady>>;
capture: CaptureResult | null;
}> {
let latestCapture: CaptureResult | null = null;
const startedAt = Date.now();
const ready = await waitForCardReady(
{
sampleFingerprint: async () => {
latestCapture = await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
});
return screenFingerprint(latestCapture);
},
wait,
now: () => Date.now(),
checkAbort: checkGuard,
},
previousFingerprint,
{
minStableSamples: SCROLL_READY_STABLE_SAMPLES,
maxWaitMs: SCROLL_READY_MAX_MS,
pollIntervalMs: SCROLL_READY_POLL_MS,
acceptChangedAfterMs: SCROLL_READY_ACCEPT_CHANGED_AFTER_MS,
},
);
addScrollReadyTiming(stats, Date.now() - startedAt);
return { ready, capture: latestCapture };
} }
try { try {
while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) { while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) {
page++; page++;
stats.pages = page; stats.pages = page;
updateStats(); updateStats();
@@ -207,21 +396,23 @@ export async function runAutoScanLoop(
cols: gridModel.cols, cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows), rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets, totalTargetCount: maxTargets,
processedTargets: stats.clicked, processedTargets: stats.clicked + initialProcessedOffset,
rowsQueued, rowsQueued,
}); });
const targets = pagePlan.pageTargets; const targets = shouldSkipInitialGridTarget && page === 1
? baseTargets.slice(initialProcessedOffset)
: pagePlan.pageTargets;
if (targets.length === 0) { if (targets.length === 0) {
blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`; blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`;
break; break;
} }
setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.clicked}/${maxTargets} geklickt.`); setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.parsed}/${maxTargets} gelesen.`);
let newArtifactsOnPage = 0; let newArtifactsOnPage = 0;
for (const target of targets) { for (const target of targets) {
if (shouldStop() || stats.clicked >= maxTargets) break; if (shouldStop() || stats.parsed >= maxTargets) break;
const guardReason = await checkGuard(); const guardReason = await checkGuard();
if (guardReason) { if (guardReason) {
@@ -238,16 +429,13 @@ export async function runAutoScanLoop(
break; break;
} }
if (clickResult.moved === false || clickResult.clicked === false) { if (clickResult.inputBlocked) {
// A structural failure (cursor could not be placed, or SendInput blockedReason = clickDeliveryFailureReason(target, clickResult);
// was rejected outright) means clicks are not reaching Genshin at
// all - almost always an elevation mismatch. Abort immediately
// instead of clicking blindly through the rest of the inventory.
blockedReason = clickResult.inputBlocked
? "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten')."
: `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
break; break;
} }
if (reportedClickDeliveryFailure(clickResult)) {
appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`);
}
let ready = await awaitCardReady(); let ready = await awaitCardReady();
if (ready.abortReason) { if (ready.abortReason) {
@@ -258,6 +446,14 @@ export async function runAutoScanLoop(
let changedDetail = ready.changed; let changedDetail = ready.changed;
if (!changedDetail) { if (!changedDetail) {
if (options.processInitialSelection && !initialSelectionDuplicateSkipped && !reportedClickDeliveryFailure(clickResult)) {
initialSelectionDuplicateSkipped = true;
consecutiveMisses = 0;
stats.duplicates++;
updateStats();
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
continue;
}
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`); appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
clickResult = await clickTarget(target, "retry"); clickResult = await clickTarget(target, "retry");
stopReason = inputStopReason(clickResult); stopReason = inputStopReason(clickResult);
@@ -266,6 +462,13 @@ export async function runAutoScanLoop(
aborted = true; aborted = true;
break; break;
} }
if (clickResult.inputBlocked) {
blockedReason = clickDeliveryFailureReason(target, clickResult);
break;
}
if (reportedClickDeliveryFailure(clickResult)) {
appendAutomationLog(`warn r${target.row} c${target.col}: retry helper reported cursor/click miss; verifying detail change`);
}
ready = await awaitCardReady(); ready = await awaitCardReady();
if (ready.abortReason) { if (ready.abortReason) {
blockedReason = ready.abortReason; blockedReason = ready.abortReason;
@@ -276,6 +479,18 @@ export async function runAutoScanLoop(
} }
if (!changedDetail) { if (!changedDetail) {
if (reportedClickDeliveryFailure(clickResult)) {
blockedReason = clickDeliveryFailureReason(target, clickResult);
break;
}
if (options.processInitialSelection && !initialSelectionDuplicateSkipped) {
initialSelectionDuplicateSkipped = true;
consecutiveMisses = 0;
stats.duplicates++;
updateStats();
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
continue;
}
stats.misses++; stats.misses++;
consecutiveMisses++; consecutiveMisses++;
updateStats(); updateStats();
@@ -289,8 +504,36 @@ export async function runAutoScanLoop(
stats.verified++; stats.verified++;
const capture = await captureSelectedSource(0, true); if (ready.fingerprint) {
if (seenDetailFingerprints.has(ready.fingerprint)) {
consecutiveMisses = 0;
stats.duplicates++;
lastDetailViewFingerprint = ready.fingerprint;
updateStats();
appendAutomationLog(`duplicate visual r${target.row} c${target.col}: OCR uebersprungen`);
continue;
}
seenDetailFingerprints.add(ready.fingerprint);
}
const capture = await captureSelectedSource(0, false, {
ocrMode: "artifact",
ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
const captureSurfaceRejection = validateAutoScanEntryPreflight(capture);
if (!captureSurfaceRejection.ok) {
blockedReason = captureSurfaceRejection.reason;
appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`);
break;
}
if (capture?.ocrTimedOut) { if (capture?.ocrTimedOut) {
addCaptureTiming(stats, capture.timings);
stats.misses++; stats.misses++;
consecutiveMisses++; consecutiveMisses++;
lastDetailViewFingerprint = detailFingerprint(capture); lastDetailViewFingerprint = detailFingerprint(capture);
@@ -305,9 +548,10 @@ export async function runAutoScanLoop(
const parsed = parseArtifact(capture); const parsed = parseArtifact(capture);
const rejection = captureRejectionReason(capture, parsed); const rejection = captureRejectionReason(capture, parsed);
addCaptureTiming(stats, capture?.timings);
if (rejection) { if (rejection) {
await saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`); saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`);
if (parsed && shouldPersistParsedArtifact(parsed, true)) { if (parsed && shouldPersistParsedArtifact(parsed, true)) {
consecutiveMisses = 0; consecutiveMisses = 0;
const signature = sessionSignature(parsed); const signature = sessionSignature(parsed);
@@ -316,7 +560,7 @@ export async function runAutoScanLoop(
lastDetailViewFingerprint = detailFingerprint(capture); lastDetailViewFingerprint = detailFingerprint(capture);
seen.add(signature); seen.add(signature);
newArtifactsOnPage++; newArtifactsOnPage++;
if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++; persistArtifactLater(capture, parsed, "auto-scan-review", true);
updateStats(); updateStats();
continue; continue;
} }
@@ -375,9 +619,9 @@ export async function runAutoScanLoop(
const reason = getAutoReviewReason(capture, parsed); const reason = getAutoReviewReason(capture, parsed);
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed); const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
if (reason) { if (reason) {
await saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`); saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`);
} }
if (await persistParsedArtifact(capture, parsed, "auto-scan", needsReview)) stats.stored++; persistArtifactLater(capture, parsed, "auto-scan", needsReview);
updateStats(); updateStats();
} }
@@ -386,12 +630,12 @@ export async function runAutoScanLoop(
cols: gridModel.cols, cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows), rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets, totalTargetCount: maxTargets,
processedTargets: stats.clicked, processedTargets: stats.clicked + initialProcessedOffset,
rowsQueued, rowsQueued,
}); });
rowsQueued = endOfPagePlan.rowsQueuedAfterPage; rowsQueued = endOfPagePlan.rowsQueuedAfterPage;
if (aborted || stats.clicked >= maxTargets || shouldStop() || blockedReason) break; if (aborted || stats.parsed >= maxTargets || shouldStop() || blockedReason) break;
if (newArtifactsOnPage === 0 && page > 1) { if (newArtifactsOnPage === 0 && page > 1) {
blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`; blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`;
@@ -425,16 +669,26 @@ export async function runAutoScanLoop(
} }
} }
const scrollWaitStop = await waitDuringScan(760); const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
if (scrollWaitStop) { const scrollReady = await awaitInventoryPageReady(beforeScrollFingerprint);
blockedReason = scrollWaitStop; if (scrollReady.ready.abortReason) {
blockedReason = scrollReady.ready.abortReason;
aborted = true; aborted = true;
break; break;
} }
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture); const scrolledCapture = scrollReady.capture;
currentCapture = await captureFastSelectedSource(0, true); if (!scrolledCapture) {
const afterScrollFingerprint = screenFingerprint(currentCapture); blockedReason = "Keine Capture-Daten nach dem Scrollen.";
break;
}
currentCapture = scrolledCapture;
const scrolledSurfaceRejection = validateAutoScanEntryPreflight(scrolledCapture);
if (!scrolledSurfaceRejection.ok) {
blockedReason = scrolledSurfaceRejection.reason;
break;
}
const afterScrollFingerprint = screenFingerprint(scrolledCapture);
if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) { if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) {
blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert."; blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert.";
@@ -446,7 +700,7 @@ export async function runAutoScanLoop(
break; break;
} }
const refreshedModel = buildGridModel(currentCapture?.inventoryGrid); const refreshedModel = buildGridModel(scrolledCapture.inventoryGrid);
if (!refreshedModel) { if (!refreshedModel) {
blockedReason = "Kachel-Grid nach dem Scrollen verloren."; blockedReason = "Kachel-Grid nach dem Scrollen verloren.";
break; break;
@@ -461,24 +715,27 @@ export async function runAutoScanLoop(
} }
const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done"; const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done";
return { updateScanTiming(stats, startedAt);
return finish({
status, status,
stats, stats,
blockedReason, blockedReason,
pageCount: page, pageCount: page,
gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`, gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`,
targetCount: maxTargets, targetCount: maxTargets,
}; });
} }
export function detailFingerprint(capture: CaptureResult | null) { export function detailFingerprint(capture: CaptureResult | null) {
if (!capture) return ""; if (!capture) return "";
if (capture.detailFingerprint) return capture.detailFingerprint;
if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl); if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl);
if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl); if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return ""; return "";
} }
export function screenFingerprint(capture: CaptureResult | null) { export function screenFingerprint(capture: CaptureResult | null) {
if (capture?.inventoryFingerprint) return capture.inventoryFingerprint;
if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl); if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl);
if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl); if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return ""; return "";
+13 -13
View File
@@ -42,7 +42,7 @@ describe("automationPlanner", () => {
}); });
it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => { it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => {
const targets = Array.from({ length: 40 }, (_, index) => ({ const targets = Array.from({ length: 32 }, (_, index) => ({
row: Math.floor(index / 8), row: Math.floor(index / 8),
col: index % 8, col: index % 8,
x: index * 10, x: index * 10,
@@ -52,31 +52,31 @@ describe("automationPlanner", () => {
const firstPage = buildInventoryPagePlan({ const firstPage = buildInventoryPagePlan({
targets, targets,
cols: 8, cols: 8,
rows: 5, rows: 4,
totalTargetCount: 50, totalTargetCount: 45,
processedTargets: 0, processedTargets: 0,
rowsQueued: 0, rowsQueued: 0,
}); });
expect(firstPage.pageTargets).toHaveLength(40); expect(firstPage.pageTargets).toHaveLength(32);
expect(firstPage.startIndex).toBe(0); expect(firstPage.startIndex).toBe(0);
expect(firstPage.scrollRowsAfterPage).toBe(2); expect(firstPage.scrollRowsAfterPage).toBe(2);
const finalPage = buildInventoryPagePlan({ const finalPage = buildInventoryPagePlan({
targets, targets,
cols: 8, cols: 8,
rows: 5, rows: 4,
totalTargetCount: 50, totalTargetCount: 45,
processedTargets: 40, processedTargets: 32,
rowsQueued: 5, rowsQueued: 4,
}); });
expect(finalPage.pageTargets).toHaveLength(10); expect(finalPage.pageTargets).toHaveLength(13);
expect(finalPage.startIndex).toBe(24); expect(finalPage.startIndex).toBe(16);
expect(finalPage.pageTargets[0]).toMatchObject({ row: 3, col: 0 }); expect(finalPage.pageTargets[0]).toMatchObject({ row: 2, col: 0 });
expect(finalPage.scrollRowsAfterPage).toBe(0); expect(finalPage.scrollRowsAfterPage).toBe(0);
}); });
it("keeps the first page top-aligned when the whole inventory fits inside one visible page", () => { it("keeps the first page top-aligned when the whole inventory fits inside one visible page", () => {
const targets = Array.from({ length: 40 }, (_, index) => ({ const targets = Array.from({ length: 32 }, (_, index) => ({
row: Math.floor(index / 8), row: Math.floor(index / 8),
col: index % 8, col: index % 8,
x: index * 10, x: index * 10,
@@ -86,7 +86,7 @@ describe("automationPlanner", () => {
const singlePage = buildInventoryPagePlan({ const singlePage = buildInventoryPagePlan({
targets, targets,
cols: 8, cols: 8,
rows: 5, rows: 4,
totalTargetCount: 16, totalTargetCount: 16,
processedTargets: 0, processedTargets: 0,
rowsQueued: 0, rowsQueued: 0,
+15
View File
@@ -46,6 +46,21 @@ describe("waitForCardReady", () => {
expect(result.ready).toBe(true); // budget spent, but content did change expect(result.ready).toBe(true); // budget spent, but content did change
}); });
it("proceeds with changed animated content after the configured minimum elapsed time", async () => {
const animated = ["a1", "a2", "a3", "a4", "a5"];
const { deps } = harness(animated, 90);
const result = await waitForCardReady(deps, "old", {
minStableSamples: 2,
maxWaitMs: 900,
pollIntervalMs: 90,
acceptChangedAfterMs: 180,
});
expect(result.ready).toBe(true);
expect(result.changed).toBe(true);
expect(result.stable).toBe(false);
expect(result.polls).toBe(3);
});
it("reports not-ready when the detail never changes within budget", async () => { it("reports not-ready when the detail never changes within budget", async () => {
const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90); const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90);
const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 }); const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 });
+8 -3
View File
@@ -16,6 +16,8 @@ export interface CardReadyOptions {
maxWaitMs?: number; maxWaitMs?: number;
/** Delay between samples. */ /** Delay between samples. */
pollIntervalMs?: number; pollIntervalMs?: number;
/** Proceed with changed-but-animated content after this much elapsed time. */
acceptChangedAfterMs?: number;
} }
export interface CardReadyDeps { export interface CardReadyDeps {
@@ -53,6 +55,7 @@ export async function waitForCardReady(
const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE); const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE);
const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS); const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS); const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS);
const acceptChangedAfterMs = Math.max(0, options.acceptChangedAfterMs ?? maxWaitMs);
const start = deps.now(); const start = deps.now();
let previousSample = ""; let previousSample = "";
@@ -77,11 +80,13 @@ export async function waitForCardReady(
const changed = Boolean(latest) && latest !== previousFingerprint; const changed = Boolean(latest) && latest !== previousFingerprint;
const stable = stableCount >= minStable; const stable = stableCount >= minStable;
if (changed && stable) { const elapsedMs = deps.now() - start;
return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls };
if (changed && (stable || elapsedMs >= acceptChangedAfterMs)) {
return { ready: true, changed: true, stable, fingerprint: latest, abortReason: "", polls };
} }
if (deps.now() - start >= maxWaitMs) { if (elapsedMs >= maxWaitMs) {
// Budget spent. Proceed if the content has at least changed, even if it is // Budget spent. Proceed if the content has at least changed, even if it is
// still animating (never fully stabilizes). // still animating (never fully stabilizes).
return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls }; return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls };
+21
View File
@@ -27,6 +27,27 @@ type GenshinGameDataContract = typeof gameData & {
pieceAliases?: Record<string, string>; pieceAliases?: Record<string, string>;
characterAliases?: Record<string, string>; characterAliases?: Record<string, string>;
}; };
lookup?: {
normalizedKeys?: {
sets?: Record<string, string>;
pieces?: Record<string, string>;
slots?: Record<string, string>;
stats?: Record<string, string>;
characters?: Record<string, string>;
};
goodKeys?: {
sets?: Record<string, string>;
pieces?: Record<string, string>;
stats?: Record<string, string>;
characters?: Record<string, string>;
};
setToPieces?: Record<string, string[]>;
validation?: {
valid?: boolean;
errors?: string[];
warnings?: string[];
};
};
mainStatsBySlot?: Record<string, string[]>; mainStatsBySlot?: Record<string, string[]>;
mainStatValueReferences?: Record<string, Array<{ stat: string; base: number; max: number }>>; mainStatValueReferences?: Record<string, Array<{ stat: string; base: number; max: number }>>;
characters?: Character[]; characters?: Character[];
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { matchPiece, matchSet, matchSlot, matchStat, validateLookupPackage } from "./genshinLookup";
import { genshinGameData } from "./genshinData";
describe("genshinLookup", () => {
it("matches canonical, alias, and fuzzy artifact values", () => {
expect(matchSlot("Sands of Eon Vi")).toMatchObject({ value: "Sands of Eon", source: "alias" });
expect(matchSet("Viridescent Venere")).toMatchObject({ value: "Viridescent Venerer", source: "alias" });
expect(matchPiece("Viridescent Venerers Determination").value).toBe("Viridescent Venerer's Determination");
expect(matchStat("Crit Damage")).toMatchObject({ value: "CRIT DMG", source: "alias" });
});
it("validates the bundled lookup package", () => {
const status = validateLookupPackage();
expect(status.valid).toBe(true);
expect(status.errors).toEqual([]);
expect(status.summary.artifactPieces).toBeGreaterThan(0);
});
it("rejects missing set references", () => {
const broken = {
...genshinGameData,
artifactPieces: [{ name: "Broken Piece", setName: "Missing Set", slot: "Flower of Life", relicType: "EQUIP_BRACER" }],
};
const status = validateLookupPackage(broken as unknown as typeof genshinGameData);
expect(status.valid).toBe(false);
expect(status.errors.join("\n")).toContain("Missing Set");
});
it("rejects duplicate GOOD set keys", () => {
const broken = {
...genshinGameData,
lookup: {
...genshinGameData.lookup,
goodKeys: {
...genshinGameData.lookup?.goodKeys,
sets: {
one: "Duplicate",
two: "Duplicate",
},
},
},
};
const status = validateLookupPackage(broken as unknown as typeof genshinGameData);
expect(status.valid).toBe(false);
expect(status.errors.join("\n")).toContain("Duplicate GOOD set key");
});
});
+211
View File
@@ -0,0 +1,211 @@
import { simplifyForMatch } from "./fuzzyMatch.js";
import {
artifactPieces,
characterAliases,
genshinGameData,
globalMainStats,
globalSubstats,
knownCharacters,
knownSets,
mainStatsBySlot,
pieceAliases,
setAliases,
slotAliases,
slotNames,
statAliases,
} from "./genshinData.js";
export type LookupMatchSource = "exact" | "alias" | "fuzzy" | "missing";
export interface LookupMatch {
value: string;
confidence: number;
source: LookupMatchSource;
}
export interface LookupValidationStatus {
valid: boolean;
errors: string[];
warnings: string[];
summary: {
artifactSets: number;
artifactPieces: number;
characters: number;
stats: number;
generatedAt: string;
sourceVersion: string;
};
}
type LookupData = typeof genshinGameData & {
lookup?: {
normalizedKeys?: {
sets?: Record<string, string>;
pieces?: Record<string, string>;
slots?: Record<string, string>;
stats?: Record<string, string>;
characters?: Record<string, string>;
};
goodKeys?: {
sets?: Record<string, string>;
pieces?: Record<string, string>;
stats?: Record<string, string>;
characters?: Record<string, string>;
};
setToPieces?: Record<string, string[]>;
validation?: {
valid?: boolean;
errors?: string[];
warnings?: string[];
};
};
};
const lookupData = genshinGameData as LookupData;
const normalizedSets = lookupData.lookup?.normalizedKeys?.sets ?? buildNormalizedMap(knownSets);
const normalizedPieces = lookupData.lookup?.normalizedKeys?.pieces ?? buildNormalizedMap(artifactPieces.map((piece) => piece.name));
const normalizedSlots = lookupData.lookup?.normalizedKeys?.slots ?? buildNormalizedMap(slotNames);
const normalizedStats = lookupData.lookup?.normalizedKeys?.stats ?? buildNormalizedMap([...globalMainStats, ...globalSubstats]);
const normalizedCharacters = lookupData.lookup?.normalizedKeys?.characters ?? buildNormalizedMap(knownCharacters);
export function normalizeLookupKey(value: string) {
return simplifyForMatch(value).replace(/[^a-z0-9]+/g, "");
}
export function matchPiece(raw: string, minConfidence = 0.72): LookupMatch {
return matchLookup(raw, normalizedPieces, pieceAliases, minConfidence);
}
export function matchSet(raw: string, minConfidence = 0.72): LookupMatch {
return matchLookup(raw, normalizedSets, setAliases, minConfidence);
}
export function matchSlot(raw: string, minConfidence = 0.72): LookupMatch {
return matchLookup(raw, normalizedSlots, slotAliases, minConfidence);
}
export function matchStat(raw: string, minConfidence = 0.72): LookupMatch {
return matchLookup(raw, normalizedStats, statAliases, minConfidence);
}
export function matchCharacter(raw: string, minConfidence = 0.72): LookupMatch {
return matchLookup(raw, normalizedCharacters, characterAliases, minConfidence);
}
export function validateLookupPackage(data: LookupData = lookupData): LookupValidationStatus {
const errors: string[] = [];
const warnings: string[] = [];
const pieces = data.artifactPieces ?? artifactPieces;
const sets = data.artifactSets ?? [];
const characters = data.characters ?? [];
const slots = data.slots ?? slotNames;
const stats = data.stats?.main ?? data.mainStats ?? [];
const setNames = new Set(sets.map((set) => set.name).filter(Boolean));
const slotNameSet = new Set(slots);
if (!sets.length) errors.push("No artifact sets in lookup package.");
if (!pieces.length) errors.push("No artifact pieces in lookup package.");
if (!characters.length) warnings.push("No characters in lookup package.");
for (const piece of pieces) {
if (!piece.name) errors.push("Artifact piece without name.");
if (!piece.setName || !setNames.has(piece.setName)) errors.push(`Piece "${piece.name}" references missing set "${piece.setName}".`);
if (!piece.slot || !slotNameSet.has(piece.slot)) errors.push(`Piece "${piece.name}" references missing slot "${piece.slot}".`);
}
const goodSets = Object.values(data.lookup?.goodKeys?.sets ?? {});
const duplicateGoodSet = firstDuplicate(goodSets);
if (duplicateGoodSet) errors.push(`Duplicate GOOD set key "${duplicateGoodSet}".`);
for (const [alias, target] of Object.entries(data.aliases?.stats ?? {})) {
if (!stats.includes(target) && !(data.stats?.sub ?? data.substats ?? []).includes(target)) {
errors.push(`Stat alias "${alias}" targets unknown stat "${target}".`);
}
}
const generatedAt = typeof data.generatedAt === "string" ? data.generatedAt : "";
const sourceVersion = typeof data.sourceVersion === "string" ? data.sourceVersion : "unknown";
if (!generatedAt) warnings.push("Lookup package has no generatedAt timestamp.");
return {
valid: errors.length === 0,
errors,
warnings,
summary: {
artifactSets: sets.length,
artifactPieces: pieces.length,
characters: characters.length,
stats: stats.length,
generatedAt,
sourceVersion,
},
};
}
function matchLookup(
raw: string,
normalized: Record<string, string>,
aliases: Record<string, string>,
minConfidence: number,
): LookupMatch {
const key = normalizeLookupKey(raw);
if (!key) return missingMatch();
const alias = Object.entries(aliases).find(([from]) => normalizeLookupKey(from) === key);
if (alias) return { value: alias[1], confidence: 96, source: "alias" };
if (normalized[key]) return { value: normalized[key], confidence: 100, source: "exact" };
let bestValue = "";
let bestScore = 0;
for (const [candidateKey, value] of Object.entries(normalized)) {
const score = similarity(key, candidateKey);
if (score > bestScore) {
bestScore = score;
bestValue = value;
}
}
return bestScore >= minConfidence
? { value: bestValue, confidence: Math.round(bestScore * 100), source: "fuzzy" }
: missingMatch();
}
function missingMatch(): LookupMatch {
return { value: "", confidence: 0, source: "missing" };
}
function buildNormalizedMap(values: string[]) {
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
}
function firstDuplicate(values: string[]) {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) return value;
seen.add(value);
}
return "";
}
function similarity(a: string, b: string) {
if (a === b) return 1;
if (!a || !b) return 0;
const distance = levenshtein(a, b);
return 1 - distance / Math.max(a.length, b.length);
}
function levenshtein(a: string, b: string) {
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
const current = new Array<number>(b.length + 1);
for (let i = 1; i <= a.length; i++) {
current[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
}
previous.splice(0, previous.length, ...current);
}
return previous[b.length];
}
+32 -6
View File
@@ -46,13 +46,17 @@ describe("layoutProfile", () => {
expect(profileDetailRect(HD)).toEqual({ x: 1308, y: 120, width: 492, height: 838 }); 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", () => { it("produces the split artifact crops in top-to-bottom order, all clamped", () => {
const detail = profileDetailRect(QHD); const detail = profileDetailRect(QHD);
const crops = detailCropRects(detail, QHD); const crops = detailCropRects(detail, QHD);
expect(crops.map((crop) => crop.id)).toEqual([ expect(crops.map((crop) => crop.id)).toEqual([
"artifact-title", "artifact-name",
"artifact-main-stat", "artifact-slot",
"artifact-main-stat-label",
"artifact-main-stat-value",
"artifact-level",
"artifact-substats", "artifact-substats",
"artifact-set-effects",
"artifact-footer", "artifact-footer",
]); ]);
let previousY = -1; let previousY = -1;
@@ -65,6 +69,28 @@ describe("layoutProfile", () => {
} }
}); });
it("matches Inventory Kamera-like 1080p artifact field crops", () => {
const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-slot"]).toEqual({ x: 1328, y: 185, width: 234, height: 40 });
expect(crops["artifact-main-stat-label"]).toEqual({ x: 1328, y: 264, width: 224, height: 35 });
expect(crops["artifact-level"]).toEqual({ x: 1333, y: 425, width: 70, height: 35 });
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 193 });
});
it("uses Inventory Kamera's tighter substat crop in the fast auto-scan profile", () => {
const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD, { fastProfile: true }).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 154 });
});
it("shifts level and substat crops for sanctified artifacts like Inventory Kamera", () => {
const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD, { sanctified: true }).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-level"]).toEqual({ x: 1333, y: 468, width: 70, height: 35 });
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 517, width: 408, height: 193 });
});
it("places the inventory count crop inside the inventory panel", () => { it("places the inventory count crop inside the inventory panel", () => {
const detail = profileDetailRect(QHD); const detail = profileDetailRect(QHD);
const inv = inventoryRect(QHD, detail); const inv = inventoryRect(QHD, detail);
@@ -77,9 +103,9 @@ describe("layoutProfile", () => {
const detail = profileDetailRect(QHD); const detail = profileDetailRect(QHD);
const grid = inventoryGrid(QHD, detail); const grid = inventoryGrid(QHD, detail);
expect(grid.cols).toBe(8); expect(grid.cols).toBe(8);
expect(grid.rows).toBe(5); expect(grid.rows).toBe(4);
expect(grid.source).toBe("detected"); expect(grid.source).toBe("detected");
expect(grid.centers).toHaveLength(40); expect(grid.centers).toHaveLength(32);
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true); expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
}); });
@@ -88,7 +114,7 @@ describe("layoutProfile", () => {
const grid = inventoryGrid(HD, detail); const grid = inventoryGrid(HD, detail);
expect(grid.centers[0]).toEqual({ x: 179, y: 254, row: 0, col: 0 }); 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[7]).toEqual({ x: 1201, y: 254, row: 0, col: 7 });
expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 958, row: 4, col: 7 }); expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 782, row: 3, col: 7 });
}); });
it("reports a missing grid when the inventory panel is too small", () => { it("reports a missing grid when the inventory panel is too small", () => {
+67 -15
View File
@@ -90,12 +90,21 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
); );
} }
// The four OCR crops inside the detail panel, as fractions of the detail rect. // OCR crops inside the detail panel, as fractions of the detail rect. These are
export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] { // deliberately closer to Inventory Kamera's split-card model than the older
// broad crops: small fields get small OCR profiles, while the legacy parser
// still accepts old review samples with artifact-title/artifact-main-stat.
export function detailCropRects(
detailRect: LayoutRect,
imageSize: { width: number; height: number },
options: { sanctified?: boolean; fastProfile?: boolean } = {},
): CropTemplateRect[] {
const sanctifiedShift = options.sanctified ? 0.0520 : 0;
const substatsHeight = options.fastProfile ? 0.1841 : 0.2301;
const templates: CropTemplateRect[] = [ const templates: CropTemplateRect[] = [
{ {
id: "artifact-title", id: "artifact-name",
label: "Artifact title", label: "Artifact name",
rect: { rect: {
x: Math.round(detailRect.x), x: Math.round(detailRect.x),
y: Math.round(detailRect.y), y: Math.round(detailRect.y),
@@ -104,33 +113,73 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
}, },
}, },
{ {
id: "artifact-main-stat", id: "artifact-slot",
label: "Main stat", label: "Artifact slot",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.0405),
y: Math.round(detailRect.y + detailRect.height * 0.0772),
width: Math.round(detailRect.width * 0.4757),
height: Math.round(detailRect.height * 0.0475),
},
},
{
id: "artifact-main-stat-label",
label: "Main stat label",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.0405),
y: Math.round(detailRect.y + detailRect.height * 0.1722),
width: Math.round(detailRect.width * 0.4555),
height: Math.round(detailRect.height * 0.0416),
},
},
{
id: "artifact-main-stat-value",
label: "Main stat value",
rect: { rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055), x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.075), y: Math.round(detailRect.y + detailRect.height * 0.205),
width: Math.round(detailRect.width * 0.58), width: Math.round(detailRect.width * 0.42),
height: Math.round(detailRect.height * 0.26), height: Math.round(detailRect.height * 0.105),
},
},
{
id: "artifact-level",
label: "Artifact level",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.0506),
y: Math.round(detailRect.y + detailRect.height * (0.3634 + sanctifiedShift)),
width: Math.round(detailRect.width * 0.1417),
height: Math.round(detailRect.height * 0.0416),
}, },
}, },
{ {
id: "artifact-substats", id: "artifact-substats",
label: "Substats", label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.0605),
y: Math.round(detailRect.y + detailRect.height * (0.4216 + sanctifiedShift)),
width: Math.round(detailRect.width * 0.8297),
height: Math.round(detailRect.height * substatsHeight),
},
},
{
id: "artifact-set-effects",
label: "Set effects",
rect: { rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055), x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.34), y: Math.round(detailRect.y + detailRect.height * 0.655),
width: Math.round(detailRect.width * 0.86), width: Math.round(detailRect.width * 0.86),
height: Math.round(detailRect.height * 0.27), height: Math.round(detailRect.height * 0.16),
}, },
}, },
{ {
id: "artifact-footer", id: "artifact-footer",
label: "Footer", label: "Footer",
rect: { rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055), x: Math.round(detailRect.x + detailRect.width * 0.15),
y: Math.round(detailRect.y + detailRect.height * 0.82), y: Math.round(detailRect.y + detailRect.height * 0.938),
width: Math.round(detailRect.width * 0.86), width: Math.round(detailRect.width * 0.86),
height: Math.round(detailRect.height * 0.14), height: Math.round(detailRect.height * 0.06),
}, },
}, },
]; ];
@@ -173,7 +222,10 @@ export function inventoryGrid(imageSize: { width: number; height: number }, deta
const stepX = Math.round(imageSize.width * 0.076); const stepX = Math.round(imageSize.width * 0.076);
const stepY = Math.round(imageSize.height * 0.163); const stepY = Math.round(imageSize.height * 0.163);
const visibleRows = 5; // Inventory Kamera treats the artifact inventory as 32 safe click targets per
// full page. The apparent fifth row sits in the bottom control band on 16:9
// captures and is not a reliable target during automated scrolling.
const visibleRows = 4;
const startX = Math.round(imageSize.width * 0.093); const startX = Math.round(imageSize.width * 0.093);
const startY = Math.round(imageSize.height * 0.235); const startY = Math.round(imageSize.height * 0.235);
+12
View File
@@ -40,6 +40,18 @@ describe("ocrPreprocess", () => {
expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127); expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127);
}); });
it("can increase contrast before histogramming small numeric crops", () => {
const bitmap = bitmapFrom([
[118, 118, 118],
[138, 138, 138],
], 2, 1);
const normal = computeLuminanceHistogram(bitmap);
const contrasted = computeLuminanceHistogram(bitmap, { contrast: 80 });
const normalValues = normal.flatMap((count, value) => Array.from({ length: count }, () => value));
const contrastedValues = contrasted.flatMap((count, value) => Array.from({ length: count }, () => value));
expect(Math.max(...contrastedValues) - Math.min(...contrastedValues)).toBeGreaterThan(Math.max(...normalValues) - Math.min(...normalValues));
});
it("inverts bright foreground to black-on-white by default", () => { it("inverts bright foreground to black-on-white by default", () => {
// Bright text pixel + dark background pixel. // Bright text pixel + dark background pixel.
const bitmap = bitmapFrom([ const bitmap = bitmapFrom([
+13 -4
View File
@@ -20,6 +20,8 @@ export interface BinarizeOptions {
invertBrightForeground?: boolean; invertBrightForeground?: boolean;
/** Override Otsu with a fixed 0-255 luminance threshold. */ /** Override Otsu with a fixed 0-255 luminance threshold. */
threshold?: number; threshold?: number;
/** Optional contrast adjustment in the usual -255..255 image-processing range. */
contrast?: number;
} }
const BYTES_PER_PIXEL = 4; const BYTES_PER_PIXEL = 4;
@@ -35,12 +37,19 @@ function luminanceAt(data: Uint8Array | Buffer, index: number): number {
return 0.299 * r + 0.587 * g + 0.114 * b; return 0.299 * r + 0.587 * g + 0.114 * b;
} }
export function computeLuminanceHistogram(bitmap: Bitmap): number[] { function adjustContrast(value: number, contrast = 0) {
if (!contrast) return value;
const safeContrast = Math.max(-255, Math.min(255, contrast));
const factor = (259 * (safeContrast + 255)) / (255 * (259 - safeContrast));
return Math.max(0, Math.min(255, factor * (value - 128) + 128));
}
export function computeLuminanceHistogram(bitmap: Bitmap, options: Pick<BinarizeOptions, "contrast"> = {}): number[] {
const histogram = new Array<number>(256).fill(0); const histogram = new Array<number>(256).fill(0);
const { data, width, height } = bitmap; const { data, width, height } = bitmap;
const pixels = width * height; const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) { for (let pixel = 0; pixel < pixels; pixel++) {
const value = Math.round(luminanceAt(data, pixel * BYTES_PER_PIXEL)); const value = Math.round(adjustContrast(luminanceAt(data, pixel * BYTES_PER_PIXEL), options.contrast));
histogram[Math.max(0, Math.min(255, value))]++; histogram[Math.max(0, Math.min(255, value))]++;
} }
return histogram; return histogram;
@@ -82,13 +91,13 @@ export function otsuThreshold(histogram: readonly number[]): number {
export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap { export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap {
const { data, width, height } = bitmap; const { data, width, height } = bitmap;
const invert = options.invertBrightForeground ?? true; const invert = options.invertBrightForeground ?? true;
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap)); const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap, options));
const output = Buffer.alloc(width * height * BYTES_PER_PIXEL); const output = Buffer.alloc(width * height * BYTES_PER_PIXEL);
const pixels = width * height; const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) { for (let pixel = 0; pixel < pixels; pixel++) {
const index = pixel * BYTES_PER_PIXEL; const index = pixel * BYTES_PER_PIXEL;
const isBright = luminanceAt(data, index) > threshold; const isBright = adjustContrast(luminanceAt(data, index), options.contrast) > threshold;
// Bright foreground text -> black; dark background -> white (inverted). // Bright foreground text -> black; dark background -> white (inverted).
const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0); const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0);
output[index] = value; output[index] = value;
+132
View File
@@ -0,0 +1,132 @@
import { detailFingerprint } from "./autoScanLoop";
import type { CaptureResult, ClickResult, KeyPressResult } from "../types/global";
export type ScanDiagnosticSeverity = "info" | "ok" | "warn" | "error";
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
type InventoryCount = NonNullable<CaptureResult["inventoryCount"]>;
export interface ScanDiagnosticEvent {
id: string;
at: string;
phase: string;
severity: ScanDiagnosticSeverity;
message: string;
details?: Record<string, string | number | boolean | null | undefined>;
capture?: {
id: string;
name: string;
width: number;
height: number;
capturedAt: string;
target?: CaptureResult["captureTarget"];
fingerprint: string;
inventoryFingerprint?: string;
layoutWarning?: string;
grid?: {
rows: number;
cols: number;
confidence: number;
source: InventoryGrid["source"];
targets: number;
};
count?: {
current: number;
total: number;
confidence: number;
source: InventoryCount["source"];
text: string;
};
artifactDetail?: NonNullable<CaptureResult["artifactDetail"]>;
paimonMenu?: NonNullable<CaptureResult["paimonMenu"]>;
sanctified?: boolean;
screenshots?: {
detail?: string;
inventory?: string;
full?: string;
};
};
}
export function createScanDiagnosticEvent(input: {
phase: string;
severity?: ScanDiagnosticSeverity;
message: string;
details?: ScanDiagnosticEvent["details"];
capture?: CaptureResult | null;
includeFullScreenshot?: boolean;
}): ScanDiagnosticEvent {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
at: new Date().toISOString(),
phase: input.phase,
severity: input.severity ?? "info",
message: input.message,
details: input.details,
capture: input.capture ? summarizeCapture(input.capture, Boolean(input.includeFullScreenshot)) : undefined,
};
}
export function summarizeClickResult(result: ClickResult) {
return {
ok: result.ok,
moved: result.moved,
clicked: result.clicked,
inputBlocked: result.inputBlocked,
focused: result.focused,
cursor: `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`,
foreground: result.foregroundProcess,
target: result.targetProcess,
};
}
export function summarizeKeyPressResult(result: KeyPressResult | null | undefined) {
return {
ok: Boolean(result?.ok),
key: result?.key,
inputBlocked: Boolean(result?.inputBlocked),
focused: Boolean(result?.focused),
eventsSent: result?.eventsSent ?? 0,
foreground: result?.foregroundProcess,
target: result?.targetProcess,
};
}
function summarizeCapture(capture: CaptureResult, includeFullScreenshot: boolean): NonNullable<ScanDiagnosticEvent["capture"]> {
return {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
capturedAt: capture.capturedAt,
target: capture.captureTarget,
fingerprint: detailFingerprint(capture),
inventoryFingerprint: capture.inventoryFingerprint,
layoutWarning: capture.layout?.warning || undefined,
grid: capture.inventoryGrid
? {
rows: capture.inventoryGrid.rows,
cols: capture.inventoryGrid.cols,
confidence: capture.inventoryGrid.confidence,
source: capture.inventoryGrid.source,
targets: capture.inventoryGrid.centers.length,
}
: undefined,
count: capture.inventoryCount
? {
current: capture.inventoryCount.current,
total: capture.inventoryCount.total,
confidence: capture.inventoryCount.confidence,
source: capture.inventoryCount.source,
text: capture.inventoryCount.text,
}
: undefined,
artifactDetail: capture.artifactDetail,
paimonMenu: capture.paimonMenu,
sanctified: capture.sanctified,
screenshots: {
detail: capture.detailDataUrl,
inventory: capture.inventoryDataUrl,
full: includeFullScreenshot ? capture.dataUrl : undefined,
},
};
}
+46
View File
@@ -30,6 +30,23 @@ describe("scannerLearning", () => {
expect(shouldSaveReviewSample({ confidence: 90, notes: ["Main stat not confidently parsed."], fields: { mainStat: { confidence: 0 } } })).toBe(true); expect(shouldSaveReviewSample({ confidence: 90, notes: ["Main stat not confidently parsed."], fields: { mainStat: { confidence: 0 } } })).toBe(true);
}); });
it("does not save automatic review samples only because level or equipped is missing", () => {
expect(shouldSaveReviewSample({
confidence: 77,
notes: ["Artifact level not confidently parsed.", "equipped confidence is low; review before trusting it."],
fields: {
name: { confidence: 96 },
slot: { confidence: 96 },
level: { confidence: 0 },
mainStat: { confidence: 94 },
mainValue: { confidence: 96 },
setName: { confidence: 92 },
equipped: { confidence: 45 },
substats: { confidence: 96 },
},
})).toBe(false);
});
it("derives conservative replacements from OCR review samples", () => { it("derives conservative replacements from OCR review samples", () => {
const reviewCapture: CaptureResult = { const reviewCapture: CaptureResult = {
id: "review", id: "review",
@@ -53,6 +70,35 @@ describe("scannerLearning", () => {
expect(learned?.textReplacements?.Moor).toBe("Moon"); expect(learned?.textReplacements?.Moor).toBe("Moon");
}); });
it("derives conservative replacements from split artifact OCR fields", () => {
const reviewCapture: CaptureResult = {
id: "review",
name: "review",
width: 1920,
height: 1080,
dataUrl: "",
capturedAt: new Date(0).toISOString(),
ocr: [
{ id: "artifact-name", label: "Name", text: "Heldenepos's Unspcken Tale", confidence: 73 },
{ id: "artifact-slot", label: "Slot", text: "Goblet of Eonothen", confidence: 74 },
{ id: "artifact-main-stat-label", label: "Main stat label", text: "Pvro DMG Bonus", confidence: 72 },
{ id: "artifact-main-stat-value", label: "Main stat value", text: "46.G%", confidence: 66 },
],
};
const learned = deriveScannerLearningRules(reviewCapture, parsedArtifact({
name: "Heldenepos's Unspoken Tale",
slot: "Goblet of Eonothem",
mainStat: "Pyro DMG Bonus",
mainValue: "46.6%",
}));
expect(learned?.textReplacements?.["Heldenepos's Unspcken Tale"]).toBe("Heldenepos's Unspoken Tale");
expect(learned?.textReplacements?.Eonothen).toBe("Eonothem");
expect(learned?.textReplacements?.Pvro).toBe("Pyro");
expect(learned?.textReplacements?.["46.G%"]).toBe("46.6%");
});
it("counts learned rules", () => { it("counts learned rules", () => {
expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2); expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2);
}); });
+18 -2
View File
@@ -43,12 +43,12 @@ export function applyScannerLearningRules(capture: CaptureResult | null, rules?:
export function shouldSaveReviewSample(parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }> } | null) { export function shouldSaveReviewSample(parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }> } | null) {
if (!parsed) return true; if (!parsed) return true;
if (parsed.confidence < 82) return true;
const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"];
if (criticalFields.some((fieldName) => { if (criticalFields.some((fieldName) => {
const field = parsed.fields[fieldName]; const field = parsed.fields[fieldName];
return field ? field.confidence < 70 : false; return field ? field.confidence < 70 : false;
})) return true; })) return true;
if (reviewRelevantConfidence(parsed.fields) < 82) return true;
if (parsed.notes.some((note) => /main stat not confidently parsed|main stat value not confidently parsed|set name not confidently parsed|slot not confidently parsed|artifact name not confidently parsed/i.test(note))) { if (parsed.notes.some((note) => /main stat not confidently parsed|main stat value not confidently parsed|set name not confidently parsed|slot not confidently parsed|artifact name not confidently parsed/i.test(note))) {
return true; return true;
} }
@@ -60,7 +60,7 @@ export function shouldFlagArtifactForReview(
parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }>; substats?: string[] } | null, parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }>; substats?: string[] } | null,
) { ) {
if (!parsed) return true; if (!parsed) return true;
if (parsed.confidence < 78) return true; if (reviewRelevantConfidence(parsed.fields) < 78) return true;
const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"];
if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) return true; if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) return true;
@@ -81,6 +81,14 @@ export function shouldFlagArtifactForReview(
return false; return false;
} }
function reviewRelevantConfidence(fields: Record<string, { confidence: number }>) {
const relevant = Object.entries(fields)
.filter(([fieldName]) => fieldName !== "level" && fieldName !== "equipped")
.map(([, field]) => field.confidence);
if (relevant.length === 0) return 0;
return Math.round(relevant.reduce((sum, confidence) => sum + confidence, 0) / relevant.length);
}
export function countScannerLearningRules(rules?: Partial<ScannerLearningRules> | null) { export function countScannerLearningRules(rules?: Partial<ScannerLearningRules> | null) {
return Object.keys(rules?.textReplacements ?? {}).length; return Object.keys(rules?.textReplacements ?? {}).length;
} }
@@ -120,8 +128,16 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate)
switch (id) { switch (id) {
case "artifact-title": case "artifact-title":
return [parsed.name, parsed.slot]; return [parsed.name, parsed.slot];
case "artifact-name":
return [parsed.name];
case "artifact-slot":
return [parsed.slot];
case "artifact-main-stat": case "artifact-main-stat":
return [parsed.mainStat, parsed.mainValue]; return [parsed.mainStat, parsed.mainValue];
case "artifact-main-stat-label":
return [parsed.mainStat];
case "artifact-main-stat-value":
return [parsed.mainValue];
case "artifact-substats": case "artifact-substats":
return parsed.substats; return parsed.substats;
case "artifact-set-effects": case "artifact-set-effects":
+58 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { clampScanLimit, clampSkipRows, resolveScanTargetCount } from "./scannerSession"; import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampScanLimit, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
describe("scannerSession helpers", () => { describe("scannerSession helpers", () => {
it("clamps scan target counts into a sane range", () => { it("clamps scan target counts into a sane range", () => {
@@ -18,4 +18,61 @@ describe("scannerSession helpers", () => {
expect(clampSkipRows(-5)).toBe(0); expect(clampSkipRows(-5)).toBe(0);
expect(clampSkipRows(99)).toBe(8); expect(clampSkipRows(99)).toBe(8);
}); });
it("updates scan timing and projects the 100-artifact run", () => {
const stats = { ...emptyAutoScanStats, parsed: 4, verified: 2 };
addCaptureTiming(stats, { totalMs: 1200, ocrMs: 900 });
addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 });
updateScanTiming(stats, 1000, 9000);
expect(stats.elapsedMs).toBe(8000);
expect(stats.activeScanMs).toBe(8000);
expect(stats.averageMsPerParsed).toBe(2000);
expect(stats.activeAverageMsPerParsed).toBe(2000);
expect(stats.artifactsPerMinute).toBe(30);
expect(stats.activeArtifactsPerMinute).toBe(30);
expect(stats.projectedMsFor100).toBe(200000);
expect(stats.activeProjectedMsFor100).toBe(200000);
expect(stats.captureMs).toBe(2000);
expect(stats.ocrMs).toBe(1400);
expect(stats.averageCaptureMs).toBe(1000);
expect(stats.averageOcrMs).toBe(700);
expect(stats.captureP50Ms).toBe(800);
expect(stats.captureP90Ms).toBe(1200);
expect(stats.ocrP50Ms).toBe(500);
expect(stats.ocrP90Ms).toBe(900);
});
it("tracks detail-card and scroll readiness timing separately from OCR", () => {
const stats = { ...emptyAutoScanStats, parsed: 2, verified: 2 };
addCardReadyTiming(stats, 180);
addCardReadyTiming(stats, 220);
addScrollReadyTiming(stats, 320);
updateScanTiming(stats, 1000, 3000);
expect(stats.cardReadyMs).toBe(400);
expect(stats.cardReadyCount).toBe(2);
expect(stats.averageCardReadyMs).toBe(200);
expect(stats.scrollReadyMs).toBe(320);
expect(stats.scrollReadyCount).toBe(1);
expect(stats.averageScrollReadyMs).toBe(320);
});
it("preserves active scan timing when final elapsed time includes queued write flush", () => {
const stats = { ...emptyAutoScanStats, parsed: 5, verified: 5, activeScanMs: 4000, writeFlushMs: 900 };
updateScanTiming(stats, 1000, 6000, { preserveActiveScanMs: true });
expect(stats.elapsedMs).toBe(5000);
expect(stats.activeScanMs).toBe(4000);
expect(stats.writeFlushMs).toBe(900);
expect(stats.averageMsPerParsed).toBe(1000);
expect(stats.activeAverageMsPerParsed).toBe(800);
expect(stats.projectedMsFor100).toBe(100000);
expect(stats.activeProjectedMsFor100).toBe(80000);
});
it("keeps active scan timing growing during the live scan phase", () => {
const stats = { ...emptyAutoScanStats, parsed: 2, verified: 2, activeScanMs: 800 };
updateScanTiming(stats, 1000, 5000);
expect(stats.elapsedMs).toBe(4000);
expect(stats.activeScanMs).toBe(4000);
expect(stats.activeAverageMsPerParsed).toBe(2000);
});
}); });
+118
View File
@@ -8,6 +8,29 @@ export type AutoScanStats = {
duplicates: number; duplicates: number;
misses: number; misses: number;
pages: number; pages: number;
elapsedMs: number;
activeScanMs: number;
writeFlushMs: number;
averageMsPerParsed: number;
activeAverageMsPerParsed: number;
artifactsPerMinute: number;
activeArtifactsPerMinute: number;
projectedMsFor100: number;
activeProjectedMsFor100: number;
captureMs: number;
ocrMs: number;
averageCaptureMs: number;
averageOcrMs: number;
captureP50Ms: number;
captureP90Ms: number;
ocrP50Ms: number;
ocrP90Ms: number;
cardReadyMs: number;
cardReadyCount: number;
averageCardReadyMs: number;
scrollReadyMs: number;
scrollReadyCount: number;
averageScrollReadyMs: number;
}; };
export type ScanSummary = AutoScanStats & { export type ScanSummary = AutoScanStats & {
@@ -27,8 +50,103 @@ export const emptyAutoScanStats: AutoScanStats = {
duplicates: 0, duplicates: 0,
misses: 0, misses: 0,
pages: 0, pages: 0,
elapsedMs: 0,
activeScanMs: 0,
writeFlushMs: 0,
averageMsPerParsed: 0,
activeAverageMsPerParsed: 0,
artifactsPerMinute: 0,
activeArtifactsPerMinute: 0,
projectedMsFor100: 0,
activeProjectedMsFor100: 0,
captureMs: 0,
ocrMs: 0,
averageCaptureMs: 0,
averageOcrMs: 0,
captureP50Ms: 0,
captureP90Ms: 0,
ocrP50Ms: 0,
ocrP90Ms: 0,
cardReadyMs: 0,
cardReadyCount: 0,
averageCardReadyMs: 0,
scrollReadyMs: 0,
scrollReadyCount: 0,
averageScrollReadyMs: 0,
}; };
const timingSamples = new WeakMap<AutoScanStats, { captureMs: number[]; ocrMs: number[] }>();
export function updateScanTiming(
stats: AutoScanStats,
startedAt: number,
now = Date.now(),
options: { preserveActiveScanMs?: boolean } = {},
) {
const elapsedMs = Math.max(0, Math.round(now - startedAt));
stats.elapsedMs = elapsedMs;
if (options.preserveActiveScanMs) {
if (!stats.activeScanMs || stats.activeScanMs > elapsedMs) stats.activeScanMs = elapsedMs;
} else {
stats.activeScanMs = elapsedMs;
}
stats.averageMsPerParsed = stats.parsed > 0 ? Math.round(elapsedMs / stats.parsed) : 0;
stats.activeAverageMsPerParsed = stats.parsed > 0 ? Math.round(stats.activeScanMs / stats.parsed) : 0;
stats.artifactsPerMinute = elapsedMs > 0 && stats.parsed > 0
? Math.round((stats.parsed * 60000 / elapsedMs) * 10) / 10
: 0;
stats.activeArtifactsPerMinute = stats.activeScanMs > 0 && stats.parsed > 0
? Math.round((stats.parsed * 60000 / stats.activeScanMs) * 10) / 10
: 0;
stats.projectedMsFor100 = stats.averageMsPerParsed > 0 ? stats.averageMsPerParsed * 100 : 0;
stats.activeProjectedMsFor100 = stats.activeAverageMsPerParsed > 0 ? stats.activeAverageMsPerParsed * 100 : 0;
stats.averageCaptureMs = stats.verified > 0 ? Math.round(stats.captureMs / stats.verified) : 0;
stats.averageOcrMs = stats.verified > 0 ? Math.round(stats.ocrMs / stats.verified) : 0;
const samples = timingSamples.get(stats);
stats.captureP50Ms = percentile(samples?.captureMs, 50);
stats.captureP90Ms = percentile(samples?.captureMs, 90);
stats.ocrP50Ms = percentile(samples?.ocrMs, 50);
stats.ocrP90Ms = percentile(samples?.ocrMs, 90);
stats.averageCardReadyMs = stats.cardReadyCount > 0 ? Math.round(stats.cardReadyMs / stats.cardReadyCount) : 0;
stats.averageScrollReadyMs = stats.scrollReadyCount > 0 ? Math.round(stats.scrollReadyMs / stats.scrollReadyCount) : 0;
return stats;
}
export function addCaptureTiming(
stats: AutoScanStats,
timing?: { totalMs?: number; ocrMs?: number } | null,
) {
if (!timing) return stats;
const captureMs = Math.max(0, Math.round(timing.totalMs ?? 0));
const ocrMs = Math.max(0, Math.round(timing.ocrMs ?? 0));
stats.captureMs += captureMs;
stats.ocrMs += ocrMs;
const samples = timingSamples.get(stats) ?? { captureMs: [], ocrMs: [] };
samples.captureMs.push(captureMs);
samples.ocrMs.push(ocrMs);
timingSamples.set(stats, samples);
return stats;
}
export function addCardReadyTiming(stats: AutoScanStats, elapsedMs: number) {
stats.cardReadyMs += Math.max(0, Math.round(elapsedMs));
stats.cardReadyCount += 1;
return stats;
}
export function addScrollReadyTiming(stats: AutoScanStats, elapsedMs: number) {
stats.scrollReadyMs += Math.max(0, Math.round(elapsedMs));
stats.scrollReadyCount += 1;
return stats;
}
function percentile(values: readonly number[] | undefined, p: number) {
if (!values || values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[index];
}
export function clampScanLimit(value: number) { export function clampScanLimit(value: number) {
return Math.max(1, Math.min(1800, Math.round(value || 1))); return Math.max(1, Math.min(1800, Math.round(value || 1)));
} }
+1 -1
View File
@@ -53,7 +53,7 @@ export function AppPageLayout({ controller }: AppPageLayoutProps) {
overlayIcon={<Eye size={16} />} overlayIcon={<Eye size={16} />}
demoIcon={<Play size={16} />} demoIcon={<Play size={16} />}
/> />
{activeView !== "diagnose" && <AppMetrics metricCards={metricCards} />} {activeView !== "scan" && activeView !== "diagnose" && <AppMetrics metricCards={metricCards} />}
{(activeView === "scan" || activeView === "diagnose") && ( {(activeView === "scan" || activeView === "diagnose") && (
<ScanView <ScanView
mode={activeView === "diagnose" ? "diagnose" : "workspace"} mode={activeView === "diagnose" ? "diagnose" : "workspace"}
+3
View File
@@ -19,6 +19,7 @@ import type {
ScannerCommand, ScannerCommand,
ScannerStatusPayload, ScannerStatusPayload,
ScannerLearningRulePayload, ScannerLearningRulePayload,
KeyPressResult,
} from "../types/global"; } from "../types/global";
import type { AppSnapshot } from "../types/domain"; import type { AppSnapshot } from "../types/domain";
import type { StoredArtifactRecord } from "../types/storage"; import type { StoredArtifactRecord } from "../types/storage";
@@ -57,6 +58,7 @@ export interface AssistantBridge {
focusGenshinForScanStart: () => Promise<FocusGenshinResult>; focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
keyPress: (key: string) => Promise<KeyPressResult>;
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void; onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
} }
@@ -99,6 +101,7 @@ export function getAssistantBridge(): AssistantBridge | null {
focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()), focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()),
clickScreen: (x: number, y: number) => api.clickScreen(x, y), clickScreen: (x: number, y: number) => api.clickScreen(x, y),
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY), scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY),
keyPress: (key: string) => api.keyPress(key),
showOverlay: () => api.showOverlay(), showOverlay: () => api.showOverlay(),
onScannerCommand: (callback) => api.onScannerCommand(callback), onScannerCommand: (callback) => api.onScannerCommand(callback),
}; };
+464 -68
View File
@@ -24,6 +24,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body { body {
margin: 0; margin: 0;
min-width: 1100px; min-width: 1100px;
@@ -187,7 +195,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 260px 1fr;
min-height: 100vh; height: 100vh;
overflow: hidden;
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%), linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%),
linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%); linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%);
@@ -197,6 +206,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
background: rgba(13, 9, 25, 0.72); background: rgba(13, 9, 25, 0.72);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04); box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
@@ -301,8 +312,13 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.main-panel { .main-panel {
padding: 26px; display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
min-height: 0;
min-width: 0; min-width: 0;
overflow: hidden;
padding: 18px;
} }
.topbar { .topbar {
@@ -314,9 +330,9 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.topbar h1 { .topbar h1 {
max-width: 780px; max-width: 780px;
margin: 4px 0 0; margin: 2px 0 0;
color: #fbf8ff; color: #fbf8ff;
font-size: 30px; font-size: 22px;
letter-spacing: 0; letter-spacing: 0;
text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25); text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25);
} }
@@ -334,6 +350,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0;
} }
.topbar-status { .topbar-status {
@@ -347,8 +364,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.ghost-button, .ghost-button,
.primary-button { .primary-button {
height: 40px; height: 36px;
padding: 0 14px; padding: 0 12px;
} }
.ghost-button { .ghost-button {
@@ -450,6 +467,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.content-grid { .content-grid {
display: grid; display: grid;
gap: 14px; gap: 14px;
min-height: 0;
overflow: hidden;
} }
.scan-layout { .scan-layout {
@@ -457,6 +476,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.panel { .panel {
min-height: 0;
overflow: hidden;
padding: 18px; padding: 18px;
} }
@@ -1044,9 +1065,13 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-workbench { .scanner-workbench {
display: grid; display: grid;
gap: 14px; grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px;
min-height: 0;
height: 100%;
width: 100%; width: 100%;
justify-self: stretch; justify-self: stretch;
overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: background:
@@ -1054,7 +1079,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
rgba(18, 12, 35, 0.7); rgba(18, 12, 35, 0.7);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
padding: 18px; padding: 14px;
} }
.scanner-header, .scanner-header,
@@ -1071,7 +1096,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-header h2, .scanner-header h2,
.modal-header h2 { .modal-header h2 {
margin: 3px 0 0; margin: 3px 0 0;
font-size: 22px; font-size: 20px;
} }
.scanner-subcopy { .scanner-subcopy {
@@ -1102,7 +1127,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.source-select { .source-select {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: 6px; gap: 4px;
} }
.source-select span { .source-select span {
@@ -1113,7 +1138,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.source-select select { .source-select select {
width: 100%; width: 100%;
min-height: 42px; min-height: 34px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: rgba(15, 10, 29, 0.78); background: rgba(15, 10, 29, 0.78);
@@ -1155,7 +1180,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
color: var(--danger); color: var(--danger);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
padding: 12px 14px; padding: 10px 12px;
} }
.dev-toggle { .dev-toggle {
@@ -1170,22 +1195,23 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-scan-card { .player-scan-card {
display: grid; display: grid;
gap: 12px; gap: 8px;
min-height: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 8px;
background: rgba(15, 10, 29, 0.6); background: rgba(15, 10, 29, 0.6);
padding: 16px; padding: 10px;
} }
.player-scan-row { .player-scan-row {
display: flex; display: flex;
align-items: end; align-items: end;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 8px;
} }
.player-scan-row .source-select { .player-scan-row .source-select {
min-width: 260px; min-width: 240px;
flex: 1; flex: 1;
} }
@@ -1220,7 +1246,14 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
}
.player-scan-lower {
display: grid;
grid-template-columns: minmax(360px, auto) minmax(360px, 1fr);
gap: 10px; gap: 10px;
align-items: center;
} }
.learning-strip { .learning-strip {
@@ -1259,10 +1292,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.runtime-pill { .runtime-pill {
min-height: 34px; min-height: 30px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 10px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(188, 154, 255, 0.22); border: 1px solid rgba(188, 154, 255, 0.22);
background: rgba(15, 10, 29, 0.55); background: rgba(15, 10, 29, 0.55);
@@ -1284,14 +1317,14 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.scan-cta { .scan-cta {
min-height: 44px; min-height: 36px;
padding: 0 22px; padding: 0 16px;
font-size: 14px; font-size: 14px;
} }
.stop-button { .stop-button {
min-height: 44px; min-height: 36px;
padding: 0 20px; padding: 0 18px;
border: 1px solid rgba(255, 140, 157, 0.5); border: 1px solid rgba(255, 140, 157, 0.5);
border-radius: 10px; border-radius: 10px;
background: rgba(255, 140, 157, 0.14); background: rgba(255, 140, 157, 0.14);
@@ -1310,8 +1343,11 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-status { .player-status {
margin: 0; margin: 0;
color: var(--text-soft); color: var(--text-soft);
font-size: 13px; font-size: 12px;
line-height: 1.5; line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.scanner-preflight { .scanner-preflight {
@@ -1362,11 +1398,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-progress { .player-progress {
display: grid; display: grid;
gap: 8px; gap: 7px;
min-width: 0;
} }
.player-progress-bar { .player-progress-bar {
height: 8px; height: 7px;
border-radius: 999px; border-radius: 999px;
background: rgba(188, 154, 255, 0.12); background: rgba(188, 154, 255, 0.12);
overflow: hidden; overflow: hidden;
@@ -1382,9 +1419,21 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.player-progress-stats { .player-progress-stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 14px; gap: 6px;
color: var(--text-muted); color: var(--text-muted);
font-size: 12px; font-size: 11px;
}
.player-progress-stats span {
display: inline-flex;
align-items: center;
min-height: 26px;
gap: 4px;
padding: 0 8px;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 999px;
background: rgba(8, 6, 18, 0.38);
white-space: nowrap;
} }
.player-progress-stats strong { .player-progress-stats strong {
@@ -1414,7 +1463,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.scanner-settings-modal { .scanner-settings-modal {
width: min(760px, 92vw); width: min(820px, 92vw);
} }
.diagnostics-actions { .diagnostics-actions {
@@ -1427,6 +1476,25 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.settings-preflight {
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(160px, 1.1fr);
}
.settings-preflight p {
align-self: stretch;
display: grid;
align-content: center;
margin: 0;
border: 1px solid rgba(188, 154, 255, 0.12);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
color: #f4efff;
font-size: 15px;
font-weight: 800;
line-height: 1.35;
padding: 10px 12px;
}
.diagnostics-status { .diagnostics-status {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1569,48 +1637,123 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
font-size: 11px; font-size: 11px;
} }
.scan-config-strip { .scan-settings-layout {
display: grid;
gap: 12px;
}
.scan-settings-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-stepper {
display: grid; display: grid;
grid-template-columns: 170px 170px minmax(0, 1fr);
gap: 10px; gap: 10px;
align-items: end;
border: 1px solid rgba(188, 154, 255, 0.14); border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px; border-radius: 8px;
background: rgba(10, 7, 20, 0.36); background: rgba(10, 7, 20, 0.36);
padding: 12px; padding: 12px;
} }
.scan-config-strip label { .settings-stepper-head {
display: grid; min-height: 42px;
gap: 6px;
} }
.scan-config-strip span { .settings-stepper-head div {
display: grid;
gap: 4px;
}
.settings-stepper-head span,
.scan-settings-note strong {
color: var(--text-soft); color: var(--text-soft);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
} }
.scan-config-strip input { .settings-stepper-head small,
min-height: 38px; .scan-settings-note span {
color: var(--text-muted);
font-size: 12px;
line-height: 1.35;
}
.settings-stepper-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
gap: 8px;
}
.settings-stepper-row input {
min-width: 0;
min-height: 44px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: rgba(15, 10, 29, 0.78); background: rgba(15, 10, 29, 0.78);
color: #f4efff; color: #f4efff;
padding: 0 10px; padding: 0 12px;
font-size: 18px;
font-weight: 800;
text-align: center;
outline: none; outline: none;
} }
.scan-config-strip input:focus { .settings-stepper-row input:focus {
border-color: rgba(126, 231, 242, 0.52); border-color: rgba(126, 231, 242, 0.52);
box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08);
} }
.scan-config-strip p { .settings-stepper-row input::-webkit-outer-spin-button,
.settings-stepper-row input::-webkit-inner-spin-button {
margin: 0; margin: 0;
color: var(--text-muted); appearance: none;
}
.stepper-button,
.settings-presets button {
min-height: 44px;
border: 1px solid rgba(188, 154, 255, 0.22);
border-radius: 8px;
background: rgba(188, 154, 255, 0.08);
color: #f4efff;
font-weight: 900;
cursor: pointer;
}
.stepper-button {
font-size: 22px;
line-height: 1;
}
.stepper-button:hover,
.stepper-button:focus-visible,
.settings-presets button:hover,
.settings-presets button:focus-visible {
border-color: rgba(126, 231, 242, 0.45);
background: rgba(126, 231, 242, 0.12);
}
.settings-presets {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.settings-presets button {
min-height: 34px;
color: var(--text-soft);
font-size: 12px; font-size: 12px;
line-height: 1.45; }
.scan-settings-note {
display: grid;
gap: 4px;
border: 1px solid rgba(126, 231, 242, 0.16);
border-radius: 8px;
background: rgba(126, 231, 242, 0.06);
padding: 11px 12px;
} }
.grid-detection-strip { .grid-detection-strip {
@@ -1682,23 +1825,142 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.scan-evidence-timeline {
display: grid;
gap: 10px;
max-height: 520px;
overflow-y: auto;
padding-right: 4px;
}
.scan-evidence-event {
display: grid;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid rgba(255, 255, 255, 0.24);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
padding: 10px;
}
.scan-evidence-event.ok {
border-left-color: var(--mint);
}
.scan-evidence-event.warn {
border-left-color: var(--amber);
}
.scan-evidence-event.error {
border-left-color: var(--danger);
}
.scan-evidence-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.scan-evidence-header span,
.scan-evidence-header em {
color: var(--text-muted);
font-size: 11px;
font-style: normal;
font-weight: 800;
text-transform: uppercase;
}
.scan-evidence-header strong {
color: var(--text);
font-size: 13px;
}
.scan-evidence-event p {
margin: 0;
color: var(--text-soft);
font-size: 12px;
line-height: 1.35;
}
.scan-evidence-details {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.scan-evidence-details span {
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 999px;
background: rgba(0, 0, 0, 0.16);
color: var(--text-muted);
font-size: 11px;
padding: 4px 7px;
}
.scan-evidence-capture {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(220px, 1.2fr);
gap: 10px;
align-items: start;
}
.scan-evidence-capture > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.scan-evidence-capture strong {
color: var(--text);
font-size: 12px;
}
.scan-evidence-capture span {
color: var(--text-muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.scan-evidence-capture .evidence-warning {
color: var(--amber);
}
.scan-evidence-images {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.scan-evidence-images img {
width: 100%;
max-height: 160px;
object-fit: contain;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
}
.scanner-main-grid { .scanner-main-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); grid-template-columns: minmax(190px, 210px) minmax(520px, 1fr);
gap: 14px; gap: 12px;
align-items: stretch; align-items: start;
} }
.capture-stage-shell { .capture-stage-shell {
display: grid; display: grid;
gap: 10px; gap: 8px;
min-width: 0; min-width: 0;
} }
.capture-stage { .capture-stage {
display: grid; display: grid;
min-height: clamp(440px, 58vh, 700px); width: 100%;
max-height: 720px; aspect-ratio: 41 / 80;
min-height: 0;
max-height: 410px;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(188, 154, 255, 0.16); border: 1px solid rgba(188, 154, 255, 0.16);
@@ -1706,19 +1968,31 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
background: rgba(5, 4, 12, 0.72); background: rgba(5, 4, 12, 0.72);
} }
.capture-stage-shell.is-empty .capture-stage {
aspect-ratio: 41 / 80;
max-height: 410px;
}
.capture-stage-shell.has-capture .capture-stage {
background:
radial-gradient(circle at 50% 34%, rgba(126, 231, 242, 0.08), transparent 44%),
rgba(5, 4, 12, 0.78);
}
.capture-stage img { .capture-stage img {
display: block; display: block;
width: auto; width: auto;
height: auto; height: auto;
max-width: min(100%, 980px); max-width: 100%;
max-height: min(100%, 680px); max-height: 100%;
object-fit: contain; object-fit: contain;
object-position: center top;
} }
.capture-stage-meta { .capture-stage-meta {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 6px;
} }
.capture-stage-meta div { .capture-stage-meta div {
@@ -1727,7 +2001,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
border: 1px solid rgba(188, 154, 255, 0.14); border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px; border-radius: 8px;
background: rgba(8, 6, 18, 0.34); background: rgba(8, 6, 18, 0.34);
padding: 10px 12px; padding: 7px 8px;
} }
.capture-stage-meta span { .capture-stage-meta span {
@@ -1738,36 +2012,45 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.capture-stage-meta strong { .capture-stage-meta strong {
color: #f4efff; color: #f4efff;
font-size: 12px; font-size: 11px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.empty-stage { .empty-stage {
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center;
gap: 8px; gap: 8px;
padding: 18px;
color: var(--text-soft); color: var(--text-soft);
font-size: 12px;
line-height: 1.35;
text-align: center; text-align: center;
} }
.empty-stage strong { .empty-stage strong {
color: #f4efff; color: #f4efff;
font-size: 14px;
}
.empty-stage span {
max-width: 22ch;
} }
.scanner-result-panel { .scanner-result-panel {
display: grid; display: grid;
align-content: start; align-content: start;
max-width: 360px; max-width: none;
gap: 12px; gap: 10px;
border: 1px solid rgba(126, 231, 242, 0.18); border: 1px solid rgba(126, 231, 242, 0.18);
border-radius: 8px; border-radius: 8px;
background: rgba(126, 231, 242, 0.07); background: rgba(126, 231, 242, 0.07);
padding: 14px; padding: 12px;
} }
.result-heading h3 { .result-heading h3 {
margin: 3px 0 0; margin: 3px 0 0;
font-size: 18px; font-size: 17px;
} }
.result-score { .result-score {
@@ -1798,8 +2081,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-result-brief span { .scanner-result-brief span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 30px; min-height: 28px;
padding: 0 10px; padding: 0 9px;
border: 1px solid rgba(188, 154, 255, 0.14); border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 999px; border-radius: 999px;
background: rgba(8, 6, 18, 0.38); background: rgba(8, 6, 18, 0.38);
@@ -1811,7 +2094,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
.scanner-result-actions { .scanner-result-actions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 8px;
} }
.scanner-result-caption { .scanner-result-caption {
@@ -2045,6 +2328,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.player-scan-lower {
grid-template-columns: 1fr;
}
.scanner-toolbar { .scanner-toolbar {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@@ -2054,12 +2341,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
justify-content: flex-start; justify-content: flex-start;
} }
.scan-config-strip { .scan-settings-controls {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr;
} }
.scan-config-strip p { .settings-preflight {
grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.capture-stage-meta { .capture-stage-meta {
@@ -2068,7 +2355,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
.capture-stage img { .capture-stage img {
max-height: 640px; max-height: 100%;
} }
button:disabled { button:disabled {
@@ -2186,6 +2473,11 @@ button:disabled {
.diagnose-view { .diagnose-view {
display: grid; display: grid;
gap: 14px; gap: 14px;
min-height: 0;
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
width: 100%; width: 100%;
} }
@@ -2230,9 +2522,113 @@ button:disabled {
gap: 12px; gap: 12px;
} }
.diagnose-card-heading h3 {
margin: 2px 0 0;
font-size: 17px;
}
.app-diagnosis-card {
gap: 12px;
}
.diagnosis-source {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 999px;
padding: 7px 10px;
color: var(--text-soft);
font-size: 12px;
white-space: nowrap;
}
.app-diagnosis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.app-diagnosis-section {
display: grid;
gap: 8px;
min-width: 0;
border-left: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
padding: 11px 12px;
}
.app-diagnosis-section.ok {
border-left-color: var(--mint);
}
.app-diagnosis-section.warn {
border-left-color: var(--amber);
}
.app-diagnosis-section.risk {
border-left-color: #ff6b8a;
}
.app-diagnosis-section.next {
border-left-color: var(--cyan);
}
.app-diagnosis-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--text);
}
.app-diagnosis-title strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-diagnosis-section ul {
display: grid;
gap: 7px;
margin: 0;
padding-left: 16px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.35;
}
.app-diagnosis-section li::marker {
color: rgba(255, 255, 255, 0.45);
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.diagnose-grid { .diagnose-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-diagnosis-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 720px) {
.scan-evidence-capture {
grid-template-columns: 1fr;
}
.scan-evidence-images {
grid-template-columns: 1fr;
}
.app-diagnosis-grid {
grid-template-columns: 1fr;
}
.diagnosis-source {
width: 100%;
justify-content: center;
}
}
+76 -1
View File
@@ -3,6 +3,18 @@ import type { StoredArtifactRecord } from "./storage";
export interface CaptureOptions { export interface CaptureOptions {
skipOcr?: boolean; skipOcr?: boolean;
skipOcrUnlessArtifactDetail?: boolean;
ocrMode?: "full" | "artifact";
ocrProfile?: "full" | "fast";
ocrEngine?: "current" | "native-tesseract" | "benchmark" | "ik-traineddata";
scanEntryMode?: "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
omitFullFrame?: boolean;
omitDetailPreview?: boolean;
omitInventoryPreview?: boolean;
omitCrops?: boolean;
omitCropImages?: boolean;
omitEquippedOcr?: boolean;
omitLockState?: boolean;
} }
export interface CaptureSourceInfo { export interface CaptureSourceInfo {
@@ -16,7 +28,7 @@ export interface CaptureCrop {
id: string; id: string;
label: string; label: string;
rect: { x: number; y: number; width: number; height: number }; rect: { x: number; y: number; width: number; height: number };
dataUrl: string; dataUrl?: string;
} }
export interface OcrResult { export interface OcrResult {
@@ -24,6 +36,7 @@ export interface OcrResult {
label: string; label: string;
text: string; text: string;
confidence: number; confidence: number;
elapsedMs?: number;
} }
export type CaptureTarget = "genshin-client" | "primary-screen" | "desktop-source"; export type CaptureTarget = "genshin-client" | "primary-screen" | "desktop-source";
@@ -38,6 +51,8 @@ export interface CaptureResult {
captureTarget?: CaptureTarget; captureTarget?: CaptureTarget;
detailDataUrl?: string; detailDataUrl?: string;
inventoryDataUrl?: string; inventoryDataUrl?: string;
detailFingerprint?: string;
inventoryFingerprint?: string;
ocrSkipped?: boolean; ocrSkipped?: boolean;
ocrTimedOut?: boolean; ocrTimedOut?: boolean;
crops?: CaptureCrop[]; crops?: CaptureCrop[];
@@ -49,6 +64,23 @@ export interface CaptureResult {
confidence: number; confidence: number;
source: "detected" | "fallback" | "missing"; source: "detected" | "fallback" | "missing";
}; };
artifactDetail?: {
present: boolean;
confidence: number;
orangeHits: number;
greenHits: number;
textHits: number;
titleOrangeHits?: number;
upperTextHits?: number;
lowerGreenHits?: number;
};
paimonMenu?: {
present: boolean;
confidence: number;
profileLightPct: number;
profileCreamPct: number;
menuTileDarkPct: number;
};
inventoryCount?: { inventoryCount?: {
current: number; current: number;
total: number; total: number;
@@ -57,11 +89,23 @@ export interface CaptureResult {
text: string; text: string;
}; };
locked?: boolean; locked?: boolean;
sanctified?: boolean;
layout?: { layout?: {
aspect: string; aspect: string;
isSixteenNine: boolean; isSixteenNine: boolean;
warning: string; warning: string;
}; };
timings?: {
totalMs: number;
prepareMs: number;
ocrMs: number;
cropCount: number;
ocrEngine: CaptureOptions["ocrEngine"];
ocrWorkerPoolSize?: number;
ocrProfile?: CaptureOptions["ocrProfile"];
ocrFieldMs?: Record<string, number>;
ocrSkipped: boolean;
};
} }
export interface WindowBounds { export interface WindowBounds {
@@ -97,6 +141,7 @@ export interface RuntimeInfo {
ok: boolean; ok: boolean;
isElevated: boolean; isElevated: boolean;
platform: string; platform: string;
appBuild?: AppRuntimeInfo;
hotkeys?: Record<string, boolean>; hotkeys?: Record<string, boolean>;
genshinFound?: boolean; genshinFound?: boolean;
genshinHwnd?: number; genshinHwnd?: number;
@@ -106,6 +151,15 @@ export interface RuntimeInfo {
helperPid?: number; helperPid?: number;
} }
export interface AppRuntimeInfo {
signature: string;
pid: number;
startedAt: string;
cwd: string;
isDev: boolean;
expectedOcrWorkerPoolSize?: number;
}
export interface FocusGenshinResult { export interface FocusGenshinResult {
focused: boolean; focused: boolean;
alreadyForeground: boolean; alreadyForeground: boolean;
@@ -132,6 +186,8 @@ export type ScannerCommand =
| { | {
type: "start-auto"; type: "start-auto";
scanLimit?: number; scanLimit?: number;
scanEntryMode?: CaptureOptions["scanEntryMode"];
ocrEngine?: CaptureOptions["ocrEngine"];
}; };
export interface ScannerLearningRulePayload { export interface ScannerLearningRulePayload {
@@ -184,6 +240,17 @@ export interface ScrollResult {
isElevated?: boolean; isElevated?: boolean;
} }
export interface KeyPressResult {
ok: boolean;
key: string;
focused?: boolean;
foregroundProcess?: string;
targetProcess?: string;
isElevated?: boolean;
inputBlocked?: boolean;
eventsSent?: number;
}
export type ArtifactSaveResult = ArtifactStoreSaveResult; export type ArtifactSaveResult = ArtifactStoreSaveResult;
export interface ArtifactStoreLoadResult { export interface ArtifactStoreLoadResult {
@@ -267,9 +334,16 @@ export interface ScannerStatusPayload {
snapshotBuilds: number; snapshotBuilds: number;
grid: CaptureResult["inventoryGrid"] | null; grid: CaptureResult["inventoryGrid"] | null;
automationLog: string[]; automationLog: string[];
diagnosticEvents?: unknown[];
runtimeInfo: RuntimeInfo | null; runtimeInfo: RuntimeInfo | null;
appBuild?: AppRuntimeInfo;
storedTotal: number | null; storedTotal: number | null;
learningRuleCount: number; learningRuleCount: number;
lookupStatus?: unknown;
ocrEngine?: CaptureOptions["ocrEngine"];
ocrWarmup?: unknown;
entryMode?: CaptureOptions["scanEntryMode"];
benchmarkSummary?: unknown;
updatedAt: string | null; updatedAt: string | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -309,6 +383,7 @@ declare global {
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>; captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
keyPress: (key: string) => Promise<KeyPressResult>;
getAutomationGuard: () => Promise<AutomationGuard>; getAutomationGuard: () => Promise<AutomationGuard>;
focusMainWindow: () => Promise<BooleanResult>; focusMainWindow: () => Promise<BooleanResult>;
focusGenshin: () => Promise<FocusGenshinResult>; focusGenshin: () => Promise<FocusGenshinResult>;
+3
View File
@@ -6,6 +6,9 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
watch: {
ignored: ["**/outputs/**"],
},
}, },
build: { build: {
outDir: "dist", outDir: "dist",