From f791d1464cc33952178323b050fd01e7c03ba96d Mon Sep 17 00:00:00 2001 From: AzuTear Date: Tue, 7 Jul 2026 22:02:24 +0200 Subject: [PATCH 1/2] Improve IK-style artifact scanner pipeline --- .gitignore | 3 + docs/ARCHITECTURE.md | 20 +- docs/AUTOMATION_LIVE_SCAN.md | 283 +++- docs/CHECKLISTS.md | 13 + docs/DECISIONS.md | 47 + docs/PROJECT.md | 16 +- docs/ocr-eval.md | 3 + docs/scanner-ik-progress-report.md | 301 ++++ docs/scanner-rework-status.md | 91 +- electron/bootstrap/ipcBootstrap.ts | 3 + electron/devControlServer.ts | 180 ++- electron/ipc/captureHandlers.ts | 5 +- electron/main.ts | 674 +++++++- electron/preload.cjs | 1 + electron/preload.ts | 1 + .../repositories/reviewSamplesRepository.ts | 27 +- electron/services/inputHelper.ts | 58 + native/input-helper/Program.cs | 42 + package.json | 6 + scripts/dev-admin-start.ps1 | 16 + scripts/generate-genshin-data.cjs | 106 ++ scripts/kill-stale-instances.ps1 | 59 +- scripts/live-soak.ps1 | 685 ++++++++ src/data/genshinGameData.json | 1430 ++++++++++++++++- .../scan/components/DiagnosticsView.tsx | 142 +- .../scan/components/ScanMainSection.tsx | 20 +- .../components/ScanTopControlsSection.tsx | 84 +- .../hooks/useScanMainSectionModel.ts | 10 - .../hooks/useScanSummaryFooterModel.ts | 14 +- .../hooks/useScanTopControlsModel.ts | 24 +- .../components/modals/ScanSettingsModal.tsx | 80 +- .../modals/hooks/useScanDetailsModalModel.ts | 23 +- .../hooks/useScanDiagnosticsModalModel.ts | 48 +- .../modals/hooks/useScanSettingsModalModel.ts | 123 +- .../scan/hooks/scanViewControllerService.ts | 9 +- .../scan/hooks/scanViewReviewHelpers.ts | 65 +- .../scan/hooks/scanViewScanActions.ts | 353 +++- .../scan/hooks/useScanCommandListener.ts | 13 +- .../scan/hooks/useScanSnapshotPublisher.ts | 29 + src/features/scan/hooks/useScanViewActions.ts | 72 +- .../scan/hooks/useScanViewController.ts | 25 +- src/features/scan/types.ts | 3 + .../rendererBridgeRepositoryFactory.ts | 6 + .../rendererBridgeRepositoryTypes.ts | 2 + src/lib/artifactOcrParser.test.ts | 91 ++ src/lib/artifactOcrParser.ts | 57 +- src/lib/autoScanEntry.test.ts | 93 ++ src/lib/autoScanEntry.ts | 89 + src/lib/autoScanLoop.test.ts | 462 +++++- src/lib/autoScanLoop.ts | 373 ++++- src/lib/automationPlanner.test.ts | 26 +- src/lib/cardReadyGate.test.ts | 15 + src/lib/cardReadyGate.ts | 11 +- src/lib/genshinData.ts | 21 + src/lib/genshinLookup.test.ts | 48 + src/lib/genshinLookup.ts | 211 +++ src/lib/layoutProfile.test.ts | 38 +- src/lib/layoutProfile.ts | 82 +- src/lib/ocrPreprocess.test.ts | 12 + src/lib/ocrPreprocess.ts | 17 +- src/lib/scanDiagnosticsLog.ts | 132 ++ src/lib/scannerLearning.test.ts | 46 + src/lib/scannerLearning.ts | 20 +- src/lib/scannerSession.test.ts | 59 +- src/lib/scannerSession.ts | 118 ++ src/pages/app/AppPageLayout.tsx | 2 +- src/services/assistantBridge.ts | 3 + src/styles/global.css | 532 +++++- src/types/global.d.ts | 77 +- vite.config.ts | 3 + 70 files changed, 7408 insertions(+), 445 deletions(-) create mode 100644 docs/scanner-ik-progress-report.md create mode 100644 scripts/live-soak.ps1 create mode 100644 src/lib/autoScanEntry.test.ts create mode 100644 src/lib/autoScanEntry.ts create mode 100644 src/lib/genshinLookup.test.ts create mode 100644 src/lib/genshinLookup.ts create mode 100644 src/lib/scanDiagnosticsLog.ts diff --git a/.gitignore b/.gitignore index ccbf4ed..46dda82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ dist/ dist-electron/ outputs/dist/ +outputs/admin-start/ +outputs/live-capture/ +outputs/live-soak/ # Logs *.log diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7910b29..41f6188 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,6 +55,9 @@ flowchart LR | `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/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/demoData.ts` | Temporary local demo snapshot | | `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). - 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". +- 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). - 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 -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. diff --git a/docs/AUTOMATION_LIVE_SCAN.md b/docs/AUTOMATION_LIVE_SCAN.md index c63daf5..f8dd59d 100644 --- a/docs/AUTOMATION_LIVE_SCAN.md +++ b/docs/AUTOMATION_LIVE_SCAN.md @@ -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 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 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" ``` +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: ```powershell @@ -105,10 +171,221 @@ Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" | 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 temporary scan limit. It does not change the normal UI setting. The normal 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// +``` + +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 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: - 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` - second tile center: `x=325`, `y=254`, `row=0`, `col=1` - 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`. 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`. +8. For IK-target claims, attach or cite `scan-performance-assessment.json` from + a non-stale `npm run scan:goal:compare` run. diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md index 3678290..600848e 100644 --- a/docs/CHECKLISTS.md +++ b/docs/CHECKLISTS.md @@ -4,6 +4,7 @@ - [ ] The expected crop or capture behavior is clear. - [ ] 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. - [ ] OCR uncertainty remains inspectable in Details. - [ ] Parser output does not silently trust low-confidence text. @@ -12,6 +13,18 @@ - [ ] `npm run build` passes. - [ ] 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 - [ ] The main workflow remains visible without unnecessary scrolling. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9980791..a35e08f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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-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-011 | Quality-gated Inventory Kamera comparison before OCR default changes | Accepted | 2026-07-07 | ## 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 reads, hooks, injection, game-file modification, deleting, feeding, enhancing, 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. diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 1206ddf..eb545f6 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -3,6 +3,8 @@ 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 the latest Inventory-Kamera comparison work, see +[scanner-ik-progress-report.md](scanner-ik-progress-report.md). ## 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 | | 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 | | 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` | @@ -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. - 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 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: `/automation/probe-click?index=1` changed the selected artifact and `/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. - 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. +- 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 @@ -233,8 +242,11 @@ Status: 2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples. 3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules. 4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions. -5. Extend the learning system from text-only fixes into crop/UI profile tuning. -6. Resume recommendation work only when scan accuracy is consistently trustworthy. +5. Run `npm run scan:goal:compare` after `/health.appBuild.signature` matches + 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 diff --git a/docs/ocr-eval.md b/docs/ocr-eval.md index 5273adb..d29e967 100644 --- a/docs/ocr-eval.md +++ b/docs/ocr-eval.md @@ -2,12 +2,15 @@ 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). +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 ```powershell npm run eval # full accuracy report for the seed corpus 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 diff --git a/docs/scanner-ik-progress-report.md b/docs/scanner-ik-progress-report.md new file mode 100644 index 0000000..d5bd5b4 --- /dev/null +++ b/docs/scanner-ik-progress-report.md @@ -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. diff --git a/docs/scanner-rework-status.md b/docs/scanner-rework-status.md index 65fe1df..d9dbd08 100644 --- a/docs/scanner-rework-status.md +++ b/docs/scanner-rework-status.md @@ -5,6 +5,22 @@ Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in live automation runbook, see [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) - **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 with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records, 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 @@ -46,12 +118,27 @@ resolution or without UI work best tested live: 1. **Validate/tune OCR preprocessing** on more real captures — confirm invert + threshold + upscale factor help (not hurt) actual Tesseract reads. The text-level eval harness cannot measure image preprocessing. -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. -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 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 diff --git a/electron/bootstrap/ipcBootstrap.ts b/electron/bootstrap/ipcBootstrap.ts index 3e290e2..05802cf 100644 --- a/electron/bootstrap/ipcBootstrap.ts +++ b/electron/bootstrap/ipcBootstrap.ts @@ -14,6 +14,7 @@ import type { ClickResult, AutomationGuard, ScrollResult, + KeyPressResult, SaveResultWithPath, SaveSnapshotResult, GoodDatabase, @@ -60,6 +61,7 @@ interface CaptureHandlersDependencies { captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; clickScreen: (x: number, y: number) => Promise; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + keyPress: (key: string) => Promise; getAutomationGuard: () => Promise; } @@ -96,6 +98,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) { captureSource: dependencies.captureSource, clickScreen: dependencies.clickScreen, scrollScreen: dependencies.scrollScreen, + keyPress: dependencies.keyPress, getAutomationGuard: dependencies.getAutomationGuard, }); } diff --git a/electron/devControlServer.ts b/electron/devControlServer.ts index bd9cebe..d672ea3 100644 --- a/electron/devControlServer.ts +++ b/electron/devControlServer.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import http, { type Server } from "node:http"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import type { CaptureOptions, CaptureResult, @@ -9,14 +11,20 @@ import type { ReviewSampleListResult, ScannerCommand, ScannerStatusPayload, + AppRuntimeInfo, } from "../src/types/global.js"; +import { validateLookupPackage } from "../src/lib/genshinLookup.js"; + +const execFileAsync = promisify(execFile); interface DevControlServerDependencies { registeredHotkeys: Record; + appBuild: AppRuntimeInfo; hasMainWindow: () => boolean; sendScannerCommand: (command: ScannerCommand | "probe-click") => void; clickScreen: (x: number, y: number) => Promise; scannerStatus: () => ScannerStatusPayload; + warmOcr: (engine: "current" | "ik-traineddata") => Promise; loadReviewSamples: (limit?: number) => Promise; listCaptureSources: () => Promise; captureSource: ( @@ -25,6 +33,7 @@ interface DevControlServerDependencies { focusGenshin?: boolean, options?: CaptureOptions, ) => Promise; + requestShutdown?: (reason: string) => void; } 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"); 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; } if (url.pathname === "/scanner/start") { const limit = Number(url.searchParams.get("limit") ?? Number.NaN); - const command: ScannerCommand = Number.isFinite(limit) && limit > 0 - ? { type: "start-auto", scanLimit: limit } + const entry = url.searchParams.get("entry"); + 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"; deps.sendScannerCommand(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() }); 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; + }> = []; + 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>((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") { deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20)) .then((payload: unknown) => writeDevJson(res, 200, payload)) diff --git a/electron/ipc/captureHandlers.ts b/electron/ipc/captureHandlers.ts index 3e03b32..f2d46e9 100644 --- a/electron/ipc/captureHandlers.ts +++ b/electron/ipc/captureHandlers.ts @@ -1,11 +1,12 @@ 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 { listSources: () => Promise; captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; clickScreen: (x: number, y: number) => Promise; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + keyPress: (key: string) => Promise; getAutomationGuard: () => Promise; } @@ -14,6 +15,7 @@ export function registerCaptureHandlers({ captureSource, clickScreen, scrollScreen, + keyPress, getAutomationGuard, }: CaptureCommandDependencies) { 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: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()); } diff --git a/electron/main.ts b/electron/main.ts index e056258..dbf263f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,9 +2,10 @@ import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nati import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import type { Server } from "node:http"; +import { cpus } from "node:os"; import path from "node:path"; 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 { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; @@ -15,6 +16,8 @@ import type { CaptureResult, GoodDatabase, GoodImportFileResult, + OcrResult, + AppRuntimeInfo, SaveResultWithPath, ScannerCommand, ScannerLearningRulePayload, @@ -50,6 +53,8 @@ app.commandLine.appendSwitch("disable-gpu-sandbox"); const __dirname = path.dirname(fileURLToPath(import.meta.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 overlayWindow: BrowserWindow | null = null; @@ -211,6 +216,7 @@ async function readRuntimeInfo() { ok: true, isElevated: result.isElevated, platform: result.platform, + appBuild: appRuntimeInfo(), hotkeys: registeredHotkeys, genshinFound: result.genshinFound, genshinHwnd: result.genshinHwnd ?? undefined, @@ -220,7 +226,7 @@ async function readRuntimeInfo() { helperPid: result.helperPid, }; } 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) { - 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) { - 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() { @@ -439,13 +473,19 @@ function startDevControlServer() { if (!isDev || devControlServer) return; devControlServer = createDevControlServer({ registeredHotkeys, + appBuild: appRuntimeInfo(), hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()), sendScannerCommand, clickScreen: clickScreenCommand, - scannerStatus: () => scannerDevStatus, + scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }), + warmOcr: (engine) => warmOcrWorkerPool(engine), loadReviewSamples, listCaptureSources, captureSource, + requestShutdown: (reason) => { + console.log(`[dev-control] shutdown requested: ${reason}`); + app.quit(); + }, }); } @@ -489,60 +529,284 @@ function createOverlayWindow() { }); } -function dataUrlToBuffer(dataUrl: string) { - const base64 = dataUrl.replace(/^data:image\/png;base64,/, ""); - return Buffer.from(base64, "base64"); -} +// Inventory Kamera keeps a pool of native Tesseract engines and scans artifact +// fields concurrently. Our fast artifact profile has four useful OCR parameter +// 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"; -// One shared OCR worker. Creating a Tesseract worker per capture added ~1s -// to every artifact during batch scans. -let ocrWorkerPromise: ReturnType | null = null; +type OcrWorker = Awaited>; +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 | null; + runQueue: Promise; +}; -function getOcrWorker() { - if (!ocrWorkerPromise) { - ocrWorkerPromise = createWorker("eng"); +const ocrWorkerPools: Record = { + current: { poolPromise: null, runQueue: Promise.resolve() }, + "ik-traineddata": { poolPromise: null, runQueue: Promise.resolve() }, +}; +const ocrWarmupStatuses: Record = { + 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))); } - return ocrWorkerPromise; + const logicalCores = cpus().length || 4; + return Math.max(2, Math.min(4, logicalCores - 1)); } -async function resetOcrWorker() { - const broken = ocrWorkerPromise; - ocrWorkerPromise = null; - if (broken) { - try { - const worker = await broken; - await worker.terminate(); - } catch { - // Worker never initialized; nothing to clean up. +function appRuntimeInfo(): AppRuntimeInfo { + return { + signature: APP_RUNTIME_SIGNATURE, + pid: process.pid, + startedAt: APP_RUNTIME_STARTED_AT, + cwd: process.cwd(), + isDev, + expectedOcrWorkerPoolSize: OCR_WORKER_POOL_SIZE, + }; +} + +function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine { + return options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current"; +} + +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) { + try { + const pool = await broken; + await Promise.all(pool.map((slot) => slot.worker.terminate().catch(() => undefined))); + } catch { + // 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) { + const params = ocrParametersForCrop(cropId); + return `${params.tessedit_pageseg_mode}:${params.tessedit_char_whitelist}`; +} + +async function applyOcrParameters(slot: OcrWorkerSlot, cropId: string) { + const params = ocrParametersForCrop(cropId); + const key = ocrParametersKey(cropId); + if (key === slot.parametersKey) return; + await slot.worker.setParameters(params); + 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 worker = await getOcrWorker(); - const results = []; - for (const crop of crops) { - const recognized = await worker.recognize(dataUrlToBuffer(crop.dataUrl)); - results.push({ - id: crop.id, - label: crop.label, - text: cleanOcrText(crop.id, recognized.data.text), - confidence: Math.round(recognized.data.confidence), - }); - } + 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; } catch (error) { - await resetOcrWorker(); + await resetOcrWorker(engine); throw error; } } -async function runOcrOnCropsWithTimeout(crops: Array<{ id: string; label: string; dataUrl: string }>, timeoutMs = 6500) { +async function recognizeCropWithSlot(slot: OcrWorkerSlot, crop: OcrCropPayload): Promise { + 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; try { return await Promise.race([ - runOcrOnCrops(crops).then((ocr) => ({ ocr, timedOut: false })), + runQueuedOcrOnCrops(crops, engine).then((ocr) => ({ ocr, timedOut: false })), new Promise<{ ocr: Awaited>; timedOut: boolean }>((resolve) => { 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; } - if (cropId === "artifact-title") { + if (cropId === "artifact-title" || cropId === "artifact-name" || cropId === "artifact-slot" || cropId === "artifact-main-stat-label") { return normalized .filter((line) => /[A-Za-z]/.test(line)) .slice(0, 2) @@ -583,10 +847,23 @@ function cleanOcrText(cropId: string, text: string) { .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") { return normalized .filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line)) - .slice(0, 5) + .slice(0, 6) .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; } +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) { return new Promise((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(); } +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 // grayscale + Otsu-binarize with inversion (artifact text is the bright // 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 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(); - if (!size.width || !size.height) return upscaled.toDataURL(); - const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height }); + if (!size.width || !size.height) return upscaled.toPNG(); + const binarized = binarizeForOcr( + { data: upscaled.getBitmap(), width: size.width, height: size.height }, + cropId === "artifact-level" ? { contrast: 80 } : {}, + ); return nativeImage .createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height }) - .toDataURL(); + .toPNG(); } function createCrops( @@ -727,10 +1189,27 @@ function createCrops( imageSize: { width: number; height: number }, detailRect: 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({ id: "inventory-count", label: "Inventory count", @@ -741,12 +1220,14 @@ function createCrops( return templates .map((template) => { const rect = clampCaptureRect(template.rect, imageSize); + const ocrEnabled = !skipCropOcr; return { id: template.id, label: template.label, rect, - dataUrl: imageCropDataUrl(sourceImage, rect, imageSize), - ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize), + dataUrl: omitCropImages ? undefined : imageCropDataUrl(sourceImage, rect, imageSize), + ocrImage: ocrEnabled ? preprocessedCropPngBuffer(sourceImage, rect, imageSize, template.id) : undefined, + ocrEnabled, }; }) .filter((crop) => crop.rect.width > 0 && crop.rect.height > 0); @@ -815,6 +1296,7 @@ async function buildCaptureResult( captureTarget: CaptureResult["captureTarget"], options: CaptureOptions = {}, ) { + const buildStartedAt = Date.now(); const size = sourceImage.getSize(); if (!size.width || !size.height) { throw new Error("Capture produced an empty image."); @@ -822,22 +1304,52 @@ async function buildCaptureResult( const bitmap = sourceImage.getBitmap(); 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 crops = createCrops(sourceImage, size, detailRect, inventoryRect); - const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size); - const lockImage = sourceImage.crop(lockRect); - const lockSize = lockImage.getSize(); - const locked = lockSize.width > 0 && lockSize.height > 0 - ? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height }) - : undefined; - const croppedPayload = crops.map((crop) => ({ - id: crop.id, - label: crop.label, - // OCR reads the preprocessed (upscaled + binarized) crop; the original is - // kept below for the diagnostics UI. - dataUrl: crop.ocrDataUrl ?? crop.dataUrl, - })); - const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload); + 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 lockImage = sourceImage.crop(lockRect); + const lockSize = lockImage.getSize(); + return lockSize.width > 0 && lockSize.height > 0 + ? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height }) + : undefined; + })(); + 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, + label: crop.label, + image: crop.ocrImage, + } + : null; + }) + .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); return { @@ -845,14 +1357,16 @@ async function buildCaptureResult( name: sourceName, width: size.width, height: size.height, - dataUrl: sourceImage.toDataURL(), + dataUrl: omitFullFrame ? "" : sourceImage.toDataURL(), capturedAt: new Date().toISOString(), captureTarget, - detailDataUrl: imageCropDataUrl(sourceImage, detailRect, size), - inventoryDataUrl: imageCropDataUrl(sourceImage, inventoryRect, size), + detailDataUrl: omitDetailPreview ? undefined : imageCropDataUrl(sourceImage, detailRect, size), + inventoryDataUrl: omitInventoryPreview ? undefined : imageCropDataUrl(sourceImage, inventoryRect, size), + detailFingerprint, + inventoryFingerprint, ocr: recognized.ocr, ocrTimedOut: recognized.timedOut, - ocrSkipped: Boolean(options.skipOcr), + ocrSkipped: shouldSkipOcr, crops: crops.map((crop) => ({ id: crop.id, label: crop.label, @@ -865,13 +1379,30 @@ async function buildCaptureResult( dataUrl: crop.dataUrl, })), inventoryGrid: inferInventoryGrid(size, detailRect), + artifactDetail, + paimonMenu, inventoryCount: count, locked, + sanctified, layout: { aspect: aspectRatioLabel(size), isSixteenNine: isSixteenNine(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>((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), clickScreen: (x: number, y: number) => clickScreenCommand(x, y), scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => scrollScreenCommand(notches, anchorX, anchorY), + keyPress: (key: string) => keyPressCommand(key), getAutomationGuard: () => getAutomationGuardCommand(), }); createMainWindow(); registerScannerHotkeys(); startDevControlServer(); + void warmOcrWorkerPool("current"); }); app.on("activate", () => { @@ -1025,4 +1558,3 @@ function initializeAppLifecycle() { } initializeAppLifecycle(); - diff --git a/electron/preload.cjs b/electron/preload.cjs index 304f8f9..20d4a08 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld("assistantApi", { captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options), clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y), scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY), + keyPress: (key) => ipcRenderer.invoke("automation:keyPress", key), getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"), focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), diff --git a/electron/preload.ts b/electron/preload.ts index 12ae90e..c8d1d66 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("assistantApi", { 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), 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"), focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), diff --git a/electron/repositories/reviewSamplesRepository.ts b/electron/repositories/reviewSamplesRepository.ts index 3ff8f4d..8d1f90c 100644 --- a/electron/repositories/reviewSamplesRepository.ts +++ b/electron/repositories/reviewSamplesRepository.ts @@ -3,6 +3,9 @@ import path from "node:path"; import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.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 { private readonly filePath: string; @@ -12,9 +15,12 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort { async list(limit = 50): Promise { 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 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 .slice(-safeLimit) .map((line) => { @@ -25,7 +31,8 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort { } }) .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 { return { ok: true, samples: [], total: 0, path: this.filePath }; } @@ -37,3 +44,17 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort { 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(); + } +} diff --git a/electron/services/inputHelper.ts b/electron/services/inputHelper.ts index 68c87b0..daf4468 100644 --- a/electron/services/inputHelper.ts +++ b/electron/services/inputHelper.ts @@ -7,6 +7,7 @@ import type { FocusGenshinResult, GdiCaptureResult, HelperOperationResponse, + KeyPressResult, WindowBounds, RuntimeInfo, ScrollResult, @@ -115,6 +116,32 @@ function Send-MouseClickBatch { 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 { $pt = New-Object Native.InputHelper+POINT [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null @@ -358,6 +385,21 @@ while ($true) { $response.notchesSent = $sentTotal $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" { $clientBounds = Get-GenshinClientBounds if ($null -eq $clientBounds) { @@ -545,6 +587,7 @@ export interface InputHelperService { getGenshinWindowBounds(): Promise; clickScreen(x: number, y: number): Promise; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; + keyPress(key: string): Promise; getAutomationGuard(): Promise; capturePrimaryScreenViaGdi(): Promise; 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() { const result = await request("cursor", {}, 4000); return { @@ -688,6 +745,7 @@ export function createInputHelperService(options: { userDataPath: string; exePat getGenshinWindowBounds, clickScreen, scrollScreen, + keyPress, getAutomationGuard, capturePrimaryScreenViaGdi, dispose: () => inputHelper.dispose(), diff --git a/native/input-helper/Program.cs b/native/input-helper/Program.cs index b469339..7cd3ee7 100644 --- a/native/input-helper/Program.cs +++ b/native/input-helper/Program.cs @@ -169,6 +169,23 @@ internal static class Program 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": { var bounds = GetGenshinClientBounds(); @@ -423,6 +440,30 @@ internal static class Program return Native.SendInput(1, inputs, Marshal.SizeOf()); } + 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()); + } + + 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) => 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_LEFTUP = 0x0004; public const uint MOUSEEVENTF_WHEEL = 0x0800; + public const int KEYEVENTF_KEYUP = 0x0002; public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000; public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001; public const uint SPIF_SENDCHANGE = 0x0002; diff --git a/package.json b/package.json index b91295f..d52e833 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,12 @@ "lint": "tsc --noEmit", "test": "vitest run", "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", "data:genshin": "node scripts/generate-genshin-data.cjs" }, diff --git a/scripts/dev-admin-start.ps1 b/scripts/dev-admin-start.ps1 index 59f652d..13e98fb 100644 --- a/scripts/dev-admin-start.ps1 +++ b/scripts/dev-admin-start.ps1 @@ -56,6 +56,22 @@ try { & (Join-Path $PSScriptRoot "kill-stale-instances.ps1") $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 try { $portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1 diff --git a/scripts/generate-genshin-data.cjs b/scripts/generate-genshin-data.cjs index b61191a..386e7d9 100644 --- a/scripts/generate-genshin-data.cjs +++ b/scripts/generate-genshin-data.cjs @@ -44,6 +44,9 @@ const artifactPieces = artifacts const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot])); 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 = [ 'Elemental Mastery', @@ -198,6 +201,23 @@ const data = { '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: { artifactDetailEn: { language: 'English', @@ -231,3 +251,89 @@ function slotFromRelicType(relicType) { 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, + }, + }; +} diff --git a/scripts/kill-stale-instances.ps1 b/scripts/kill-stale-instances.ps1 index d69bbfd..df58d79 100644 --- a/scripts/kill-stale-instances.ps1 +++ b/scripts/kill-stale-instances.ps1 @@ -16,8 +16,25 @@ $ErrorActionPreference = "Stop" $project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path $electronPath = Join-Path $project "node_modules\electron\dist\electron.exe" +$devControlPort = 17317 $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 | Where-Object { @@ -26,15 +43,47 @@ Get-CimInstance Win32_Process | ($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*") } | ForEach-Object { - Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))" - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue - $killed++ + $candidatePids[[int]$_.ProcessId] = $_.Name } +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++ + } catch { + Write-Host "WARNUNG: Konnte PID $($entry.Key) nicht beenden: $($_.Exception.Message)" -ForegroundColor Yellow + $failed++ + } +} + if ($killed -gt 0) { # Windows braucht einen Moment, um Ports/Handles wirklich freizugeben. Start-Sleep -Milliseconds 500 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 } diff --git a/scripts/live-soak.ps1 b/scripts/live-soak.ps1 new file mode 100644 index 0000000..fb2802b --- /dev/null +++ b/scripts/live-soak.ps1 @@ -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 { + } +} diff --git a/src/data/genshinGameData.json b/src/data/genshinGameData.json index 3a14cdd..f2157ac 100644 --- a/src/data/genshinGameData.json +++ b/src/data/genshinGameData.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-07-04T17:41:26.015Z", + "generatedAt": "2026-07-07T14:36:39.508Z", "source": { "package": "genshin-db", "version": "5.2.12", @@ -5281,6 +5281,1434 @@ "Qiqi ": "Qiqi" } }, + "lookup": { + "normalizedKeys": { + "sets": { + "adaycarvedfromrisingwinds": "A Day Carved From Rising Winds", + "adventurer": "Adventurer", + "archaicpetra": "Archaic Petra", + "aubadeofmorningstarandmoon": "Aubade of Morningstar and Moon", + "berserker": "Berserker", + "blizzardstrayer": "Blizzard Strayer", + "bloodstainedchivalry": "Bloodstained Chivalry", + "braveheart": "Brave Heart", + "celestialgift": "Celestial Gift", + "crimsonwitchofflames": "Crimson Witch of Flames", + "deepwoodmemories": "Deepwood Memories", + "defenderswill": "Defender's Will", + "desertpavilionchronicle": "Desert Pavilion Chronicle", + "disenchantmentindeepshadow": "Disenchantment in Deep Shadow", + "echoesofanoffering": "Echoes of an Offering", + "emblemofseveredfate": "Emblem of Severed Fate", + "finaleofthedeepgalleries": "Finale of the Deep Galleries", + "flowerofparadiselost": "Flower of Paradise Lost", + "fragmentofharmonicwhimsy": "Fragment of Harmonic Whimsy", + "gambler": "Gambler", + "gildeddreams": "Gilded Dreams", + "gladiatorsfinale": "Gladiator's Finale", + "goldentroupe": "Golden Troupe", + "heartofdepth": "Heart of Depth", + "huskofopulentdreams": "Husk of Opulent Dreams", + "instructor": "Instructor", + "lavawalker": "Lavawalker", + "longnightsoath": "Long Night's Oath", + "luckydog": "Lucky Dog", + "maidenbeloved": "Maiden Beloved", + "marechausseehunter": "Marechaussee Hunter", + "martialartist": "Martial Artist", + "nightoftheskysunveiling": "Night of the Sky's Unveiling", + "nighttimewhispersintheechoingwoods": "Nighttime Whispers in the Echoing Woods", + "noblesseoblige": "Noblesse Oblige", + "nymphsdream": "Nymph's Dream", + "obsidiancodex": "Obsidian Codex", + "oceanhuedclam": "Ocean-Hued Clam", + "paleflame": "Pale Flame", + "prayersfordestiny": "Prayers for Destiny", + "prayersforillumination": "Prayers for Illumination", + "prayersforwisdom": "Prayers for Wisdom", + "prayerstospringtime": "Prayers to Springtime", + "resolutionofsojourner": "Resolution of Sojourner", + "retracingbolide": "Retracing Bolide", + "scholar": "Scholar", + "scrolloftheheroofcindercity": "Scroll of the Hero of Cinder City", + "shimenawasreminiscence": "Shimenawa's Reminiscence", + "silkenmoonsserenade": "Silken Moon's Serenade", + "songofdayspast": "Song of Days Past", + "tenacityofthemillelith": "Tenacity of the Millelith", + "theexile": "The Exile", + "thunderingfury": "Thundering Fury", + "thundersoother": "Thundersoother", + "tinymiracle": "Tiny Miracle", + "travelingdoctor": "Traveling Doctor", + "unfinishedreverie": "Unfinished Reverie", + "vermillionhereafter": "Vermillion Hereafter", + "viridescentvenerer": "Viridescent Venerer", + "vourukashasglow": "Vourukasha's Glow", + "wandererstroupe": "Wanderer's Troupe" + }, + "pieces": { + "ahornunwinded": "A Horn Unwinded", + "amomentcongealed": "A Moment Congealed", + "anoteinspringsleich": "A Note in Spring's Leich", + "atimeofinsight": "A Time of Insight", + "adventurersbandana": "Adventurer's Bandana", + "adventurersflower": "Adventurer's Flower", + "adventurersgoldengoblet": "Adventurer's Golden Goblet", + "adventurerspocketwatch": "Adventurer's Pocket Watch", + "adventurerstailfeather": "Adventurer's Tail Feather", + "amethystcrown": "Amethyst Crown", + "ancientabscission": "Ancient Abscission", + "ancientseasnocturnalmusing": "Ancient Sea's Nocturnal Musing", + "aykhanoumsmyriad": "Ay-Khanoum's Myriad", + "bardsarrowfeather": "Bard's Arrow Feather", + "beasttamerstalisman": "Beast Tamer's Talisman", + "berserkersbattlemask": "Berserker's Battle Mask", + "berserkersbonegoblet": "Berserker's Bone Goblet", + "berserkersindigofeather": "Berserker's Indigo Feather", + "berserkersrose": "Berserker's Rose", + "berserkerstimepiece": "Berserker's Timepiece", + "bloodstainedblackplume": "Bloodstained Black Plume", + "bloodstainedchevaliersgoblet": "Bloodstained Chevalier's Goblet", + "bloodstainedfinalhour": "Bloodstained Final Hour", + "bloodstainedflowerofiron": "Bloodstained Flower of Iron", + "bloodstainedironmask": "Bloodstained Iron Mask", + "bloomofthemindsdesire": "Bloom of the Mind's Desire", + "bloomtimes": "Bloom Times", + "brokenrimesecho": "Broken Rime's Echo", + "calabashofawakening": "Calabash of Awakening", + "capriciousvisage": "Capricious Visage", + "ceremonialwarplume": "Ceremonial War-Plume", + "chaliceofthefont": "Chalice of the Font", + "compassionateladieshat": "Compassionate Ladies' Hat", + "concertsfinalhour": "Concert's Final Hour", + "conductorstophat": "Conductor's Top Hat", + "coppercompass": "Copper Compass", + "cowryofparting": "Cowry of Parting", + "crownofparting": "Crown of Parting", + "crownofthebefallen": "Crown of the Befallen", + "crownofthebrave": "Crown of the Brave", + "crownofthesaints": "Crown of the Saints", + "crownofwatatsumi": "Crown of Watatsumi", + "crownlesscrown": "Crownless Crown", + "crystaltearofthewanderer": "Crystal Tear of the Wanderer", + "darkfruitofbrightflowers": "Dark Fruit of Bright Flowers", + "dawnsbrilliantoath": "Dawn's Brilliant Oath", + "deepgallerysbestowedbanquet": "Deep Gallery's Bestowed Banquet", + "deepgallerysdistantpact": "Deep Gallery's Distant Pact", + "deepgallerysechoingsong": "Deep Gallery's Echoing Song", + "deepgalleryslostcrown": "Deep Gallery's Lost Crown", + "deepgallerysmomentofoblivion": "Deep Gallery's Moment of Oblivion", + "deeppalacesplume": "Deep Palace's Plume", + "defenderoftheenchantingdream": "Defender of the Enchanting Dream", + "demonwarriorsfeathermask": "Demon-Warrior's Feather Mask", + "dreamingsteelbloom": "Dreaming Steelbloom", + "dyedtassel": "Dyed Tassel", + "echoingsoundfromdayspast": "Echoing Sound From Days Past", + "endofthegoldenrealm": "End of the Golden Realm", + "entanglingbloom": "Entangling Bloom", + "exilescirclet": "Exile's Circlet", + "exilesfeather": "Exile's Feather", + "exilesflower": "Exile's Flower", + "exilesgoblet": "Exile's Goblet", + "exilespocketwatch": "Exile's Pocket Watch", + "fadedemeraldtail": "Faded Emerald Tail", + "faithfulhourglass": "Faithful Hourglass", + "feastofboundlessjoy": "Feast of Boundless Joy", + "featherofhomecoming": "Feather of Homecoming", + "featherofindeliblesin": "Feather of Indelible Sin", + "featherofjaggedpeaks": "Feather of Jagged Peaks", + "featherofjudgment": "Feather of Judgment", + "featherofnascentlight": "Feather of Nascent Light", + "felldragonsmonocle": "Fell Dragon's Monocle", + "flowerofaccolades": "Flower of Accolades", + "flowerofcrevicedcliff": "Flower of Creviced Cliff", + "floweringlife": "Flowering Life", + "flowingrings": "Flowing Rings", + "forgottenoathofdayspast": "Forgotten Oath of Days Past", + "forgottenvessel": "Forgotten Vessel", + "fortitudeofthebrave": "Fortitude of the Brave", + "frostdevoteesdelirium": "Frost Devotee's Delirium", + "frostweaveddignity": "Frost-Weaved Dignity", + "frozenhomelandsdemise": "Frozen Homeland's Demise", + "gamblersbrooch": "Gambler's Brooch", + "gamblersdicecup": "Gambler's Dice Cup", + "gamblersearrings": "Gambler's Earrings", + "gamblersfeatheraccessory": "Gambler's Feather Accessory", + "gamblerspocketwatch": "Gambler's Pocket Watch", + "generalsancienthelm": "General's Ancient Helm", + "gildedcorsage": "Gilded Corsage", + "gladiatorsdestiny": "Gladiator's Destiny", + "gladiatorsintoxication": "Gladiator's Intoxication", + "gladiatorslonging": "Gladiator's Longing", + "gladiatorsnostalgia": "Gladiator's Nostalgia", + "gladiatorstriumphus": "Gladiator's Triumphus", + "gobletofchiseledcrag": "Goblet of Chiseled Crag", + "gobletofthesojourner": "Goblet of the Sojourner", + "gobletofthunderingdeep": "Goblet of Thundering Deep", + "goldenbirdsshedding": "Golden Bird's Shedding", + "goldenerasprelude": "Golden Era's Prelude", + "goldennightsbustle": "Golden Night's Bustle", + "goldensongsvariation": "Golden Song's Variation", + "goldentroupesreward": "Golden Troupe's Reward", + "guardiansband": "Guardian's Band", + "guardiansclock": "Guardian's Clock", + "guardiansflower": "Guardian's Flower", + "guardianssigil": "Guardian's Sigil", + "guardiansvessel": "Guardian's Vessel", + "gustofnostalgia": "Gust of Nostalgia", + "harmonioussymphonyprelude": "Harmonious Symphony Prelude", + "heartofcomradeship": "Heart of Comradeship", + "heartofkhvarenasbrilliance": "Heart of Khvarena's Brilliance", + "heavensentcrown": "Heavensent Crown", + "heavensentdecree": "Heavensent Decree", + "heavensentdemise": "Heavensent Demise", + "heavensentfragrance": "Heavensent Fragrance", + "heavensentreward": "Heavensent Reward", + "heldenepossunspokentale": "Heldenepos's Unspoken Tale", + "heroesteaparty": "Heroes' Tea Party", + "holycrownofthebeliever": "Holy Crown of the Believer", + "honestquill": "Honest Quill", + "honeyedfinalfeast": "Honeyed Final Feast", + "hopefulheart": "Hopeful Heart", + "hourofsoothingthunder": "Hour of Soothing Thunder", + "hourglassofthunder": "Hourglass of Thunder", + "huntersbrooch": "Hunter's Brooch", + "icebreakersresolve": "Icebreaker's Resolve", + "ichorshowerrhapsody": "Ichor Shower Rhapsody", + "inremembranceofviridescentfields": "In Remembrance of Viridescent Fields", + "instructorsbrooch": "Instructor's Brooch", + "instructorscap": "Instructor's Cap", + "instructorsfeatheraccessory": "Instructor's Feather Accessory", + "instructorspocketwatch": "Instructor's Pocket Watch", + "instructorsteacup": "Instructor's Tea Cup", + "iridescencethatceasedamidstglory": "Iridescence That Ceased Amidst Glory", + "jadeleaf": "Jade Leaf", + "joyousgloryofthepure": "Joyous Glory of the Pure", + "labyrinthwayfarer": "Labyrinth Wayfarer", + "lampofthelost": "Lamp of the Lost", + "laurelcoronet": "Laurel Coronet", + "lavawalkersepiphany": "Lavawalker's Epiphany", + "lavawalkersresolution": "Lavawalker's Resolution", + "lavawalkerssalvation": "Lavawalker's Salvation", + "lavawalkerstorment": "Lavawalker's Torment", + "lavawalkerswisdom": "Lavawalker's Wisdom", + "legacyofthedeserthighborn": "Legacy of the Desert High-Born", + "lightkeeperspledge": "Lightkeeper's Pledge", + "luckydogsclover": "Lucky Dog's Clover", + "luckydogseaglefeather": "Lucky Dog's Eagle Feather", + "luckydogsgoblet": "Lucky Dog's Goblet", + "luckydogshourglass": "Lucky Dog's Hourglass", + "luckydogssilvercirclet": "Lucky Dog's Silver Circlet", + "magnanimousinkbottle": "Magnanimous Ink Bottle", + "magnificenttsuba": "Magnificent Tsuba", + "maidensdistantlove": "Maiden's Distant Love", + "maidensfadingbeauty": "Maiden's Fading Beauty", + "maidensfleetingleisure": "Maiden's Fleeting Leisure", + "maidensheartstrickeninfatuation": "Maiden's Heart-stricken Infatuation", + "maidenspassingyouth": "Maiden's Passing Youth", + "martialartistsbandana": "Martial Artist's Bandana", + "martialartistsfeatheraccessory": "Martial Artist's Feather Accessory", + "martialartistsredflower": "Martial Artist's Red Flower", + "martialartistswaterhourglass": "Martial Artist's Water Hourglass", + "martialartistswinecup": "Martial Artist's Wine Cup", + "maskofsolitudebasalt": "Mask of Solitude Basalt", + "masterpiecesoverture": "Masterpiece's Overture", + "medalofthebrave": "Medal of the Brave", + "minnesangofloveandlament": "Minnesang of Love and Lament", + "mockingmask": "Mocking Mask", + "momentofattainment": "Moment of Attainment", + "momentofcessation": "Moment of Cessation", + "momentofjudgment": "Moment of Judgment", + "momentofthepact": "Moment of the Pact", + "momentthatceaseduponwakingfromgranddreams": "Moment That Ceased Upon Waking From Grand Dreams", + "moonlitofferingsfinalhour": "Moonlit Offering's Final Hour", + "moonlitofferingslibation": "Moonlit Offering's Libation", + "moonlitofferingsopulentdream": "Moonlit Offering's Opulent Dream", + "moonlitofferingspartinglight": "Moonlit Offering's Parting Light", + "moonlitofferingssilvercrown": "Moonlit Offering's Silver Crown", + "morningdewsmoment": "Morning Dew's Moment", + "mountainrangersmarker": "Mountain Ranger's Marker", + "mysticsgolddial": "Mystic's Gold Dial", + "mythsofthenightrealm": "Myths of the Night Realm", + "nightingalestailfeather": "Nightingale's Tail Feather", + "noblespledgingvessel": "Noble's Pledging Vessel", + "nymphsconstancy": "Nymph's Constancy", + "odysseanflower": "Odyssean Flower", + "omenofthunderstorm": "Omen of Thunderstorm", + "orichalceoustimedial": "Orichalceous Time-Dial", + "ornatekabuto": "Ornate Kabuto", + "outsetofthebrave": "Outset of the Brave", + "ovationsthatceaseduponfestivity": "Ovations That Ceased Upon Festivity", + "pearlcage": "Pearl Cage", + "pendulumthatceasedamidstagreatfall": "Pendulum That Ceased Amidst a Great Fall", + "plumeofluxury": "Plume of Luxury", + "poetryofdayspast": "Poetry of Days Past", + "prebanquetofthecontenders": "Pre-Banquet of the Contenders", + "pristineplumeoftheblessed": "Pristine Plume of the Blessed", + "promiseddreamofdayspast": "Promised Dream of Days Past", + "prospectofthebrave": "Prospect of the Brave", + "reckoningofthexenogenic": "Reckoning of the Xenogenic", + "recollectionofdayspast": "Recollection of Days Past", + "revelationstoll": "Revelation's Toll", + "rootofthespiritmarrow": "Root of the Spirit-Marrow", + "royalflora": "Royal Flora", + "royalmasque": "Royal Masque", + "royalplume": "Royal Plume", + "royalpocketwatch": "Royal Pocket Watch", + "royalsilverurn": "Royal Silver Urn", + "scarletvessel": "Scarlet Vessel", + "scholarofvines": "Scholar of Vines", + "scholarsbookmark": "Scholar's Bookmark", + "scholarsclock": "Scholar's Clock", + "scholarsinkcup": "Scholar's Ink Cup", + "scholarslens": "Scholar's Lens", + "scholarsquillpen": "Scholar's Quill Pen", + "seadyedblossom": "Sea-Dyed Blossom", + "secretkeepersmagicbottle": "Secret-Keeper's Magic Bottle", + "selflessfloralaccessory": "Selfless Floral Accessory", + "shadowofthesandking": "Shadow of the Sand King", + "shaftofremembrance": "Shaft of Remembrance", + "sharpnessthatceaseduponwondrouscreation": "Sharpness That Ceased Upon Wondrous Creation", + "skeletalhat": "Skeletal Hat", + "snowsweptmemory": "Snowswept Memory", + "solarrelic": "Solar Relic", + "songoflife": "Song of Life", + "soulscentbloom": "Soulscent Bloom", + "stainlessbloom": "Stainless Bloom", + "stamenofkhvarenasorigin": "Stamen of Khvarena's Origin", + "stormcage": "Storm Cage", + "summernightsbloom": "Summer Night's Bloom", + "summernightsfinale": "Summer Night's Finale", + "summernightsmask": "Summer Night's Mask", + "summernightsmoment": "Summer Night's Moment", + "summernightswaterballoon": "Summer Night's Waterballoon", + "sunderedfeather": "Sundered Feather", + "sundialofenduringjade": "Sundial of Enduring Jade", + "sundialofthesojourner": "Sundial of the Sojourner", + "surpassingcup": "Surpassing Cup", + "survivorofcatastrophe": "Survivor of Catastrophe", + "symboloffelicitation": "Symbol of Felicitation", + "thefirstdaysofthecityofkings": "The First Days of the City of Kings", + "thegrandjapeoftheturningoffate": "The Grand Jape of the Turning of Fate", + "thesunkenyears": "The Sunken Years", + "thewineflaskoverwhichtheplanwashatched": "The Wine-Flask Over Which the Plan Was Hatched", + "thundersummonerscrown": "Thunder Summoner's Crown", + "thunderbirdsmercy": "Thunderbird's Mercy", + "thunderingpoise": "Thundering Poise", + "thundersoothersdiadem": "Thundersoother's Diadem", + "thundersoothersgoblet": "Thundersoother's Goblet", + "thundersoothersheart": "Thundersoother's Heart", + "thundersoothersplume": "Thundersoother's Plume", + "tiaraofflame": "Tiara of Flame", + "tiaraoffrost": "Tiara of Frost", + "tiaraofthunder": "Tiara of Thunder", + "tiaraoftorrents": "Tiara of Torrents", + "timepieceofthelostpath": "Timepiece of the Lost Path", + "tinymiraclesearrings": "Tiny Miracle's Earrings", + "tinymiraclesfeather": "Tiny Miracle's Feather", + "tinymiraclesflower": "Tiny Miracle's Flower", + "tinymiraclesgoblet": "Tiny Miracle's Goblet", + "tinymiracleshourglass": "Tiny Miracle's Hourglass", + "travelingdoctorshandkerchief": "Traveling Doctor's Handkerchief", + "travelingdoctorsmedicinepot": "Traveling Doctor's Medicine Pot", + "travelingdoctorsowlfeather": "Traveling Doctor's Owl Feather", + "travelingdoctorspocketwatch": "Traveling Doctor's Pocket Watch", + "travelingdoctorssilverlotus": "Traveling Doctor's Silver Lotus", + "troupesdawnlight": "Troupe's Dawnlight", + "undyingonesmourningbell": "Undying One's Mourning Bell", + "vesselofplenty": "Vessel of Plenty", + "veteransvisage": "Veteran's Visage", + "vibrantpinion": "Vibrant Pinion", + "viridescentarrowfeather": "Viridescent Arrow Feather", + "viridescentvenerersdetermination": "Viridescent Venerer's Determination", + "viridescentvenerersdiadem": "Viridescent Venerer's Diadem", + "viridescentvenerersvessel": "Viridescent Venerer's Vessel", + "wanderersstringkettle": "Wanderer's String-Kettle", + "wanderingscholarsclawcup": "Wandering Scholar's Claw Cup", + "whimsicaldanceofthewithered": "Whimsical Dance of the Withered", + "wickedmagesplumule": "Wicked Mage's Plumule", + "wiltingfeast": "Wilting Feast", + "windborneflowersspruchdichtung": "Windborne Flower's Spruchdichtung", + "winestainedtricorne": "Wine-Stained Tricorne", + "wisedoctorspinion": "Wise Doctor's Pinion", + "witchsendtime": "Witch's End Time", + "witchseverburningplume": "Witch's Ever-Burning Plume", + "witchsflowerofblaze": "Witch's Flower of Blaze", + "witchsheartflames": "Witch's Heart Flames", + "witchsscorchinghat": "Witch's Scorching Hat" + }, + "slots": { + "floweroflife": "Flower of Life", + "plumeofdeath": "Plume of Death", + "sandsofeon": "Sands of Eon", + "gobletofeonothem": "Goblet of Eonothem", + "circletoflogos": "Circlet of Logos" + }, + "stats": { + "elementalmastery": "Elemental Mastery", + "energyrecharge": "Energy Recharge", + "critrate": "CRIT Rate", + "critdmg": "CRIT DMG", + "healingbonus": "Healing Bonus", + "atk": "ATK%", + "hp": "HP%", + "def": "DEF%", + "hydrodmgbonus": "Hydro DMG Bonus", + "pyrodmgbonus": "Pyro DMG Bonus", + "electrodmgbonus": "Electro DMG Bonus", + "cryodmgbonus": "Cryo DMG Bonus", + "dendrodmgbonus": "Dendro DMG Bonus", + "anemodmgbonus": "Anemo DMG Bonus", + "geodmgbonus": "Geo DMG Bonus", + "physicaldmgbonus": "Physical DMG Bonus" + }, + "characters": { + "aether": "Aether", + "aino": "Aino", + "albedo": "Albedo", + "alhaitham": "Alhaitham", + "aloy": "Aloy", + "amber": "Amber", + "aratakiitto": "Arataki Itto", + "arlecchino": "Arlecchino", + "baizhu": "Baizhu", + "barbara": "Barbara", + "beidou": "Beidou", + "bennett": "Bennett", + "candace": "Candace", + "charlotte": "Charlotte", + "chasca": "Chasca", + "chevreuse": "Chevreuse", + "chiori": "Chiori", + "chongyun": "Chongyun", + "citlali": "Citlali", + "clorinde": "Clorinde", + "collei": "Collei", + "columbina": "Columbina", + "cyno": "Cyno", + "dahlia": "Dahlia", + "dehya": "Dehya", + "diluc": "Diluc", + "diona": "Diona", + "dori": "Dori", + "durin": "Durin", + "emilie": "Emilie", + "escoffier": "Escoffier", + "eula": "Eula", + "faruzan": "Faruzan", + "fischl": "Fischl", + "flins": "Flins", + "freminet": "Freminet", + "furina": "Furina", + "gaming": "Gaming", + "ganyu": "Ganyu", + "gorou": "Gorou", + "hutao": "Hu Tao", + "iansan": "Iansan", + "ifa": "Ifa", + "illuga": "Illuga", + "ineffa": "Ineffa", + "jahoda": "Jahoda", + "jean": "Jean", + "kachina": "Kachina", + "kaedeharakazuha": "Kaedehara Kazuha", + "kaeya": "Kaeya", + "kamisatoayaka": "Kamisato Ayaka", + "kamisatoayato": "Kamisato Ayato", + "kaveh": "Kaveh", + "keqing": "Keqing", + "kinich": "Kinich", + "kirara": "Kirara", + "klee": "Klee", + "kujousara": "Kujou Sara", + "kukishinobu": "Kuki Shinobu", + "lanyan": "Lan Yan", + "lauma": "Lauma", + "layla": "Layla", + "linnea": "Linnea", + "lisa": "Lisa", + "lohen": "Lohen", + "lumine": "Lumine", + "lynette": "Lynette", + "lyney": "Lyney", + "manekin": "Manekin", + "manekina": "Manekina", + "mavuika": "Mavuika", + "mika": "Mika", + "mona": "Mona", + "mualani": "Mualani", + "nahida": "Nahida", + "navia": "Navia", + "nefer": "Nefer", + "neuvillette": "Neuvillette", + "nicole": "Nicole", + "nilou": "Nilou", + "ningguang": "Ningguang", + "noelle": "Noelle", + "ororon": "Ororon", + "prune": "Prune", + "qiqi": "Qiqi", + "raidenshogun": "Raiden Shogun", + "razor": "Razor", + "rosaria": "Rosaria", + "sandrone": "Sandrone", + "sangonomiyakokomi": "Sangonomiya Kokomi", + "sayu": "Sayu", + "sethos": "Sethos", + "shenhe": "Shenhe", + "shikanoinheizou": "Shikanoin Heizou", + "sigewinne": "Sigewinne", + "skirk": "Skirk", + "sucrose": "Sucrose", + "tartaglia": "Tartaglia", + "thoma": "Thoma", + "tighnari": "Tighnari", + "varesa": "Varesa", + "varka": "Varka", + "venti": "Venti", + "wanderer": "Wanderer", + "wriothesley": "Wriothesley", + "xiangling": "Xiangling", + "xianyun": "Xianyun", + "xiao": "Xiao", + "xilonen": "Xilonen", + "xingqiu": "Xingqiu", + "xinyan": "Xinyan", + "yaemiko": "Yae Miko", + "yanfei": "Yanfei", + "yaoyao": "Yaoyao", + "yelan": "Yelan", + "yoimiya": "Yoimiya", + "yumemizukimizuki": "Yumemizuki Mizuki", + "yunjin": "Yun Jin", + "zhongli": "Zhongli", + "zibai": "Zibai" + } + }, + "goodKeys": { + "sets": { + "A Day Carved From Rising Winds": "ADayCarvedFromRisingWinds", + "Adventurer": "Adventurer", + "Archaic Petra": "ArchaicPetra", + "Aubade of Morningstar and Moon": "AubadeOfMorningstarAndMoon", + "Berserker": "Berserker", + "Blizzard Strayer": "BlizzardStrayer", + "Bloodstained Chivalry": "BloodstainedChivalry", + "Brave Heart": "BraveHeart", + "Celestial Gift": "CelestialGift", + "Crimson Witch of Flames": "CrimsonWitchOfFlames", + "Deepwood Memories": "DeepwoodMemories", + "Defender's Will": "DefendersWill", + "Desert Pavilion Chronicle": "DesertPavilionChronicle", + "Disenchantment in Deep Shadow": "DisenchantmentInDeepShadow", + "Echoes of an Offering": "EchoesOfAnOffering", + "Emblem of Severed Fate": "EmblemOfSeveredFate", + "Finale of the Deep Galleries": "FinaleOfTheDeepGalleries", + "Flower of Paradise Lost": "FlowerOfParadiseLost", + "Fragment of Harmonic Whimsy": "FragmentOfHarmonicWhimsy", + "Gambler": "Gambler", + "Gilded Dreams": "GildedDreams", + "Gladiator's Finale": "GladiatorsFinale", + "Golden Troupe": "GoldenTroupe", + "Heart of Depth": "HeartOfDepth", + "Husk of Opulent Dreams": "HuskOfOpulentDreams", + "Instructor": "Instructor", + "Lavawalker": "Lavawalker", + "Long Night's Oath": "LongNightsOath", + "Lucky Dog": "LuckyDog", + "Maiden Beloved": "MaidenBeloved", + "Marechaussee Hunter": "MarechausseeHunter", + "Martial Artist": "MartialArtist", + "Night of the Sky's Unveiling": "NightOfTheSkysUnveiling", + "Nighttime Whispers in the Echoing Woods": "NighttimeWhispersInTheEchoingWoods", + "Noblesse Oblige": "NoblesseOblige", + "Nymph's Dream": "NymphsDream", + "Obsidian Codex": "ObsidianCodex", + "Ocean-Hued Clam": "OceanHuedClam", + "Pale Flame": "PaleFlame", + "Prayers for Destiny": "PrayersForDestiny", + "Prayers for Illumination": "PrayersForIllumination", + "Prayers for Wisdom": "PrayersForWisdom", + "Prayers to Springtime": "PrayersToSpringtime", + "Resolution of Sojourner": "ResolutionOfSojourner", + "Retracing Bolide": "RetracingBolide", + "Scholar": "Scholar", + "Scroll of the Hero of Cinder City": "ScrollOfTheHeroOfCinderCity", + "Shimenawa's Reminiscence": "ShimenawasReminiscence", + "Silken Moon's Serenade": "SilkenMoonsSerenade", + "Song of Days Past": "SongOfDaysPast", + "Tenacity of the Millelith": "TenacityOfTheMillelith", + "The Exile": "TheExile", + "Thundering Fury": "ThunderingFury", + "Thundersoother": "Thundersoother", + "Tiny Miracle": "TinyMiracle", + "Traveling Doctor": "TravelingDoctor", + "Unfinished Reverie": "UnfinishedReverie", + "Vermillion Hereafter": "VermillionHereafter", + "Viridescent Venerer": "ViridescentVenerer", + "Vourukasha's Glow": "VourukashasGlow", + "Wanderer's Troupe": "WanderersTroupe" + }, + "pieces": { + "A Horn Unwinded": "AHornUnwinded", + "A Moment Congealed": "AMomentCongealed", + "A Note in Spring's Leich": "ANoteInSpringsLeich", + "A Time of Insight": "ATimeOfInsight", + "Adventurer's Bandana": "AdventurersBandana", + "Adventurer's Flower": "AdventurersFlower", + "Adventurer's Golden Goblet": "AdventurersGoldenGoblet", + "Adventurer's Pocket Watch": "AdventurersPocketWatch", + "Adventurer's Tail Feather": "AdventurersTailFeather", + "Amethyst Crown": "AmethystCrown", + "Ancient Abscission": "AncientAbscission", + "Ancient Sea's Nocturnal Musing": "AncientSeasNocturnalMusing", + "Ay-Khanoum's Myriad": "AyKhanoumsMyriad", + "Bard's Arrow Feather": "BardsArrowFeather", + "Beast Tamer's Talisman": "BeastTamersTalisman", + "Berserker's Battle Mask": "BerserkersBattleMask", + "Berserker's Bone Goblet": "BerserkersBoneGoblet", + "Berserker's Indigo Feather": "BerserkersIndigoFeather", + "Berserker's Rose": "BerserkersRose", + "Berserker's Timepiece": "BerserkersTimepiece", + "Bloodstained Black Plume": "BloodstainedBlackPlume", + "Bloodstained Chevalier's Goblet": "BloodstainedChevaliersGoblet", + "Bloodstained Final Hour": "BloodstainedFinalHour", + "Bloodstained Flower of Iron": "BloodstainedFlowerOfIron", + "Bloodstained Iron Mask": "BloodstainedIronMask", + "Bloom of the Mind's Desire": "BloomOfTheMindsDesire", + "Bloom Times": "BloomTimes", + "Broken Rime's Echo": "BrokenRimesEcho", + "Calabash of Awakening": "CalabashOfAwakening", + "Capricious Visage": "CapriciousVisage", + "Ceremonial War-Plume": "CeremonialWarPlume", + "Chalice of the Font": "ChaliceOfTheFont", + "Compassionate Ladies' Hat": "CompassionateLadiesHat", + "Concert's Final Hour": "ConcertsFinalHour", + "Conductor's Top Hat": "ConductorsTopHat", + "Copper Compass": "CopperCompass", + "Cowry of Parting": "CowryOfParting", + "Crown of Parting": "CrownOfParting", + "Crown of the Befallen": "CrownOfTheBefallen", + "Crown of the Brave": "CrownOfTheBrave", + "Crown of the Saints": "CrownOfTheSaints", + "Crown of Watatsumi": "CrownOfWatatsumi", + "Crownless Crown": "CrownlessCrown", + "Crystal Tear of the Wanderer": "CrystalTearOfTheWanderer", + "Dark Fruit of Bright Flowers": "DarkFruitOfBrightFlowers", + "Dawn's Brilliant Oath": "DawnsBrilliantOath", + "Deep Gallery's Bestowed Banquet": "DeepGallerysBestowedBanquet", + "Deep Gallery's Distant Pact": "DeepGallerysDistantPact", + "Deep Gallery's Echoing Song": "DeepGallerysEchoingSong", + "Deep Gallery's Lost Crown": "DeepGallerysLostCrown", + "Deep Gallery's Moment of Oblivion": "DeepGallerysMomentOfOblivion", + "Deep Palace's Plume": "DeepPalacesPlume", + "Defender of the Enchanting Dream": "DefenderOfTheEnchantingDream", + "Demon-Warrior's Feather Mask": "DemonWarriorsFeatherMask", + "Dreaming Steelbloom": "DreamingSteelbloom", + "Dyed Tassel": "DyedTassel", + "Echoing Sound From Days Past": "EchoingSoundFromDaysPast", + "End of the Golden Realm": "EndOfTheGoldenRealm", + "Entangling Bloom": "EntanglingBloom", + "Exile's Circlet": "ExilesCirclet", + "Exile's Feather": "ExilesFeather", + "Exile's Flower": "ExilesFlower", + "Exile's Goblet": "ExilesGoblet", + "Exile's Pocket Watch": "ExilesPocketWatch", + "Faded Emerald Tail": "FadedEmeraldTail", + "Faithful Hourglass": "FaithfulHourglass", + "Feast of Boundless Joy": "FeastOfBoundlessJoy", + "Feather of Homecoming": "FeatherOfHomecoming", + "Feather of Indelible Sin": "FeatherOfIndelibleSin", + "Feather of Jagged Peaks": "FeatherOfJaggedPeaks", + "Feather of Judgment": "FeatherOfJudgment", + "Feather of Nascent Light": "FeatherOfNascentLight", + "Fell Dragon's Monocle": "FellDragonsMonocle", + "Flower of Accolades": "FlowerOfAccolades", + "Flower of Creviced Cliff": "FlowerOfCrevicedCliff", + "Flowering Life": "FloweringLife", + "Flowing Rings": "FlowingRings", + "Forgotten Oath of Days Past": "ForgottenOathOfDaysPast", + "Forgotten Vessel": "ForgottenVessel", + "Fortitude of the Brave": "FortitudeOfTheBrave", + "Frost Devotee's Delirium": "FrostDevoteesDelirium", + "Frost-Weaved Dignity": "FrostWeavedDignity", + "Frozen Homeland's Demise": "FrozenHomelandsDemise", + "Gambler's Brooch": "GamblersBrooch", + "Gambler's Dice Cup": "GamblersDiceCup", + "Gambler's Earrings": "GamblersEarrings", + "Gambler's Feather Accessory": "GamblersFeatherAccessory", + "Gambler's Pocket Watch": "GamblersPocketWatch", + "General's Ancient Helm": "GeneralsAncientHelm", + "Gilded Corsage": "GildedCorsage", + "Gladiator's Destiny": "GladiatorsDestiny", + "Gladiator's Intoxication": "GladiatorsIntoxication", + "Gladiator's Longing": "GladiatorsLonging", + "Gladiator's Nostalgia": "GladiatorsNostalgia", + "Gladiator's Triumphus": "GladiatorsTriumphus", + "Goblet of Chiseled Crag": "GobletOfChiseledCrag", + "Goblet of the Sojourner": "GobletOfTheSojourner", + "Goblet of Thundering Deep": "GobletOfThunderingDeep", + "Golden Bird's Shedding": "GoldenBirdsShedding", + "Golden Era's Prelude": "GoldenErasPrelude", + "Golden Night's Bustle": "GoldenNightsBustle", + "Golden Song's Variation": "GoldenSongsVariation", + "Golden Troupe's Reward": "GoldenTroupesReward", + "Guardian's Band": "GuardiansBand", + "Guardian's Clock": "GuardiansClock", + "Guardian's Flower": "GuardiansFlower", + "Guardian's Sigil": "GuardiansSigil", + "Guardian's Vessel": "GuardiansVessel", + "Gust of Nostalgia": "GustOfNostalgia", + "Harmonious Symphony Prelude": "HarmoniousSymphonyPrelude", + "Heart of Comradeship": "HeartOfComradeship", + "Heart of Khvarena's Brilliance": "HeartOfKhvarenasBrilliance", + "Heavensent Crown": "HeavensentCrown", + "Heavensent Decree": "HeavensentDecree", + "Heavensent Demise": "HeavensentDemise", + "Heavensent Fragrance": "HeavensentFragrance", + "Heavensent Reward": "HeavensentReward", + "Heldenepos's Unspoken Tale": "HeldenepossUnspokenTale", + "Heroes' Tea Party": "HeroesTeaParty", + "Holy Crown of the Believer": "HolyCrownOfTheBeliever", + "Honest Quill": "HonestQuill", + "Honeyed Final Feast": "HoneyedFinalFeast", + "Hopeful Heart": "HopefulHeart", + "Hour of Soothing Thunder": "HourOfSoothingThunder", + "Hourglass of Thunder": "HourglassOfThunder", + "Hunter's Brooch": "HuntersBrooch", + "Icebreaker's Resolve": "IcebreakersResolve", + "Ichor Shower Rhapsody": "IchorShowerRhapsody", + "In Remembrance of Viridescent Fields": "InRemembranceOfViridescentFields", + "Instructor's Brooch": "InstructorsBrooch", + "Instructor's Cap": "InstructorsCap", + "Instructor's Feather Accessory": "InstructorsFeatherAccessory", + "Instructor's Pocket Watch": "InstructorsPocketWatch", + "Instructor's Tea Cup": "InstructorsTeaCup", + "Iridescence That Ceased Amidst Glory": "IridescenceThatCeasedAmidstGlory", + "Jade Leaf": "JadeLeaf", + "Joyous Glory of the Pure": "JoyousGloryOfThePure", + "Labyrinth Wayfarer": "LabyrinthWayfarer", + "Lamp of the Lost": "LampOfTheLost", + "Laurel Coronet": "LaurelCoronet", + "Lavawalker's Epiphany": "LavawalkersEpiphany", + "Lavawalker's Resolution": "LavawalkersResolution", + "Lavawalker's Salvation": "LavawalkersSalvation", + "Lavawalker's Torment": "LavawalkersTorment", + "Lavawalker's Wisdom": "LavawalkersWisdom", + "Legacy of the Desert High-Born": "LegacyOfTheDesertHighBorn", + "Lightkeeper's Pledge": "LightkeepersPledge", + "Lucky Dog's Clover": "LuckyDogsClover", + "Lucky Dog's Eagle Feather": "LuckyDogsEagleFeather", + "Lucky Dog's Goblet": "LuckyDogsGoblet", + "Lucky Dog's Hourglass": "LuckyDogsHourglass", + "Lucky Dog's Silver Circlet": "LuckyDogsSilverCirclet", + "Magnanimous Ink Bottle": "MagnanimousInkBottle", + "Magnificent Tsuba": "MagnificentTsuba", + "Maiden's Distant Love": "MaidensDistantLove", + "Maiden's Fading Beauty": "MaidensFadingBeauty", + "Maiden's Fleeting Leisure": "MaidensFleetingLeisure", + "Maiden's Heart-stricken Infatuation": "MaidensHeartStrickenInfatuation", + "Maiden's Passing Youth": "MaidensPassingYouth", + "Martial Artist's Bandana": "MartialArtistsBandana", + "Martial Artist's Feather Accessory": "MartialArtistsFeatherAccessory", + "Martial Artist's Red Flower": "MartialArtistsRedFlower", + "Martial Artist's Water Hourglass": "MartialArtistsWaterHourglass", + "Martial Artist's Wine Cup": "MartialArtistsWineCup", + "Mask of Solitude Basalt": "MaskOfSolitudeBasalt", + "Masterpiece's Overture": "MasterpiecesOverture", + "Medal of the Brave": "MedalOfTheBrave", + "Minnesang of Love and Lament": "MinnesangOfLoveAndLament", + "Mocking Mask": "MockingMask", + "Moment of Attainment": "MomentOfAttainment", + "Moment of Cessation": "MomentOfCessation", + "Moment of Judgment": "MomentOfJudgment", + "Moment of the Pact": "MomentOfThePact", + "Moment That Ceased Upon Waking From Grand Dreams": "MomentThatCeasedUponWakingFromGrandDreams", + "Moonlit Offering's Final Hour": "MoonlitOfferingsFinalHour", + "Moonlit Offering's Libation": "MoonlitOfferingsLibation", + "Moonlit Offering's Opulent Dream": "MoonlitOfferingsOpulentDream", + "Moonlit Offering's Parting Light": "MoonlitOfferingsPartingLight", + "Moonlit Offering's Silver Crown": "MoonlitOfferingsSilverCrown", + "Morning Dew's Moment": "MorningDewsMoment", + "Mountain Ranger's Marker": "MountainRangersMarker", + "Mystic's Gold Dial": "MysticsGoldDial", + "Myths of the Night Realm": "MythsOfTheNightRealm", + "Nightingale's Tail Feather": "NightingalesTailFeather", + "Noble's Pledging Vessel": "NoblesPledgingVessel", + "Nymph's Constancy": "NymphsConstancy", + "Odyssean Flower": "OdysseanFlower", + "Omen of Thunderstorm": "OmenOfThunderstorm", + "Orichalceous Time-Dial": "OrichalceousTimeDial", + "Ornate Kabuto": "OrnateKabuto", + "Outset of the Brave": "OutsetOfTheBrave", + "Ovations That Ceased Upon Festivity": "OvationsThatCeasedUponFestivity", + "Pearl Cage": "PearlCage", + "Pendulum That Ceased Amidst a Great Fall": "PendulumThatCeasedAmidstAGreatFall", + "Plume of Luxury": "PlumeOfLuxury", + "Poetry of Days Past": "PoetryOfDaysPast", + "Pre-Banquet of the Contenders": "PreBanquetOfTheContenders", + "Pristine Plume of the Blessed": "PristinePlumeOfTheBlessed", + "Promised Dream of Days Past": "PromisedDreamOfDaysPast", + "Prospect of the Brave": "ProspectOfTheBrave", + "Reckoning of the Xenogenic": "ReckoningOfTheXenogenic", + "Recollection of Days Past": "RecollectionOfDaysPast", + "Revelation's Toll": "RevelationsToll", + "Root of the Spirit-Marrow": "RootOfTheSpiritMarrow", + "Royal Flora": "RoyalFlora", + "Royal Masque": "RoyalMasque", + "Royal Plume": "RoyalPlume", + "Royal Pocket Watch": "RoyalPocketWatch", + "Royal Silver Urn": "RoyalSilverUrn", + "Scarlet Vessel": "ScarletVessel", + "Scholar of Vines": "ScholarOfVines", + "Scholar's Bookmark": "ScholarsBookmark", + "Scholar's Clock": "ScholarsClock", + "Scholar's Ink Cup": "ScholarsInkCup", + "Scholar's Lens": "ScholarsLens", + "Scholar's Quill Pen": "ScholarsQuillPen", + "Sea-Dyed Blossom": "SeaDyedBlossom", + "Secret-Keeper's Magic Bottle": "SecretKeepersMagicBottle", + "Selfless Floral Accessory": "SelflessFloralAccessory", + "Shadow of the Sand King": "ShadowOfTheSandKing", + "Shaft of Remembrance": "ShaftOfRemembrance", + "Sharpness That Ceased Upon Wondrous Creation": "SharpnessThatCeasedUponWondrousCreation", + "Skeletal Hat": "SkeletalHat", + "Snowswept Memory": "SnowsweptMemory", + "Solar Relic": "SolarRelic", + "Song of Life": "SongOfLife", + "Soulscent Bloom": "SoulscentBloom", + "Stainless Bloom": "StainlessBloom", + "Stamen of Khvarena's Origin": "StamenOfKhvarenasOrigin", + "Storm Cage": "StormCage", + "Summer Night's Bloom": "SummerNightsBloom", + "Summer Night's Finale": "SummerNightsFinale", + "Summer Night's Mask": "SummerNightsMask", + "Summer Night's Moment": "SummerNightsMoment", + "Summer Night's Waterballoon": "SummerNightsWaterballoon", + "Sundered Feather": "SunderedFeather", + "Sundial of Enduring Jade": "SundialOfEnduringJade", + "Sundial of the Sojourner": "SundialOfTheSojourner", + "Surpassing Cup": "SurpassingCup", + "Survivor of Catastrophe": "SurvivorOfCatastrophe", + "Symbol of Felicitation": "SymbolOfFelicitation", + "The First Days of the City of Kings": "TheFirstDaysOfTheCityOfKings", + "The Grand Jape of the Turning of Fate": "TheGrandJapeOfTheTurningOfFate", + "The Sunken Years": "TheSunkenYears", + "The Wine-Flask Over Which the Plan Was Hatched": "TheWineFlaskOverWhichThePlanWasHatched", + "Thunder Summoner's Crown": "ThunderSummonersCrown", + "Thunderbird's Mercy": "ThunderbirdsMercy", + "Thundering Poise": "ThunderingPoise", + "Thundersoother's Diadem": "ThundersoothersDiadem", + "Thundersoother's Goblet": "ThundersoothersGoblet", + "Thundersoother's Heart": "ThundersoothersHeart", + "Thundersoother's Plume": "ThundersoothersPlume", + "Tiara of Flame": "TiaraOfFlame", + "Tiara of Frost": "TiaraOfFrost", + "Tiara of Thunder": "TiaraOfThunder", + "Tiara of Torrents": "TiaraOfTorrents", + "Timepiece of the Lost Path": "TimepieceOfTheLostPath", + "Tiny Miracle's Earrings": "TinyMiraclesEarrings", + "Tiny Miracle's Feather": "TinyMiraclesFeather", + "Tiny Miracle's Flower": "TinyMiraclesFlower", + "Tiny Miracle's Goblet": "TinyMiraclesGoblet", + "Tiny Miracle's Hourglass": "TinyMiraclesHourglass", + "Traveling Doctor's Handkerchief": "TravelingDoctorsHandkerchief", + "Traveling Doctor's Medicine Pot": "TravelingDoctorsMedicinePot", + "Traveling Doctor's Owl Feather": "TravelingDoctorsOwlFeather", + "Traveling Doctor's Pocket Watch": "TravelingDoctorsPocketWatch", + "Traveling Doctor's Silver Lotus": "TravelingDoctorsSilverLotus", + "Troupe's Dawnlight": "TroupesDawnlight", + "Undying One's Mourning Bell": "UndyingOnesMourningBell", + "Vessel of Plenty": "VesselOfPlenty", + "Veteran's Visage": "VeteransVisage", + "Vibrant Pinion": "VibrantPinion", + "Viridescent Arrow Feather": "ViridescentArrowFeather", + "Viridescent Venerer's Determination": "ViridescentVenerersDetermination", + "Viridescent Venerer's Diadem": "ViridescentVenerersDiadem", + "Viridescent Venerer's Vessel": "ViridescentVenerersVessel", + "Wanderer's String-Kettle": "WanderersStringKettle", + "Wandering Scholar's Claw Cup": "WanderingScholarsClawCup", + "Whimsical Dance of the Withered": "WhimsicalDanceOfTheWithered", + "Wicked Mage's Plumule": "WickedMagesPlumule", + "Wilting Feast": "WiltingFeast", + "Windborne Flower's Spruchdichtung": "WindborneFlowersSpruchdichtung", + "Wine-Stained Tricorne": "WineStainedTricorne", + "Wise Doctor's Pinion": "WiseDoctorsPinion", + "Witch's End Time": "WitchsEndTime", + "Witch's Ever-Burning Plume": "WitchsEverBurningPlume", + "Witch's Flower of Blaze": "WitchsFlowerOfBlaze", + "Witch's Heart Flames": "WitchsHeartFlames", + "Witch's Scorching Hat": "WitchsScorchingHat" + }, + "stats": { + "Elemental Mastery": "eleMas", + "Energy Recharge": "enerRech_", + "CRIT Rate": "critRate_", + "CRIT DMG": "critDMG_", + "Healing Bonus": "heal_", + "ATK%": "atk_", + "HP%": "hp_", + "DEF%": "def_", + "ATK": "atk", + "HP": "hp", + "DEF": "def", + "Hydro DMG Bonus": "hydro_dmg_", + "Pyro DMG Bonus": "pyro_dmg_", + "Electro DMG Bonus": "electro_dmg_", + "Cryo DMG Bonus": "cryo_dmg_", + "Dendro DMG Bonus": "dendro_dmg_", + "Anemo DMG Bonus": "anemo_dmg_", + "Geo DMG Bonus": "geo_dmg_", + "Physical DMG Bonus": "physical_dmg_" + }, + "characters": { + "Aether": "Aether", + "Aino": "Aino", + "Albedo": "Albedo", + "Alhaitham": "Alhaitham", + "Aloy": "Aloy", + "Amber": "Amber", + "Arataki Itto": "AratakiItto", + "Arlecchino": "Arlecchino", + "Baizhu": "Baizhu", + "Barbara": "Barbara", + "Beidou": "Beidou", + "Bennett": "Bennett", + "Candace": "Candace", + "Charlotte": "Charlotte", + "Chasca": "Chasca", + "Chevreuse": "Chevreuse", + "Chiori": "Chiori", + "Chongyun": "Chongyun", + "Citlali": "Citlali", + "Clorinde": "Clorinde", + "Collei": "Collei", + "Columbina": "Columbina", + "Cyno": "Cyno", + "Dahlia": "Dahlia", + "Dehya": "Dehya", + "Diluc": "Diluc", + "Diona": "Diona", + "Dori": "Dori", + "Durin": "Durin", + "Emilie": "Emilie", + "Escoffier": "Escoffier", + "Eula": "Eula", + "Faruzan": "Faruzan", + "Fischl": "Fischl", + "Flins": "Flins", + "Freminet": "Freminet", + "Furina": "Furina", + "Gaming": "Gaming", + "Ganyu": "Ganyu", + "Gorou": "Gorou", + "Hu Tao": "HuTao", + "Iansan": "Iansan", + "Ifa": "Ifa", + "Illuga": "Illuga", + "Ineffa": "Ineffa", + "Jahoda": "Jahoda", + "Jean": "Jean", + "Kachina": "Kachina", + "Kaedehara Kazuha": "KaedeharaKazuha", + "Kaeya": "Kaeya", + "Kamisato Ayaka": "KamisatoAyaka", + "Kamisato Ayato": "KamisatoAyato", + "Kaveh": "Kaveh", + "Keqing": "Keqing", + "Kinich": "Kinich", + "Kirara": "Kirara", + "Klee": "Klee", + "Kujou Sara": "KujouSara", + "Kuki Shinobu": "KukiShinobu", + "Lan Yan": "LanYan", + "Lauma": "Lauma", + "Layla": "Layla", + "Linnea": "Linnea", + "Lisa": "Lisa", + "Lohen": "Lohen", + "Lumine": "Lumine", + "Lynette": "Lynette", + "Lyney": "Lyney", + "Manekin": "Manekin", + "Manekina": "Manekina", + "Mavuika": "Mavuika", + "Mika": "Mika", + "Mona": "Mona", + "Mualani": "Mualani", + "Nahida": "Nahida", + "Navia": "Navia", + "Nefer": "Nefer", + "Neuvillette": "Neuvillette", + "Nicole": "Nicole", + "Nilou": "Nilou", + "Ningguang": "Ningguang", + "Noelle": "Noelle", + "Ororon": "Ororon", + "Prune": "Prune", + "Qiqi": "Qiqi", + "Raiden Shogun": "RaidenShogun", + "Razor": "Razor", + "Rosaria": "Rosaria", + "Sandrone": "Sandrone", + "Sangonomiya Kokomi": "SangonomiyaKokomi", + "Sayu": "Sayu", + "Sethos": "Sethos", + "Shenhe": "Shenhe", + "Shikanoin Heizou": "ShikanoinHeizou", + "Sigewinne": "Sigewinne", + "Skirk": "Skirk", + "Sucrose": "Sucrose", + "Tartaglia": "Tartaglia", + "Thoma": "Thoma", + "Tighnari": "Tighnari", + "Varesa": "Varesa", + "Varka": "Varka", + "Venti": "Venti", + "Wanderer": "Wanderer", + "Wriothesley": "Wriothesley", + "Xiangling": "Xiangling", + "Xianyun": "Xianyun", + "Xiao": "Xiao", + "Xilonen": "Xilonen", + "Xingqiu": "Xingqiu", + "Xinyan": "Xinyan", + "Yae Miko": "YaeMiko", + "Yanfei": "Yanfei", + "Yaoyao": "Yaoyao", + "Yelan": "Yelan", + "Yoimiya": "Yoimiya", + "Yumemizuki Mizuki": "YumemizukiMizuki", + "Yun Jin": "YunJin", + "Zhongli": "Zhongli", + "Zibai": "Zibai" + } + }, + "setToPieces": { + "A Day Carved From Rising Winds": [ + "A Note in Spring's Leich", + "Dawn's Brilliant Oath", + "Heldenepos's Unspoken Tale", + "Minnesang of Love and Lament", + "Windborne Flower's Spruchdichtung" + ], + "Adventurer": [ + "Adventurer's Bandana", + "Adventurer's Flower", + "Adventurer's Golden Goblet", + "Adventurer's Pocket Watch", + "Adventurer's Tail Feather" + ], + "Archaic Petra": [ + "Feather of Jagged Peaks", + "Flower of Creviced Cliff", + "Goblet of Chiseled Crag", + "Mask of Solitude Basalt", + "Sundial of Enduring Jade" + ], + "Aubade of Morningstar and Moon": [ + "Moonlit Offering's Final Hour", + "Moonlit Offering's Libation", + "Moonlit Offering's Opulent Dream", + "Moonlit Offering's Parting Light", + "Moonlit Offering's Silver Crown" + ], + "Berserker": [ + "Berserker's Battle Mask", + "Berserker's Bone Goblet", + "Berserker's Indigo Feather", + "Berserker's Rose", + "Berserker's Timepiece" + ], + "Blizzard Strayer": [ + "Broken Rime's Echo", + "Frost-Weaved Dignity", + "Frozen Homeland's Demise", + "Icebreaker's Resolve", + "Snowswept Memory" + ], + "Bloodstained Chivalry": [ + "Bloodstained Black Plume", + "Bloodstained Chevalier's Goblet", + "Bloodstained Final Hour", + "Bloodstained Flower of Iron", + "Bloodstained Iron Mask" + ], + "Brave Heart": [ + "Crown of the Brave", + "Fortitude of the Brave", + "Medal of the Brave", + "Outset of the Brave", + "Prospect of the Brave" + ], + "Celestial Gift": [ + "Heavensent Crown", + "Heavensent Decree", + "Heavensent Demise", + "Heavensent Fragrance", + "Heavensent Reward" + ], + "Crimson Witch of Flames": [ + "Witch's End Time", + "Witch's Ever-Burning Plume", + "Witch's Flower of Blaze", + "Witch's Heart Flames", + "Witch's Scorching Hat" + ], + "Deepwood Memories": [ + "A Time of Insight", + "Labyrinth Wayfarer", + "Lamp of the Lost", + "Laurel Coronet", + "Scholar of Vines" + ], + "Defender's Will": [ + "Guardian's Band", + "Guardian's Clock", + "Guardian's Flower", + "Guardian's Sigil", + "Guardian's Vessel" + ], + "Desert Pavilion Chronicle": [ + "Defender of the Enchanting Dream", + "End of the Golden Realm", + "Legacy of the Desert High-Born", + "The First Days of the City of Kings", + "Timepiece of the Lost Path" + ], + "Disenchantment in Deep Shadow": [ + "Iridescence That Ceased Amidst Glory", + "Moment That Ceased Upon Waking From Grand Dreams", + "Ovations That Ceased Upon Festivity", + "Pendulum That Ceased Amidst a Great Fall", + "Sharpness That Ceased Upon Wondrous Creation" + ], + "Echoes of an Offering": [ + "Chalice of the Font", + "Flowing Rings", + "Jade Leaf", + "Soulscent Bloom", + "Symbol of Felicitation" + ], + "Emblem of Severed Fate": [ + "Magnificent Tsuba", + "Ornate Kabuto", + "Scarlet Vessel", + "Storm Cage", + "Sundered Feather" + ], + "Finale of the Deep Galleries": [ + "Deep Gallery's Bestowed Banquet", + "Deep Gallery's Distant Pact", + "Deep Gallery's Echoing Song", + "Deep Gallery's Lost Crown", + "Deep Gallery's Moment of Oblivion" + ], + "Flower of Paradise Lost": [ + "A Moment Congealed", + "Amethyst Crown", + "Ay-Khanoum's Myriad", + "Secret-Keeper's Magic Bottle", + "Wilting Feast" + ], + "Fragment of Harmonic Whimsy": [ + "Ancient Sea's Nocturnal Musing", + "Harmonious Symphony Prelude", + "Ichor Shower Rhapsody", + "The Grand Jape of the Turning of Fate", + "Whimsical Dance of the Withered" + ], + "Gambler": [ + "Gambler's Brooch", + "Gambler's Dice Cup", + "Gambler's Earrings", + "Gambler's Feather Accessory", + "Gambler's Pocket Watch" + ], + "Gilded Dreams": [ + "Dreaming Steelbloom", + "Feather of Judgment", + "Honeyed Final Feast", + "Shadow of the Sand King", + "The Sunken Years" + ], + "Gladiator's Finale": [ + "Gladiator's Destiny", + "Gladiator's Intoxication", + "Gladiator's Longing", + "Gladiator's Nostalgia", + "Gladiator's Triumphus" + ], + "Golden Troupe": [ + "Golden Bird's Shedding", + "Golden Era's Prelude", + "Golden Night's Bustle", + "Golden Song's Variation", + "Golden Troupe's Reward" + ], + "Heart of Depth": [ + "Copper Compass", + "Gilded Corsage", + "Goblet of Thundering Deep", + "Gust of Nostalgia", + "Wine-Stained Tricorne" + ], + "Husk of Opulent Dreams": [ + "Bloom Times", + "Calabash of Awakening", + "Plume of Luxury", + "Skeletal Hat", + "Song of Life" + ], + "Instructor": [ + "Instructor's Brooch", + "Instructor's Cap", + "Instructor's Feather Accessory", + "Instructor's Pocket Watch", + "Instructor's Tea Cup" + ], + "Lavawalker": [ + "Lavawalker's Epiphany", + "Lavawalker's Resolution", + "Lavawalker's Salvation", + "Lavawalker's Torment", + "Lavawalker's Wisdom" + ], + "Long Night's Oath": [ + "A Horn Unwinded", + "Dyed Tassel", + "Lightkeeper's Pledge", + "Nightingale's Tail Feather", + "Undying One's Mourning Bell" + ], + "Lucky Dog": [ + "Lucky Dog's Clover", + "Lucky Dog's Eagle Feather", + "Lucky Dog's Goblet", + "Lucky Dog's Hourglass", + "Lucky Dog's Silver Circlet" + ], + "Maiden Beloved": [ + "Maiden's Distant Love", + "Maiden's Fading Beauty", + "Maiden's Fleeting Leisure", + "Maiden's Heart-stricken Infatuation", + "Maiden's Passing Youth" + ], + "Marechaussee Hunter": [ + "Forgotten Vessel", + "Hunter's Brooch", + "Masterpiece's Overture", + "Moment of Judgment", + "Veteran's Visage" + ], + "Martial Artist": [ + "Martial Artist's Bandana", + "Martial Artist's Feather Accessory", + "Martial Artist's Red Flower", + "Martial Artist's Water Hourglass", + "Martial Artist's Wine Cup" + ], + "Night of the Sky's Unveiling": [ + "Bloom of the Mind's Desire", + "Crown of the Befallen", + "Feather of Indelible Sin", + "Revelation's Toll", + "Vessel of Plenty" + ], + "Nighttime Whispers in the Echoing Woods": [ + "Compassionate Ladies' Hat", + "Faithful Hourglass", + "Honest Quill", + "Magnanimous Ink Bottle", + "Selfless Floral Accessory" + ], + "Noblesse Oblige": [ + "Royal Flora", + "Royal Masque", + "Royal Plume", + "Royal Pocket Watch", + "Royal Silver Urn" + ], + "Nymph's Dream": [ + "Fell Dragon's Monocle", + "Heroes' Tea Party", + "Nymph's Constancy", + "Odyssean Flower", + "Wicked Mage's Plumule" + ], + "Obsidian Codex": [ + "Crown of the Saints", + "Myths of the Night Realm", + "Pre-Banquet of the Contenders", + "Reckoning of the Xenogenic", + "Root of the Spirit-Marrow" + ], + "Ocean-Hued Clam": [ + "Cowry of Parting", + "Crown of Watatsumi", + "Deep Palace's Plume", + "Pearl Cage", + "Sea-Dyed Blossom" + ], + "Pale Flame": [ + "Mocking Mask", + "Moment of Cessation", + "Stainless Bloom", + "Surpassing Cup", + "Wise Doctor's Pinion" + ], + "Prayers for Destiny": [ + "Tiara of Torrents" + ], + "Prayers for Illumination": [ + "Tiara of Flame" + ], + "Prayers for Wisdom": [ + "Tiara of Thunder" + ], + "Prayers to Springtime": [ + "Tiara of Frost" + ], + "Resolution of Sojourner": [ + "Crown of Parting", + "Feather of Homecoming", + "Goblet of the Sojourner", + "Heart of Comradeship", + "Sundial of the Sojourner" + ], + "Retracing Bolide": [ + "Summer Night's Bloom", + "Summer Night's Finale", + "Summer Night's Mask", + "Summer Night's Moment", + "Summer Night's Waterballoon" + ], + "Scholar": [ + "Scholar's Bookmark", + "Scholar's Clock", + "Scholar's Ink Cup", + "Scholar's Lens", + "Scholar's Quill Pen" + ], + "Scroll of the Hero of Cinder City": [ + "Beast Tamer's Talisman", + "Demon-Warrior's Feather Mask", + "Mountain Ranger's Marker", + "Mystic's Gold Dial", + "Wandering Scholar's Claw Cup" + ], + "Shimenawa's Reminiscence": [ + "Capricious Visage", + "Entangling Bloom", + "Hopeful Heart", + "Morning Dew's Moment", + "Shaft of Remembrance" + ], + "Silken Moon's Serenade": [ + "Crystal Tear of the Wanderer", + "Frost Devotee's Delirium", + "Holy Crown of the Believer", + "Joyous Glory of the Pure", + "Pristine Plume of the Blessed" + ], + "Song of Days Past": [ + "Echoing Sound From Days Past", + "Forgotten Oath of Days Past", + "Poetry of Days Past", + "Promised Dream of Days Past", + "Recollection of Days Past" + ], + "Tenacity of the Millelith": [ + "Ceremonial War-Plume", + "Flower of Accolades", + "General's Ancient Helm", + "Noble's Pledging Vessel", + "Orichalceous Time-Dial" + ], + "The Exile": [ + "Exile's Circlet", + "Exile's Feather", + "Exile's Flower", + "Exile's Goblet", + "Exile's Pocket Watch" + ], + "Thundering Fury": [ + "Hourglass of Thunder", + "Omen of Thunderstorm", + "Survivor of Catastrophe", + "Thunder Summoner's Crown", + "Thunderbird's Mercy" + ], + "Thundersoother": [ + "Hour of Soothing Thunder", + "Thundersoother's Diadem", + "Thundersoother's Goblet", + "Thundersoother's Heart", + "Thundersoother's Plume" + ], + "Tiny Miracle": [ + "Tiny Miracle's Earrings", + "Tiny Miracle's Feather", + "Tiny Miracle's Flower", + "Tiny Miracle's Goblet", + "Tiny Miracle's Hourglass" + ], + "Traveling Doctor": [ + "Traveling Doctor's Handkerchief", + "Traveling Doctor's Medicine Pot", + "Traveling Doctor's Owl Feather", + "Traveling Doctor's Pocket Watch", + "Traveling Doctor's Silver Lotus" + ], + "Unfinished Reverie": [ + "Crownless Crown", + "Dark Fruit of Bright Flowers", + "Faded Emerald Tail", + "Moment of Attainment", + "The Wine-Flask Over Which the Plan Was Hatched" + ], + "Vermillion Hereafter": [ + "Feather of Nascent Light", + "Flowering Life", + "Moment of the Pact", + "Solar Relic", + "Thundering Poise" + ], + "Viridescent Venerer": [ + "In Remembrance of Viridescent Fields", + "Viridescent Arrow Feather", + "Viridescent Venerer's Determination", + "Viridescent Venerer's Diadem", + "Viridescent Venerer's Vessel" + ], + "Vourukasha's Glow": [ + "Ancient Abscission", + "Feast of Boundless Joy", + "Heart of Khvarena's Brilliance", + "Stamen of Khvarena's Origin", + "Vibrant Pinion" + ], + "Wanderer's Troupe": [ + "Bard's Arrow Feather", + "Concert's Final Hour", + "Conductor's Top Hat", + "Troupe's Dawnlight", + "Wanderer's String-Kettle" + ] + }, + "validation": { + "valid": true, + "errors": [], + "warnings": [], + "summary": { + "artifactSets": 61, + "artifactPieces": 289, + "characters": 120, + "stats": 29 + } + } + }, "uiProfiles": { "artifactDetailEn": { "language": "English", diff --git a/src/features/scan/components/DiagnosticsView.tsx b/src/features/scan/components/DiagnosticsView.tsx index d13ccbb..904ed48 100644 --- a/src/features/scan/components/DiagnosticsView.tsx +++ b/src/features/scan/components/DiagnosticsView.tsx @@ -1,5 +1,5 @@ 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 { ScanViewControllerResult } from "../types"; import { FieldConfidenceList } from "./ScanResultCards"; @@ -14,6 +14,53 @@ interface DiagnosticsViewProps { 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 // workspace: runtime + rights, grid detection, learning + data-package status, // fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/ @@ -39,6 +86,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe playerProgress, reviewStatus, automationLogLines, + diagnosticEvents, canSaveReviewSample, handleSaveReviewSample, } = useScanDiagnosticsModalModel({ @@ -106,6 +154,37 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe +
+
+
+

Aktueller App-Stand

+

Scanner zuerst, Empfehlungen danach

+
+ + + Quelle: Docs + Live-Status + +
+
+ {appDiagnosisSections.map((section) => { + const Icon = section.icon; + return ( +
+
+ + {section.title} +
+
    + {section.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); + })} +
+
+

Status

@@ -196,6 +275,67 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
+
+
+
+

Evidence timeline

+

Scan-Flugschreiber

+
+ + + letzte {diagnosticEvents.length} + +
+ {diagnosticEvents.length > 0 ? ( +
+ {diagnosticEvents.slice().reverse().map((event) => ( +
+
+ {new Date(event.at).toLocaleTimeString()} + {event.phase} + {event.severity} +
+

{event.message}

+ {event.details && ( +
+ {Object.entries(event.details).map(([key, value]) => ( + {key}: {String(value ?? "-")} + ))} +
+ )} + {event.capture && ( +
+
+ {event.capture.name} + {event.capture.width}x{event.capture.height} · {event.capture.target ?? "capture"} · fp {event.capture.fingerprint} + {event.capture.grid && ( + grid {event.capture.grid.cols}x{event.capture.grid.rows} · {event.capture.grid.targets} targets · {event.capture.grid.confidence}% · {event.capture.grid.source} + )} + {event.capture.count && ( + count {event.capture.count.current}/{event.capture.count.total || "?"} · {event.capture.count.confidence}% · {event.capture.count.text || "-"} + )} + {event.capture.artifactDetail && ( + detail {event.capture.artifactDetail.present ? "yes" : "no"} · {event.capture.artifactDetail.confidence}% · orange {event.capture.artifactDetail.orangeHits} · text {event.capture.artifactDetail.textHits} + )} + {event.capture.paimonMenu && ( + paimon {event.capture.paimonMenu.present ? "yes" : "no"} · {event.capture.paimonMenu.confidence}% + )} + {event.capture.layoutWarning && {event.capture.layoutWarning}} +
+
+ {event.capture.screenshots?.inventory && {`${event.phase}} + {event.capture.screenshots?.detail && {`${event.phase}} +
+
+ )} +
+ ))} +
+ ) : ( +

Noch keine Evidence-Events. Starte einen Capture oder Auto-Scan, dann erscheinen hier Schritte mit Screenshots.

+ )} +
+

Crops, OCR & Confidence

diff --git a/src/features/scan/components/ScanMainSection.tsx b/src/features/scan/components/ScanMainSection.tsx index 0246328..aed965c 100644 --- a/src/features/scan/components/ScanMainSection.tsx +++ b/src/features/scan/components/ScanMainSection.tsx @@ -7,14 +7,9 @@ export function ScanMainSection({ latestCapture, captureStatus, parsedArtifact, - sourceLabel, - gridLabel, - inventoryLabel, activeTargetCount, storedTotal, reviewSampleTotal, - learningRulesLoaded, - learningRuleCount, setDetailsOpen, autoScanRunning, canOpenReviewQueue, @@ -27,29 +22,25 @@ export function ScanMainSection({ captureImageSrc, captureImageAlt, hasCapture, - captureModeText, resultHeading, noArtifactText, noCaptureMessage, targetLabel, dbLabel, reviewLabel, - rulesLabel, } = useScanMainSectionModel({ latestCapture, parsedArtifact, activeTargetCount, storedTotal, reviewSampleTotal, - learningRulesLoaded, - learningRuleCount, setDetailsOpen, openReviewQueue, }); return (
-
+
{hasCapture ? ( {captureImageAlt} @@ -61,10 +52,6 @@ export function ScanMainSection({
)}
-
-
Quelle{sourceLabel}
-
Inventar{inventoryLabel}
-
+

{captureStatus}

diff --git a/src/features/scan/components/ScanTopControlsSection.tsx b/src/features/scan/components/ScanTopControlsSection.tsx index cae02fe..9070175 100644 --- a/src/features/scan/components/ScanTopControlsSection.tsx +++ b/src/features/scan/components/ScanTopControlsSection.tsx @@ -32,7 +32,7 @@ export function ScanTopControlsSection({ openDiagnostics, captureSingleArtifact, stopScan, - runVisibleGridScan, + runGuidedAutoScan, runAutoReviewScan, bridgeStatusText, bridgePillClass, @@ -46,7 +46,6 @@ export function ScanTopControlsSection({ refreshCaptureSourcesTitle, diagnosticsButtonTitle, scanSetupButtonTitle, - showPlayerProgress, progressWidth, progressStats, } = useScanTopControlsModel({ @@ -64,8 +63,7 @@ export function ScanTopControlsSection({

Scanner

-

Artifact capture workspace

-

Quelle waehlen, Auto-Scan starten, Ergebnis rechts pruefen. Dev-Details im Diagnose-Tab.

+

Artifact Scan

{bridgeStatusText} @@ -119,46 +117,42 @@ export function ScanTopControlsSection({
-
- - - - {autoScanRunning && ( - - )} -
+ + + {autoScanRunning && ( + + )} +
-

- {playerStatusText} -

- - {showPlayerProgress && (
@@ -171,7 +165,11 @@ export function ScanTopControlsSection({ ))}
- )} +
+ +

+ {playerStatusText} +

); diff --git a/src/features/scan/components/hooks/useScanMainSectionModel.ts b/src/features/scan/components/hooks/useScanMainSectionModel.ts index c6830e9..7060183 100644 --- a/src/features/scan/components/hooks/useScanMainSectionModel.ts +++ b/src/features/scan/components/hooks/useScanMainSectionModel.ts @@ -8,14 +8,12 @@ export interface ScanMainSectionModel { captureImageSrc: string; captureImageAlt: string; hasCapture: boolean; - captureModeText: string; resultHeading: string; noArtifactText: string; noCaptureMessage: string; targetLabel: string; dbLabel: string; reviewLabel: string; - rulesLabel: string; } type UseScanMainSectionModelProps = Pick< @@ -25,8 +23,6 @@ type UseScanMainSectionModelProps = Pick< | "activeTargetCount" | "storedTotal" | "reviewSampleTotal" - | "learningRulesLoaded" - | "learningRuleCount" | "setDetailsOpen" | "openReviewQueue" >; @@ -37,8 +33,6 @@ export function useScanMainSectionModel({ activeTargetCount, storedTotal, reviewSampleTotal, - learningRulesLoaded, - learningRuleCount, setDetailsOpen, openReviewQueue, }: UseScanMainSectionModelProps): ScanMainSectionModel { @@ -53,14 +47,12 @@ export function useScanMainSectionModel({ const captureImageAlt = latestCapture ? `Latest capture from ${latestCapture.name}` : "Latest capture is not available yet"; - const captureModeText = latestCapture ? "Erkannt" : "Warte"; 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 noCaptureMessage = noArtifactText; const targetLabel = `Ziel ${activeTargetCount}`; const dbLabel = `DB ${storedTotal ?? "-"}`; const reviewLabel = `Review ${reviewSampleTotal}`; - const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`; return { canOpenDetails, @@ -69,13 +61,11 @@ export function useScanMainSectionModel({ captureImageSrc, captureImageAlt, hasCapture: Boolean(latestCapture), - captureModeText, resultHeading, noArtifactText, noCaptureMessage, targetLabel, dbLabel, reviewLabel, - rulesLabel, }; } diff --git a/src/features/scan/components/hooks/useScanSummaryFooterModel.ts b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts index 8dbb083..f333233 100644 --- a/src/features/scan/components/hooks/useScanSummaryFooterModel.ts +++ b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts @@ -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 { summaryCopy: string; @@ -12,10 +20,10 @@ export function useScanSummaryFooterModel({ }: ScanSummaryFooterProps): ScanSummaryFooterModel { const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan" ? "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 - ? `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; return { diff --git a/src/features/scan/components/hooks/useScanTopControlsModel.ts b/src/features/scan/components/hooks/useScanTopControlsModel.ts index e5a386a..61ed13d 100644 --- a/src/features/scan/components/hooks/useScanTopControlsModel.ts +++ b/src/features/scan/components/hooks/useScanTopControlsModel.ts @@ -15,7 +15,7 @@ export interface ScanTopControlsModel { openDiagnostics: () => void; captureSingleArtifact: () => void; stopScan: () => void; - runVisibleGridScan: () => void; + runGuidedAutoScan: () => void; runAutoReviewScan: () => void; bridgeStatusText: string; bridgePillClass: string; @@ -47,7 +47,7 @@ export function useScanTopControlsModel({ setSettingsOpen, setDiagnosticsOpen, requestScanStop, - runVisibleGridScan, + runGuidedAutoScan, runAutoReviewScan, autoScanRunning, canCaptureSource, @@ -77,17 +77,20 @@ export function useScanTopControlsModel({ const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]); const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]); const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]); + const startGuidedAutoScan = useCallback(() => { + void runGuidedAutoScan(); + }, [runGuidedAutoScan]); const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt"; const bridgePillClass = bridgeReady ? "elevated" : "standard"; const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard"; const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard"; const playerStatusText = reviewStatus || (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."); const autoScanButtonTitle = requiresAdminForAutoScan ? "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 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."; @@ -104,11 +107,20 @@ export function useScanTopControlsModel({ { label: "Klicks", value: autoScanStats.clicked }, { label: "Positionen", value: autoScanStats.attempted }, { label: "Verifiziert", value: autoScanStats.verified }, + { label: "ms/Artifact", value: autoScanStats.averageMsPerParsed || "-" }, { label: "Gespeichert", value: autoScanStats.stored }, { label: "Review", value: autoScanStats.review }, { 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 { @@ -124,7 +136,7 @@ export function useScanTopControlsModel({ openDiagnostics, captureSingleArtifact, stopScan, - runVisibleGridScan, + runGuidedAutoScan: startGuidedAutoScan, runAutoReviewScan, bridgeStatusText, bridgePillClass, diff --git a/src/features/scan/components/modals/ScanSettingsModal.tsx b/src/features/scan/components/modals/ScanSettingsModal.tsx index aa79f35..a4af8f0 100644 --- a/src/features/scan/components/modals/ScanSettingsModal.tsx +++ b/src/features/scan/components/modals/ScanSettingsModal.tsx @@ -1,5 +1,6 @@ import type { ScanSettingsModalProps } from "./types"; import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel"; +import type { StepperControlModel } from "./hooks/useScanSettingsModalModel"; export function ScanSettingsModal({ open, @@ -16,9 +17,9 @@ export function ScanSettingsModal({ const { closeSettings, - handleScanLimitChange, - handleSkipRowsChange, stopPropagation, + scanLimitControl, + skipRowsControl, inventoryCountText, inventoryClassName, scanLimitClassName, @@ -50,33 +51,17 @@ export function ScanSettingsModal({
-
- - -

- 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. -

+
+
+ + +
+
+ Manuelle Werte bleiben erhalten. + Die erkannte Inventar-Anzahl ist nur ein Vorschlag und oberer Deckel. Startzeilen brauchst du nur, wenn du nicht oben beginnst. +
-
+
Inventarzaehler {inventoryCountText} @@ -97,3 +82,42 @@ export function ScanSettingsModal({
); } + +function StepperControl({ control }: { control: StepperControlModel }) { + return ( +
+
+
+ {control.label} + {control.helper} +
+
+
+ + + +
+
+ {control.presets.map((value) => ( + + ))} +
+
+ ); +} diff --git a/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts index 705c90e..d3155a7 100644 --- a/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts +++ b/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts @@ -33,21 +33,24 @@ export function useScanDetailsModalModel({ const crops = latestCapture?.crops ?? []; 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 { closeDetails, stopPropagation, parsedNotes, showParsedNotes: parsedNotes.length > 0, - cropRows: crops.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, - })), + cropRows, ocrRows: ocr.map((entry) => ({ id: entry.id, label: entry.label, @@ -55,7 +58,7 @@ export function useScanDetailsModalModel({ text: entry.text || "No text detected", })), debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`, - showCrops: crops.length > 0, + showCrops: cropRows.length > 0, showOcr: ocr.length > 0, }; } diff --git a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts index 62cc9aa..770650c 100644 --- a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts +++ b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts @@ -1,8 +1,10 @@ import { detailFingerprint } from "../../../../../lib/autoScanLoop"; import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData"; import { dataPackageStatus } from "../../../../../lib/dataPackageStatus"; +import { validateLookupPackage } from "../../../../../lib/genshinLookup"; import { useCallback, useMemo, type MouseEvent } from "react"; import type { ScanDiagnosticsModalProps } from "../types"; +import type { ScanDiagnosticEvent } from "../../../../../lib/scanDiagnosticsLog"; export interface ScanDiagnosticsModelProgress { width: number; @@ -37,6 +39,7 @@ export interface ScanDiagnosticsModalModel { showDevRows: boolean; reviewStatus: string; automationLogLines: string[]; + diagnosticEvents: ScanDiagnosticEvent[]; canSaveReviewSample: boolean; } @@ -98,7 +101,11 @@ export function useScanDiagnosticsModalModel({ const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading"; 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 width = Math.min( @@ -128,6 +135,25 @@ export function useScanDiagnosticsModalModel({ { label: "duplicates", value: controller.autoScanStats.duplicates }, { label: "misses", value: controller.autoScanStats.misses }, { 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, @@ -139,6 +165,25 @@ export function useScanDiagnosticsModalModel({ controller.autoScanStats.duplicates, controller.autoScanStats.misses, 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, reviewStatus: controller.reviewStatus, automationLogLines: controller.automationLog, + diagnosticEvents: controller.diagnosticEvents, canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact), }; } diff --git a/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts b/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts index 7744eca..dbad180 100644 --- a/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts +++ b/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts @@ -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 { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession"; +export interface StepperControlModel { + label: string; + value: string; + helper: string; + min: number; + max: number; + presets: number[]; + onChange: (event: ChangeEvent) => void; + onBlur: (event: FocusEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + decrement: () => void; + increment: () => void; + applyPreset: (value: number) => void; +} + export interface ScanSettingsModalModel { closeSettings: () => void; - handleScanLimitChange: (event: ChangeEvent) => void; - handleSkipRowsChange: (event: ChangeEvent) => void; stopPropagation: (event: MouseEvent) => void; + scanLimitControl: StepperControlModel; + skipRowsControl: StepperControlModel; inventoryCountText: string; inventoryClassName: string; scanLimitClassName: string; @@ -36,17 +51,71 @@ export function useScanSettingsModalModel({ setScanLimitTouched: (touched: boolean) => void; setSkipRows: (rows: number) => void; }): 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 handleScanLimitChange = useCallback((event: ChangeEvent) => { - setScanLimitTouched(true); - setScanLimit(clampScanLimit(Number(event.target.value))); - }, [setScanLimit, setScanLimitTouched]); - const handleSkipRowsChange = useCallback((event: ChangeEvent) => { - setSkipRows(clampSkipRows(Number(event.target.value))); - }, [setSkipRows]); const stopPropagation = useCallback((event: MouseEvent) => { 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) => { + setScanLimitText(event.target.value.replace(/\D/g, "").slice(0, 4)); + }, []); + + const handleSkipRowsChange = useCallback((event: ChangeEvent) => { + 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) => { + commitScanLimit(event.target.value); + }, [commitScanLimit]); + + const handleSkipRowsBlur = useCallback((event: FocusEvent) => { + commitSkipRows(event.target.value); + }, [commitSkipRows]); + + const handleScanLimitKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== "Enter") return; + commitScanLimit(event.currentTarget.value); + event.currentTarget.blur(); + }, [commitScanLimit]); + + const handleSkipRowsKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== "Enter") return; + commitSkipRows(event.currentTarget.value); + event.currentTarget.blur(); + }, [commitSkipRows]); + const detectedInventoryCount = latestCapture?.inventoryCount?.current; const detectedInventoryTotal = latestCapture?.inventoryCount?.total; const inventoryClassName = detectedInventoryCount ? "ok" : "neutral"; @@ -56,14 +125,40 @@ export function useScanSettingsModalModel({ const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard"; const skipRowsClassName = skipRows > 0 ? "ok" : "standard"; const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard"; - const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${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 activeTargetText = `${activeTargetCount} Ziele · ${focusModeText}`; + const scanSummaryText = "Auto-Scan zaehlt Positionen, verifizierte Ansichten und gespeicherte Artifacts getrennt."; return { closeSettings, - handleScanLimitChange, - handleSkipRowsChange, 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, inventoryClassName, scanLimitClassName, diff --git a/src/features/scan/hooks/scanViewControllerService.ts b/src/features/scan/hooks/scanViewControllerService.ts index d60e0c1..26a4096 100644 --- a/src/features/scan/hooks/scanViewControllerService.ts +++ b/src/features/scan/hooks/scanViewControllerService.ts @@ -3,6 +3,7 @@ import type { ScannerLearningRules } from "../../../lib/scannerLearning"; import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; import type { ScanActionContext } from "./scanViewScanActions"; +import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; import type { ReviewStateContext, } from "./scanViewReviewHelpers"; @@ -42,6 +43,7 @@ export interface ScanActionContextInput { setReviewStatus: Dispatch>; appendAutomationLog: (line: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + appendDiagnosticEvent: (event: Omit[0], "includeFullScreenshot">) => void; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; persistParsedArtifact: ( capture: CaptureResult | null, @@ -60,7 +62,11 @@ export interface ScanActionContextInput { focusGenshin?: boolean, options?: CaptureOptions, ) => Promise; - captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + captureFastSelectedSource: ( + delayMs?: number, + focusGenshin?: boolean, + options?: CaptureOptions, + ) => Promise; } export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext { @@ -98,6 +104,7 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi setReviewStatus: input.setReviewStatus, appendAutomationLog: input.appendAutomationLog, appendClickDiagnostics: input.appendClickDiagnostics, + appendDiagnosticEvent: input.appendDiagnosticEvent, parseArtifact: input.parseArtifact, persistParsedArtifact: input.persistParsedArtifact, shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false), diff --git a/src/features/scan/hooks/scanViewReviewHelpers.ts b/src/features/scan/hooks/scanViewReviewHelpers.ts index 1fa662a..be6dd1d 100644 --- a/src/features/scan/hooks/scanViewReviewHelpers.ts +++ b/src/features/scan/hooks/scanViewReviewHelpers.ts @@ -277,29 +277,52 @@ export async function saveReviewSample( } if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false }; + const compactAutomaticSample = /^automatic:/i.test(reason); + const sampleCapture = compactAutomaticSample + ? { + 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[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, + name: capture.name, + width: capture.width, + height: capture.height, + dataUrl: capture.dataUrl, + detailDataUrl: capture.detailDataUrl, + inventoryDataUrl: capture.inventoryDataUrl, + captureTarget: capture.captureTarget, + capturedAt: capture.capturedAt, + crops: capture.crops?.map((crop: NonNullable[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, + }; + const result = await reviewSamplesRepo.saveSample({ reason, - capture: { - id: capture.id, - name: capture.name, - width: capture.width, - height: capture.height, - dataUrl: capture.dataUrl, - detailDataUrl: capture.detailDataUrl, - inventoryDataUrl: capture.inventoryDataUrl, - captureTarget: capture.captureTarget, - capturedAt: capture.capturedAt, - crops: capture.crops?.map((crop: NonNullable[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, - }, + capture: sampleCapture, parsed, }); diff --git a/src/features/scan/hooks/scanViewScanActions.ts b/src/features/scan/hooks/scanViewScanActions.ts index 5d2c1e7..3815106 100644 --- a/src/features/scan/hooks/scanViewScanActions.ts +++ b/src/features/scan/hooks/scanViewScanActions.ts @@ -1,8 +1,10 @@ import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner"; +import { artifactTabClickTarget, keyPressBlocked, validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry"; import { captureRejectionReason } from "../../../lib/scannerCaptureQuality"; 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 { summarizeClickResult, summarizeKeyPressResult, type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories"; import type { AutomationGuard, @@ -36,8 +38,9 @@ export interface ScanActionContext { setReviewStatus: Dispatch>; appendAutomationLog: (line: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + appendDiagnosticEvent: (event: Omit[0], "includeFullScreenshot">) => void; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; - captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise; saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise; @@ -47,6 +50,9 @@ export interface ScanActionContext { export interface VisibleGridScanOptions { scanLimit?: number; + scanEntryMode?: ScanEntryMode; + processInitialSelection?: boolean; + ocrEngine?: CaptureOptions["ocrEngine"]; } function buildScanSignature(parsed: ParsedArtifactCandidate) { @@ -83,6 +89,11 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise(); const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 }; + const startedAt = Date.now(); + const updateManualStats = () => { + updateScanTiming(stats, startedAt); + setAutoScanStats({ ...stats }); + }; let idleTicks = 0; const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount); const maxIdleTicks = 90; @@ -96,7 +107,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : ""; await focusDashboard(); @@ -165,6 +178,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi setReviewStatus, appendAutomationLog, appendClickDiagnostics, + appendDiagnosticEvent, captureSelectedSource, captureFastSelectedSource, parseArtifact, @@ -177,8 +191,14 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi focusDashboard, } = context; 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 (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) { + setReviewStatus("Auto-Scan-Einstieg ist nicht verfuegbar: Keypress-Bridge fehlt."); + return; + } const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo); if (requiresAdminForAutoScan) { @@ -200,6 +220,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi setScanSummary(null); setAutoScanStats(emptyAutoScanStats); 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 adminBlockReason = automationBlockReason(freshRuntime); @@ -207,6 +233,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi setAutoScanRunning(false); setReviewStatus(adminBlockReason); 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({ mode: "Automatischer Scan", status: "blocked", @@ -220,6 +252,17 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi if (freshRuntime) { const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found"; 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; @@ -234,6 +277,18 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi 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 || "?"}`, ); + 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.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 konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten."; setReviewStatus(reason); + appendDiagnosticEvent({ + phase: "focus", + severity: "error", + message: reason, + details: { + found: focusResult?.genshinFound, + target: focusResult?.targetProcess, + foreground: focusResult?.foregroundProcess, + }, + }); setScanSummary({ mode: "Automatischer Scan", status: "blocked", @@ -256,7 +321,46 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi 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( { @@ -285,7 +389,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi automationRepo?.getAutomationGuard?.() ?? Promise.resolve({ 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, parseArtifact, persistParsedArtifact, @@ -302,6 +406,9 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi scanLimit, skipRows, 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(); 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({ - mode: "Automatischer Scan", + mode: scanEntryMode === "visible-inventory" ? `Automatischer Scan [${ocrEngine}]` : `Automatischer Scan (${scanEntryMode}) [${ocrEngine}]`, status: result.status, ...result.stats, targetCount: result.targetCount, gridLabel: result.gridLabel, }); } + +async function prepareAutoScanEntry({ + mode, + automationRepo, + captureFastSelectedSource, + appendAutomationLog, + appendDiagnosticEvent, +}: { + mode: ScanEntryMode; + automationRepo: AutomationRepositoryPort; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + appendAutomationLog: (line: string) => void; + appendDiagnosticEvent: (event: Omit[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; + appendAutomationLog: (line: string) => void; + appendDiagnosticEvent: (event: Omit[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; + 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; +} diff --git a/src/features/scan/hooks/useScanCommandListener.ts b/src/features/scan/hooks/useScanCommandListener.ts index 06d83ee..c04386e 100644 --- a/src/features/scan/hooks/useScanCommandListener.ts +++ b/src/features/scan/hooks/useScanCommandListener.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; 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"; interface ScanCommandListenerInput { @@ -9,6 +9,7 @@ interface ScanCommandListenerInput { isScanning: boolean; selectedSourceId: string; requestScanStop: (reason: string) => void; + runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise; } @@ -18,6 +19,7 @@ export function useScanCommandListener({ isScanning, selectedSourceId, requestScanStop, + runGuidedAutoScan, runVisibleGridScan, }: ScanCommandListenerInput) { useEffect(() => { @@ -29,9 +31,12 @@ export function useScanCommandListener({ } const commandType = typeof command === "string" ? command : command.type; if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) { - const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit }; - void runVisibleGridScan(options); + if (typeof command === "string" || !command.scanEntryMode) { + 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]); } diff --git a/src/features/scan/hooks/useScanSnapshotPublisher.ts b/src/features/scan/hooks/useScanSnapshotPublisher.ts index 907987b..abedd95 100644 --- a/src/features/scan/hooks/useScanSnapshotPublisher.ts +++ b/src/features/scan/hooks/useScanSnapshotPublisher.ts @@ -4,6 +4,8 @@ import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSessio import type { RuntimeInfo } from "../../../types/global"; import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { CaptureResult } from "../../../types/global"; +import { validateLookupPackage } from "../../../lib/genshinLookup"; +import type { ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; type InventoryGrid = NonNullable; @@ -17,6 +19,7 @@ interface ScanSnapshotPublisherInput { snapshot: AppSnapshot; latestInventoryGrid: InventoryGrid | null | undefined; automationLog: string[]; + diagnosticEvents: ScanDiagnosticEvent[]; runtimeInfo: RuntimeInfo | null; storedTotal: number | null; learningRuleCount: number; @@ -33,6 +36,7 @@ export function useScanSnapshotPublisher({ snapshot, latestInventoryGrid, automationLog, + diagnosticEvents, runtimeInfo, storedTotal, learningRuleCount, @@ -52,9 +56,33 @@ export function useScanSnapshotPublisher({ snapshotBuilds: snapshot.builds.length, grid: latestInventoryGrid ?? null, 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, storedTotal, 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(), }).catch(() => undefined); }, [ @@ -67,6 +95,7 @@ export function useScanSnapshotPublisher({ snapshot, latestInventoryGrid, automationLog, + diagnosticEvents, runtimeInfo, storedTotal, learningRuleCount, diff --git a/src/features/scan/hooks/useScanViewActions.ts b/src/features/scan/hooks/useScanViewActions.ts index efe3ce1..d8d1010 100644 --- a/src/features/scan/hooks/useScanViewActions.ts +++ b/src/features/scan/hooks/useScanViewActions.ts @@ -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 { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions"; import { @@ -21,6 +21,8 @@ import type { } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; import type { RuntimeInfo } from "../../../types/global"; +import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; +import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry"; type BooleanSetter = Dispatch>; type NumberSetter = Dispatch>; @@ -49,6 +51,7 @@ interface ScanViewActionInput { setReviewStatus: StringSetter; appendAutomationLog: (line: string) => void; appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + appendDiagnosticEvent: (event: Omit[0], "includeFullScreenshot">) => void; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; artifactRepo?: ArtifactRepositoryPort; reviewSamplesRepo?: ReviewSampleRepositoryPort; @@ -77,6 +80,7 @@ export interface ScanViewActionResult { loadReviewQueue: () => Promise; openReviewQueue: () => Promise; runAutoReviewScan: () => Promise; + runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise; } @@ -99,6 +103,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe setReviewStatus, appendAutomationLog, appendClickDiagnostics, + appendDiagnosticEvent, parseArtifact, artifactRepo, reviewSamplesRepo, @@ -116,6 +121,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe canCaptureSource, setReviewQueueOpen, } = input; + const learningInitializedRef = useRef(false); const requestScanStop = useCallback((reason = "Stop angefordert.") => { stopVisibleScanRef.current = true; @@ -154,8 +160,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe ); useEffect(() => { + if (learningInitializedRef.current) return; + if (!learningRepo && !reviewSamplesRepo && !artifactRepo) return; + learningInitializedRef.current = true; void initializeLearningState(reviewContext); - }, [reviewContext]); + }, [artifactRepo, learningRepo, reviewContext, reviewSamplesRepo]); const focusDashboard = useCallback(async () => { try { @@ -215,12 +224,25 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe setReviewStatus, appendAutomationLog, appendClickDiagnostics, + appendDiagnosticEvent, parseArtifact, persistParsedArtifact: parseArtifactAndPersist, saveReviewSample: handleSaveReviewSample, focusDashboard, - captureSelectedSource, - captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }), + captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, { + 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, @@ -240,6 +262,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe setReviewStatus, appendAutomationLog, appendClickDiagnostics, + appendDiagnosticEvent, parseArtifact, parseArtifactAndPersist, handleSaveReviewSample, @@ -276,12 +299,52 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe 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({ automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, + runGuidedAutoScan, runVisibleGridScan, }); @@ -291,6 +354,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe loadReviewQueue: loadReviewQueueAction, openReviewQueue: openReviewQueueModal, runAutoReviewScan, + runGuidedAutoScan, runVisibleGridScan, }; } diff --git a/src/features/scan/hooks/useScanViewController.ts b/src/features/scan/hooks/useScanViewController.ts index 468410d..6c41326 100644 --- a/src/features/scan/hooks/useScanViewController.ts +++ b/src/features/scan/hooks/useScanViewController.ts @@ -14,6 +14,7 @@ import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher"; import { useScanViewActions } from "./useScanViewActions"; import { useScanViewStateSync } from "./useScanViewStateSync"; 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 { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; import type { StoredArtifactRecord } from "../../../types/storage"; @@ -56,6 +57,7 @@ export function useScanViewController({ const [scanLimitTouched, setScanLimitTouched] = useState(false); const [skipRows, setSkipRows] = useState(0); const [automationLog, setAutomationLog] = useState([]); + const [diagnosticEvents, setDiagnosticEvents] = useState([]); const [storedTotal, setStoredTotal] = useState(null); const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1"); const [scannerLearningRules, setScannerLearningRules] = useState({ textReplacements: {} }); @@ -92,12 +94,28 @@ export function useScanViewController({ setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]); }, []); + const appendDiagnosticEvent = useCallback((event: Omit[0], "includeFullScreenshot">) => { + setDiagnosticEvents((previous) => [ + ...previous.slice(-23), + createScanDiagnosticEvent({ + ...event, + includeFullScreenshot: false, + }), + ]); + }, []); + const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => { const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`; const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`; const blocked = result.inputBlocked ? " input:blocked" : ""; 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(() => { setDevMode((previous) => { @@ -118,6 +136,7 @@ export function useScanViewController({ loadReviewQueue: loadReviewQueueAction, openReviewQueue: openReviewQueueModal, runAutoReviewScan, + runGuidedAutoScan, runVisibleGridScan, } = useScanViewActions({ autoScanRunning, @@ -137,6 +156,7 @@ export function useScanViewController({ setReviewStatus, appendAutomationLog, appendClickDiagnostics, + appendDiagnosticEvent, parseArtifact, artifactRepo, reviewSamplesRepo, @@ -209,6 +229,7 @@ export function useScanViewController({ snapshot, latestInventoryGrid: latestCapture?.inventoryGrid ?? null, automationLog, + diagnosticEvents, runtimeInfo, storedTotal, learningRuleCount, @@ -230,6 +251,7 @@ export function useScanViewController({ scanLimitTouched, skipRows, automationLog, + diagnosticEvents, storedTotal, devMode, scannerLearningRules, @@ -267,6 +289,7 @@ export function useScanViewController({ loadReviewQueue: loadReviewQueueAction, openReviewQueue: openReviewQueueModal, runAutoReviewScan, + runGuidedAutoScan, runVisibleGridScan, canGoodInterop, exportGoodFromStore, diff --git a/src/features/scan/types.ts b/src/features/scan/types.ts index 76e35bd..341d46b 100644 --- a/src/features/scan/types.ts +++ b/src/features/scan/types.ts @@ -3,6 +3,7 @@ import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, R import type { StoredArtifactRecord } from "../../types/storage"; import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession"; import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser"; +import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog"; import type { ScannerLearningRules } from "../../lib/scannerLearning"; import type { Dispatch, SetStateAction } from "react"; import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis"; @@ -36,6 +37,7 @@ export interface ScanViewControllerResult { scanLimitTouched: boolean; skipRows: number; automationLog: string[]; + diagnosticEvents: ScanDiagnosticEvent[]; storedTotal: number | null; devMode: boolean; scannerLearningRules: ScannerLearningRules; @@ -79,6 +81,7 @@ export interface ScanViewControllerResult { loadReviewQueue: () => Promise; openReviewQueue: () => Promise; runAutoReviewScan: () => Promise; + runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise; runVisibleGridScan: () => Promise; canGoodInterop: boolean; exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>; diff --git a/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts b/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts index 2f94dc3..9ea2bcf 100644 --- a/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts +++ b/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts @@ -22,6 +22,7 @@ import type { RuntimeInfo, SaveResultWithPath, ScrollResult, + KeyPressResult, AutomationGuard, ClickResult, ReviewSampleListResult, @@ -111,6 +112,10 @@ function emptyScrollResult(): ScrollResult { return { ok: false, notchesSent: 0, inputBlocked: false }; } +function emptyKeyPressResult(key = ""): KeyPressResult { + return { ok: false, key, inputBlocked: false, eventsSent: 0 }; +} + function emptyBooleanResult(): BooleanResult { return { ok: false }; } @@ -203,6 +208,7 @@ export function createRendererRepositories(): RendererRepositories | null { clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()), scrollScreen: (notches, anchorX, anchorY) => createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()), + keyPress: (key) => createBridgeSafeCall(() => bridge.keyPress(key), emptyKeyPressResult(key)), onCommand: bridge.onScannerCommand, }; diff --git a/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts b/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts index 6a19363..21d8885 100644 --- a/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts +++ b/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts @@ -20,6 +20,7 @@ import type { SaveResultWithPath, ScrollResult, ScannerLearningRulePayload, + KeyPressResult, } from "../../types/global"; import type { AppSnapshot } from "../../types/domain"; import type { StoredArtifactRecord } from "../../types/storage"; @@ -64,6 +65,7 @@ export interface AutomationRepositoryPort { focusMainWindow(): Promise; clickScreen(x: number, y: number): Promise; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; + keyPress(key: string): Promise; onCommand(callback: (command: ScannerCommand) => void): () => void; } diff --git a/src/lib/artifactOcrParser.test.ts b/src/lib/artifactOcrParser.test.ts index c9372df..8fad501 100644 --- a/src/lib/artifactOcrParser.test.ts +++ b/src/lib/artifactOcrParser.test.ts @@ -95,6 +95,83 @@ describe("parseArtifactCandidate", () => { 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", () => { const parsed = parseArtifactCandidate(captureFromOcr({ "artifact-title": "Holy Crown of the Believer\nCirclet of Logos", @@ -342,4 +419,18 @@ describe("parseArtifactCandidate", () => { expect(sands?.mainValue).toBe("46.6%"); 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"); + }); }); diff --git a/src/lib/artifactOcrParser.ts b/src/lib/artifactOcrParser.ts index 66d7dd1..f5d9204 100644 --- a/src/lib/artifactOcrParser.ts +++ b/src/lib/artifactOcrParser.ts @@ -19,6 +19,7 @@ import { textReplacements, } from "./genshinData.js"; import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js"; +import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js"; import { implausibleSubstats } from "./substatRolls.js"; 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)]), ); const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n")); - const titleText = byId.get("artifact-title") ?? ""; - const mainText = byId.get("artifact-main-stat") ?? ""; + const nameText = byId.get("artifact-name") ?? ""; + 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 setText = byId.get("artifact-set-effects") ?? ""; const footerText = byId.get("artifact-footer") ?? ""; const nameField = parseArtifactName(titleText); - const slotField = parseSlot(titleText + "\n" + allText, nameField.value); - const levelField = parseArtifactLevel(substatText + "\n" + mainText + "\n" + allText); + const slotField = parseSlot([slotOnlyText, titleText, allText].filter(Boolean).join("\n"), nameField); + const levelField = parseArtifactLevel([levelText, substatText, mainText, allText].filter(Boolean).join("\n")); const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null; const level = parsedLevel ?? 0; 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 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 notes: string[] = []; @@ -161,6 +169,10 @@ function parseArtifactName(titleText: string): ParsedField { .filter(Boolean); 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); 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"); } -function parseSlot(text: string, artifactName: string): ParsedField { +function parseSlot(text: string, artifactName: ParsedField): ParsedField { const slotLines = text .split("\n") .map((line) => cleanupOcrLabel(line)) .filter(Boolean); 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); 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); if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback"); - const derivedSlot = artifactName ? pieceToSlot.get(artifactName) ?? "" : ""; - return derivedSlot ? field(derivedSlot, 94, "derived") : field("", 0, "missing"); + const derivedSlot = artifactName.value ? pieceToSlot.get(artifactName.value) ?? "" : ""; + return derivedSlot ? field(derivedSlot, derivedConfidence(artifactName, 94), "derived") : field("", 0, "missing"); } -function parseSetName(setText: string, artifactName: string): ParsedField { - const setFromPiece = artifactName ? pieceToSet.get(artifactName) : undefined; +function parseSetName(setText: string, artifactName: ParsedField): ParsedField { + const setFromPiece = artifactName.value ? pieceToSet.get(artifactName.value) : undefined; const candidateLines = setText .split("\n") .map((line) => line.trim().replace(/:$/, "")) .filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line)); 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); 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); 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"); } +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 { const lines = normalizeText(text) .split("\n") @@ -294,6 +319,10 @@ function inferMainStat(slot: string, text: string): ParsedField { if (direct) return field(promotePercentVariant(direct, text), 94, "ocr"); 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); 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)hp(\s|$)/i.test(text)) return "HP%"; if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%"; + if (hasPercentValue && /(^|\s)dlt(\s|$)/i.test(text)) return "DEF%"; return ""; } @@ -461,6 +491,10 @@ function parseEquippedCharacter(text: string): ParsedField { if (!equippedLine) return field("Not detected", 45, "missing"); 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) : ""; if (alias) return field(alias, 96, "database"); const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null; @@ -585,4 +619,3 @@ function sortLongestFirst(values: string[]) { function escapeRegex(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - diff --git a/src/lib/autoScanEntry.test.ts b/src/lib/autoScanEntry.test.ts new file mode 100644 index 0000000..4a61e82 --- /dev/null +++ b/src/lib/autoScanEntry.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/lib/autoScanEntry.ts b/src/lib/autoScanEntry.ts new file mode 100644 index 0000000..8e40f50 --- /dev/null +++ b/src/lib/autoScanEntry.ts @@ -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: "" }; +} diff --git a/src/lib/autoScanLoop.test.ts b/src/lib/autoScanLoop.test.ts index a87bbd5..9afcb00 100644 --- a/src/lib/autoScanLoop.test.ts +++ b/src/lib/autoScanLoop.test.ts @@ -50,6 +50,7 @@ function capture(overrides: Partial = {}): ScanTestCapture { detailDataUrl: "data:image/png;base64,DETAIL-AAA", inventoryDataUrl: "data:image/png;base64,GRID-AAA", inventoryGrid: sampleGrid, + artifactDetail: { present: true, confidence: 84, orangeHits: 12, greenHits: 8, textHits: 28 }, ...overrides, }; } @@ -81,6 +82,11 @@ describe("autoScanLoop fingerprints", () => { 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", () => { const captureA = { inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64), @@ -101,7 +107,7 @@ describe("autoScanLoop fingerprints", () => { 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({ captureTarget: "primary-screen", 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 }), }, 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, persistParsedArtifact: async () => false, saveReviewSample: async () => ({ ok: true }), @@ -141,8 +147,460 @@ describe("autoScanLoop fingerprints", () => { }; 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.status).toBe("done"); 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((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); }); }); diff --git a/src/lib/autoScanLoop.ts b/src/lib/autoScanLoop.ts index fafdaef..091ec12 100644 --- a/src/lib/autoScanLoop.ts +++ b/src/lib/autoScanLoop.ts @@ -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 { sessionSignature } from "./artifactStore"; import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner"; @@ -6,7 +6,8 @@ import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./au import { waitForCardReady } from "./cardReadyGate"; import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality"; 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 // ADR-007): one click per tile, a fixed settle delay, one retry if the detail @@ -22,8 +23,8 @@ type AutoScanApi = { export type AutoScanLoopDependencies = { api: AutoScanApi; - captureSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; - captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; persistParsedArtifact: ( capture: CaptureResult | null, @@ -49,6 +50,9 @@ export type AutoScanLoopOptions = { scanLimit: number; skipRows: number; detectedInventoryCount?: number | null; + processInitialSelection?: boolean; + skipInitialGridTarget?: boolean; + ocrEngine?: CaptureOptions["ocrEngine"]; }; export type AutoScanLoopResult = { @@ -62,9 +66,14 @@ export type AutoScanLoopResult = { // Card-ready gating replaces a fixed settle delay: poll the detail fingerprint // until it has changed and stabilized (or the budget is spent). See cardReadyGate. -const CARD_READY_MAX_MS = 900; -const CARD_READY_POLL_MS = 90; +const CARD_READY_MAX_MS = 420; +const CARD_READY_POLL_MS = 60; 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 UNREADABLE_ABORT_THRESHOLD = 5; @@ -89,28 +98,66 @@ export async function runAutoScanLoop( } = deps; const stats: AutoScanStats = { ...emptyAutoScanStats }; + const startedAt = Date.now(); const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount); const rowsToSkip = clampSkipRows(options.skipRows); const seen = new Set(); + const seenDetailFingerprints = new Set(); const seenPageFingerprints = new Set(); let page = 0; let blockedReason = ""; let aborted = false; let consecutiveMisses = 0; let rowsQueued = 0; - const primaryScreenStartWarning = - "Start-Capture ist vom Primary-Screen, kein spezifischer Genshin-Client-Marker vorhanden - Auto-Scan wird mit Vorsicht fortgesetzt."; - - function updateStats() { + let writeQueue: Promise = Promise.resolve(); + function updateStats(preserveActiveScanMs = false) { + updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs }); setAutoScanStats({ ...stats }); } - async function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) { - const saved = await saveReviewSample(capture, parsed, reason); - if (saved?.ok) { - stats.review++; - updateStats(); - } + function enqueueWrite(label: string, task: () => Promise) { + 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); + if (saved?.ok) { + stats.review++; + 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() { @@ -154,41 +201,183 @@ export async function runAutoScanLoop( return clickResult; } - let currentCapture = await captureSelectedSource(0, true); - const isPrimaryCapture = currentCapture?.captureTarget === "primary-screen"; - const initialCaptureRejection = isPrimaryCapture ? "" : captureSourceRejectionReason(currentCapture); + function reportedClickDeliveryFailure(result: ClickResult) { + return result.moved === false || result.clicked === false; + } + + 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); if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) { const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren."; setReviewStatus(reason); - return { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets }; - } - - if (isPrimaryCapture) { - appendAutomationLog(primaryScreenStartWarning); + return finish({ status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets }); } let lastDetailSignature = ""; - const initialParsed = parseArtifact(currentCapture); - if (initialParsed) lastDetailSignature = sessionSignature(initialParsed); 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() { - 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, now: () => Date.now(), checkAbort: checkGuard, }, 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>; + 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 { - while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) { + while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) { page++; stats.pages = page; updateStats(); @@ -207,21 +396,23 @@ export async function runAutoScanLoop( cols: gridModel.cols, rows: Math.max(1, gridModel.rows - pageSkipRows), totalTargetCount: maxTargets, - processedTargets: stats.clicked, + processedTargets: stats.clicked + initialProcessedOffset, rowsQueued, }); - const targets = pagePlan.pageTargets; + const targets = shouldSkipInitialGridTarget && page === 1 + ? baseTargets.slice(initialProcessedOffset) + : pagePlan.pageTargets; if (targets.length === 0) { blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`; 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; for (const target of targets) { - if (shouldStop() || stats.clicked >= maxTargets) break; + if (shouldStop() || stats.parsed >= maxTargets) break; const guardReason = await checkGuard(); if (guardReason) { @@ -238,16 +429,13 @@ export async function runAutoScanLoop( break; } - if (clickResult.moved === false || clickResult.clicked === false) { - // A structural failure (cursor could not be placed, or SendInput - // 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.`; + if (clickResult.inputBlocked) { + blockedReason = clickDeliveryFailureReason(target, clickResult); break; } + if (reportedClickDeliveryFailure(clickResult)) { + appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`); + } let ready = await awaitCardReady(); if (ready.abortReason) { @@ -258,6 +446,14 @@ export async function runAutoScanLoop( let changedDetail = ready.changed; 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`); clickResult = await clickTarget(target, "retry"); stopReason = inputStopReason(clickResult); @@ -266,6 +462,13 @@ export async function runAutoScanLoop( aborted = true; 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(); if (ready.abortReason) { blockedReason = ready.abortReason; @@ -276,6 +479,18 @@ export async function runAutoScanLoop( } 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++; consecutiveMisses++; updateStats(); @@ -289,8 +504,36 @@ export async function runAutoScanLoop( 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) { + addCaptureTiming(stats, capture.timings); stats.misses++; consecutiveMisses++; lastDetailViewFingerprint = detailFingerprint(capture); @@ -305,9 +548,10 @@ export async function runAutoScanLoop( const parsed = parseArtifact(capture); const rejection = captureRejectionReason(capture, parsed); + addCaptureTiming(stats, capture?.timings); 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)) { consecutiveMisses = 0; const signature = sessionSignature(parsed); @@ -316,7 +560,7 @@ export async function runAutoScanLoop( lastDetailViewFingerprint = detailFingerprint(capture); seen.add(signature); newArtifactsOnPage++; - if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++; + persistArtifactLater(capture, parsed, "auto-scan-review", true); updateStats(); continue; } @@ -375,9 +619,9 @@ export async function runAutoScanLoop( const reason = getAutoReviewReason(capture, parsed); const needsReview = reason ? true : shouldFlagArtifactForReview(parsed); 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(); } @@ -386,12 +630,12 @@ export async function runAutoScanLoop( cols: gridModel.cols, rows: Math.max(1, gridModel.rows - pageSkipRows), totalTargetCount: maxTargets, - processedTargets: stats.clicked, + processedTargets: stats.clicked + initialProcessedOffset, rowsQueued, }); rowsQueued = endOfPagePlan.rowsQueuedAfterPage; - if (aborted || stats.clicked >= maxTargets || shouldStop() || blockedReason) break; + if (aborted || stats.parsed >= maxTargets || shouldStop() || blockedReason) break; if (newArtifactsOnPage === 0 && page > 1) { 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); - if (scrollWaitStop) { - blockedReason = scrollWaitStop; + const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture); + const scrollReady = await awaitInventoryPageReady(beforeScrollFingerprint); + if (scrollReady.ready.abortReason) { + blockedReason = scrollReady.ready.abortReason; aborted = true; break; } - const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture); - currentCapture = await captureFastSelectedSource(0, true); - const afterScrollFingerprint = screenFingerprint(currentCapture); + const scrolledCapture = scrollReady.capture; + if (!scrolledCapture) { + 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) { blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert."; @@ -446,7 +700,7 @@ export async function runAutoScanLoop( break; } - const refreshedModel = buildGridModel(currentCapture?.inventoryGrid); + const refreshedModel = buildGridModel(scrolledCapture.inventoryGrid); if (!refreshedModel) { blockedReason = "Kachel-Grid nach dem Scrollen verloren."; break; @@ -461,24 +715,27 @@ export async function runAutoScanLoop( } const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done"; - return { + updateScanTiming(stats, startedAt); + return finish({ status, stats, blockedReason, pageCount: page, gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`, targetCount: maxTargets, - }; + }); } export function detailFingerprint(capture: CaptureResult | null) { if (!capture) return ""; + if (capture.detailFingerprint) return capture.detailFingerprint; if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl); if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl); return ""; } export function screenFingerprint(capture: CaptureResult | null) { + if (capture?.inventoryFingerprint) return capture.inventoryFingerprint; if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl); if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl); return ""; diff --git a/src/lib/automationPlanner.test.ts b/src/lib/automationPlanner.test.ts index 51481d5..a731bdb 100644 --- a/src/lib/automationPlanner.test.ts +++ b/src/lib/automationPlanner.test.ts @@ -42,7 +42,7 @@ describe("automationPlanner", () => { }); 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), col: index % 8, x: index * 10, @@ -52,31 +52,31 @@ describe("automationPlanner", () => { const firstPage = buildInventoryPagePlan({ targets, cols: 8, - rows: 5, - totalTargetCount: 50, + rows: 4, + totalTargetCount: 45, processedTargets: 0, rowsQueued: 0, }); - expect(firstPage.pageTargets).toHaveLength(40); + expect(firstPage.pageTargets).toHaveLength(32); expect(firstPage.startIndex).toBe(0); expect(firstPage.scrollRowsAfterPage).toBe(2); const finalPage = buildInventoryPagePlan({ targets, cols: 8, - rows: 5, - totalTargetCount: 50, - processedTargets: 40, - rowsQueued: 5, + rows: 4, + totalTargetCount: 45, + processedTargets: 32, + rowsQueued: 4, }); - expect(finalPage.pageTargets).toHaveLength(10); - expect(finalPage.startIndex).toBe(24); - expect(finalPage.pageTargets[0]).toMatchObject({ row: 3, col: 0 }); + expect(finalPage.pageTargets).toHaveLength(13); + expect(finalPage.startIndex).toBe(16); + expect(finalPage.pageTargets[0]).toMatchObject({ row: 2, col: 0 }); expect(finalPage.scrollRowsAfterPage).toBe(0); }); 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), col: index % 8, x: index * 10, @@ -86,7 +86,7 @@ describe("automationPlanner", () => { const singlePage = buildInventoryPagePlan({ targets, cols: 8, - rows: 5, + rows: 4, totalTargetCount: 16, processedTargets: 0, rowsQueued: 0, diff --git a/src/lib/cardReadyGate.test.ts b/src/lib/cardReadyGate.test.ts index e24fcf2..d4a0ffa 100644 --- a/src/lib/cardReadyGate.test.ts +++ b/src/lib/cardReadyGate.test.ts @@ -46,6 +46,21 @@ describe("waitForCardReady", () => { 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 () => { const { deps } = harness(["old", "old", "old", "old", "old", "old"], 90); const result = await waitForCardReady(deps, "old", { minStableSamples: 2, maxWaitMs: 200, pollIntervalMs: 90 }); diff --git a/src/lib/cardReadyGate.ts b/src/lib/cardReadyGate.ts index 6dad824..f9b5f6a 100644 --- a/src/lib/cardReadyGate.ts +++ b/src/lib/cardReadyGate.ts @@ -16,6 +16,8 @@ export interface CardReadyOptions { maxWaitMs?: number; /** Delay between samples. */ pollIntervalMs?: number; + /** Proceed with changed-but-animated content after this much elapsed time. */ + acceptChangedAfterMs?: number; } export interface CardReadyDeps { @@ -53,6 +55,7 @@ export async function waitForCardReady( const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE); const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS); const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS); + const acceptChangedAfterMs = Math.max(0, options.acceptChangedAfterMs ?? maxWaitMs); const start = deps.now(); let previousSample = ""; @@ -77,11 +80,13 @@ export async function waitForCardReady( const changed = Boolean(latest) && latest !== previousFingerprint; const stable = stableCount >= minStable; - if (changed && stable) { - return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls }; + const elapsedMs = deps.now() - start; + + 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 // still animating (never fully stabilizes). return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls }; diff --git a/src/lib/genshinData.ts b/src/lib/genshinData.ts index 37e7446..d1eb09c 100644 --- a/src/lib/genshinData.ts +++ b/src/lib/genshinData.ts @@ -27,6 +27,27 @@ type GenshinGameDataContract = typeof gameData & { pieceAliases?: Record; characterAliases?: Record; }; + lookup?: { + normalizedKeys?: { + sets?: Record; + pieces?: Record; + slots?: Record; + stats?: Record; + characters?: Record; + }; + goodKeys?: { + sets?: Record; + pieces?: Record; + stats?: Record; + characters?: Record; + }; + setToPieces?: Record; + validation?: { + valid?: boolean; + errors?: string[]; + warnings?: string[]; + }; + }; mainStatsBySlot?: Record; mainStatValueReferences?: Record>; characters?: Character[]; diff --git a/src/lib/genshinLookup.test.ts b/src/lib/genshinLookup.test.ts new file mode 100644 index 0000000..74f4439 --- /dev/null +++ b/src/lib/genshinLookup.test.ts @@ -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"); + }); +}); diff --git a/src/lib/genshinLookup.ts b/src/lib/genshinLookup.ts new file mode 100644 index 0000000..d191dd3 --- /dev/null +++ b/src/lib/genshinLookup.ts @@ -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; + pieces?: Record; + slots?: Record; + stats?: Record; + characters?: Record; + }; + goodKeys?: { + sets?: Record; + pieces?: Record; + stats?: Record; + characters?: Record; + }; + setToPieces?: Record; + 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, + aliases: Record, + 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(); + 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(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]; +} diff --git a/src/lib/layoutProfile.test.ts b/src/lib/layoutProfile.test.ts index cb95763..987e16c 100644 --- a/src/lib/layoutProfile.test.ts +++ b/src/lib/layoutProfile.test.ts @@ -46,13 +46,17 @@ describe("layoutProfile", () => { 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 crops = detailCropRects(detail, QHD); expect(crops.map((crop) => crop.id)).toEqual([ - "artifact-title", - "artifact-main-stat", + "artifact-name", + "artifact-slot", + "artifact-main-stat-label", + "artifact-main-stat-value", + "artifact-level", "artifact-substats", + "artifact-set-effects", "artifact-footer", ]); 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", () => { const detail = profileDetailRect(QHD); const inv = inventoryRect(QHD, detail); @@ -77,9 +103,9 @@ describe("layoutProfile", () => { const detail = profileDetailRect(QHD); const grid = inventoryGrid(QHD, detail); expect(grid.cols).toBe(8); - expect(grid.rows).toBe(5); + expect(grid.rows).toBe(4); 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); }); @@ -88,7 +114,7 @@ describe("layoutProfile", () => { const grid = inventoryGrid(HD, detail); expect(grid.centers[0]).toEqual({ x: 179, y: 254, row: 0, col: 0 }); expect(grid.centers[7]).toEqual({ x: 1201, y: 254, row: 0, col: 7 }); - expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 958, row: 4, col: 7 }); + 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", () => { diff --git a/src/lib/layoutProfile.ts b/src/lib/layoutProfile.ts index fda193c..babf1e8 100644 --- a/src/lib/layoutProfile.ts +++ b/src/lib/layoutProfile.ts @@ -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. -export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] { +// OCR crops inside the detail panel, as fractions of the detail rect. These are +// 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[] = [ { - id: "artifact-title", - label: "Artifact title", + id: "artifact-name", + label: "Artifact name", rect: { x: Math.round(detailRect.x), y: Math.round(detailRect.y), @@ -104,33 +113,73 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb }, }, { - id: "artifact-main-stat", - label: "Main stat", + id: "artifact-slot", + 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: { x: Math.round(detailRect.x + detailRect.width * 0.055), - y: Math.round(detailRect.y + detailRect.height * 0.075), - width: Math.round(detailRect.width * 0.58), - height: Math.round(detailRect.height * 0.26), + y: Math.round(detailRect.y + detailRect.height * 0.205), + width: Math.round(detailRect.width * 0.42), + 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", 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: { 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), - height: Math.round(detailRect.height * 0.27), + height: Math.round(detailRect.height * 0.16), }, }, { id: "artifact-footer", label: "Footer", rect: { - x: Math.round(detailRect.x + detailRect.width * 0.055), - y: Math.round(detailRect.y + detailRect.height * 0.82), + x: Math.round(detailRect.x + detailRect.width * 0.15), + y: Math.round(detailRect.y + detailRect.height * 0.938), 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 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 startY = Math.round(imageSize.height * 0.235); diff --git a/src/lib/ocrPreprocess.test.ts b/src/lib/ocrPreprocess.test.ts index eaa97f2..207a636 100644 --- a/src/lib/ocrPreprocess.test.ts +++ b/src/lib/ocrPreprocess.test.ts @@ -40,6 +40,18 @@ describe("ocrPreprocess", () => { expect(otsuThreshold(new Array(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", () => { // Bright text pixel + dark background pixel. const bitmap = bitmapFrom([ diff --git a/src/lib/ocrPreprocess.ts b/src/lib/ocrPreprocess.ts index 18c08c3..5fb4379 100644 --- a/src/lib/ocrPreprocess.ts +++ b/src/lib/ocrPreprocess.ts @@ -20,6 +20,8 @@ export interface BinarizeOptions { invertBrightForeground?: boolean; /** Override Otsu with a fixed 0-255 luminance threshold. */ threshold?: number; + /** Optional contrast adjustment in the usual -255..255 image-processing range. */ + contrast?: number; } 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; } -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 = {}): number[] { const histogram = new Array(256).fill(0); const { data, width, height } = bitmap; const pixels = width * height; 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))]++; } return histogram; @@ -82,13 +91,13 @@ export function otsuThreshold(histogram: readonly number[]): number { export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap { const { data, width, height } = bitmap; 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 pixels = width * height; for (let pixel = 0; pixel < pixels; 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). const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0); output[index] = value; diff --git a/src/lib/scanDiagnosticsLog.ts b/src/lib/scanDiagnosticsLog.ts new file mode 100644 index 0000000..b1c89c4 --- /dev/null +++ b/src/lib/scanDiagnosticsLog.ts @@ -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; +type InventoryCount = NonNullable; + +export interface ScanDiagnosticEvent { + id: string; + at: string; + phase: string; + severity: ScanDiagnosticSeverity; + message: string; + details?: Record; + 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; + paimonMenu?: NonNullable; + 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 { + 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, + }, + }; +} diff --git a/src/lib/scannerLearning.test.ts b/src/lib/scannerLearning.test.ts index ec58876..e3005c3 100644 --- a/src/lib/scannerLearning.test.ts +++ b/src/lib/scannerLearning.test.ts @@ -30,6 +30,23 @@ describe("scannerLearning", () => { 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", () => { const reviewCapture: CaptureResult = { id: "review", @@ -53,6 +70,35 @@ describe("scannerLearning", () => { 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", () => { expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2); }); diff --git a/src/lib/scannerLearning.ts b/src/lib/scannerLearning.ts index 597ad1a..4c185f8 100644 --- a/src/lib/scannerLearning.ts +++ b/src/lib/scannerLearning.ts @@ -43,12 +43,12 @@ export function applyScannerLearningRules(capture: CaptureResult | null, rules?: export function shouldSaveReviewSample(parsed: { confidence: number; notes: string[]; fields: Record } | null) { if (!parsed) return true; - if (parsed.confidence < 82) return true; const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; if (criticalFields.some((fieldName) => { const field = parsed.fields[fieldName]; return field ? field.confidence < 70 : false; })) 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))) { return true; } @@ -60,7 +60,7 @@ export function shouldFlagArtifactForReview( parsed: { confidence: number; notes: string[]; fields: Record; substats?: string[] } | null, ) { if (!parsed) return true; - if (parsed.confidence < 78) return true; + if (reviewRelevantConfidence(parsed.fields) < 78) return true; const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) return true; @@ -81,6 +81,14 @@ export function shouldFlagArtifactForReview( return false; } +function reviewRelevantConfidence(fields: Record) { + 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 | null) { return Object.keys(rules?.textReplacements ?? {}).length; } @@ -120,8 +128,16 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate) switch (id) { case "artifact-title": return [parsed.name, parsed.slot]; + case "artifact-name": + return [parsed.name]; + case "artifact-slot": + return [parsed.slot]; case "artifact-main-stat": return [parsed.mainStat, parsed.mainValue]; + case "artifact-main-stat-label": + return [parsed.mainStat]; + case "artifact-main-stat-value": + return [parsed.mainValue]; case "artifact-substats": return parsed.substats; case "artifact-set-effects": diff --git a/src/lib/scannerSession.test.ts b/src/lib/scannerSession.test.ts index 72dcfb1..2f357e1 100644 --- a/src/lib/scannerSession.test.ts +++ b/src/lib/scannerSession.test.ts @@ -1,5 +1,5 @@ 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", () => { it("clamps scan target counts into a sane range", () => { @@ -18,4 +18,61 @@ describe("scannerSession helpers", () => { expect(clampSkipRows(-5)).toBe(0); 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); + }); }); diff --git a/src/lib/scannerSession.ts b/src/lib/scannerSession.ts index c045da1..94f0fd5 100644 --- a/src/lib/scannerSession.ts +++ b/src/lib/scannerSession.ts @@ -8,6 +8,29 @@ export type AutoScanStats = { duplicates: number; misses: 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 & { @@ -27,8 +50,103 @@ export const emptyAutoScanStats: AutoScanStats = { duplicates: 0, misses: 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(); + +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) { return Math.max(1, Math.min(1800, Math.round(value || 1))); } diff --git a/src/pages/app/AppPageLayout.tsx b/src/pages/app/AppPageLayout.tsx index 483add4..0ffe7f4 100644 --- a/src/pages/app/AppPageLayout.tsx +++ b/src/pages/app/AppPageLayout.tsx @@ -53,7 +53,7 @@ export function AppPageLayout({ controller }: AppPageLayoutProps) { overlayIcon={} demoIcon={} /> - {activeView !== "diagnose" && } + {activeView !== "scan" && activeView !== "diagnose" && } {(activeView === "scan" || activeView === "diagnose") && ( Promise; clickScreen: (x: number, y: number) => Promise; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + keyPress: (key: string) => Promise; onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void; } @@ -99,6 +101,7 @@ export function getAssistantBridge(): AssistantBridge | null { focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()), clickScreen: (x: number, y: number) => api.clickScreen(x, y), scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY), + keyPress: (key: string) => api.keyPress(key), showOverlay: () => api.showOverlay(), onScannerCommand: (callback) => api.onScannerCommand(callback), }; diff --git a/src/styles/global.css b/src/styles/global.css index b2342af..6e9c9b8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -24,6 +24,14 @@ box-sizing: border-box; } +html, +body, +#root { + width: 100%; + height: 100%; + overflow: hidden; +} + body { margin: 0; min-width: 1100px; @@ -187,7 +195,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .app-shell { display: grid; grid-template-columns: 260px 1fr; - min-height: 100vh; + height: 100vh; + overflow: hidden; background: linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%), 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; flex-direction: column; gap: 24px; + min-height: 0; + overflow: hidden; border-right: 1px solid var(--line); background: rgba(13, 9, 25, 0.72); 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 { - padding: 26px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + min-height: 0; min-width: 0; + overflow: hidden; + padding: 18px; } .topbar { @@ -314,9 +330,9 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .topbar h1 { max-width: 780px; - margin: 4px 0 0; + margin: 2px 0 0; color: #fbf8ff; - font-size: 30px; + font-size: 22px; letter-spacing: 0; 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; align-items: center; gap: 10px; + min-width: 0; } .topbar-status { @@ -347,8 +364,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .ghost-button, .primary-button { - height: 40px; - padding: 0 14px; + height: 36px; + padding: 0 12px; } .ghost-button { @@ -450,6 +467,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .content-grid { display: grid; gap: 14px; + min-height: 0; + overflow: hidden; } .scan-layout { @@ -457,6 +476,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t } .panel { + min-height: 0; + overflow: hidden; padding: 18px; } @@ -1044,9 +1065,13 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .scanner-workbench { display: grid; - gap: 14px; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 10px; + min-height: 0; + height: 100%; width: 100%; justify-self: stretch; + overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: @@ -1054,7 +1079,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t rgba(18, 12, 35, 0.7); box-shadow: var(--glass-shadow); backdrop-filter: blur(22px); - padding: 18px; + padding: 14px; } .scanner-header, @@ -1071,7 +1096,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .scanner-header h2, .modal-header h2 { margin: 3px 0 0; - font-size: 22px; + font-size: 20px; } .scanner-subcopy { @@ -1102,7 +1127,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .source-select { display: grid; min-width: 0; - gap: 6px; + gap: 4px; } .source-select span { @@ -1113,7 +1138,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .source-select select { width: 100%; - min-height: 42px; + min-height: 34px; border: 1px solid var(--line); border-radius: 8px; 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); font-size: 13px; font-weight: 700; - padding: 12px 14px; + padding: 10px 12px; } .dev-toggle { @@ -1170,22 +1195,23 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .player-scan-card { display: grid; - gap: 12px; + gap: 8px; + min-height: 0; border: 1px solid var(--line); - border-radius: 12px; + border-radius: 8px; background: rgba(15, 10, 29, 0.6); - padding: 16px; + padding: 10px; } .player-scan-row { display: flex; align-items: end; flex-wrap: wrap; - gap: 10px; + gap: 8px; } .player-scan-row .source-select { - min-width: 260px; + min-width: 240px; flex: 1; } @@ -1220,7 +1246,14 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t display: flex; align-items: center; flex-wrap: wrap; + gap: 8px; +} + +.player-scan-lower { + display: grid; + grid-template-columns: minmax(360px, auto) minmax(360px, 1fr); gap: 10px; + align-items: center; } .learning-strip { @@ -1259,10 +1292,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t } .runtime-pill { - min-height: 34px; + min-height: 30px; display: inline-flex; align-items: center; - padding: 0 12px; + padding: 0 10px; border-radius: 999px; border: 1px solid rgba(188, 154, 255, 0.22); 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 { - min-height: 44px; - padding: 0 22px; + min-height: 36px; + padding: 0 16px; font-size: 14px; } .stop-button { - min-height: 44px; - padding: 0 20px; + min-height: 36px; + padding: 0 18px; border: 1px solid rgba(255, 140, 157, 0.5); border-radius: 10px; 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 { margin: 0; color: var(--text-soft); - font-size: 13px; - line-height: 1.5; + font-size: 12px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .scanner-preflight { @@ -1362,11 +1398,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .player-progress { display: grid; - gap: 8px; + gap: 7px; + min-width: 0; } .player-progress-bar { - height: 8px; + height: 7px; border-radius: 999px; background: rgba(188, 154, 255, 0.12); overflow: hidden; @@ -1382,9 +1419,21 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .player-progress-stats { display: flex; flex-wrap: wrap; - gap: 14px; + gap: 6px; 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 { @@ -1414,7 +1463,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t } .scanner-settings-modal { - width: min(760px, 92vw); + width: min(820px, 92vw); } .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)); } +.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 { display: grid; 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; } -.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; - grid-template-columns: 170px 170px minmax(0, 1fr); gap: 10px; - align-items: end; border: 1px solid rgba(188, 154, 255, 0.14); border-radius: 8px; background: rgba(10, 7, 20, 0.36); padding: 12px; } -.scan-config-strip label { - display: grid; - gap: 6px; +.settings-stepper-head { + min-height: 42px; } -.scan-config-strip span { +.settings-stepper-head div { + display: grid; + gap: 4px; +} + +.settings-stepper-head span, +.scan-settings-note strong { color: var(--text-soft); font-size: 11px; font-weight: 800; } -.scan-config-strip input { - min-height: 38px; +.settings-stepper-head small, +.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-radius: 8px; background: rgba(15, 10, 29, 0.78); color: #f4efff; - padding: 0 10px; + padding: 0 12px; + font-size: 18px; + font-weight: 800; + text-align: center; outline: none; } -.scan-config-strip input:focus { +.settings-stepper-row input:focus { border-color: rgba(126, 231, 242, 0.52); 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; - 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; - 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 { @@ -1682,23 +1825,142 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t 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 { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); - gap: 14px; - align-items: stretch; + grid-template-columns: minmax(190px, 210px) minmax(520px, 1fr); + gap: 12px; + align-items: start; } .capture-stage-shell { display: grid; - gap: 10px; + gap: 8px; min-width: 0; } .capture-stage { display: grid; - min-height: clamp(440px, 58vh, 700px); - max-height: 720px; + width: 100%; + aspect-ratio: 41 / 80; + min-height: 0; + max-height: 410px; place-items: center; overflow: hidden; 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); } +.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 { display: block; width: auto; height: auto; - max-width: min(100%, 980px); - max-height: min(100%, 680px); + max-width: 100%; + max-height: 100%; object-fit: contain; + object-position: center top; } .capture-stage-meta { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; } .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-radius: 8px; background: rgba(8, 6, 18, 0.34); - padding: 10px 12px; + padding: 7px 8px; } .capture-stage-meta span { @@ -1738,36 +2012,45 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .capture-stage-meta strong { color: #f4efff; - font-size: 12px; + font-size: 11px; overflow-wrap: anywhere; } .empty-stage { display: grid; place-items: center; + align-content: center; gap: 8px; + padding: 18px; color: var(--text-soft); + font-size: 12px; + line-height: 1.35; text-align: center; } .empty-stage strong { color: #f4efff; + font-size: 14px; +} + +.empty-stage span { + max-width: 22ch; } .scanner-result-panel { display: grid; align-content: start; - max-width: 360px; - gap: 12px; + max-width: none; + gap: 10px; border: 1px solid rgba(126, 231, 242, 0.18); border-radius: 8px; background: rgba(126, 231, 242, 0.07); - padding: 14px; + padding: 12px; } .result-heading h3 { margin: 3px 0 0; - font-size: 18px; + font-size: 17px; } .result-score { @@ -1798,8 +2081,8 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t .scanner-result-brief span { display: inline-flex; align-items: center; - min-height: 30px; - padding: 0 10px; + min-height: 28px; + padding: 0 9px; border: 1px solid rgba(188, 154, 255, 0.14); border-radius: 999px; 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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; + gap: 8px; } .scanner-result-caption { @@ -2045,6 +2328,10 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t grid-template-columns: 1fr; } + .player-scan-lower { + grid-template-columns: 1fr; + } + .scanner-toolbar { align-items: stretch; flex-direction: column; @@ -2054,12 +2341,12 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t justify-content: flex-start; } - .scan-config-strip { - grid-template-columns: 1fr 1fr; + .scan-settings-controls { + grid-template-columns: 1fr; } - .scan-config-strip p { - grid-column: 1 / -1; + .settings-preflight { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .capture-stage-meta { @@ -2068,7 +2355,7 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t } .capture-stage img { - max-height: 640px; + max-height: 100%; } button:disabled { @@ -2186,6 +2473,11 @@ button:disabled { .diagnose-view { display: grid; gap: 14px; + min-height: 0; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 6px; width: 100%; } @@ -2230,9 +2522,113 @@ button:disabled { 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) { .diagnose-grid { 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; + } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 177ca03..4e198a5 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -3,6 +3,18 @@ import type { StoredArtifactRecord } from "./storage"; export interface CaptureOptions { 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 { @@ -16,7 +28,7 @@ export interface CaptureCrop { id: string; label: string; rect: { x: number; y: number; width: number; height: number }; - dataUrl: string; + dataUrl?: string; } export interface OcrResult { @@ -24,6 +36,7 @@ export interface OcrResult { label: string; text: string; confidence: number; + elapsedMs?: number; } export type CaptureTarget = "genshin-client" | "primary-screen" | "desktop-source"; @@ -38,6 +51,8 @@ export interface CaptureResult { captureTarget?: CaptureTarget; detailDataUrl?: string; inventoryDataUrl?: string; + detailFingerprint?: string; + inventoryFingerprint?: string; ocrSkipped?: boolean; ocrTimedOut?: boolean; crops?: CaptureCrop[]; @@ -49,6 +64,23 @@ export interface CaptureResult { confidence: number; 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?: { current: number; total: number; @@ -57,11 +89,23 @@ export interface CaptureResult { text: string; }; locked?: boolean; + sanctified?: boolean; layout?: { aspect: string; isSixteenNine: boolean; warning: string; }; + timings?: { + totalMs: number; + prepareMs: number; + ocrMs: number; + cropCount: number; + ocrEngine: CaptureOptions["ocrEngine"]; + ocrWorkerPoolSize?: number; + ocrProfile?: CaptureOptions["ocrProfile"]; + ocrFieldMs?: Record; + ocrSkipped: boolean; + }; } export interface WindowBounds { @@ -97,6 +141,7 @@ export interface RuntimeInfo { ok: boolean; isElevated: boolean; platform: string; + appBuild?: AppRuntimeInfo; hotkeys?: Record; genshinFound?: boolean; genshinHwnd?: number; @@ -106,6 +151,15 @@ export interface RuntimeInfo { helperPid?: number; } +export interface AppRuntimeInfo { + signature: string; + pid: number; + startedAt: string; + cwd: string; + isDev: boolean; + expectedOcrWorkerPoolSize?: number; +} + export interface FocusGenshinResult { focused: boolean; alreadyForeground: boolean; @@ -132,6 +186,8 @@ export type ScannerCommand = | { type: "start-auto"; scanLimit?: number; + scanEntryMode?: CaptureOptions["scanEntryMode"]; + ocrEngine?: CaptureOptions["ocrEngine"]; }; export interface ScannerLearningRulePayload { @@ -184,6 +240,17 @@ export interface ScrollResult { 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 interface ArtifactStoreLoadResult { @@ -267,9 +334,16 @@ export interface ScannerStatusPayload { snapshotBuilds: number; grid: CaptureResult["inventoryGrid"] | null; automationLog: string[]; + diagnosticEvents?: unknown[]; runtimeInfo: RuntimeInfo | null; + appBuild?: AppRuntimeInfo; storedTotal: number | null; learningRuleCount: number; + lookupStatus?: unknown; + ocrEngine?: CaptureOptions["ocrEngine"]; + ocrWarmup?: unknown; + entryMode?: CaptureOptions["scanEntryMode"]; + benchmarkSummary?: unknown; updatedAt: string | null; [key: string]: unknown; } @@ -309,6 +383,7 @@ declare global { captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; clickScreen: (x: number, y: number) => Promise; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + keyPress: (key: string) => Promise; getAutomationGuard: () => Promise; focusMainWindow: () => Promise; focusGenshin: () => Promise; diff --git a/vite.config.ts b/vite.config.ts index 21bb926..be5ae01 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ server: { port: 5173, strictPort: true, + watch: { + ignored: ["**/outputs/**"], + }, }, build: { outDir: "dist", From 8b73c01e46365bac7da0287cb7ef22305755a913 Mon Sep 17 00:00:00 2001 From: AzuTear Date: Thu, 9 Jul 2026 08:44:50 +0200 Subject: [PATCH 2/2] Prepare scanner branch for merge --- .gitignore | 1 + docs/ARCHITECTURE.md | 39 +- docs/AUTOMATION_LIVE_SCAN.md | 247 +- docs/CHECKLISTS.md | 10 +- docs/CONVENTIONS.md | 4 + docs/DECISIONS.md | 8 +- docs/MERGE_READINESS.md | 58 + docs/PROJECT.md | 48 +- docs/ocr-eval.md | 51 +- docs/scanner-ik-progress-report.md | 151 +- docs/scanner-rework-status.md | 135 +- electron/appWindowManager.ts | 145 + electron/devControlServer.ts | 6 +- electron/main.ts | 266 +- .../repositories/scannerLearningRepository.ts | 65 +- electron/services/goodFileService.ts | 57 + electron/services/inputHelper.ts | 439 +-- .../services/inputHelperPowerShellFallback.ts | 441 +++ electron/services/pngBitmap.ts | 92 + native/input-helper/Program.cs | 8 +- package.json | 10 + scripts/dev-admin-start.ps1 | 7 +- scripts/dev-admin.ps1 | 6 +- scripts/export-review-eval-candidates.cjs | 288 ++ scripts/live-preflight.cjs | 148 + scripts/live-soak.ps1 | 111 +- scripts/prepare-confirmed-review-case.cjs | 109 + scripts/validate-scan-assessment.cjs | 189 ++ src/data/genshinGameData.json | 5 + src/eval/corpus/confirmedReviewCorpus.test.ts | 18 + src/eval/corpus/confirmedReviewCorpus.ts | 15 + src/eval/corpus/index.ts | 24 + src/eval/livePreflightScript.test.ts | 56 + src/eval/ocrEval.test.ts | 18 +- src/eval/pngBitmap.test.ts | 36 + .../prepareConfirmedReviewCaseScript.test.ts | 91 + src/eval/reviewEvalCandidatesScript.test.ts | 90 + src/eval/reviewSampleCorpus.ts | 2 +- .../scanAssessmentValidatorScript.test.ts | 355 +++ .../app/services/appControllerService.ts | 17 +- .../scan/components/DiagnosticsView.tsx | 5 +- .../hooks/useScanSummaryFooterModel.ts | 2 +- .../hooks/useScanDiagnosticsModalModel.ts | 4 + .../scan/hooks/scanViewControllerService.ts | 9 + .../scan/hooks/scanViewEntryActions.ts | 254 ++ .../scan/hooks/scanViewReviewHelpers.ts | 85 +- .../scan/hooks/scanViewScanActions.ts | 248 +- src/features/scan/hooks/useScanGoodInterop.ts | 63 + src/features/scan/hooks/useScanViewActions.ts | 22 +- .../scan/hooks/useScanViewController.ts | 52 +- .../scan/hooks/useScanViewStateSync.ts | 3 +- src/lib/artifactOcrParser.test.ts | 85 + src/lib/artifactOcrParser.ts | 36 +- src/lib/autoScanController.test.ts | 4 +- src/lib/autoScanController.ts | 1 - src/lib/autoScanLoop.test.ts | 2 +- src/lib/autoScanLoop.ts | 182 +- src/lib/lockDetection.test.ts | 21 + src/lib/lockDetection.ts | 56 +- src/lib/scanReviewUtils.test.ts | 47 + src/lib/scanReviewUtils.ts | 2 +- src/lib/scannerLearning.test.ts | 27 +- src/lib/scannerLearning.ts | 57 +- src/lib/scannerSession.test.ts | 8 +- src/lib/scannerSession.ts | 31 + src/styles/base.css | 2471 +++++++++++++++ src/styles/diagnostics.css | 164 + src/styles/global.css | 2636 +---------------- src/types/global.d.ts | 30 + 69 files changed, 6700 insertions(+), 3773 deletions(-) create mode 100644 docs/MERGE_READINESS.md create mode 100644 electron/appWindowManager.ts create mode 100644 electron/services/goodFileService.ts create mode 100644 electron/services/inputHelperPowerShellFallback.ts create mode 100644 electron/services/pngBitmap.ts create mode 100644 scripts/export-review-eval-candidates.cjs create mode 100644 scripts/live-preflight.cjs create mode 100644 scripts/prepare-confirmed-review-case.cjs create mode 100644 scripts/validate-scan-assessment.cjs create mode 100644 src/eval/corpus/confirmedReviewCorpus.test.ts create mode 100644 src/eval/corpus/confirmedReviewCorpus.ts create mode 100644 src/eval/corpus/index.ts create mode 100644 src/eval/livePreflightScript.test.ts create mode 100644 src/eval/pngBitmap.test.ts create mode 100644 src/eval/prepareConfirmedReviewCaseScript.test.ts create mode 100644 src/eval/reviewEvalCandidatesScript.test.ts create mode 100644 src/eval/scanAssessmentValidatorScript.test.ts create mode 100644 src/features/scan/hooks/scanViewEntryActions.ts create mode 100644 src/features/scan/hooks/useScanGoodInterop.ts create mode 100644 src/lib/scanReviewUtils.test.ts create mode 100644 src/styles/base.css create mode 100644 src/styles/diagnostics.css diff --git a/.gitignore b/.gitignore index 46dda82..9378661 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ outputs/dist/ outputs/admin-start/ outputs/live-capture/ outputs/live-soak/ +outputs/review-eval-candidates/ # Logs *.log diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41f6188..e038147 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,10 +49,19 @@ flowchart LR | Module | Responsibility | | --- | --- | -| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, input/capture sidecar orchestration, JSON artifact store, dev-only scanner control endpoints | +| `electron/main.ts` | Electron app composition, dependency wiring, app lifecycle, hotkeys, and IPC/dev-control registration | +| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery | +| `electron/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup | +| `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable | +| `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling | | `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` | | `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store | -| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview | +| `src/App.tsx` | Thin React entry that renders the app page | +| `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing | +| `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly | +| `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration | +| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and IK-style fallback paths | +| `src/features/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports | | `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/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data | @@ -63,6 +72,9 @@ flowchart LR | `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats | | `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` | | `src/types/*` | Shared app, capture, and domain contracts | +| `src/styles/global.css` | Stylesheet entrypoint importing split style modules | +| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay styles | +| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles | ## Dependency Rules @@ -129,7 +141,8 @@ sequenceDiagram - SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing. - Dev-only probes under `http://127.0.0.1:17317` are used for live validation: `/automation/probe-click?index=N` tests one read-only tile selection, and - `/scanner/start?limit=N` starts an auto-scan with a temporary limit payload. + `/scanner/start?entry=visible-inventory&limit=N` starts an auto-scan with a + temporary limit payload from an already visible artifact detail view. The live known-good result on 2026-07-07 is documented in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). - Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts. @@ -140,15 +153,25 @@ sequenceDiagram - 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. +- The normal scan button does not navigate into inventory when that preflight + fails; it blocks and asks the operator to open the Artifact inventory with a + visible detail card. Explicit Dev-Control entry modes can still test direct + inventory or Paimon-menu choreography, but they are not the merge-ready + default path. +- Item verification uses the artifact OCR capture's own detail fingerprint, so + the loop no longer performs a separate card-ready capture before OCR. Page + waits remain fingerprint based and can proceed as soon as the inventory pane + changes and stabilizes, while still accepting IK-like 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). - The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them. Parsed artifacts from both modes are persisted into `artifact-store.json` keyed by a content signature that excludes the equipped character, so re-equipping updates a record instead of duplicating it. Leveling an artifact currently creates a new record (documented limitation until rescan-merge exists). +During auto-scan, artifact store writes can be batched and flushed after the +click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path. +Auto-scan artifact captures also bypass Electron source-list enumeration and use +the GDI capture helper directly once the selected source/Genshin state has been +preflighted. Manual captures and source refresh still use `desktopCapturer`. ## Security And Safety diff --git a/docs/AUTOMATION_LIVE_SCAN.md b/docs/AUTOMATION_LIVE_SCAN.md index f8dd59d..a6b9d9d 100644 --- a/docs/AUTOMATION_LIVE_SCAN.md +++ b/docs/AUTOMATION_LIVE_SCAN.md @@ -5,8 +5,8 @@ movement, click input, elevation, and live validation status. ## Current Known-Good State -Validated live on 2026-07-07 with Genshin open in the artifact inventory at -1920x1080, English UI: +Validated live with Genshin open in the artifact inventory at 1920x1080, +English UI: - `npm run dev:admin` starts the app elevated after the user confirms UAC. - Runtime status reported `isElevated: true`, `genshinFound: true`, and @@ -19,15 +19,24 @@ Validated live on 2026-07-07 with Genshin open in the artifact inventory at - A bounded live auto-scan via `/scanner/start?limit=2` completed with: `clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`, `review: 2`, `misses: 0`, `status: "done"`. +- On 2026-07-08, a visible-inventory 50-artifact run completed with `50/50` + parsed and stored, `0` review, `0` duplicates, and `0` misses. Throughput + was still slow at `61765 ms` elapsed (`1235 ms/artifact`). +- On 2026-07-09, the current-engine visible-inventory path completed + `/scanner/start?entry=visible-inventory&limit=20&engine=current` with + `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and + `0` misses in `8047 ms` elapsed (`402 ms/artifact`). A same-session artifact + detail capture also persisted an equipped footer as `equipped: "Citlali"` and + an unlocked grey lock as `locked: false`. 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. 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. +from the `APP_RUNTIME_SIGNATURE` in `electron/main.ts`, or after source changes +that have not been loaded by a fresh elevated runtime. Restart the elevated app +through `npm run dev:admin` and confirm UAC before collecting new 50/100 +artifact evidence. ## Elevation And UAC @@ -101,23 +110,24 @@ Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?row=0&col=3" For live validation, prefer a bounded scan first: ```powershell -Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2" +Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&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 visible-inventory path is the merge-relevant safe path. It requires the +Artifact inventory to already be open with a visible artifact detail card. 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. +scan. Otherwise it blocks with an operator-facing status and asks the user to +open the Artifact inventory with a visible detail card. OCR/review/store work +starts only after the artifact-detail preflight passes. + +The explicit Dev-Control entry modes below remain available for targeted +experiments only. They send read-only navigation, but they are not the +merge-ready default because live testing showed that `auto-entry` can leave the +app in the Paimon menu when the starting state is not what the choreography +expects. ```powershell Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=paimon-menu&limit=2" @@ -147,13 +157,11 @@ 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. +After each click the loop now performs one fast artifact capture/OCR pass and +uses that capture's detail fingerprint to verify that the selected artifact +changed. This removes the old separate card-ready capture from the hot path. If +the detail fingerprint is unchanged, the loop retries once and then follows the +normal miss/block guards. 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 @@ -186,15 +194,87 @@ 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 +`activeAverageMsPerParsed`, `averageCaptureMs`, +`averageCaptureRoundTripMs`, `averageCaptureRoundTripOverheadMs`, +`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. +queue. If `averageCaptureRoundTripOverheadMs` is high, native capture encode, +Base64 transport, Electron image decode, or IPC/render scheduling is the next +bottleneck. The current 3 artifacts/second target requires `averageMsPerParsed` +at or below `333 ms` on a clean 20-artifact iteration. + +Latest live timing evidence on 2026-07-08: + +- Probe: `/automation/probe-click?index=1` returned `clicked: true`, + `inputBlocked: false`, `changed: true`, and `captureTarget: + "genshin-client"`. +- Baseline after helper/hot-loop cleanup: + `/scanner/start?entry=visible-inventory&limit=50&engine=current` completed + `50/50` parsed and stored with `0` review, `0` duplicates, `0` misses, + `2` pages, `elapsedMs: 61765`, `averageMsPerParsed: 1235`, + `averageCaptureMs: 186`, `averageOcrMs: 162`, and + `averageScrollReadyMs: 844`. +- Deferred-write experiment: + the same 50-artifact run completed `50/50` with `0` misses but regressed to + `elapsedMs: 63616` because 50 single-record writes produced + `writeFlushMs: 8163`. +- Current source replaces that experiment with batch persist and quiet + auto-scan UI captures. This is code-validated, but the batch version still + needs a fresh elevated live run; the follow-up restart was blocked because the + admin runtime did not become reachable after shutdown/UAC. +- Direct GDI hot-path validation: + after skipping `desktopCapturer.getSources()` in auto-scan artifact captures, + the 20-artifact iteration baseline improved to `20/20` parsed, `19` stored, + `0` review, `1` duplicate, `0` misses, `7966 ms` elapsed, + `398 ms/artifact`, `averageCaptureMs: 193`, `averageOcrMs: 167`, + `averageClickMs: 2`, and `writeFlushMs: 4`. This is roughly + `2.5 artifacts/second` on the first visible page. +- Scroll-path validation with the same direct GDI hot path: + `/scanner/start?entry=visible-inventory&limit=45&engine=current` completed + `45/45` parsed, `42` stored, `0` review, `3` duplicates, `0` misses, + `2` pages, `18625 ms` elapsed, `414 ms/artifact`, `averageCaptureMs: 187`, + `averageOcrMs: 162`, and one scroll readiness wait of `173 ms`. +- 100-artifact direct-GDI validation: + `/scanner/start?entry=visible-inventory&limit=100&engine=current` completed + on runtime signature `2026-07-08-direct-gdi-hotpath` with `100/100` parsed, + `97` stored, `0` review, `3` duplicates, `0` misses, `4` pages, + `42064 ms` elapsed, `421 ms/artifact`, `averageCaptureMs: 179`, + `averageOcrMs: 154`, `averageClickMs: 2`, `writeFlushMs: 6`, and `3` + scroll readiness waits averaging `176 ms`. +- OCR/parser eval after this speed pass: `npm run eval` passed with `23/23` + exact-match cases, `100%` field accuracy, and `100%` critical fields. This is + a regression gate, not a substitute for manually checking live artifact values. +- 3 artifacts/second preparation: + auto-scan artifact captures now also omit the detail-preview payload and + expose `averageCaptureRoundTripMs` plus + `averageCaptureRoundTripOverheadMs`. The first live run exposed a false + `missing-crops-or-ocr` review trigger because the hot path intentionally omits + `detailDataUrl`; this is fixed in `getAutoReviewReason`. +- 3 artifacts/second live attempts: + after the review fix, a clean `limit=20` run completed `20/20` parsed, + `19` stored, `0` review, `1` duplicate, `0` misses, `7285 ms` elapsed, + or `364 ms/artifact` (`2.75 artifacts/second`). The stable final run on + signature `2026-07-08-direct-gdi-reviewfix` completed `20/20`, `18` stored, + `0` review, `2` duplicates, `0` misses, `7973 ms` elapsed, or + `399 ms/artifact`. 3 artifacts/second is not proven. +- Rejected speed experiments: + detail-region capture, `GAA_OCR_WORKERS=5`, DataURL-to-buffer decode, and + substat OCR `PSM.SINGLE_COLUMN` were all live/benchmark tested and were slower + than the direct-GDI baseline. Keep `GAA_OCR_WORKERS=4` for current runs. +- Quality-gated current-vs-IK comparison: + `npm run scan:goal:compare:validated` produced + `outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json` + with `createdAt: 2026-07-08T18:41:11.6120957+02:00`. + The final validator summary passed at `limit=100` with winner `current`, + `activeAvg: 378 ms/artifact`, `projected100: 37800 ms`, `missRate: 0`, and + `reviewRate: 0`. The `current` 100-artifact run parsed `100/100`, stored `97`, + had `0` review, `0` misses, and crossed `4` pages. The `ik-traineddata` + 100-artifact run parsed `97/100`, had `5` review and `3` misses, and was not + qualified because it parsed fewer artifacts than requested. 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 @@ -222,9 +302,9 @@ pool size. It also returns per-field OCR timings under 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 +which omits the low-value set-effect crop and the main-stat-value crop that can +be derived from slot, main-stat label, and level. The slot crop remains enabled +in the fast profile because it improved live-read quality. 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 @@ -239,10 +319,13 @@ detection unless a caller explicitly overrides that option; add 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; +The fast auto-scan profile now keeps the optional Equipped footer OCR on real +artifact-read captures when the footer marker is visible, so stored artifacts +can record the equipped character without requiring a separate manual capture. +Name, level, main-stat label, footer, 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. +them. Preflight and readiness poll captures still skip OCR/crops/lock-state +work because they only prove surface and fingerprint changes. 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 @@ -314,23 +397,45 @@ Default sequence: 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` +6. `/scanner/start?entry=visible-inventory&limit=2` +7. `/scanner/start?entry=visible-inventory&limit=5` +8. `/scanner/start?entry=visible-inventory&limit=10` +9. `/scanner/start?entry=visible-inventory&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:live:preflight +npm run scan:live:preflight:wait npm run scan:goal npm run scan:goal:current npm run scan:goal:ik +npm run scan:iterate:compare:validated +npm run scan:iterate:compare:validated:wait npm run scan:goal:compare +npm run scan:goal:compare:validated +npm run scan:goal:compare:validated:wait ``` -That run first warms/benchmarks `current` vs. `ik-traineddata`, then scans +`scan:live:preflight` checks `/health`, `/scanner/status`, the current +`APP_RUNTIME_SIGNATURE`, elevation, and whether Genshin is visible to the helper +before a long live scan is attempted. +Use `npm run scan:live:preflight:wait` during manual startup after `npm run +dev:admin`; it waits up to 120 seconds for the elevated dev-control server and +runtime checks to become ready. The non-waiting command remains the default for +validated scan chains so automation fails fast on a missing runtime. + +Use `npm run scan:iterate:compare:validated` for fast iteration while tuning OCR, +parser, capture, or readiness behavior. It runs the same preflight, compares +`current` vs. `ik-traineddata` at `limit=20`, and validates the newest assessment +with `--limit=20 --summary`. This is the preferred loop while debugging because +it gives quality-gated feedback without waiting for the full `2, 5, 20, 45, 100` +goal sequence. Use `npm run scan:iterate:compare:validated:wait` directly after +UAC if the elevated runtime may still be starting. + +The goal 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 @@ -345,7 +450,32 @@ 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. +is used as the tie-breaker. For IK-target claims, check `goal100Decision`; it +must read `qualified-comparison: winner=`, and +`goal100.comparisonComplete` must be `true` so a single-engine 100-artifact run +is not mistaken for a current-vs-IK comparison. + +Validate the saved assessment before using it as final evidence: + +```powershell +npm run scan:assessment:validate -- --latest +npm run scan:assessment:validate -- --input=\scan-performance-assessment.json +``` + +`--latest` searches `outputs/live-soak/` for the newest +`scan-performance-assessment.json`. Use explicit `--input` when comparing older +or archived runs. Add `--expect-winner=current` or +`--expect-winner=ik-traineddata` when validating a specific engine claim instead +of accepting any qualified winner. Add `--limit=20` for a short iteration run +instead of the final 100-artifact proof. Add `--summary` when you want a short +report-ready PASS/FAIL output that includes the input assessment path and +assessment `createdAt` timestamp. + +`npm run scan:goal:compare:validated` is the preferred final command: it runs +the live preflight first, then the full comparison, and then validates the +newest assessment with `--summary`. Use +`npm run scan:goal:compare:validated:wait` for the same final flow when starting +immediately after UAC. The assessment ranking can be verified without Genshin or the Electron app: @@ -356,6 +486,34 @@ 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. +## Review-To-Eval Quality Loop + +After any live scan that creates review samples, export candidates before adding +anything to the permanent eval corpus: + +```powershell +npm run eval:review-candidates -- --limit=80 +``` + +Read `outputs/review-eval-candidates/review-eval-candidates.md`. It is a review +worklist, not ground truth. Only after the expected fields are confirmed or +corrected against the real artifact should a case be moved into +`src/eval/corpus/confirmedReviewCorpus.ts`. This prevents the parser from +grading itself and keeps `npm run eval` meaningful. The exporter deduplicates +samples, puts complete modern OCR captures first, and marks missing fast-profile +fields so stale or partial captures are easier to ignore. Unconfirmed exporter +output must stay in `outputs/review-eval-candidates/`. + +For a manually checked candidate, generate a paste-ready confirmed-case snippet: + +```powershell +npm run eval:prepare-confirmed -- --candidate= --expect-file=.\path\to\expect.json +``` + +The command requires explicit labels and writes only to the ignored outputs +folder. Review the snippet before adding it to +`src/eval/corpus/confirmedReviewCorpus.ts`. + For the current implementation summary and IK comparison rationale, see [scanner-ik-progress-report.md](scanner-ik-progress-report.md). @@ -429,7 +587,8 @@ Before marking an automation change done: 3. Run `npm test`. 4. Run `npm run build`. 5. If Genshin is available, run `/automation/probe-click?index=1`. -6. For scan-loop changes, run `/scanner/start?limit=2` before any broader scan. +6. For scan-loop changes, run + `/scanner/start?entry=visible-inventory&limit=2` before any broader scan. 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. + a non-stale `npm run scan:goal:compare:validated` run. diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md index 600848e..49387a6 100644 --- a/docs/CHECKLISTS.md +++ b/docs/CHECKLISTS.md @@ -16,9 +16,17 @@ ## IK-Speed Or OCR-Engine Claim - [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`. +- [ ] `npm run scan:live:preflight` passes, or use the validated comparison command that runs it first. +- [ ] During manual UAC startup, `npm run scan:live:preflight:wait` may be used before the scan chain. - [ ] `npm run scan:assessment:test` passes. +- [ ] Use `npm run scan:iterate:compare:validated` or `npm run scan:iterate:compare:validated:wait` for short 20-artifact tuning loops. +- [ ] Prefer `npm run scan:goal:compare:validated` or `npm run scan:goal:compare:validated:wait` for the final live comparison because it runs preflight, comparison, and assessment validation in sequence. - [ ] The run includes `scan-performance-assessment.json`. -- [ ] The 100-artifact run finishes cleanly. +- [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=\scan-performance-assessment.json` passes. +- [ ] If claiming a specific winner, the validator is run with `--expect-winner=current` or `--expect-winner=ik-traineddata`. +- [ ] Final report cites the validator JSON or `--summary` output, including the assessment path. +- [ ] For iteration claims, the 20-artifact run finishes cleanly and is validated with `--limit=20`. +- [ ] For final claims, 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%. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 980bd95..9c688c1 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -11,10 +11,13 @@ This document defines project engineering standards. ## File Organization - Keep Electron OS integration in `electron/`. +- Keep Electron `main.ts` as composition/wiring. Move durable window, file, helper, capture, OCR, or dev-control responsibilities into named modules under `electron/`. - Keep React components in `src/`, with extraction when `App.tsx` becomes hard to review. +- Keep feature controller hooks small enough to review. If a hook owns persistence, import/export, entry choreography, scan-loop orchestration, and UI state at once, split those concerns into feature-local hooks or services. - Keep pure domain logic in `src/lib/`. - Keep shared contracts in `src/types/`. - Keep generated outputs in `dist/`, `dist-electron/`, and `outputs/`. +- Keep `src/styles/global.css` as the stylesheet entrypoint. Put broad app styles in `base.css` and dev/diagnostic-only styling in `diagnostics.css` unless a more specific style module is introduced. ## UI Rules @@ -30,6 +33,7 @@ This document defines project engineering standards. - Confidence and raw OCR details must remain inspectable. - Heuristics should fail safely into unknown fields or review notes. - Do not add irreversible game actions. +- Keep auto-entry choreography separate from scan-loop execution. Entry code may navigate to a readable artifact detail state; loop code should process verified grid targets. ## TypeScript Rules diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a35e08f..3bd4108 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -323,7 +323,11 @@ engine or claiming IK parity. A qualified scan result must: `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. +Genshin. The assessment also records `goal100Decision` and +`goal100.comparisonComplete`; IK-target claims require a qualified 100-artifact +winner and a complete `current` vs. `ik-traineddata` comparison. +`npm run scan:assessment:validate -- --summary` prints the assessment path and +`createdAt` timestamp so reports can cite the exact evidence file. ### Consequences @@ -332,4 +336,6 @@ Genshin. 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 validated `:wait` scan scripts are acceptable for manual post-UAC startup; + non-waiting scripts remain useful when automation should fail fast. - The goal remains open until the 100-artifact qualified comparison is captured. diff --git a/docs/MERGE_READINESS.md b/docs/MERGE_READINESS.md new file mode 100644 index 0000000..01eb6c9 --- /dev/null +++ b/docs/MERGE_READINESS.md @@ -0,0 +1,58 @@ +# Merge Readiness + +Current branch: `codex/ik-scanner-progress` + +This checklist records the evidence needed before merging this scanner branch +into `main`. It separates merge-relevant proof from optional follow-up work. + +## Merge-Relevant Evidence + +| Area | Status | Evidence | +| --- | --- | --- | +| TypeScript/lint gate | Passed | `npm run lint` | +| Unit/regression suite | Passed | `npm test` with 207 tests, including equipped footer, lock detection, and PNG lock-crop regression coverage | +| Production build | Passed | `npm run build` | +| Whitespace check | Passed | `git diff --check` | +| OCR eval gate | Passed | `npm run eval` with `23/23` exact-match cases and `100%` critical fields | +| Scan assessment self-test | Passed | `npm run scan:assessment:test`; the fixture intentionally expects `ik-traineddata` to win its synthetic `limit=100` case while real live-winner claims stay tied to archived live assessments | +| Live runtime preflight | Passed | `npm run scan:live:preflight`; signature `2026-07-08-direct-gdi-reviewfix`, elevated yes, Genshin found | +| Safe visible-inventory scan path | Passed | 2026-07-09 live run: `/scanner/start?entry=visible-inventory&limit=20&engine=current` completed `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, `0` misses, `8047 ms` elapsed | +| Equipped character OCR/persist path | Passed for live smoke | 2026-07-09 live captures read equipped footers for `Citlali` and `Linnea`; parser regression covers `Equipped: Linnea l` -> `Linnea` | +| Unlocked lock state | Passed for live smoke | 2026-07-09 Smart Capture reported `locked: false` on an unlocked artifact; after restart, `lockSignal.ratio: 0` with threshold `0.06` | +| Positive locked lock state | Passed | 2026-07-09 Smart Capture on a visibly locked artifact reported `locked: true`, `lockSignal.ratio: 0.14797913950456323`, threshold `0.06` | +| Lock-state persistence | Passed | 2026-07-09 `/scanner/start?entry=visible-inventory&limit=1&engine=current` stored `A Note in Spring's Leich` with `equipped: "Citlali"` and `locked: true` | +| Lock-state diagnostics | Passed | Capture results include `lockSignal.ratio`, `lockSignal.threshold`, and the lock crop rect. Detection uses decoded PNG crop pixels to avoid native bitmap channel-order ambiguity | +| Unsafe auto-entry default | Mitigated | Normal guided Auto-Scan now blocks when no artifact detail card is visible instead of falling back to `auto-entry` | +| Review-to-eval export | Passed by tests | `npm run eval:review-candidates` script is covered by `src/eval/reviewEvalCandidatesScript.test.ts` and output is Git-ignored | + +## Final Gate + +```powershell +npm run lint +npm test +npm run build +git diff --check +``` + +Latest run after the lock-state fix: passed with `207` tests. +`npm run eval` and `npm run scan:assessment:test` also passed after the +lock-state fix. + +## Explicitly Not Merge-Blocking + +- `auto-entry`, `direct-inventory`, and `paimon-menu` are still experimental + Dev-Control entry modes. They can be tested later with low limits, but they + are no longer the normal merge path. +- The optional `3 artifacts/second` target is not proven. The stable current + path is closer to `2.5` to `2.75 artifacts/second` on clean 20-artifact live + runs. +- Native Tesseract/IK-traineddata is not the default. The latest documented + qualified live 100-artifact comparison in this environment had `current` as + winner; the assessment self-test is a synthetic validator fixture, not a live + winner claim. + +## Merge Recommendation + +Ready for final human diff review and then merge into `main`. The previously +open `locked: true` proof now passes for both Smart Capture and auto-scan +persistence. diff --git a/docs/PROJECT.md b/docs/PROJECT.md index eb545f6..5b85799 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -5,6 +5,7 @@ This document is the source of truth for project intent, scope, runtime facts, a 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). +For the current branch merge checklist, see [MERGE_READINESS.md](MERGE_READINESS.md). ## Project Identity @@ -60,7 +61,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 | | 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 | +| 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:validated` or `npm run scan:goal:compare:validated:wait` quality-gated report | | 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 | | Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` | @@ -104,19 +105,34 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin - Elevated live automation is validated in the current dev environment: `/automation/probe-click?index=1` changed the selected artifact and `/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses. +- A 2026-07-08 visible-inventory 50-artifact run completed cleanly with + 50/50 parsed and stored, 0 review, 0 duplicates, and 0 misses. It is stable + but still too slow for the 2-3 artifacts/second target. +- Later 2026-07-08 direct-GDI hot-path runs completed 20/20 parsed with + 0 misses and 0 review. The best clean 20-artifact iteration reached + 7285 ms, or roughly 2.75 artifacts/second; the final stable + `2026-07-08-direct-gdi-reviewfix` run completed in 7973 ms. The + 3 artifacts/second target remains unproven. +- The same direct-GDI path completed a 100-artifact run with 100/100 parsed, + 0 review, 0 misses, and 42064 ms elapsed across 4 pages. +- Review samples can now be exported with `npm run eval:review-candidates` into + a Git-ignored human-labeling worklist. This is the next quality phase before + adding more OCR corpus cases or trusting review queue data as labels. ### What is still structurally weak - The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be. -- Broader scan soak testing still needs to increase the live limit gradually and - validate scroll/page transitions beyond the first visible row. +- Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs + with 0 misses on the current engine. The current-vs-IK-traineddata comparison + is now captured; `current` won the qualified 100-artifact comparison on + 2026-07-08. - OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be. - Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way. - The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow. - 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. +- The latest source has completed the final current-vs-IK-traineddata live + comparison for this environment. Repeatability and 3 artifacts/second are + still open. ### Current product conclusion @@ -171,6 +187,9 @@ Outcome: 5. safe derivation from piece/slot/value references - Main stat/value inference is tightened with slot constraints and reference tables. - Bad parses automatically generate structured review reasons. +- Equipped-character parsing is canonical-data constrained: noisy known names can + match through aliases/fuzzy lookup, but unknown footer fragments stay + `Not detected` instead of being stored as invented character names. Status: - In progress @@ -191,6 +210,8 @@ Outcome: - resume or stop - Progress counts only when a new verified artifact or duplicate signature is confirmed. - Repeated pages, unchanged detail cards, blocked cursor movement, and scroll failures stop the scan with diagnosis instead of producing fake progress. +- Fast artifact-read captures include the equipped footer when an equipped + marker is visible; preflight and polling captures still skip expensive OCR. Status: - In progress @@ -225,7 +246,9 @@ Outcome: - Review samples become both parser regression fixtures and learning inputs. Status: -- Planned +- Prepared in code for text replacements, field aliases, constrained fixes, + crop adjustment proposals, and UI-profile adjustment proposals. Crop/profile + changes still require live review before being auto-applied. ### Phase 6 - Recommendations come back on top of a trusted scanner @@ -241,11 +264,12 @@ Status: 1. Finish scan-page cleanup so the main operator view is no longer noisy. 2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples. 3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules. -4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions. -5. Run `npm run scan:goal:compare` after `/health.appBuild.signature` matches - 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. +4. Repeat the qualified current-vs-IK-traineddata comparison in a later live + session before making stronger speed/default-engine claims. +5. Validate equipped-character footer reads and a positive `locked=true` sample + from known artifacts; export candidates with `npm run eval:review-candidates`. +6. Grow the confirmed OCR corpus from review samples before tightening parser + thresholds further. 7. Resume recommendation work only when scan accuracy is consistently trustworthy. ## Open Questions diff --git a/docs/ocr-eval.md b/docs/ocr-eval.md index d29e967..8719f3a 100644 --- a/docs/ocr-eval.md +++ b/docs/ocr-eval.md @@ -3,14 +3,19 @@ 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). 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`. +review/miss rates are measured by `npm run scan:iterate:compare:validated` for +short iteration and `npm run scan:goal:compare:validated` for the final +100-artifact proof. Use the `:wait` variants directly after UAC startup. ## Run it ```powershell npm run eval # full accuracy report for the seed corpus +npm run eval:review-candidates # export unconfirmed review samples for human labeling npm test # runs the eval gate alongside the rest of the suite npm run scan:assessment:test # verifies quality-first scan ranking logic +npm run scan:iterate:compare:validated:wait # 20-artifact live comparison +npm run scan:goal:compare:validated:wait # final 100-artifact live comparison ``` The report prints exact-match rate, overall field accuracy, a per-field @@ -31,6 +36,35 @@ breakdown (critical fields marked with `*`), and every failing case with an The review queue is the corpus source. A saved review sample carries the OCR text plus the parser's *guess* - `reviewSampleToEvalCase` extracts both. +For the local Electron queue, run: + +```powershell +npm run eval:review-candidates -- --limit=80 +``` + +This writes: + +- `outputs/review-eval-candidates/review-eval-candidates.json` +- `outputs/review-eval-candidates/review-eval-candidates.md` + +The exporter deduplicates samples, puts complete modern OCR captures first, +marks missing fast-profile fields so stale/partial captures do not crowd out +useful cases, and surfaces ownership/lock evidence (`artifact-footer`, +`equipped`, and `locked=true/false`) for the next validation pass. + +After manually checking one candidate against the real artifact, create a +confirmed corpus snippet with explicit expected labels: + +```powershell +npm run eval:prepare-confirmed -- --candidate= --expect-file=.\path\to\expect.json +``` + +The script reads the latest +`outputs/review-eval-candidates/review-eval-candidates.json` by default and +writes a `.confirmed.ts` snippet under `outputs/review-eval-candidates/`. +It refuses to run without explicit labels, so parser guesses are not silently +promoted to ground truth. Review that snippet, then paste the object into +`src/eval/corpus/confirmedReviewCorpus.ts`. The parser's guess is a label **candidate, not ground truth** (using it directly would be the parser grading itself). To add a real case: @@ -38,11 +72,20 @@ would be the parser grading itself). To add a real case: 1. Convert review samples with `reviewSamplesToEvalCases(records)`. 2. Open each produced case and confirm or correct the `expect` values against what the artifact actually is in-game. Set `confirmed: true`. -3. Move the corrected case into a file under `src/eval/corpus/` and add it to the - corpus array. +3. Move the corrected case into + `src/eval/corpus/confirmedReviewCorpus.ts`. The main eval gate imports + `src/eval/corpus/index.ts`, which combines the seed corpus with confirmed + review cases. + +`src/eval/corpus/confirmedReviewCorpus.test.ts` rejects common corpus mistakes: +duplicate case ids, missing OCR text, empty labels, missing source notes, or a +case that was copied in without `confirmed: true`. Prefer cases that cover new failure modes: unseen resolutions, new sets or -characters, and OCR noise the current corpus does not exercise. +characters, equipped footer noise, and OCR noise the current corpus does not +exercise. `locked` is a capture-side visual flag rather than a text parser field; +validate it from review-export metadata and live screenshots instead of adding +it to the OCR eval labels. ## When a change moves a number diff --git a/docs/scanner-ik-progress-report.md b/docs/scanner-ik-progress-report.md index d5bd5b4..291e699 100644 --- a/docs/scanner-ik-progress-report.md +++ b/docs/scanner-ik-progress-report.md @@ -16,17 +16,22 @@ Inventory-Kamera-style artifact scanner: 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. +- The OCR worker pool, field crop split, page model, scroll model, and direct + detail-change verification 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. +The current-vs-IK-traineddata comparison proof is now captured. On 2026-07-08, +`npm run scan:goal:compare:validated` passed with +`outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`. +At `limit=100`, `current` won the qualified comparison with `100/100` parsed, +`0` review, `0` misses, `378 ms/artifact` active average, and `37800 ms` +projected time for 100 artifacts. `ik-traineddata` was not qualified at +`limit=100` because it parsed `97/100`, had `5` review and `3` misses. The +separate 3 artifacts/second target is still not proven. ## What Changed @@ -51,13 +56,19 @@ against a canonical package before it is accepted. 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. + set effects, main-stat value crop, crop images, full-frame payloads, and + inventory preview payloads. The equipped footer remains in real artifact-read + captures when its marker is visible, because ownership now matters for phase 1 + validation. Preflight and poll captures still skip OCR/crops/lock-state work. + The slot crop remains in the fast path because it materially improved + real-read quality. - 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. +- Exact visual duplicate skipping is disabled in the hottest path; duplicate + handling now primarily uses parsed artifact signatures so OCR is not skipped + solely from a crop fingerprint collision. Why this matters: @@ -76,8 +87,16 @@ manual-debug capture that OCRs every visible thing. - `npm run scan:goal:current` - `npm run scan:goal:ik` - `npm run scan:goal:compare` + - `npm run scan:goal:compare:validated` + - `npm run scan:goal:compare:validated:wait` + - `npm run scan:iterate:compare:validated` + - `npm run scan:iterate:compare:validated:wait` + - `npm run scan:live:preflight` + - `npm run scan:live:preflight:wait` - `scan-performance-assessment.json` ranks runs by quality first and speed - second. + second, and records whether the 100-artifact result is a complete + current-vs-IK comparison through `goal100Decision` and + `goal100.comparisonComplete`. Important rule: @@ -113,12 +132,15 @@ outside the artifact inventory/detail state. 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. +- Detail-change verification now uses the artifact OCR capture itself instead + of a separate card-ready capture before OCR. - 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. +- Store/review writes are held out of the click/capture/OCR hot path. The + latest source batches artifact store writes before the final summary instead + of issuing one save/reload cycle per artifact. +- Auto-scan artifact captures skip Electron source enumeration in the hot path + and call the GDI capture helper directly. - Focus is done once at scan start; hot-loop captures do not refocus every tile. Why this matters: @@ -175,21 +197,67 @@ Latest repo validation after the recent changes: | `npm run build` | Passed | | `git diff --check` | Passed | -Live evidence already collected earlier on 2026-07-07: +Live evidence already collected: - 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. +- On 2026-07-08, `/scanner/start?entry=visible-inventory&limit=50&engine=current` + completed `50/50` parsed and stored, `0` review, `0` duplicates, `0` misses, + `2` pages, `61765 ms` elapsed, `1235 ms/artifact`, `averageCaptureMs: 186`, + and `averageOcrMs: 162`. +- A deferred single-write flush experiment also completed `50/50`, but regressed + to `63616 ms` because `writeFlushMs` was `8163`; the source now uses batch + persist instead, pending a fresh elevated live measurement. +- After direct GDI hot-path optimization, a 20-artifact run completed + `20/20` parsed, `19` stored, `0` review, `1` duplicate, `0` misses, + `7966 ms` elapsed, `398 ms/artifact`, `averageCaptureMs: 193`, and + `averageOcrMs: 167`. +- A 45-artifact direct-GDI run completed `45/45` parsed, `42` stored, + `0` review, `3` duplicates, `0` misses, `2` pages, `18625 ms` elapsed, + `414 ms/artifact`, `averageCaptureMs: 187`, and `averageOcrMs: 162`. +- A 100-artifact direct-GDI run on signature + `2026-07-08-direct-gdi-hotpath` completed `100/100` parsed, `97` stored, + `0` review, `3` duplicates, `0` misses, `4` pages, `42064 ms` elapsed, + `421 ms/artifact`, `averageCaptureMs: 179`, and `averageOcrMs: 154`. +- `npm run eval` passed after the speed work with `23/23` exact-match cases, + `100%` field accuracy, and `100%` critical fields. +- The 3 artifacts/second target is now prepared in code but not live-proven: + artifact hot-path captures omit detail preview payloads, and stats expose + capture roundtrip/overhead timing. A qualifying 20-artifact run must finish in + `<= 6667 ms` with 0 misses and no silent OCR quality regression. +- Follow-up 3/s attempts on 2026-07-08 fixed the false review trigger caused by + omitted detail previews. The best clean `limit=20` run reached `7285 ms` + (`364 ms/artifact`, about `2.75 artifacts/second`) with `20/20` parsed, + `0` review, and `0` misses. The final stable run on + `2026-07-08-direct-gdi-reviewfix` completed `20/20` with `0` review, + `0` misses, and `7973 ms` elapsed (`399 ms/artifact`). Detail-region capture, + 5 OCR workers, DataURL buffer decode, and substat `PSM.SINGLE_COLUMN` were + tested and rejected as slower than the direct-GDI baseline. +- Final current-vs-IK-traineddata comparison on 2026-07-08: + `npm run scan:goal:compare:validated` passed. Evidence file: + `outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`, + `createdAt: 2026-07-08T18:41:11.6120957+02:00`. `goal100Decision` was + `qualified-comparison: winner=current`; `goal100.comparisonComplete` was true. + `current` completed `100/100` parsed, `97` stored, `0` review, `0` misses, + `4` pages, `378 ms/artifact` active average. `ik-traineddata` completed + `97/100` parsed, `92` stored, `5` review, `3` misses and was rejected for + parsing fewer artifacts than requested. +- The review queue now has a bounded corpus-growth workflow: + `npm run eval:review-candidates` writes a deduplicated, Git-ignored worklist + to `outputs/review-eval-candidates/`. This separates complete modern OCR + samples from stale captures and prevents the parser's own guess from being + promoted to ground truth without human confirmation. After manual checking, + `npm run eval:prepare-confirmed` turns one candidate plus explicit expected + labels into a paste-ready confirmed corpus snippet. 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. +- The fast path and current-vs-IK-traineddata 100-artifact comparison are proven + in the current live environment. 3 artifacts/second is not proven; remaining + speed work needs a larger OCR or capture-pipeline change, not more click + tuning. ## Inventory Kamera Comparison @@ -199,13 +267,14 @@ Current live limitation: | 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 | +| Item wait | About 200 ms fixed wait | No separate wait capture; OCR capture verifies changed detail | | 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 | +| Capture hot path | Direct window/screen capture without source-list scan per item | Direct GDI capture in auto-scan artifact loop | | 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 | +| 100-artifact proof | Reference target | Qualified current-vs-IK comparison captured; `current` won with `100/100`, 0 review, 0 misses, 37.8s projected | What is theoretically better than before: @@ -220,19 +289,29 @@ What is theoretically better than before: 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. +- 3 artifacts/second is not proven. +- Native Inventory Kamera outside this app was not re-run in the same session; + the completed comparison is against the bundled `ik-traineddata` scan engine. ## Theoretical Runtime Flow +For short iteration while tuning: + +1. Start current elevated app with `npm run dev:admin` and confirm UAC. +2. Run `npm run scan:iterate:compare:validated:wait` from a visible artifact inventory + when starting directly after UAC, or `npm run scan:iterate:compare:validated` + if preflight already passes. +3. Inspect `scan-performance-assessment.json`, review samples, and timings if the + 20-artifact comparison fails quality gates. + 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`. +5. Run `npm run scan:goal:compare:validated:wait` directly after UAC, or + `npm run scan:goal:compare:validated` if preflight already passes. 6. For each engine and limit (`2, 5, 20, 45, 100`): - focus Genshin once, - verify lookup and layout, @@ -274,17 +353,15 @@ Expected bottleneck sequence: ## 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 +1. Keep `current` as the default OCR engine for now; it won the qualified + current-vs-IK-traineddata live comparison. +2. If pursuing 3 artifacts/second, focus on capture/OCR pipeline changes rather + than click timing. +3. 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. +4. Validate a positive locked-artifact sample. +5. Keep recommendations secondary until scanner quality remains stable across + repeated live sessions. ## Definition of Done for the IK Target diff --git a/docs/scanner-rework-status.md b/docs/scanner-rework-status.md index d9dbd08..42f70b2 100644 --- a/docs/scanner-rework-status.md +++ b/docs/scanner-rework-status.md @@ -14,12 +14,19 @@ 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. + direct detail-fingerprint verification from the OCR capture, page-overlap + planning, and batched 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. +- The final current-vs-IK-traineddata 100-artifact comparison is now proven for + the current live environment. On 2026-07-08, + `npm run scan:goal:compare:validated` passed with evidence at + `outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`. + `current` won with `100/100` parsed, `0` review, `0` misses, and + `378 ms/artifact` active average. `ik-traineddata` was rejected at 100 because + it parsed `97/100`, had `5` review and `3` misses. The next optional speed + target remains `3 artifacts/second`, which means `333 ms/artifact` or faster + on clean 20-artifact iterations. ## Done (implemented, unit-tested, build green) @@ -55,13 +62,13 @@ Current status: 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 + OCR on the real artifact-read captures, 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. + the scan with an OCR-free preflight capture, 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 @@ -105,10 +112,77 @@ Current status: 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. + The assessment also reports `goal100Decision` and + `goal100.comparisonComplete`, so a single-engine 100-artifact run cannot be + misread as the final IK comparison. Use + `npm run scan:iterate:compare:validated:wait` for the 20-artifact live + iteration and `npm run scan:goal:compare:validated:wait` for the final proof + when starting directly after UAC. The validator `--summary` output includes + the assessment path and timestamp for reporting. - **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. +- **Hot-loop speed pass (2026-07-08)** - the scan loop no longer performs a + separate card-ready capture before OCR; the artifact OCR capture itself + verifies detail-fingerprint change. Routine click diagnostics and scan stat + publishes are throttled. Auto-scan artifact captures no longer update the + full preview/topbar UI on every tile. Store writes can be batched so the scan + path avoids per-artifact save/reload churn. Auto-scan artifact captures now + use a direct GDI hot path and skip Electron `desktopCapturer.getSources()` in + the per-artifact loop. +- **3/s instrumentation pass (2026-07-08)** - artifact hot-path captures omit + the detail-preview payload, and scan stats now split inner capture time from + end-to-end capture roundtrip time. Use `averageCaptureRoundTripMs` and + `averageCaptureRoundTripOverheadMs` in the next `limit=20` live iteration to + decide whether the next cut belongs in native capture transport or OCR. +- **3/s live attempt (2026-07-08)** - the missing-detail-preview review trigger + was fixed and tested. The best clean 20-artifact run reached `7285 ms` + (`364 ms/artifact`, about `2.75 artifacts/second`) with 0 review and 0 misses. + The final stable run on `2026-07-08-direct-gdi-reviewfix` completed `20/20` + with 0 review, 0 misses, and `7973 ms` elapsed (`399 ms/artifact`). Detail + region capture, 5 OCR workers, DataURL buffer decode, and substat + `PSM.SINGLE_COLUMN` were tested and rejected as slower. +- **Review-to-eval loop (2026-07-08)** - `npm run eval:review-candidates` + exports the local review queue into `outputs/review-eval-candidates/` as a + human-labeling worklist. The exporter deduplicates samples, surfaces complete + fast-field captures first, marks stale captures, and now surfaces equipped + footer OCR plus `locked=true/false` payload counts for the next ownership/lock + validation pass. Its output is deliberately + ignored by Git and must not be treated as ground truth until fields are + confirmed against the real artifact. Confirmed review labels now have a + dedicated corpus file, `src/eval/corpus/confirmedReviewCorpus.ts`, with tests + that reject duplicate ids, empty labels, and unconfirmed entries. The helper + `npm run eval:prepare-confirmed` generates a paste-ready confirmed-case + snippet only when explicit expected labels are provided. +- **Prepared ownership/learning loop (2026-07-08)** - fast auto-scan no longer + drops the artifact footer by profile alone; it omits footer OCR only when the + capture option explicitly requests that or when the footer marker is absent. + Parser tests cover noisy equipped names, split `Equipped:`/name footers, and + one-letter OCR fragments that must stay `Not detected`. Scanner learning now + persists text replacements, field aliases, constrained fixes, crop adjustment + proposals, and UI-profile adjustment proposals instead of truncating everything + back to text replacements. +- **Visible-inventory merge guard (2026-07-09)** - the normal guided Auto-Scan + start no longer falls back into `auto-entry` when the artifact detail card is + missing. It now blocks and asks the operator to open the Artifact inventory + with a visible detail card. The explicit `auto-entry`, `direct-inventory`, and + `paimon-menu` Dev-Control modes remain available for targeted experiments, but + they are not the merge-ready default path. +- **Ownership live smoke (2026-07-09)** - live artifact detail capture parsed + and stored an equipped footer as `equipped: "Citlali"` and the grey lock state + as `locked: false`. A same-session visible-inventory run with + `/scanner/start?entry=visible-inventory&limit=20&engine=current` completed + `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and + `0` misses in `8047 ms` elapsed (`402 ms/artifact`). +- **Locked artifact live proof (2026-07-09)** - a visibly locked artifact was + selected through a read-only inventory tile click. Smart Capture reported + `locked: true` with `lockSignal.ratio: 0.14797913950456323` over threshold + `0.06`, and `/scanner/start?entry=visible-inventory&limit=1&engine=current` + persisted the same artifact with `equipped: "Citlali"` and `locked: true`. + Lock detection now decodes the lock crop PNG before measuring active lock + pixels because Electron's native bitmap channel order was ambiguous in live + captures. ## Remaining — needs the live environment or a UI pass @@ -122,23 +196,34 @@ resolution or without UI work best tested live: 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. +3. **Validate explicit entry modes separately** from world, direct inventory, + and Paimon/menu states with low limits only. These are now Dev-Control + experiments, not the normal merge path; the normal Auto-Scan button blocks + unless the visible artifact detail card is already present. +4. **Repeat locked=true on another page/session** if lock behavior changes. + The first positive live proof passed on 2026-07-09, including store + persistence. Further repeats are useful for confidence but no longer block + the merge. -5. **Broader scan soak test** — after the bounded two-item live scan passed, - the next automation validation should increase the limit gradually and watch - for repeated pages, scroll behavior, duplicate handling, and OCR review rate. -6. **100-artifact IK comparison** — after `/health.appBuild.signature` matches - current source, run `npm run scan:goal:compare` and compare qualified - 100-artifact results. +5. **3 artifacts/second iteration** - not reached yet. The next credible path is + either native Tesseract/IK-traineddata integration that materially reduces + substat OCR time, or a larger capture pipeline change that avoids full-frame + PNG/Base64 transport without hurting safety checks. The target remains + `<= 6667 ms` elapsed for 20 parsed artifacts with 0 misses and no silent OCR + review regression. -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. +6. **Broader scan soak test** — direct-GDI current-engine runs now passed at + `20/20`, `45/45`, and `100/100` with 0 misses. Continue with repeat runs if + duplicate rate needs tuning. +7. **Repeatability pass** — repeat the qualified current-vs-IK-traineddata run + in a later live session before making major OCR-engine defaults or speed + claims beyond this environment. + +Visible-page limits up to 20, scroll/page-transition limit 45, and the final +100-artifact current-vs-IK-traineddata comparison have passed for the current +environment. Remaining soak work is repeatability, OCR corpus growth, equipped +footer confirmation repeats, locked artifact repeats, and optional 3 artifacts/second +speed work. ## Grow the eval corpus diff --git a/electron/appWindowManager.ts b/electron/appWindowManager.ts new file mode 100644 index 0000000..a53add9 --- /dev/null +++ b/electron/appWindowManager.ts @@ -0,0 +1,145 @@ +import { BrowserWindow, Menu, screen } from "electron"; +import path from "node:path"; + +export interface AppWindowManagerOptions { + preloadPath: string; + rendererUrl?: string; + rendererFilePath: string; + onMainReadyToShow?: () => void | Promise; +} + +export interface AppWindowManager { + createMainWindow: () => void; + focusMainWindow: () => { ok: boolean }; + hasMainWindow: () => boolean; + getMainWindow: () => BrowserWindow | null; + sendScannerCommand: (command: unknown) => void; + createOverlayWindow: () => void; + hideOverlayWindow: () => { ok: boolean }; +} + +export function createAppWindowManager({ + preloadPath, + rendererUrl, + rendererFilePath, + onMainReadyToShow, +}: AppWindowManagerOptions): AppWindowManager { + let mainWindow: BrowserWindow | null = null; + let overlayWindow: BrowserWindow | null = null; + + function createMainWindow() { + Menu.setApplicationMenu(null); + + mainWindow = new BrowserWindow({ + width: 1320, + height: 860, + minWidth: 1120, + minHeight: 720, + backgroundColor: "#090711", + title: "Genshin Artifact Assistant", + show: false, + autoHideMenuBar: true, + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.setMenuBarVisibility(false); + mainWindow.on("closed", () => { + mainWindow = null; + }); + mainWindow.once("ready-to-show", () => { + void onMainReadyToShow?.(); + focusMainWindow(); + }); + mainWindow.webContents.once("did-finish-load", () => { + setTimeout(() => focusMainWindow(), 350); + }); + + if (rendererUrl) { + mainWindow.loadURL(rendererUrl); + } else { + mainWindow.loadFile(rendererFilePath); + } + } + + function focusMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) return { ok: false }; + + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + // Genshin often keeps foreground focus after a scan click. Toggling + // always-on-top for one tick nudges Windows to surface the dashboard again + // without leaving it pinned above other apps. + mainWindow.setAlwaysOnTop(true, "screen-saver"); + mainWindow.focus(); + setTimeout(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setAlwaysOnTop(false); + mainWindow.focus(); + }, 250); + return { ok: true }; + } + + function sendScannerCommand(command: unknown) { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send("scanner:command", command); + } + + function createOverlayWindow() { + if (overlayWindow) { + overlayWindow.show(); + return; + } + + const display = screen.getPrimaryDisplay(); + overlayWindow = new BrowserWindow({ + x: display.workArea.x, + y: display.workArea.y, + width: display.workArea.width, + height: display.workArea.height, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + focusable: false, + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + overlayWindow.setIgnoreMouseEvents(true, { forward: true }); + + if (rendererUrl) { + overlayWindow.loadURL(`${rendererUrl}?overlay=1`); + } else { + overlayWindow.loadFile(rendererFilePath, { + query: { overlay: "1" }, + }); + } + + overlayWindow.on("closed", () => { + overlayWindow = null; + }); + } + + function hideOverlayWindow() { + overlayWindow?.close(); + return { ok: true }; + } + + return { + createMainWindow, + focusMainWindow, + hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()), + getMainWindow: () => mainWindow, + sendScannerCommand, + createOverlayWindow, + hideOverlayWindow, + }; +} diff --git a/electron/devControlServer.ts b/electron/devControlServer.ts index d672ea3..99b190e 100644 --- a/electron/devControlServer.ts +++ b/electron/devControlServer.ts @@ -113,6 +113,7 @@ async function writeDevCaptureSnapshot(capture: CaptureResult) { : null, inventoryCount: capture.inventoryCount ?? null, locked: capture.locked, + lockSignal: capture.lockSignal, crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })), ocr: capture.ocr ?? [], files, @@ -339,7 +340,10 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv captures, }; } - const summaries = await Promise.all(engines.map((engine) => runEngineBenchmark(engine))); + const summaries = []; + for (const engine of engines) { + summaries.push(await runEngineBenchmark(engine)); + } writeDevJson(res, 200, { ok: true, summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries }, diff --git a/electron/main.ts b/electron/main.ts index dbf263f..0554730 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,4 @@ -import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron"; -import fs from "node:fs/promises"; +import { app, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron"; import { existsSync } from "node:fs"; import type { Server } from "node:http"; import { cpus } from "node:os"; @@ -7,18 +6,19 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createWorker, PSM } from "tesseract.js"; import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js"; +import { pngBufferToBitmap } from "./services/pngBitmap.js"; import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; import { createDevControlServer } from "./devControlServer.js"; +import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js"; +import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js"; import type { AppSnapshot } from "../src/types/domain.js"; import type { CaptureOptions, CaptureResult, GoodDatabase, - GoodImportFileResult, OcrResult, AppRuntimeInfo, - SaveResultWithPath, ScannerCommand, ScannerLearningRulePayload, ScannerStatusPayload, @@ -39,7 +39,7 @@ import { profileDetailRect, } from "../src/lib/layoutProfile.js"; import { binarizeForOcr } from "../src/lib/ocrPreprocess.js"; -import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.js"; +import { DEFAULT_LOCK_THRESHOLD, isLocked, lockIconCropRect, lockSignalRatio } from "../src/lib/lockDetection.js"; // Chromium's renderer sandbox can refuse to fully initialize (or silently // crash the GPU/renderer process) when the hosting process runs with a full @@ -54,12 +54,11 @@ app.commandLine.appendSwitch("disable-gpu-sandbox"); const __dirname = path.dirname(fileURLToPath(import.meta.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"; +const APP_RUNTIME_SIGNATURE = "2026-07-08-direct-gdi-reviewfix"; -let mainWindow: BrowserWindow | null = null; -let overlayWindow: BrowserWindow | null = null; let registeredHotkeys: Record = {}; let devControlServer: Server | null = null; +const captureSourceNameCache = new Map(); let scannerDevStatus: ScannerStatusPayload = { running: false, reviewStatus: "", @@ -83,6 +82,8 @@ let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null; let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null; let scannerLearningRepository: ScannerLearningRepositoryPort | null = null; let inputHelperService: InputHelperService | null = null; +let appWindowManager: AppWindowManager | null = null; +let goodFileService: GoodFileService | null = null; function getInputHelperService() { if (!inputHelperService) { @@ -97,6 +98,7 @@ function resolveInputHelperExePath(): string | null { const candidates = [ process.env.INPUT_HELPER_EXE, path.join(process.resourcesPath, "input-helper", "InputHelper.exe"), + path.join(process.cwd(), "native", "input-helper", "bin", "publish", "InputHelper.exe"), path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"), ].filter((candidate): candidate is string => Boolean(candidate)); @@ -163,7 +165,7 @@ function getScannerLearningRepository() { async function writeScannerLearningRules(rules: ScannerLearningRulePayload) { const safeRules = rules && typeof rules === "object" ? rules : {}; try { - return await getScannerLearningRepository().save(safeRules as { textReplacements?: Record }); + return await getScannerLearningRepository().save(safeRules); } catch { return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} }, total: 0 }; } @@ -184,8 +186,11 @@ function getSnapshotRepository() { return context.snapshotRepository; } -function exportPath(fileName: string) { - return path.join(app.getPath("userData"), "exports", fileName); +function getGoodFileService() { + if (!goodFileService) { + throw new Error("GOOD file service is not initialized."); + } + return goodFileService; } async function loadSnapshotFromDisk() { @@ -264,6 +269,7 @@ async function getGenshinWindowBounds() { // second display exists and isn't the one Genshin occupies, move the // dashboard there so it can never cover the grid we're about to click. async function moveMainWindowOffGenshin() { + const mainWindow = getAppWindowManager().getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; const genshinBounds = await getGenshinWindowBounds(); const displays = screen.getAllDisplays(); @@ -304,8 +310,7 @@ async function showOverlayWindow() { } async function hideOverlayWindow() { - overlayWindow?.close(); - return { ok: true }; + return getAppWindowManager().hideOverlayWindow(); } async function listCaptureSources() { @@ -324,12 +329,15 @@ async function listCaptureSources() { fetchWindowIcons: true, }); - return sources.map((source) => ({ - id: source.id, - name: source.name, - isGenshinCandidate: isLikelyGenshinSourceName(source.name), - thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(), - })); + return sources.map((source) => { + captureSourceNameCache.set(source.id, source.name); + return { + id: source.id, + name: source.name, + isGenshinCandidate: isLikelyGenshinSourceName(source.name), + thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(), + }; + }); } async function toScreenPoint(x: number, y: number) { @@ -394,69 +402,45 @@ async function capturePrimaryScreenViaGdi() { async function captureSourceFromGdi(sourceId: string, sourceName: string, options: CaptureOptions = {}) { const gdi = await capturePrimaryScreenViaGdi(); - const sourceImage = nativeImage.createFromDataURL(gdi.dataUrl); + const sourceImage = nativeImageFromGdiCapture(gdi); return await buildCaptureResult(sourceImage, sourceId, sourceName, gdi.captureTarget, options); } -function createMainWindow() { - Menu.setApplicationMenu(null); +function nativeImageFromGdiCapture(gdi: Awaited>) { + return nativeImage.createFromDataURL(gdi.dataUrl); +} - mainWindow = new BrowserWindow({ - width: 1320, - height: 860, - minWidth: 1120, - minHeight: 720, - backgroundColor: "#090711", - title: "Genshin Artifact Assistant", - show: false, - autoHideMenuBar: true, - webPreferences: { - preload: path.join(__dirname, "preload.cjs"), - contextIsolation: true, - nodeIntegration: false, - }, - }); +function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) { + return Boolean( + options.ocrMode === "artifact" || + options.skipOcrUnlessArtifactDetail || + options.skipOcr || + options.omitCrops, + ); +} - mainWindow.setMenuBarVisibility(false); - mainWindow.on("closed", () => { - mainWindow = null; - }); - mainWindow.once("ready-to-show", () => { - void moveMainWindowOffGenshin(); - focusMainWindow(); - }); - mainWindow.webContents.once("did-finish-load", () => { - setTimeout(() => focusMainWindow(), 350); - }); - - if (isDev) { - mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!); - } else { - mainWindow.loadFile(path.join(__dirname, "../../dist/index.html")); +function getAppWindowManager() { + if (!appWindowManager) { + appWindowManager = createAppWindowManager({ + preloadPath: path.join(__dirname, "preload.cjs"), + rendererUrl: process.env.VITE_DEV_SERVER_URL, + rendererFilePath: path.join(__dirname, "../../dist/index.html"), + onMainReadyToShow: moveMainWindowOffGenshin, + }); } + return appWindowManager; +} + +function createMainWindow() { + getAppWindowManager().createMainWindow(); } function focusMainWindow() { - if (!mainWindow || mainWindow.isDestroyed()) return { ok: false }; - - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - // Genshin often keeps foreground focus after a scan click. Toggling - // always-on-top for one tick nudges Windows to surface the dashboard again - // without leaving it pinned above other apps. - mainWindow.setAlwaysOnTop(true, "screen-saver"); - mainWindow.focus(); - setTimeout(() => { - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.setAlwaysOnTop(false); - mainWindow.focus(); - }, 250); - return { ok: true }; + return getAppWindowManager().focusMainWindow(); } function sendScannerCommand(command: ScannerCommand | "probe-click") { - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.send("scanner:command", command); + getAppWindowManager().sendScannerCommand(command); } function registerScannerHotkeys() { @@ -474,7 +458,7 @@ function startDevControlServer() { devControlServer = createDevControlServer({ registeredHotkeys, appBuild: appRuntimeInfo(), - hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()), + hasMainWindow: () => getAppWindowManager().hasMainWindow(), sendScannerCommand, clickScreen: clickScreenCommand, scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }), @@ -490,43 +474,7 @@ function startDevControlServer() { } function createOverlayWindow() { - if (overlayWindow) { - overlayWindow.show(); - return; - } - - const display = screen.getPrimaryDisplay(); - overlayWindow = new BrowserWindow({ - x: display.workArea.x, - y: display.workArea.y, - width: display.workArea.width, - height: display.workArea.height, - transparent: true, - frame: false, - alwaysOnTop: true, - skipTaskbar: true, - resizable: false, - focusable: false, - webPreferences: { - preload: path.join(__dirname, "preload.cjs"), - contextIsolation: true, - nodeIntegration: false, - }, - }); - - overlayWindow.setIgnoreMouseEvents(true, { forward: true }); - - if (isDev) { - overlayWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL!}?overlay=1`); - } else { - overlayWindow.loadFile(path.join(__dirname, "../../dist/index.html"), { - query: { overlay: "1" }, - }); - } - - overlayWindow.on("closed", () => { - overlayWindow = null; - }); + getAppWindowManager().createOverlayWindow(); } // Inventory Kamera keeps a pool of native Tesseract engines and scans artifact @@ -818,8 +766,8 @@ async function runOcrOnCropsWithTimeout(crops: OcrCropPayload[], engine: OcrWork function cleanOcrText(cropId: string, text: string) { const normalized = text - .replace(/[“”]/g, '"') - .replace(/[’]/g, "'") + .replace(/[\u201c\u201d]/g, '"') + .replace(/[\u2019]/g, "'") .replace(/\r/g, "") .split("\n") .map((line) => line.replace(/\s+/g, " ").trim()) @@ -1126,6 +1074,7 @@ async function getAllSources() { async function findCaptureSourceById(sourceId: string) { const sources = await getAllSources(); + for (const source of sources) captureSourceNameCache.set(source.id, source.name); return sources.find((source) => source.id === sourceId) ?? null; } @@ -1172,7 +1121,7 @@ function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle function preprocessedCropPngBuffer(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }, cropId = "") { const safeRect = clampCaptureRect(rect, imageSize); const scale = cropId === "artifact-level" ? 3 : 2; - const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * scale), quality: "best" }); + const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, Math.round(safeRect.width * scale)), quality: "best" }); const size = upscaled.getSize(); if (!size.width || !size.height) return upscaled.toPNG(); const binarized = binarizeForOcr( @@ -1201,10 +1150,9 @@ function createCrops( .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 (template.id === "artifact-footer" && options.omitEquippedOcr) return false; if (!isArtifactScanMode || template.id !== "artifact-footer" || !bitmap) return true; return hasEquippedFooterMarker(bitmap, imageSize, template.rect); }); @@ -1314,16 +1262,35 @@ async function buildCaptureResult( const crops = omitCrops ? [] : createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr }); - const locked = options.omitLockState + const lockSignal = options.omitLockState ? undefined : (() => { const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size); const lockImage = sourceImage.crop(lockRect); const lockSize = lockImage.getSize(); - return lockSize.width > 0 && lockSize.height > 0 - ? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height }) + const ratio = lockSize.width > 0 && lockSize.height > 0 + ? (() => { + try { + return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG())); + } catch { + return lockSignalRatio({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height }); + } + })() + : undefined; + return typeof ratio === "number" + ? { + ratio, + threshold: DEFAULT_LOCK_THRESHOLD, + rect: { + x: lockRect.x, + y: lockRect.y, + width: lockRect.width, + height: lockRect.height, + }, + } : undefined; })(); + const locked = lockSignal ? isLocked(lockSignal.ratio, lockSignal.threshold) : undefined; const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact"); const omitDetailPreview = Boolean(options.omitDetailPreview); const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact"); @@ -1383,6 +1350,7 @@ async function buildCaptureResult( paimonMenu, inventoryCount: count, locked, + lockSignal, sanctified, layout: { aspect: aspectRatioLabel(size), @@ -1407,16 +1375,27 @@ async function buildCaptureResult( } async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions): Promise { + const captureStartedAt = Date.now(); if (!Number.isFinite(delayMs) || delayMs < 0) { delayMs = 0; } + const withElapsed = (capture: CaptureResult): CaptureResult => ({ + ...capture, + elapsedMs: Math.max(0, Date.now() - captureStartedAt), + }); + await waitDelay(Math.floor(delayMs)); if (focusGenshin) { await focusGenshinForScanStart(); } + if (shouldUseDirectGdiHotPath(options ?? {})) { + const cachedName = captureSourceNameCache.get(sourceId) ?? "Genshin GDI Capture"; + return withElapsed(await captureSourceFromGdi(sourceId, cachedName, options ?? {})); + } + const source = await findCaptureSourceById(sourceId); if (!source) { throw new Error("Capture source not found."); @@ -1425,7 +1404,7 @@ async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false const isGenshinCandidate = isLikelyGenshinSourceName(source.name); if (isGenshinCandidate) { try { - return await captureSourceFromGdi(sourceId, source.name, options ?? {}); + return withElapsed(await captureSourceFromGdi(sourceId, source.name, options ?? {})); } catch { // Fall back to desktop thumbnail capture for robustness in low-permission // or transient capture failures. OCR will still produce a best-effort result. @@ -1434,56 +1413,16 @@ async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false const sourceImage = source.thumbnail; if (sourceImage.isEmpty()) { - return await captureSourceFromGdi(sourceId, source.name, options ?? {}); + return withElapsed(await captureSourceFromGdi(sourceId, source.name, options ?? {})); } - return await buildCaptureResult( + return withElapsed(await buildCaptureResult( sourceImage, sourceId, source.name, sourceId.startsWith("screen:") ? "desktop-source" : "genshin-client", options ?? {}, - ); -} - -async function exportGood(payload: GoodDatabase): Promise { - const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`; - const filePath = exportPath(fileNameSafe); - try { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); - return { ok: true, path: filePath }; - } catch { - return { ok: false, path: filePath }; - } -} - -async function importGoodFile(): Promise { - const dialogOptions = { - title: "GOOD-Datei importieren", - properties: ["openFile"], - filters: [{ name: "GOOD JSON", extensions: ["json"] }], - } satisfies Electron.OpenDialogOptions; - const dialogResult = mainWindow && !mainWindow.isDestroyed() - ? await dialog.showOpenDialog(mainWindow, dialogOptions) - : await dialog.showOpenDialog(dialogOptions); - - if (dialogResult.canceled || dialogResult.filePaths.length === 0) { - return { ok: false, canceled: true, path: "" }; - } - - const filePath = dialogResult.filePaths[0]; - try { - const text = await fs.readFile(filePath, "utf8"); - return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) }; - } catch (error) { - return { - ok: false, - canceled: false, - path: filePath, - error: error instanceof Error ? error.message : String(error), - }; - } + )); } function initializeAppLifecycle() { @@ -1494,6 +1433,7 @@ function initializeAppLifecycle() { reviewSamplesRepository = repositoryContext.reviewSamplesRepository; scannerLearningRepository = repositoryContext.scannerLearningRepository; inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() }); + goodFileService = createGoodFileService(path.join(userDataPath, "exports")); registerIpcHandlers({ focusMainWindow: () => focusMainWindow(), @@ -1513,8 +1453,8 @@ function initializeAppLifecycle() { loadReviewSamples: (limit?: number) => loadReviewSamples(limit), loadScannerLearningRules: () => loadScannerLearningRules(), writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules), - exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload), - importGoodFile: () => importGoodFile(), + exportGood: (exportPayload: GoodDatabase) => getGoodFileService().exportGood(exportPayload), + importGoodFile: () => getGoodFileService().importGoodFile(getAppWindowManager().getMainWindow()), listSources: () => listCaptureSources(), captureSource: ( id: string, @@ -1535,7 +1475,7 @@ function initializeAppLifecycle() { }); app.on("activate", () => { - if (!mainWindow || mainWindow.isDestroyed()) { + if (!getAppWindowManager().hasMainWindow()) { createMainWindow(); } }); diff --git a/electron/repositories/scannerLearningRepository.ts b/electron/repositories/scannerLearningRepository.ts index ec282a9..d2a0c94 100644 --- a/electron/repositories/scannerLearningRepository.ts +++ b/electron/repositories/scannerLearningRepository.ts @@ -16,22 +16,71 @@ export class ScannerLearningRepository implements ScannerLearningRepositoryPort return { ok: true, path: this.filePath, - rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} }, + rules: parsed && typeof parsed === "object" ? parsed : emptyRules(), }; } catch { - return { ok: true, path: this.filePath, rules: { textReplacements: {} } }; + return { ok: true, path: this.filePath, rules: emptyRules() }; } } async save(rules: ScannerLearningRules): Promise { const current = await this.load(); - const nextTextReplacements = { - ...((current.rules as { textReplacements?: Record })?.textReplacements ?? {}), - ...((rules as { textReplacements?: Record })?.textReplacements ?? {}), - }; - const payload: ScannerLearningRules = { textReplacements: nextTextReplacements }; + const payload: ScannerLearningRules = mergeRules(current.rules, rules); await fs.mkdir(path.dirname(this.filePath), { recursive: true }); await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8"); - return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length }; + return { ok: true, path: this.filePath, rules: payload, total: countRules(payload) }; } } + +function emptyRules(): ScannerLearningRules { + return { + textReplacements: {}, + fieldAliases: {}, + constrainedFixes: {}, + cropAdjustments: {}, + uiProfileAdjustments: {}, + }; +} + +function mergeRules(current: ScannerLearningRules, incoming: ScannerLearningRules): ScannerLearningRules { + return { + textReplacements: { + ...(current.textReplacements ?? {}), + ...(incoming.textReplacements ?? {}), + }, + fieldAliases: mergeNested(current.fieldAliases, incoming.fieldAliases), + constrainedFixes: { + ...(current.constrainedFixes ?? {}), + ...(incoming.constrainedFixes ?? {}), + }, + cropAdjustments: { + ...(current.cropAdjustments ?? {}), + ...(incoming.cropAdjustments ?? {}), + }, + uiProfileAdjustments: { + ...(current.uiProfileAdjustments ?? {}), + ...(incoming.uiProfileAdjustments ?? {}), + }, + }; +} + +function mergeNested( + current: Record> | undefined, + incoming: Record> | undefined, +) { + const merged: Record> = {}; + for (const [field, values] of Object.entries(current ?? {})) merged[field] = { ...(values ?? {}) }; + for (const [field, values] of Object.entries(incoming ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(values ?? {}) }; + return merged; +} + +function countRules(rules: ScannerLearningRules) { + const fieldAliases = Object.values(rules.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0); + return ( + Object.keys(rules.textReplacements ?? {}).length + + fieldAliases + + Object.keys(rules.constrainedFixes ?? {}).length + + Object.keys(rules.cropAdjustments ?? {}).length + + Object.keys(rules.uiProfileAdjustments ?? {}).length + ); +} diff --git a/electron/services/goodFileService.ts b/electron/services/goodFileService.ts new file mode 100644 index 0000000..fe817f2 --- /dev/null +++ b/electron/services/goodFileService.ts @@ -0,0 +1,57 @@ +import { dialog, type BrowserWindow } from "electron"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { GoodDatabase, GoodImportFileResult, SaveResultWithPath } from "../../src/types/global.js"; + +export interface GoodFileService { + exportGood: (payload: GoodDatabase) => Promise; + importGoodFile: (parentWindow?: BrowserWindow | null) => Promise; +} + +export function createGoodFileService(exportDirectory: string): GoodFileService { + function exportPath(fileName: string) { + return path.join(exportDirectory, fileName); + } + + async function exportGood(payload: GoodDatabase): Promise { + const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`; + const filePath = exportPath(fileNameSafe); + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); + return { ok: true, path: filePath }; + } catch { + return { ok: false, path: filePath }; + } + } + + async function importGoodFile(parentWindow?: BrowserWindow | null): Promise { + const dialogOptions = { + title: "GOOD-Datei importieren", + properties: ["openFile"], + filters: [{ name: "GOOD JSON", extensions: ["json"] }], + } satisfies Electron.OpenDialogOptions; + const dialogResult = parentWindow && !parentWindow.isDestroyed() + ? await dialog.showOpenDialog(parentWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + + if (dialogResult.canceled || dialogResult.filePaths.length === 0) { + return { ok: false, canceled: true, path: "" }; + } + + const filePath = dialogResult.filePaths[0]; + try { + const text = await fs.readFile(filePath, "utf8"); + return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) }; + } catch (error) { + return { + ok: false, + canceled: false, + path: filePath, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + return { exportGood, importGoodFile }; +} diff --git a/electron/services/inputHelper.ts b/electron/services/inputHelper.ts index daf4468..11a289c 100644 --- a/electron/services/inputHelper.ts +++ b/electron/services/inputHelper.ts @@ -13,444 +13,7 @@ import type { ScrollResult, } from "../../src/types/global.js"; -const INPUT_HELPER_SCRIPT = String.raw` -$ErrorActionPreference = "Stop" -Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms - -$signature = @" -[DllImport("user32.dll")] -public static extern bool SetProcessDPIAware(); -[DllImport("shcore.dll")] -public static extern int SetProcessDpiAwareness(int value); -[DllImport("user32.dll")] -public static extern bool SetCursorPos(int X, int Y); -[DllImport("user32.dll")] -public static extern bool GetCursorPos(out POINT lpPoint); -[DllImport("user32.dll", SetLastError=true)] -public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); -[DllImport("user32.dll", SetLastError=true)] -public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); -[DllImport("user32.dll")] -public static extern short GetAsyncKeyState(int vKey); -[DllImport("user32.dll", SetLastError=true)] -public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); -[DllImport("user32.dll")] -public static extern bool SetForegroundWindow(IntPtr hWnd); -[DllImport("user32.dll")] -public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); -[DllImport("user32.dll")] -public static extern bool BringWindowToTop(IntPtr hWnd); -[DllImport("user32.dll", SetLastError=true)] -public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); -[DllImport("kernel32.dll")] -public static extern uint GetCurrentThreadId(); -[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] -public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni); -[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] -public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni); -[DllImport("user32.dll")] -public static extern IntPtr GetForegroundWindow(); -[DllImport("user32.dll")] -public static extern bool IsWindow(IntPtr hWnd); -[DllImport("user32.dll")] -public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); - -[StructLayout(LayoutKind.Sequential)] -public struct POINT { public int X; public int Y; } - -[StructLayout(LayoutKind.Sequential)] -public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } - -[StructLayout(LayoutKind.Sequential)] -public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; } - -[StructLayout(LayoutKind.Sequential)] -public struct INPUT { public int type; public MOUSEINPUT mi; } -"@ -Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native -# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the -# same symptom): the older SetProcessDPIAware() only applies a single, -# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin -# on one display, this app's window on a differently-scaled second display), -# that single scale factor is wrong for whichever monitor didn't set it, -# silently shifting every SetCursorPos/click coordinate off-target even -# though cursor readback still matches what we asked for (both go through the -# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2. -try { - [Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null -} catch { - [Native.InputHelper]::SetProcessDPIAware() | Out-Null -} -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -# SizeOf must receive a struct instance: passing the type object throws in -# Windows PowerShell 5.1 (RuntimeType cannot be marshalled). -$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT)) -$genshinHwnd = [IntPtr]::Zero - -function Send-MouseInput { - param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0) - $mouseInput = New-Object Native.InputHelper+INPUT - $mouseInput.type = 0 - $mouseInput.mi.dx = $dx - $mouseInput.mi.dy = $dy - if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData } - $mouseInput.mi.dwFlags = $flags - return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) -} - -# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves -# with bare SetCursorPos, then clicks via the InputSimulator library's -# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE -# SendInput call (two INPUT structs in the same array) - back-to-back with no -# artificial delay between them, unlike two separate SendInput calls with a -# Start-Sleep in between. Returns the number of injected events (2 = ok). -function Send-MouseClickBatch { - $down = New-Object Native.InputHelper+INPUT - $down.type = 0 - $down.mi.dwFlags = 0x0002 - $up = New-Object Native.InputHelper+INPUT - $up.type = 0 - $up.mi.dwFlags = 0x0004 - 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 { - $pt = New-Object Native.InputHelper+POINT - [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null - return $pt -} - -function Get-ProcessNameFromHwnd { - param([IntPtr]$hwnd) - if ($hwnd -eq [IntPtr]::Zero) { return "" } - $pidValue = [uint32]0 - [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null - if ($pidValue -eq 0) { return "" } - try { - return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName - } catch { - return "" - } -} - -function Get-CurrentProcessElevation { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Get-ForegroundInfo { - $hwnd = [Native.InputHelper]::GetForegroundWindow() - return @{ - foregroundHwnd = $hwnd.ToInt64() - foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd - } -} - -function Get-CursorState { - $pt = New-Object Native.InputHelper+POINT - [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null - # Only 0x8000 (key is held down right now). The 0x0001 "pressed since last - # call" bit is unreliable and fires for ESC presses that happened long - # before the scan (ESC is used constantly to navigate Genshin menus). - $esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0 - $enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0 - $f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0 - return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 } -} - -function Get-GenshinClientBounds { - $hwnd = Find-GenshinWindow - if ($hwnd -eq [IntPtr]::Zero) { return $null } - - $rect = New-Object Native.InputHelper+RECT - if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null } - - $topLeft = New-Object Native.InputHelper+POINT - $topLeft.X = 0 - $topLeft.Y = 0 - if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null } - - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - if ($width -le 0 -or $height -le 0) { return $null } - - return @{ - Left = $topLeft.X - Top = $topLeft.Y - Width = $width - Height = $height - } -} - -function Find-GenshinWindow { - if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd } - $proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1 - if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero } - return $script:genshinHwnd -} - -# Plain SetForegroundWindow from this background helper process is silently -# refused by Windows' foreground lock. Attach our thread's input queue to the -# target (and current foreground) window thread and clear the lock timeout, so -# the foreground change is honored - the same technique Inventory Kamera uses. -function Force-Foreground { - param([IntPtr]$hwnd) - $current = [Native.InputHelper]::GetCurrentThreadId() - $targetPid = [uint32]0 - $target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid) - $fgWindow = [Native.InputHelper]::GetForegroundWindow() - $foreground = [uint32]0 - if ($fgWindow -ne [IntPtr]::Zero) { - $fgPid = [uint32]0 - $foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid) - } - - $attachedTarget = $false - $attachedForeground = $false - $oldTimeout = [uint32]0 - $timeoutRead = $false - try { - if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) } - if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) } - $timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0) - [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null - # Inject a no-op input (0,0 mouse move) so this process is the last input - # source, which Windows requires before it will honor a foreground change. - Send-MouseInput -flags 0x0001 | Out-Null - [Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null - [Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null - return [Native.InputHelper]::SetForegroundWindow($hwnd) - } finally { - if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null } - if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null } - if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null } - } -} - -function Focus-GenshinWindow { - $hwnd = Find-GenshinWindow - $info = @{ - hwnd = $hwnd.ToInt64() - focused = $false - alreadyForeground = $false - foregroundProcess = "" - targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd - } - if ($hwnd -eq [IntPtr]::Zero) { return $info } - - $info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd) - if (-not $info.alreadyForeground) { - $info.setForegroundResult = Force-Foreground -hwnd $hwnd - Start-Sleep -Milliseconds 140 - } - - $foreground = [Native.InputHelper]::GetForegroundWindow() - $info.focused = ($foreground -eq $hwnd) - $info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground - return $info -} - -while ($true) { - $line = [Console]::In.ReadLine() - if ($null -eq $line) { break } - if ($line.Trim().Length -eq 0) { continue } - $response = @{ id = ""; ok = $true } - try { - $cmd = $line | ConvertFrom-Json - $response.id = "$($cmd.id)" - switch ("$($cmd.op)") { - "ping" { - $response.pong = $true - } - "cursor" { - $state = Get-CursorState - $response.cursorX = $state.cursorX - $response.cursorY = $state.cursorY - $response.escapePressed = $state.escapePressed - $response.enterPressed = $state.enterPressed - $response.f9Pressed = $state.f9Pressed - } - "runtime" { - $response.isElevated = Get-CurrentProcessElevation - $hwnd = Find-GenshinWindow - $foregroundInfo = Get-ForegroundInfo - $response.genshinFound = ($hwnd -ne [IntPtr]::Zero) - $response.genshinHwnd = $hwnd.ToInt64() - $response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd - $response.foregroundProcess = $foregroundInfo.foregroundProcess - $response.foregroundHwnd = $foregroundInfo.foregroundHwnd - $response.helperPid = $PID - } - "focus" { - $focusInfo = Focus-GenshinWindow - $response.focused = $focusInfo.focused - $response.alreadyForeground = $focusInfo.alreadyForeground - $response.foregroundProcess = $focusInfo.foregroundProcess - $response.targetProcess = $focusInfo.targetProcess - $response.genshinFound = ($focusInfo.hwnd -ne 0) - $response.setForegroundResult = $focusInfo.setForegroundResult - } - "click" { - $focusInfo = Focus-GenshinWindow - if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { - Start-Sleep -Milliseconds 120 - } - $targetX = [int]$cmd.x - $targetY = [int]$cmd.y - # Matches Inventory Kamera's verified-working sequence exactly: bare - # SetCursorPos immediately followed by a click, with NO extra move - # event and NO artificial delay between moving and clicking - IK's - # Navigation.Click(x, y) does SetCursor() then Click() back-to-back, - # zero gap. Settling delays only happen after the click, in the scan - # loop. Down+up are sent as one SendInput call (see - # Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick(). - [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null - $point = Get-CursorPoint - $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) - $clickEventsSent = 0 - if ($onTarget) { - $clickEventsSent = Send-MouseClickBatch - } - $state = Get-CursorState - $response.cursorX = $state.cursorX - $response.cursorY = $state.cursorY - $response.escapePressed = $state.escapePressed - $response.enterPressed = $state.enterPressed - $response.f9Pressed = $state.f9Pressed - $response.moved = $onTarget - $response.focused = $focusInfo.focused - $response.alreadyForeground = $focusInfo.alreadyForeground - $response.foregroundProcess = $focusInfo.foregroundProcess - $response.targetProcess = $focusInfo.targetProcess - $response.isElevated = Get-CurrentProcessElevation - # Never report a click unless the cursor is verifiably on the target. - # Real acceptance is proven later by the detail-panel fingerprint. - $response.clicked = ($onTarget -and $clickEventsSent -ge 2) - $response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2) - } - "scroll" { - $focusInfo = Focus-GenshinWindow - if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { - Start-Sleep -Milliseconds 120 - } - if ($null -ne $cmd.x -and $null -ne $cmd.y) { - [Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null - Start-Sleep -Milliseconds 30 - } - $point = Get-CursorPoint - $response.cursorX = $point.X - $response.cursorY = $point.Y - $response.focused = $focusInfo.focused - $response.foregroundProcess = $focusInfo.foregroundProcess - $response.isElevated = Get-CurrentProcessElevation - $notches = [int]$cmd.notches - $stepDelta = 120 - if ($notches -lt 0) { $stepDelta = -120 } - $count = [Math]::Abs($notches) - if ($count -gt 60) { $count = 60 } - $sentTotal = 0 - for ($i = 0; $i -lt $count; $i++) { - $sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta - Start-Sleep -Milliseconds 45 - } - $response.notchesSent = $sentTotal - $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" { - $clientBounds = Get-GenshinClientBounds - if ($null -eq $clientBounds) { - $response.found = $false - } else { - $response.found = $true - $response.left = $clientBounds.Left - $response.top = $clientBounds.Top - $response.width = $clientBounds.Width - $response.height = $clientBounds.Height - } - } - "capture" { - $clientBounds = Get-GenshinClientBounds - if ($null -eq $clientBounds) { - $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds - $clientBounds = @{ - Left = $screenBounds.Left - Top = $screenBounds.Top - Width = $screenBounds.Width - Height = $screenBounds.Height - } - $response.captureTarget = "primary-screen" - } else { - $response.captureTarget = "genshin-client" - } - $bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - $graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size) - $capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png") - $bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png) - $graphics.Dispose() - $bitmap.Dispose() - $response.path = $capturePath - $response.width = $clientBounds.Width - $response.height = $clientBounds.Height - $response.originX = $clientBounds.Left - $response.originY = $clientBounds.Top - } - default { - $response.ok = $false - $response.error = "unknown op" - } - } - } catch { - $response.ok = $false - $response.error = $_.Exception.Message - } - Write-Output (ConvertTo-Json $response -Compress) -} -`; +import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js"; class InputHelperClient { private child: ChildProcessWithoutNullStreams | null = null; diff --git a/electron/services/inputHelperPowerShellFallback.ts b/electron/services/inputHelperPowerShellFallback.ts new file mode 100644 index 0000000..e4dab52 --- /dev/null +++ b/electron/services/inputHelperPowerShellFallback.ts @@ -0,0 +1,441 @@ +// PowerShell fallback for environments where the compiled C# sidecar is unavailable. Keep the JSON protocol aligned with native/input-helper/Program.cs. + +export const INPUT_HELPER_SCRIPT = String.raw` +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +$signature = @" +[DllImport("user32.dll")] +public static extern bool SetProcessDPIAware(); +[DllImport("shcore.dll")] +public static extern int SetProcessDpiAwareness(int value); +[DllImport("user32.dll")] +public static extern bool SetCursorPos(int X, int Y); +[DllImport("user32.dll")] +public static extern bool GetCursorPos(out POINT lpPoint); +[DllImport("user32.dll", SetLastError=true)] +public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); +[DllImport("user32.dll", SetLastError=true)] +public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); +[DllImport("user32.dll")] +public static extern short GetAsyncKeyState(int vKey); +[DllImport("user32.dll", SetLastError=true)] +public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); +[DllImport("user32.dll")] +public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] +public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); +[DllImport("user32.dll")] +public static extern bool BringWindowToTop(IntPtr hWnd); +[DllImport("user32.dll", SetLastError=true)] +public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); +[DllImport("kernel32.dll")] +public static extern uint GetCurrentThreadId(); +[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] +public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni); +[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] +public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni); +[DllImport("user32.dll")] +public static extern IntPtr GetForegroundWindow(); +[DllImport("user32.dll")] +public static extern bool IsWindow(IntPtr hWnd); +[DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + +[StructLayout(LayoutKind.Sequential)] +public struct POINT { public int X; public int Y; } + +[StructLayout(LayoutKind.Sequential)] +public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + +[StructLayout(LayoutKind.Sequential)] +public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; } + +[StructLayout(LayoutKind.Sequential)] +public struct INPUT { public int type; public MOUSEINPUT mi; } +"@ +Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native +# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the +# same symptom): the older SetProcessDPIAware() only applies a single, +# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin +# on one display, this app's window on a differently-scaled second display), +# that single scale factor is wrong for whichever monitor didn't set it, +# silently shifting every SetCursorPos/click coordinate off-target even +# though cursor readback still matches what we asked for (both go through the +# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2. +try { + [Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null +} catch { + [Native.InputHelper]::SetProcessDPIAware() | Out-Null +} +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# SizeOf must receive a struct instance: passing the type object throws in +# Windows PowerShell 5.1 (RuntimeType cannot be marshalled). +$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT)) +$genshinHwnd = [IntPtr]::Zero + +function Send-MouseInput { + param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0) + $mouseInput = New-Object Native.InputHelper+INPUT + $mouseInput.type = 0 + $mouseInput.mi.dx = $dx + $mouseInput.mi.dy = $dy + if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData } + $mouseInput.mi.dwFlags = $flags + return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) +} + +# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves +# with bare SetCursorPos, then clicks via the InputSimulator library's +# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE +# SendInput call (two INPUT structs in the same array) - back-to-back with no +# artificial delay between them, unlike two separate SendInput calls with a +# Start-Sleep in between. Returns the number of injected events (2 = ok). +function Send-MouseClickBatch { + $down = New-Object Native.InputHelper+INPUT + $down.type = 0 + $down.mi.dwFlags = 0x0002 + $up = New-Object Native.InputHelper+INPUT + $up.type = 0 + $up.mi.dwFlags = 0x0004 + 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 { + $pt = New-Object Native.InputHelper+POINT + [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null + return $pt +} + +function Get-ProcessNameFromHwnd { + param([IntPtr]$hwnd) + if ($hwnd -eq [IntPtr]::Zero) { return "" } + $pidValue = [uint32]0 + [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null + if ($pidValue -eq 0) { return "" } + try { + return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName + } catch { + return "" + } +} + +function Get-CurrentProcessElevation { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-ForegroundInfo { + $hwnd = [Native.InputHelper]::GetForegroundWindow() + return @{ + foregroundHwnd = $hwnd.ToInt64() + foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + } +} + +function Get-CursorState { + $pt = New-Object Native.InputHelper+POINT + [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null + # Only 0x8000 (key is held down right now). The 0x0001 "pressed since last + # call" bit is unreliable and fires for ESC presses that happened long + # before the scan (ESC is used constantly to navigate Genshin menus). + $esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0 + $enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0 + $f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0 + return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 } +} + +function Get-GenshinClientBounds { + $hwnd = Find-GenshinWindow + if ($hwnd -eq [IntPtr]::Zero) { return $null } + + $rect = New-Object Native.InputHelper+RECT + if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null } + + $topLeft = New-Object Native.InputHelper+POINT + $topLeft.X = 0 + $topLeft.Y = 0 + if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null } + + $width = $rect.Right - $rect.Left + $height = $rect.Bottom - $rect.Top + if ($width -le 0 -or $height -le 0) { return $null } + + return @{ + Left = $topLeft.X + Top = $topLeft.Y + Width = $width + Height = $height + } +} + +function Find-GenshinWindow { + if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd } + $proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1 + if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero } + return $script:genshinHwnd +} + +# Plain SetForegroundWindow from this background helper process is silently +# refused by Windows' foreground lock. Attach our thread's input queue to the +# target (and current foreground) window thread and clear the lock timeout, so +# the foreground change is honored - the same technique Inventory Kamera uses. +function Force-Foreground { + param([IntPtr]$hwnd) + $current = [Native.InputHelper]::GetCurrentThreadId() + $targetPid = [uint32]0 + $target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid) + $fgWindow = [Native.InputHelper]::GetForegroundWindow() + $foreground = [uint32]0 + if ($fgWindow -ne [IntPtr]::Zero) { + $fgPid = [uint32]0 + $foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid) + } + + $attachedTarget = $false + $attachedForeground = $false + $oldTimeout = [uint32]0 + $timeoutRead = $false + try { + if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) } + if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) } + $timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0) + [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null + # Inject a no-op input (0,0 mouse move) so this process is the last input + # source, which Windows requires before it will honor a foreground change. + Send-MouseInput -flags 0x0001 | Out-Null + [Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null + [Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null + return [Native.InputHelper]::SetForegroundWindow($hwnd) + } finally { + if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null } + if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null } + if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null } + } +} + +function Focus-GenshinWindow { + $hwnd = Find-GenshinWindow + $info = @{ + hwnd = $hwnd.ToInt64() + focused = $false + alreadyForeground = $false + foregroundProcess = "" + targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + } + if ($hwnd -eq [IntPtr]::Zero) { return $info } + + $info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd) + if (-not $info.alreadyForeground) { + $info.setForegroundResult = Force-Foreground -hwnd $hwnd + Start-Sleep -Milliseconds 140 + } + + $foreground = [Native.InputHelper]::GetForegroundWindow() + $info.focused = ($foreground -eq $hwnd) + $info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground + return $info +} + +while ($true) { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { break } + if ($line.Trim().Length -eq 0) { continue } + $response = @{ id = ""; ok = $true } + try { + $cmd = $line | ConvertFrom-Json + $response.id = "$($cmd.id)" + switch ("$($cmd.op)") { + "ping" { + $response.pong = $true + } + "cursor" { + $state = Get-CursorState + $response.cursorX = $state.cursorX + $response.cursorY = $state.cursorY + $response.escapePressed = $state.escapePressed + $response.enterPressed = $state.enterPressed + $response.f9Pressed = $state.f9Pressed + } + "runtime" { + $response.isElevated = Get-CurrentProcessElevation + $hwnd = Find-GenshinWindow + $foregroundInfo = Get-ForegroundInfo + $response.genshinFound = ($hwnd -ne [IntPtr]::Zero) + $response.genshinHwnd = $hwnd.ToInt64() + $response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + $response.foregroundProcess = $foregroundInfo.foregroundProcess + $response.foregroundHwnd = $foregroundInfo.foregroundHwnd + $response.helperPid = $PID + } + "focus" { + $focusInfo = Focus-GenshinWindow + $response.focused = $focusInfo.focused + $response.alreadyForeground = $focusInfo.alreadyForeground + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.targetProcess = $focusInfo.targetProcess + $response.genshinFound = ($focusInfo.hwnd -ne 0) + $response.setForegroundResult = $focusInfo.setForegroundResult + } + "click" { + $focusInfo = Focus-GenshinWindow + if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { + Start-Sleep -Milliseconds 120 + } + $targetX = [int]$cmd.x + $targetY = [int]$cmd.y + # Matches Inventory Kamera's verified-working sequence exactly: bare + # SetCursorPos immediately followed by a click, with NO extra move + # event and NO artificial delay between moving and clicking - IK's + # Navigation.Click(x, y) does SetCursor() then Click() back-to-back, + # zero gap. Settling delays only happen after the click, in the scan + # loop. Down+up are sent as one SendInput call (see + # Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick(). + [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null + $point = Get-CursorPoint + $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) + $clickEventsSent = 0 + if ($onTarget) { + $clickEventsSent = Send-MouseClickBatch + } + $state = Get-CursorState + $response.cursorX = $state.cursorX + $response.cursorY = $state.cursorY + $response.escapePressed = $state.escapePressed + $response.enterPressed = $state.enterPressed + $response.f9Pressed = $state.f9Pressed + $response.moved = $onTarget + $response.focused = $focusInfo.focused + $response.alreadyForeground = $focusInfo.alreadyForeground + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.targetProcess = $focusInfo.targetProcess + $response.isElevated = Get-CurrentProcessElevation + # Never report a click unless the cursor is verifiably on the target. + # Real acceptance is proven later by the detail-panel fingerprint. + $response.clicked = ($onTarget -and $clickEventsSent -ge 2) + $response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2) + } + "scroll" { + $focusInfo = Focus-GenshinWindow + if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { + Start-Sleep -Milliseconds 120 + } + if ($null -ne $cmd.x -and $null -ne $cmd.y) { + [Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null + Start-Sleep -Milliseconds 30 + } + $point = Get-CursorPoint + $response.cursorX = $point.X + $response.cursorY = $point.Y + $response.focused = $focusInfo.focused + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.isElevated = Get-CurrentProcessElevation + $notches = [int]$cmd.notches + $stepDelta = 120 + if ($notches -lt 0) { $stepDelta = -120 } + $count = [Math]::Abs($notches) + if ($count -gt 60) { $count = 60 } + $sentTotal = 0 + for ($i = 0; $i -lt $count; $i++) { + $sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta + Start-Sleep -Milliseconds 45 + } + $response.notchesSent = $sentTotal + $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" { + $clientBounds = Get-GenshinClientBounds + if ($null -eq $clientBounds) { + $response.found = $false + } else { + $response.found = $true + $response.left = $clientBounds.Left + $response.top = $clientBounds.Top + $response.width = $clientBounds.Width + $response.height = $clientBounds.Height + } + } + "capture" { + $clientBounds = Get-GenshinClientBounds + if ($null -eq $clientBounds) { + $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $clientBounds = @{ + Left = $screenBounds.Left + Top = $screenBounds.Top + Width = $screenBounds.Width + Height = $screenBounds.Height + } + $response.captureTarget = "primary-screen" + } else { + $response.captureTarget = "genshin-client" + } + $bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size) + $capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png") + $bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + $response.path = $capturePath + $response.width = $clientBounds.Width + $response.height = $clientBounds.Height + $response.originX = $clientBounds.Left + $response.originY = $clientBounds.Top + } + default { + $response.ok = $false + $response.error = "unknown op" + } + } + } catch { + $response.ok = $false + $response.error = $_.Exception.Message + } + Write-Output (ConvertTo-Json $response -Compress) +} +`; + diff --git a/electron/services/pngBitmap.ts b/electron/services/pngBitmap.ts new file mode 100644 index 0000000..38f3ad4 --- /dev/null +++ b/electron/services/pngBitmap.ts @@ -0,0 +1,92 @@ +import { inflateSync } from "node:zlib"; +import type { Bitmap } from "../../src/lib/ocrPreprocess.js"; + +interface PngChunk { + type: string; + data: Buffer; +} + +function readChunks(buffer: Buffer): PngChunk[] { + const signature = buffer.subarray(0, 8); + if (!signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + throw new Error("Invalid PNG signature."); + } + const chunks: PngChunk[] = []; + let offset = 8; + while (offset + 12 <= buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buffer.length) throw new Error("Invalid PNG chunk length."); + chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) }); + offset = dataEnd + 4; + if (type === "IEND") break; + } + return chunks; +} + +function paethPredictor(left: number, up: number, upperLeft: number) { + const estimate = left + up - upperLeft; + const leftDistance = Math.abs(estimate - left); + const upDistance = Math.abs(estimate - up); + const upperLeftDistance = Math.abs(estimate - upperLeft); + if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left; + if (upDistance <= upperLeftDistance) return up; + return upperLeft; +} + +function unfilterScanlines(raw: Buffer, width: number, height: number, bytesPerPixel: number) { + const stride = width * bytesPerPixel; + const output = Buffer.alloc(stride * height); + let rawOffset = 0; + for (let row = 0; row < height; row++) { + const filter = raw[rawOffset++]; + const rowOffset = row * stride; + const previousRowOffset = rowOffset - stride; + for (let col = 0; col < stride; col++) { + const value = raw[rawOffset++]; + const left = col >= bytesPerPixel ? output[rowOffset + col - bytesPerPixel] : 0; + const up = row > 0 ? output[previousRowOffset + col] : 0; + const upperLeft = row > 0 && col >= bytesPerPixel ? output[previousRowOffset + col - bytesPerPixel] : 0; + let restored = value; + if (filter === 1) restored = value + left; + else if (filter === 2) restored = value + up; + else if (filter === 3) restored = value + Math.floor((left + up) / 2); + else if (filter === 4) restored = value + paethPredictor(left, up, upperLeft); + else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`); + output[rowOffset + col] = restored & 0xff; + } + } + return output; +} + +export function pngBufferToBitmap(buffer: Buffer): Bitmap { + const chunks = readChunks(buffer); + const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data; + if (!ihdr) throw new Error("PNG missing IHDR."); + const width = ihdr.readUInt32BE(0); + const height = ihdr.readUInt32BE(4); + const bitDepth = ihdr[8]; + const colorType = ihdr[9]; + const compression = ihdr[10]; + const filter = ihdr[11]; + const interlace = ihdr[12]; + if (bitDepth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0) { + throw new Error("Unsupported PNG format."); + } + const sourceBytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 0; + if (!sourceBytesPerPixel) throw new Error(`Unsupported PNG color type: ${colorType}`); + const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data)); + const unfiltered = unfilterScanlines(inflateSync(idat), width, height, sourceBytesPerPixel); + if (colorType === 6) return { data: unfiltered, width, height }; + + const rgba = Buffer.alloc(width * height * 4); + for (let pixel = 0; pixel < width * height; pixel++) { + rgba[pixel * 4] = unfiltered[pixel * 3]; + rgba[pixel * 4 + 1] = unfiltered[pixel * 3 + 1]; + rgba[pixel * 4 + 2] = unfiltered[pixel * 3 + 2]; + rgba[pixel * 4 + 3] = 255; + } + return { data: rgba, width, height }; +} diff --git a/native/input-helper/Program.cs b/native/input-helper/Program.cs index 7cd3ee7..12a8439 100644 --- a/native/input-helper/Program.cs +++ b/native/input-helper/Program.cs @@ -104,7 +104,7 @@ internal static class Program case "click": { - var info = FocusGenshinWindow(); + var info = FocusGenshinWindow(includeProcessNames: false); if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120); var targetX = GetInt(root, "x"); @@ -283,14 +283,14 @@ internal static class Program return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 }; } - private static FocusInfo FocusGenshinWindow() + private static FocusInfo FocusGenshinWindow(bool includeProcessNames = true) { var hwnd = FindGenshinWindow(); var info = new FocusInfo { Hwnd = hwnd, ForegroundProcess = "", - TargetProcess = ProcessNameFromHwnd(hwnd), + TargetProcess = includeProcessNames ? ProcessNameFromHwnd(hwnd) : "", }; if (hwnd == IntPtr.Zero) return info; @@ -303,7 +303,7 @@ internal static class Program var foreground = Native.GetForegroundWindow(); info.Focused = foreground == hwnd; - info.ForegroundProcess = ProcessNameFromHwnd(foreground); + info.ForegroundProcess = includeProcessNames ? ProcessNameFromHwnd(foreground) : ""; return info; } diff --git a/package.json b/package.json index d52e833..7a121a9 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,22 @@ "lint": "tsc --noEmit", "test": "vitest run", "eval": "vitest run src/eval/ocrEval.test.ts", + "eval:review-candidates": "node scripts/export-review-eval-candidates.cjs", + "eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs", "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:goal:compare:validated": "npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + "scan:goal:compare:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + "scan:iterate:compare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr", + "scan:iterate:compare:validated": "npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + "scan:iterate:compare:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + "scan:live:preflight": "node scripts/live-preflight.cjs", + "scan:live:preflight:wait": "node scripts/live-preflight.cjs --wait=120", "scan:assessment:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment", + "scan:assessment:validate": "node scripts/validate-scan-assessment.cjs", "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" }, diff --git a/scripts/dev-admin-start.ps1 b/scripts/dev-admin-start.ps1 index 13e98fb..0a67d48 100644 --- a/scripts/dev-admin-start.ps1 +++ b/scripts/dev-admin-start.ps1 @@ -1,5 +1,6 @@ param( - [string]$ProjectRoot + [string]$ProjectRoot, + [string]$OcrWorkers = "" ) # Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd), @@ -20,6 +21,10 @@ try { Write-Host "Projekt: $project" Write-Host "Admin-Log: $logPath" + if ($OcrWorkers) { + $env:GAA_OCR_WORKERS = $OcrWorkers + Write-Host "GAA_OCR_WORKERS: $env:GAA_OCR_WORKERS" + } # A UAC-elevated process gets its environment rebuilt fresh from the # registry; it does NOT inherit PATH edits that only exist in the calling diff --git a/scripts/dev-admin.ps1 b/scripts/dev-admin.ps1 index 38287f8..441143c 100644 --- a/scripts/dev-admin.ps1 +++ b/scripts/dev-admin.ps1 @@ -1,5 +1,6 @@ param( - [string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path + [string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path, + [string]$OcrWorkers = $env:GAA_OCR_WORKERS ) $ErrorActionPreference = "Stop" @@ -19,6 +20,9 @@ try { "-File $(Quote-ProcessArgument $script)", "-ProjectRoot $(Quote-ProcessArgument $project)" ) -join " " + if ($OcrWorkers) { + $arguments += " -OcrWorkers $(Quote-ProcessArgument $OcrWorkers)" + } Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop diff --git a/scripts/export-review-eval-candidates.cjs b/scripts/export-review-eval-candidates.cjs new file mode 100644 index 0000000..e585b8f --- /dev/null +++ b/scripts/export-review-eval-candidates.cjs @@ -0,0 +1,288 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const readline = require("node:readline"); +const os = require("node:os"); + +const DEFAULT_LIMIT = 80; + +function argValue(name, fallback = "") { + const prefix = `--${name}=`; + const match = process.argv.find((entry) => entry.startsWith(prefix)); + return match ? match.slice(prefix.length) : fallback; +} + +function defaultReviewSamplesPath() { + const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); + return path.join(appData, "genshin-artifact-assistant", "review-samples.jsonl"); +} + +function simplifyId(value) { + return String(value || "") + .replace(/[^0-9A-Za-z]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "unknown"; +} + +function parsedSummary(parsed) { + if (!parsed || typeof parsed !== "object") return {}; + return { + name: parsed.name, + slot: parsed.slot, + level: parsed.level, + mainStat: parsed.mainStat, + mainValue: parsed.mainValue, + setName: parsed.setName, + equipped: parsed.equipped, + substats: Array.isArray(parsed.substats) ? parsed.substats : [], + confidence: parsed.confidence, + notes: Array.isArray(parsed.notes) ? parsed.notes : [], + }; +} + +const REQUIRED_FAST_OCR_FIELDS = [ + "artifact-name", + "artifact-slot", + "artifact-main-stat-label", + "artifact-level", + "artifact-substats", +]; + +function ocrMap(record) { + const entries = record?.sample?.capture?.ocr; + if (!Array.isArray(entries) || entries.length === 0) return null; + const result = {}; + for (const entry of entries) { + if (typeof entry?.id === "string" && typeof entry?.text === "string") { + result[entry.id] = entry.text; + } + } + return Object.keys(result).length ? result : null; +} + +function candidateFromRecord(record, index) { + const ocr = ocrMap(record); + if (!ocr) return null; + const parsed = parsedSummary(record?.sample?.parsed); + const reason = record?.sample?.reason || "missing-reason"; + const savedAt = record?.savedAt || "unknown"; + const capture = record?.sample?.capture || {}; + const ocrFieldIds = Object.keys(ocr).sort(); + const missingFastFields = REQUIRED_FAST_OCR_FIELDS.filter((field) => !ocr[field]); + const hasEquippedFooterOcr = Boolean(ocr["artifact-footer"]); + const locked = typeof capture.locked === "boolean" ? capture.locked : null; + return { + id: `review-${simplifyId(savedAt)}-${index}`, + savedAt, + reason, + confirmed: false, + resolution: capture.width && capture.height ? `${capture.width}x${capture.height}` : "", + ocrFieldIds, + missingFastFields, + hasEquippedFooterOcr, + locked, + likelyStaleCapture: missingFastFields.includes("artifact-slot"), + parsed, + ocr, + reviewPrompt: { + action: "Confirm or correct parsed fields before moving this case into src/eval/corpus/confirmedReviewCorpus.ts.", + expectedFields: { + name: parsed.name || "", + slot: parsed.slot || "", + level: parsed.level ?? "", + mainStat: parsed.mainStat || "", + mainValue: parsed.mainValue || "", + setName: parsed.setName || "", + equipped: parsed.equipped || "", + substats: parsed.substats || [], + }, + validationChecks: { + equipped: hasEquippedFooterOcr ? "Confirm character name or mark Not detected." : "No footer OCR in this sample.", + locked: locked === null ? "No lock-state payload in this sample." : `Confirm locked=${locked}.`, + }, + }, + }; +} + +function candidateKey(candidate) { + return [ + candidate.reason, + candidate.parsed.name, + candidate.parsed.slot, + candidate.parsed.mainStat, + candidate.parsed.setName, + JSON.stringify(candidate.parsed.substats || []), + JSON.stringify(candidate.ocr), + ].join("\u001f"); +} + +function candidatePriority(candidate) { + const reason = candidate.reason || ""; + const parsed = candidate.parsed || {}; + const missingCount = candidate.missingFastFields?.length || 0; + const hasUnknownCritical = [parsed.name, parsed.slot, parsed.mainStat, parsed.setName].some((value) => String(value || "").startsWith("Unknown")); + if (candidate.likelyStaleCapture) return 4; + if (missingCount === 0 && /low-field|low-total|capture-rejected|initial-selection/i.test(reason)) return 0; + if (missingCount === 0 && (parsed.notes || []).some((note) => /fuzzy|incomplete|not confidently|low/i.test(note))) return 1; + if (missingCount === 0) return 2; + if (missingCount <= 1 && !hasUnknownCritical) return 3; + return 5; +} + +async function readCandidates(inputPath, limit) { + const candidates = []; + const seen = new Set(); + let total = 0; + let invalid = 0; + const rl = readline.createInterface({ input: fs.createReadStream(inputPath, { encoding: "utf8" }) }); + for await (const line of rl) { + if (!line.trim()) continue; + total++; + let record; + try { + record = JSON.parse(line); + } catch { + invalid++; + continue; + } + const candidate = candidateFromRecord(record, total); + if (!candidate) continue; + const key = candidateKey(candidate); + if (seen.has(key)) continue; + seen.add(key); + candidates.push(candidate); + } + candidates.sort((left, right) => { + const priority = candidatePriority(left) - candidatePriority(right); + if (priority) return priority; + const missingDiff = (left.missingFastFields?.length || 0) - (right.missingFastFields?.length || 0); + if (missingDiff) return missingDiff; + return String(right.savedAt).localeCompare(String(left.savedAt)); + }); + return { total, invalid, candidates: candidates.slice(0, limit), uniqueCandidates: candidates.length }; +} + +function increment(map, key) { + const normalizedKey = key || "unknown"; + map[normalizedKey] = (map[normalizedKey] || 0) + 1; +} + +function buildExportStats(candidates) { + const reasonCounts = {}; + const missingFastFieldCounts = {}; + let completeFastFields = 0; + let likelyStaleCaptures = 0; + let equippedFooterCandidates = 0; + let lockedTrueCandidates = 0; + let lockedFalseCandidates = 0; + for (const candidate of candidates) { + increment(reasonCounts, candidate.reason); + if (candidate.likelyStaleCapture) likelyStaleCaptures++; + if (candidate.hasEquippedFooterOcr) equippedFooterCandidates++; + if (candidate.locked === true) lockedTrueCandidates++; + if (candidate.locked === false) lockedFalseCandidates++; + const missingFields = candidate.missingFastFields || []; + if (missingFields.length === 0) completeFastFields++; + for (const field of missingFields) increment(missingFastFieldCounts, field); + } + return { + completeFastFields, + likelyStaleCaptures, + equippedFooterCandidates, + lockedTrueCandidates, + lockedFalseCandidates, + reasonCounts, + missingFastFieldCounts, + }; +} + +function markdownFor(summary, candidates) { + const lines = [ + "# Review Eval Candidates", + "", + `Source: ${summary.inputPath}`, + `Generated: ${summary.generatedAt}`, + `Records read: ${summary.recordsRead}`, + `Unique candidates: ${summary.uniqueCandidates}`, + `Exported candidates: ${summary.exportedCandidates}`, + `Complete fast-field candidates: ${summary.exportStats.completeFastFields}`, + `Likely stale captures: ${summary.exportStats.likelyStaleCaptures}`, + `Equipped footer candidates: ${summary.exportStats.equippedFooterCandidates}`, + `Locked=true candidates: ${summary.exportStats.lockedTrueCandidates}`, + `Locked=false candidates: ${summary.exportStats.lockedFalseCandidates}`, + "", + "These cases are not ground truth yet. Confirm or correct the expected fields before committing any case into `src/eval/corpus/`.", + "", + "## Export stats", + "", + "Reason counts:", + "", + ...Object.entries(summary.exportStats.reasonCounts).map(([reason, count]) => `- ${reason}: ${count}`), + "", + "Missing fast-field counts:", + "", + ...Object.entries(summary.exportStats.missingFastFieldCounts).map(([field, count]) => `- ${field}: ${count}`), + "", + ]; + for (const candidate of candidates) { + lines.push(`## ${candidate.id}`); + lines.push(""); + lines.push(`- savedAt: ${candidate.savedAt}`); + lines.push(`- reason: ${candidate.reason}`); + lines.push(`- missingFastFields: ${candidate.missingFastFields.join(", ") || "none"}`); + lines.push(`- likelyStaleCapture: ${candidate.likelyStaleCapture ? "yes" : "no"}`); + lines.push(`- hasEquippedFooterOcr: ${candidate.hasEquippedFooterOcr ? "yes" : "no"}`); + lines.push(`- locked: ${candidate.locked === null ? "unknown" : candidate.locked}`); + lines.push(`- parsed: ${candidate.parsed.name || "?"} | ${candidate.parsed.slot || "?"} | ${candidate.parsed.mainStat || "?"} | ${candidate.parsed.setName || "?"} | equipped=${candidate.parsed.equipped || "?"}`); + lines.push(`- substats: ${(candidate.parsed.substats || []).join(", ") || "?"}`); + lines.push(""); + lines.push("OCR:"); + for (const [id, text] of Object.entries(candidate.ocr)) { + lines.push(`- ${id}: ${JSON.stringify(text)}`); + } + lines.push(""); + } + return `${lines.join("\n")}\n`; +} + +async function main() { + const inputPath = path.resolve(argValue("input", defaultReviewSamplesPath())); + const outputDir = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates"))); + const limit = Math.max(1, Math.min(500, Number(argValue("limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT)); + if (!fs.existsSync(inputPath)) { + throw new Error(`Review sample file not found: ${inputPath}`); + } + fs.mkdirSync(outputDir, { recursive: true }); + const result = await readCandidates(inputPath, limit); + const summary = { + inputPath, + outputDir, + generatedAt: new Date().toISOString(), + recordsRead: result.total, + invalidRecords: result.invalid, + uniqueCandidates: result.uniqueCandidates, + exportedCandidates: result.candidates.length, + exportStats: buildExportStats(result.candidates), + }; + const payload = { summary, candidates: result.candidates }; + const jsonPath = path.join(outputDir, "review-eval-candidates.json"); + const mdPath = path.join(outputDir, "review-eval-candidates.md"); + fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + fs.writeFileSync(mdPath, markdownFor(summary, result.candidates), "utf8"); + console.log(JSON.stringify({ ok: true, ...summary, jsonPath, mdPath }, null, 2)); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} + +module.exports = { + buildExportStats, + candidateFromRecord, + candidatePriority, + defaultReviewSamplesPath, + markdownFor, + readCandidates, +}; diff --git a/scripts/live-preflight.cjs b/scripts/live-preflight.cjs new file mode 100644 index 0000000..7d99e59 --- /dev/null +++ b/scripts/live-preflight.cjs @@ -0,0 +1,148 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +function argValue(name, fallback = "") { + const prefix = `--${name}=`; + const match = process.argv.find((entry) => entry.startsWith(prefix)); + return match ? match.slice(prefix.length) : fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseWaitSeconds(value) { + if (value === "") return 0; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error("--wait must be a non-negative integer number of seconds."); + } + return parsed; +} + +function expectedSignature() { + const mainPath = path.join(process.cwd(), "electron", "main.ts"); + const source = fs.readFileSync(mainPath, "utf8"); + const match = source.match(/APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"/); + return match?.[1] || ""; +} + +async function fetchJson(baseUrl, endpoint) { + let response; + try { + response = await fetch(`${baseUrl}${endpoint}`); + } catch (error) { + throw new Error(`Could not reach ${baseUrl}${endpoint}. Start the elevated app with npm run dev:admin and confirm UAC before running live scans. (${error instanceof Error ? error.message : String(error)})`); + } + const text = await response.text(); + let payload; + try { + payload = text ? JSON.parse(text) : null; + } catch { + throw new Error(`${endpoint} returned non-JSON response (${response.status}).`); + } + if (!response.ok) throw new Error(`${endpoint} returned ${response.status}: ${JSON.stringify(payload)}`); + return payload; +} + +function validatePreflight({ health, status, expected, requireElevated = true, requireGenshin = true }) { + const errors = []; + const appBuild = health?.appBuild; + const runtime = status?.status?.runtimeInfo; + + if (!appBuild?.signature) { + errors.push("/health is missing appBuild.signature."); + } else if (expected && appBuild.signature !== expected) { + errors.push(`Runtime signature '${appBuild.signature}' does not match source '${expected}'.`); + } + + if (!status?.status) errors.push("/scanner/status is missing status payload."); + if (!runtime) { + errors.push("/scanner/status is missing runtimeInfo. Open the scanner view and restart the elevated app if needed."); + } else { + if (requireElevated && runtime.isElevated !== true) errors.push("Runtime is not elevated."); + if (requireGenshin && runtime.genshinFound !== true) errors.push("Genshin process/window was not found."); + } + + return { + ok: errors.length === 0, + errors, + signature: appBuild?.signature || "", + expectedSignature: expected || "", + isElevated: runtime?.isElevated, + genshinFound: runtime?.genshinFound, + targetProcess: runtime?.targetProcess || "", + foregroundProcess: runtime?.foregroundProcess || "", + }; +} + +function formatSummary(result) { + const lines = [ + `live preflight: ${result.ok ? "PASS" : "FAIL"}`, + `signature: ${result.signature || "missing"}`, + `expected: ${result.expectedSignature || "unknown"}`, + `elevated: ${result.isElevated === true ? "yes" : result.isElevated === false ? "no" : "unknown"}`, + `genshin: ${result.genshinFound === true ? "yes" : result.genshinFound === false ? "no" : "unknown"}`, + `target: ${result.targetProcess || "unknown"}`, + `foreground: ${result.foregroundProcess || "unknown"}`, + ]; + if (!result.ok) { + lines.push("errors:"); + for (const error of result.errors) lines.push(`- ${error}`); + } + return lines.join("\n"); +} + +async function runPreflight({ baseUrl, expected, requireElevated, requireGenshin }) { + const health = await fetchJson(baseUrl, "/health"); + const status = await fetchJson(baseUrl, "/scanner/status"); + return validatePreflight({ health, status, expected, requireElevated, requireGenshin }); +} + +async function waitForPreflight(options, waitSeconds) { + const deadline = Date.now() + waitSeconds * 1000; + let lastError = null; + let lastResult = null; + + while (true) { + try { + const result = await runPreflight(options); + lastResult = result; + if (result.ok || Date.now() >= deadline) return result; + } catch (error) { + lastError = error; + if (Date.now() >= deadline) throw lastError; + } + await sleep(1000); + } +} + +async function main() { + const baseUrl = argValue("base-url", "http://127.0.0.1:17317").replace(/\/$/, ""); + const expected = argValue("expected-signature", expectedSignature()); + const requireElevated = !hasFlag("allow-standard"); + const requireGenshin = !hasFlag("allow-missing-genshin"); + const waitSeconds = parseWaitSeconds(argValue("wait", "")); + const options = { baseUrl, expected, requireElevated, requireGenshin }; + const result = waitSeconds > 0 ? await waitForPreflight(options, waitSeconds) : await runPreflight(options); + console.log(hasFlag("json") ? JSON.stringify(result, null, 2) : formatSummary(result)); + if (!result.ok) process.exit(1); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} + +module.exports = { + formatSummary, + parseWaitSeconds, + runPreflight, + validatePreflight, +}; diff --git a/scripts/live-soak.ps1 b/scripts/live-soak.ps1 index fb2802b..dd94fb9 100644 --- a/scripts/live-soak.ps1 +++ b/scripts/live-soak.ps1 @@ -77,6 +77,7 @@ function Get-ScannerStatus { function Test-ProbeSucceeded([object]$ProbePayload) { if ($ProbePayload.ok) { return $true } if ($ProbePayload.changed) { return $true } + if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true } return $false } @@ -282,6 +283,9 @@ function New-PerformanceAssessment([object[]]$Summaries) { $limitReports += [pscustomobject]@{ limit = [int]$group.Name + engineCount = $entries.Count + enginesCompared = @($entries | ForEach-Object { $_.engine }) + comparisonComplete = (@($entries | Where-Object { $_.engine -eq "current" }).Count -gt 0 -and @($entries | Where-Object { $_.engine -eq "ik-traineddata" }).Count -gt 0) winnerEngine = $winner.engine winnerQualified = $winner.qualified winnerMissRate = $winner.missRate @@ -293,19 +297,37 @@ function New-PerformanceAssessment([object[]]$Summaries) { } $goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1) + $goal100Decision = "not-run: missing 100-artifact assessment" + if ($goal100.Count -gt 0) { + if (-not $goal100[0].comparisonComplete) { + $goal100Decision = "not-comparable: current and ik-traineddata were not both run" + } elseif (-not $goal100[0].winnerQualified) { + $goal100Decision = "not-qualified: 100-artifact winner failed quality gates" + } else { + $goal100Decision = "qualified-comparison: winner=$($goal100[0].winnerEngine)" + } + } return [pscustomobject]@{ createdAt = (Get-Date).ToString("o") + goalLimit = 100 + goalEngines = @("current", "ik-traineddata") + goal100Decision = $goal100Decision goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null } limits = $limitReports } } function Write-PerformanceAssessment([object]$Assessment) { + if ($Assessment.goal100Decision) { + Write-Host "assessment goal100: $($Assessment.goal100Decision)" + } 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 ` + Write-Host ("assessment limit={0}: winner={1} qualified={2} completeCompare={3} engines={4} missRate={5:P1} reviewRate={6:P1} activeAvg={7}ms projected100={8}ms" -f ` $limit.limit, $limit.winnerEngine, $limit.winnerQualified, + $limit.comparisonComplete, + ($limit.enginesCompared -join ","), $limit.winnerMissRate, $limit.winnerReviewRate, $limit.winnerActiveAverageMsPerParsed, @@ -359,6 +381,36 @@ function Invoke-AssessmentSelfTest { averageCardReadyMs = 205 averageScrollReadyMs = 80 }, + [pscustomobject]@{ + engine = "current" + limit = 20 + status = "done" + parsed = 20 + review = 1 + misses = 0 + activeAverageMsPerParsed = 390 + averageMsPerParsed = 405 + activeProjectedMsFor100 = 39000 + averageOcrMs = 150 + averageCaptureMs = 130 + averageCardReadyMs = 80 + averageScrollReadyMs = 0 + }, + [pscustomobject]@{ + engine = "ik-traineddata" + limit = 20 + status = "done" + parsed = 20 + review = 2 + misses = 0 + activeAverageMsPerParsed = 460 + averageMsPerParsed = 475 + activeProjectedMsFor100 = 46000 + averageOcrMs = 190 + averageCaptureMs = 130 + averageCardReadyMs = 80 + averageScrollReadyMs = 0 + }, [pscustomobject]@{ engine = "broken-fast" limit = 45 @@ -393,6 +445,7 @@ function Invoke-AssessmentSelfTest { $assessment = New-PerformanceAssessment -Summaries $synthetic $goal100 = $assessment.goal100 + $limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0] $limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0] if ($goal100.winnerEngine -ne "ik-traineddata") { @@ -401,6 +454,21 @@ function Invoke-AssessmentSelfTest { if (-not $goal100.winnerQualified) { throw "Assessment self-test failed: expected limit=100 winner to be qualified." } + if (-not $goal100.comparisonComplete) { + throw "Assessment self-test failed: expected limit=100 to be a complete current vs ik-traineddata comparison." + } + if ($assessment.goal100Decision -ne "qualified-comparison: winner=ik-traineddata") { + throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'." + } + if ($limit20.winnerEngine -ne "current") { + throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'." + } + if (-not $limit20.comparisonComplete) { + throw "Assessment self-test failed: expected limit=20 to be a complete current vs ik-traineddata comparison." + } + if (-not $limit20.winnerQualified) { + throw "Assessment self-test failed: expected limit=20 winner to be qualified." + } if ($limit45.winnerEngine -ne "current") { throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'." } @@ -411,6 +479,27 @@ function Invoke-AssessmentSelfTest { throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate." } + $singleEngineAssessment = New-PerformanceAssessment -Summaries @( + [pscustomobject]@{ + engine = "current" + limit = 100 + status = "done" + parsed = 100 + review = 0 + misses = 0 + activeAverageMsPerParsed = 500 + averageMsPerParsed = 520 + activeProjectedMsFor100 = 50000 + averageOcrMs = 180 + averageCaptureMs = 120 + averageCardReadyMs = 100 + averageScrollReadyMs = 50 + } + ) + if ($singleEngineAssessment.goal100Decision -ne "not-comparable: current and ik-traineddata were not both run") { + throw "Assessment self-test failed: expected single-engine 100 run to be not-comparable, got '$($singleEngineAssessment.goal100Decision)'." + } + Write-PerformanceAssessment $assessment Write-Host "Assessment self-test passed." -ForegroundColor Green return $assessment @@ -527,6 +616,7 @@ function Wait-ForScannerIdle([int]$Limit, [string]$Engine) { $startedAt = Get-Date $pollIndex = 0 $lastStatus = $null + $observedMatchingRun = $false while ($true) { Start-Sleep -Seconds $PollIntervalSeconds @@ -536,8 +626,21 @@ function Wait-ForScannerIdle([int]$Limit, [string]$Engine) { Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null $running = [bool]$statusPayload.status.running + $summaryTarget = if ($statusPayload.status.summary) { [int]$statusPayload.status.summary.targetCount } else { 0 } + $scanStart = @($statusPayload.status.diagnosticEvents | Where-Object { $_.phase -eq "scan-start" } | Select-Object -Last 1) + $scanStartDetails = if ($scanStart.Count -gt 0) { $scanStart[0].details } else { $null } + $scanStartLimit = if ($scanStartDetails -and $scanStartDetails.scanLimit) { [int]$scanStartDetails.scanLimit } else { 0 } + $scanStartEngine = if ($scanStartDetails -and $scanStartDetails.ocrEngine) { [string]$scanStartDetails.ocrEngine } else { "" } + $matchesScanStart = $scanStartLimit -eq $Limit -and ($scanStartEngine -eq "" -or $scanStartEngine -eq $Engine) + $matchesFinalSummary = $summaryTarget -eq $Limit + if ($running -and ($matchesScanStart -or $matchesFinalSummary)) { + $observedMatchingRun = $true + } if (-not $running) { - return $statusPayload + if ($observedMatchingRun -or $matchesScanStart -or $matchesFinalSummary) { + return $statusPayload + } + Write-Host "Waiting for scanner run limit=$Limit engine=$Engine to appear; ignoring unrelated idle status." -ForegroundColor DarkGray } $elapsed = ((Get-Date) - $startedAt).TotalSeconds @@ -619,6 +722,8 @@ try { } } 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 + } elseif (-not $probe.ok -and $probe.click -and $probe.click.clicked -and $probe.click.moved -and -not $probe.click.inputBlocked) { + Write-Host "Probe index=$index delivered input but detail did not change; continuing because the target may already be selected." -ForegroundColor Yellow } } @@ -626,7 +731,7 @@ try { 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" + $start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit&engine=$engine" Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null $finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine diff --git a/scripts/prepare-confirmed-review-case.cjs b/scripts/prepare-confirmed-review-case.cjs new file mode 100644 index 0000000..b15731c --- /dev/null +++ b/scripts/prepare-confirmed-review-case.cjs @@ -0,0 +1,109 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const EVAL_FIELDS = new Set(["name", "slot", "level", "mainStat", "mainValue", "setName", "equipped", "substats"]); + +function argValue(name, fallback = "") { + const prefix = `--${name}=`; + const match = process.argv.find((entry) => entry.startsWith(prefix)); + return match ? match.slice(prefix.length) : fallback; +} + +function parseExpectedFields() { + const inlineJson = argValue("expect-json"); + const expectFile = argValue("expect-file"); + if (!inlineJson && !expectFile) { + throw new Error("Missing expected labels. Pass --expect-json=... or --expect-file=..."); + } + const raw = (inlineJson || fs.readFileSync(path.resolve(expectFile), "utf8")).replace(/^\uFEFF/, ""); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Expected labels must be a JSON object."); + } + const expect = {}; + for (const [field, value] of Object.entries(parsed)) { + if (!EVAL_FIELDS.has(field)) throw new Error(`Unknown expected field: ${field}`); + if (field === "level") { + if (!Number.isInteger(value) || value < 0) throw new Error("Expected level must be a non-negative integer."); + expect[field] = value; + continue; + } + if (field === "substats") { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim())) { + throw new Error("Expected substats must be a non-empty string array."); + } + expect[field] = value; + continue; + } + if (typeof value !== "string" || !value.trim()) throw new Error(`Expected ${field} must be a non-empty string.`); + expect[field] = value; + } + if (Object.keys(expect).length === 0) throw new Error("Expected labels must include at least one field."); + return expect; +} + +function loadCandidate(inputPath, candidateId) { + const payload = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8")); + const candidates = Array.isArray(payload?.candidates) ? payload.candidates : []; + const candidate = candidates.find((entry) => entry.id === candidateId); + if (!candidate) throw new Error(`Candidate not found: ${candidateId}`); + if (!candidate.ocr || typeof candidate.ocr !== "object" || Object.keys(candidate.ocr).length === 0) { + throw new Error(`Candidate has no OCR payload: ${candidateId}`); + } + return candidate; +} + +function stableId(value) { + return String(value || "") + .replace(/[^0-9A-Za-z]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "review-case"; +} + +function objectLiteral(value, indent = 2) { + return JSON.stringify(value, null, indent).replace(/"([A-Za-z_$][0-9A-Za-z_$]*)":/g, "$1:"); +} + +function snippetFor(candidate, expect) { + const id = stableId(argValue("id", `confirmed-${candidate.id}`)); + const entry = { + id, + confirmed: true, + ocr: candidate.ocr, + expect, + meta: { + source: "review-sample", + resolution: candidate.resolution || undefined, + note: `${candidate.reason || "review-sample"} | savedAt=${candidate.savedAt || "unknown"} | sourceCandidate=${candidate.id}`, + }, + }; + return `${objectLiteral(entry, 2)},\n`; +} + +function defaultInputPath() { + return path.join(process.cwd(), "outputs", "review-eval-candidates", "review-eval-candidates.json"); +} + +function main() { + const inputPath = argValue("input", defaultInputPath()); + const candidateId = argValue("candidate"); + if (!candidateId) throw new Error("Missing candidate id. Pass --candidate=."); + const outputPath = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates", `${stableId(candidateId)}.confirmed.ts`))); + const candidate = loadCandidate(inputPath, candidateId); + const expect = parseExpectedFields(); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const snippet = snippetFor(candidate, expect); + fs.writeFileSync(outputPath, snippet, "utf8"); + console.log(JSON.stringify({ ok: true, candidateId, outputPath, labeledFields: Object.keys(expect) }, null, 2)); +} + +if (require.main === module) { + main(); +} + +module.exports = { + loadCandidate, + parseExpectedFields, + snippetFor, + stableId, +}; diff --git a/scripts/validate-scan-assessment.cjs b/scripts/validate-scan-assessment.cjs new file mode 100644 index 0000000..9b1207d --- /dev/null +++ b/scripts/validate-scan-assessment.cjs @@ -0,0 +1,189 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +function argValue(name, fallback = "") { + const prefix = `--${name}=`; + const match = process.argv.find((entry) => entry.startsWith(prefix)); + return match ? match.slice(prefix.length) : fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function defaultAssessmentRoot() { + return path.join(process.cwd(), "outputs", "live-soak"); +} + +function findLatestAssessment(rootDir = defaultAssessmentRoot()) { + const resolvedRoot = path.resolve(rootDir); + if (!fs.existsSync(resolvedRoot)) throw new Error(`Assessment root not found: ${resolvedRoot}`); + const candidates = fs.readdirSync(resolvedRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const assessmentPath = path.join(resolvedRoot, entry.name, "scan-performance-assessment.json"); + if (!fs.existsSync(assessmentPath)) return null; + const stat = fs.statSync(assessmentPath); + return { assessmentPath, runName: entry.name, mtimeMs: stat.mtimeMs }; + }) + .filter(Boolean) + .sort((left, right) => { + const mtimeDiff = right.mtimeMs - left.mtimeMs; + if (Math.abs(mtimeDiff) > 1) return mtimeDiff; + return right.runName.localeCompare(left.runName); + }); + if (candidates.length === 0) throw new Error(`No scan-performance-assessment.json found under: ${resolvedRoot}`); + return candidates[0].assessmentPath; +} + +function loadAssessment(inputPath) { + if (!inputPath) throw new Error("Missing assessment file. Pass --input=."); + return JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8").replace(/^\uFEFF/, "")); +} + +function findLimitAssessment(assessment, limit) { + if (limit === 100 && assessment?.goal100) return assessment.goal100; + const limits = Array.isArray(assessment?.limits) ? assessment.limits : []; + return limits.find((entry) => Number(entry?.limit) === limit) || null; +} + +function validateAssessment(assessment, options = {}) { + const errors = []; + const expectedWinner = options.expectedWinner || "any"; + const expectedLimit = options.limit === undefined ? 100 : Number(options.limit); + if (!assessment || typeof assessment !== "object") { + return { ok: false, errors: ["Assessment must be a JSON object."] }; + } + + if (!Number.isInteger(expectedLimit) || expectedLimit < 1) { + errors.push(`--limit must be a positive integer, got ${options.limit}.`); + } + + const limitAssessment = findLimitAssessment(assessment, expectedLimit); + if (!limitAssessment || typeof limitAssessment !== "object") { + errors.push(`Missing limit=${expectedLimit} assessment.`); + } + + if (expectedLimit === 100 && assessment.goal100Decision !== `qualified-comparison: winner=${limitAssessment?.winnerEngine}`) { + errors.push(`goal100Decision is not a qualified comparison: ${assessment.goal100Decision || ""}`); + } + + if (limitAssessment?.limit !== expectedLimit) { + errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? ""}.`); + } + if (limitAssessment?.comparisonComplete !== true) errors.push(`limit=${expectedLimit}.comparisonComplete must be true.`); + if (limitAssessment?.winnerQualified !== true) errors.push(`limit=${expectedLimit}.winnerQualified must be true.`); + if (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) { + errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? ""}'.`); + } + + const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : []; + const engineNames = new Set(engines.map((entry) => entry?.engine)); + for (const required of ["current", "ik-traineddata"]) { + if (!engineNames.has(required)) errors.push(`limit=${expectedLimit} is missing engine result: ${required}.`); + } + + const winner = engines.find((entry) => entry?.engine === limitAssessment?.winnerEngine); + if (!winner) { + errors.push(`Winner engine is missing from limit=${expectedLimit}.engines: ${limitAssessment?.winnerEngine ?? ""}.`); + } else { + const winnerMissRate = Number(winner.missRate); + const winnerReviewRate = Number(winner.reviewRate); + const summaryWinnerMissRate = Number(limitAssessment?.winnerMissRate); + const summaryWinnerReviewRate = Number(limitAssessment?.winnerReviewRate); + const winnerActiveAverageMsPerParsed = Number(limitAssessment?.winnerActiveAverageMsPerParsed); + const winnerActiveProjectedMsFor100 = Number(limitAssessment?.winnerActiveProjectedMsFor100); + if (winner.qualified !== true) errors.push("Winner engine result must be qualified."); + if (!Number.isFinite(winnerMissRate)) { + errors.push(`Winner missRate must be a finite number, got ${winner.missRate ?? ""}.`); + } else if (winnerMissRate > 0.02) { + errors.push(`Winner missRate exceeds 2%: ${winner.missRate}.`); + } + if (!Number.isFinite(winnerReviewRate)) { + errors.push(`Winner reviewRate must be a finite number, got ${winner.reviewRate ?? ""}.`); + } else if (winnerReviewRate > 0.15) { + errors.push(`Winner reviewRate exceeds 15%: ${winner.reviewRate}.`); + } + if (!Number.isFinite(summaryWinnerMissRate)) { + errors.push(`Winner summary missRate must be a finite number, got ${limitAssessment?.winnerMissRate ?? ""}.`); + } else if (summaryWinnerMissRate > 0.02) { + errors.push(`Winner summary missRate exceeds 2%: ${limitAssessment.winnerMissRate}.`); + } + if (!Number.isFinite(summaryWinnerReviewRate)) { + errors.push(`Winner summary reviewRate must be a finite number, got ${limitAssessment?.winnerReviewRate ?? ""}.`); + } else if (summaryWinnerReviewRate > 0.15) { + errors.push(`Winner summary reviewRate exceeds 15%: ${limitAssessment.winnerReviewRate}.`); + } + if (!Number.isFinite(winnerActiveAverageMsPerParsed) || winnerActiveAverageMsPerParsed <= 0) { + errors.push(`Winner active average timing must be a positive finite number, got ${limitAssessment?.winnerActiveAverageMsPerParsed ?? ""}.`); + } + if (!Number.isFinite(winnerActiveProjectedMsFor100) || winnerActiveProjectedMsFor100 <= 0) { + errors.push(`Winner projected100 timing must be a positive finite number, got ${limitAssessment?.winnerActiveProjectedMsFor100 ?? ""}.`); + } + } + + return { + ok: errors.length === 0, + errors, + createdAt: assessment.createdAt || "", + limit: expectedLimit, + winnerEngine: limitAssessment?.winnerEngine, + winnerActiveAverageMsPerParsed: limitAssessment?.winnerActiveAverageMsPerParsed, + winnerActiveProjectedMsFor100: limitAssessment?.winnerActiveProjectedMsFor100, + winnerMissRate: limitAssessment?.winnerMissRate, + winnerReviewRate: limitAssessment?.winnerReviewRate, + }; +} + +function formatSummary(result) { + const status = result.ok ? "PASS" : "FAIL"; + const lines = [ + `scan assessment: ${status}`, + `input: ${result.inputPath || "unknown"}`, + `createdAt: ${result.createdAt || "unknown"}`, + `limit: ${result.limit ?? "unknown"}`, + `winner: ${result.winnerEngine || "unknown"}`, + `activeAvg: ${result.winnerActiveAverageMsPerParsed ?? "unknown"}ms/artifact`, + `projected100: ${result.winnerActiveProjectedMsFor100 ?? "unknown"}ms`, + `missRate: ${result.winnerMissRate ?? "unknown"}`, + `reviewRate: ${result.winnerReviewRate ?? "unknown"}`, + ]; + if (!result.ok) { + lines.push("errors:"); + for (const error of result.errors) lines.push(`- ${error}`); + } + return lines.join("\n"); +} + +function main() { + const inputPath = hasFlag("latest") + ? findLatestAssessment(argValue("root", defaultAssessmentRoot())) + : argValue("input", process.argv[2] || ""); + const expectedWinner = argValue("expect-winner", "any"); + if (!["any", "current", "ik-traineddata"].includes(expectedWinner)) { + throw new Error("--expect-winner must be one of: any, current, ik-traineddata."); + } + const limit = Number(argValue("limit", "100")); + const assessment = loadAssessment(inputPath); + const result = validateAssessment(assessment, { expectedWinner, limit }); + const payload = { inputPath: path.resolve(inputPath), ...result }; + console.log(hasFlag("summary") ? formatSummary(payload) : JSON.stringify(payload, null, 2)); + if (!result.ok) process.exit(1); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +module.exports = { + findLatestAssessment, + findLimitAssessment, + formatSummary, + loadAssessment, + validateAssessment, +}; diff --git a/src/data/genshinGameData.json b/src/data/genshinGameData.json index f2157ac..41f3ba9 100644 --- a/src/data/genshinGameData.json +++ b/src/data/genshinGameData.json @@ -5272,6 +5272,11 @@ }, "pieceAliases": { "A Note in Springs Leich": "A Note in Spring's Leich", + "Determination oT": "Viridescent Venerer's Determination", + "Determmation oT": "Viridescent Venerer's Determination", + "From Grand Dreams. Tn aking": "Moment That Ceased Upon Waking From Grand Dreams", + "From Grand Dreams Tn aking": "Moment That Ceased Upon Waking From Grand Dreams", + "Postintty That Ceased Upon": "Moment That Ceased Upon Waking From Grand Dreams", "Viridescent Vencrers Vessel": "Viridescent Venerer's Vessel", "Holy Crown of the Believer ": "Holy Crown of the Believer" }, diff --git a/src/eval/corpus/confirmedReviewCorpus.test.ts b/src/eval/corpus/confirmedReviewCorpus.test.ts new file mode 100644 index 0000000..f263ee5 --- /dev/null +++ b/src/eval/corpus/confirmedReviewCorpus.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { confirmedReviewCorpus, ocrEvalCorpus, seedCorpus, validateConfirmedReviewCorpus } from "."; + +describe("confirmed review corpus", () => { + it("contains only human-confirmed review labels", () => { + expect(validateConfirmedReviewCorpus()).toEqual([]); + }); + + it("does not duplicate eval case ids", () => { + const ids = ocrEvalCorpus.map((entry) => entry.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("is included in the full OCR eval corpus", () => { + expect(ocrEvalCorpus).toHaveLength(seedCorpus.length + confirmedReviewCorpus.length); + }); +}); + diff --git a/src/eval/corpus/confirmedReviewCorpus.ts b/src/eval/corpus/confirmedReviewCorpus.ts new file mode 100644 index 0000000..0d9aa18 --- /dev/null +++ b/src/eval/corpus/confirmedReviewCorpus.ts @@ -0,0 +1,15 @@ +import type { OcrEvalCase } from "../ocrEvalHarness"; + +export interface ConfirmedReviewEvalCase extends OcrEvalCase { + confirmed: true; + meta: NonNullable & { + source: "review-sample"; + note: string; + }; +} + +// Human-confirmed review samples belong here after their `expect` values were +// checked against the real artifact. Do not paste unconfirmed exporter output +// directly from outputs/review-eval-candidates/. +export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = []; + diff --git a/src/eval/corpus/index.ts b/src/eval/corpus/index.ts new file mode 100644 index 0000000..5910278 --- /dev/null +++ b/src/eval/corpus/index.ts @@ -0,0 +1,24 @@ +import { confirmedReviewCorpus, type ConfirmedReviewEvalCase } from "./confirmedReviewCorpus"; +import { seedCorpus } from "./seedCorpus"; +import type { OcrEvalCase } from "../ocrEvalHarness"; + +export { confirmedReviewCorpus, seedCorpus }; +export type { ConfirmedReviewEvalCase }; + +export const ocrEvalCorpus: OcrEvalCase[] = [...seedCorpus, ...confirmedReviewCorpus]; + +export function validateConfirmedReviewCorpus(corpus: readonly ConfirmedReviewEvalCase[] = confirmedReviewCorpus) { + const errors: string[] = []; + const seen = new Set(); + for (const entry of corpus) { + if (seen.has(entry.id)) errors.push(`${entry.id}: duplicate id`); + seen.add(entry.id); + if (entry.confirmed !== true) errors.push(`${entry.id}: confirmed must be true`); + if (entry.meta.source !== "review-sample") errors.push(`${entry.id}: meta.source must be review-sample`); + if (!entry.meta.note?.trim()) errors.push(`${entry.id}: meta.note must describe the review source/reason`); + if (Object.keys(entry.expect).length === 0) errors.push(`${entry.id}: expect must label at least one field`); + if (Object.keys(entry.ocr).length === 0) errors.push(`${entry.id}: ocr must contain at least one field`); + } + return errors; +} + diff --git a/src/eval/livePreflightScript.test.ts b/src/eval/livePreflightScript.test.ts new file mode 100644 index 0000000..e1a6aaa --- /dev/null +++ b/src/eval/livePreflightScript.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +const { formatSummary, parseWaitSeconds, validatePreflight } = require("../../scripts/live-preflight.cjs") as { + formatSummary: (result: unknown) => string; + parseWaitSeconds: (value: string) => number; + validatePreflight: (input: unknown) => { ok: boolean; errors: string[]; signature: string; isElevated?: boolean; genshinFound?: boolean }; +}; + +function health(signature = "sig-current") { + return { ok: true, appBuild: { signature } }; +} + +function status(runtime = { isElevated: true, genshinFound: true, targetProcess: "GenshinImpact.exe", foregroundProcess: "GenshinImpact.exe" }) { + return { ok: true, status: { runtimeInfo: runtime } }; +} + +describe("live preflight script", () => { + it("accepts a matching elevated Genshin runtime", () => { + const result = validatePreflight({ health: health(), status: status(), expected: "sig-current" }); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + expect(result.signature).toBe("sig-current"); + }); + + it("rejects stale runtime signatures", () => { + const result = validatePreflight({ health: health("old"), status: status(), expected: "sig-current" }); + expect(result.ok).toBe(false); + expect(result.errors.join("\n")).toContain("does not match"); + }); + + it("rejects non-elevated or missing Genshin runtime by default", () => { + const result = validatePreflight({ + health: health(), + status: status({ isElevated: false, genshinFound: false, targetProcess: "", foregroundProcess: "explorer.exe" }), + expected: "sig-current", + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain("Runtime is not elevated."); + expect(result.errors).toContain("Genshin process/window was not found."); + }); + + it("formats a concise human summary", () => { + const summary = formatSummary(validatePreflight({ health: health(), status: status(), expected: "sig-current" })); + expect(summary).toContain("live preflight: PASS"); + expect(summary).toContain("elevated: yes"); + expect(summary).toContain("genshin: yes"); + }); + + it("parses optional wait seconds strictly", () => { + expect(parseWaitSeconds("")).toBe(0); + expect(parseWaitSeconds("120")).toBe(120); + expect(() => parseWaitSeconds("-1")).toThrow("--wait"); + expect(() => parseWaitSeconds("1.5")).toThrow("--wait"); + expect(() => parseWaitSeconds("soon")).toThrow("--wait"); + }); +}); diff --git a/src/eval/ocrEval.test.ts b/src/eval/ocrEval.test.ts index cb3710d..fbdcb9c 100644 --- a/src/eval/ocrEval.test.ts +++ b/src/eval/ocrEval.test.ts @@ -1,24 +1,24 @@ import { describe, expect, it } from "vitest"; import { formatReport, runOcrEval } from "./ocrEvalHarness"; -import { seedCorpus } from "./corpus/seedCorpus"; +import { ocrEvalCorpus } from "./corpus"; -// Regression gate: the seed corpus is verified ground truth, so the parser must -// read every labeled field correctly. A drop here means an OCR/parser change -// regressed a previously-correct read - look at the printed failures. If a -// change intentionally alters a correct output, update the corpus label in the -// same commit (the label is the source of truth, not the code). +// Regression gate: every case in the combined corpus is verified ground truth, +// so the parser must read every labeled field correctly. A drop here means an +// OCR/parser change regressed a previously-correct read - look at the printed +// failures. If a change intentionally alters a correct output, update the corpus +// label in the same commit (the label is the source of truth, not the code). describe("OCR eval harness", () => { - const report = runOcrEval(seedCorpus); + const report = runOcrEval(ocrEvalCorpus); it("prints the accuracy report", () => { // Surfaced in test output for humans; not an assertion. // eslint-disable-next-line no-console console.log("\n" + formatReport(report) + "\n"); - expect(report.totalCases).toBe(seedCorpus.length); + expect(report.totalCases).toBe(ocrEvalCorpus.length); }); - it("reads every labeled field on the seed corpus correctly", () => { + it("reads every labeled field on the eval corpus correctly", () => { const failureSummary = report.failures .map((failure) => { const wrong = failure.fields diff --git a/src/eval/pngBitmap.test.ts b/src/eval/pngBitmap.test.ts new file mode 100644 index 0000000..622b0e9 --- /dev/null +++ b/src/eval/pngBitmap.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { deflateSync } from "node:zlib"; +import { pngBufferToBitmap } from "../../electron/services/pngBitmap"; +import { lockSignalRatio } from "../lib/lockDetection"; + +function chunk(type: string, data: Buffer) { + const result = Buffer.alloc(12 + data.length); + result.writeUInt32BE(data.length, 0); + result.write(type, 4, 4, "ascii"); + data.copy(result, 8); + return result; +} + +function rgbPng1x1(r: number, g: number, b: number) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1, 0); + ihdr.writeUInt32BE(1, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const raw = Buffer.from([0, r, g, b]); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +describe("pngBufferToBitmap", () => { + it("decodes RGB PNG pixels for lock detection", () => { + const bitmap = pngBufferToBitmap(rgbPng1x1(235, 92, 90)); + expect(bitmap.width).toBe(1); + expect(bitmap.height).toBe(1); + expect(lockSignalRatio(bitmap)).toBe(1); + }); +}); diff --git a/src/eval/prepareConfirmedReviewCaseScript.test.ts b/src/eval/prepareConfirmedReviewCaseScript.test.ts new file mode 100644 index 0000000..f7a80c4 --- /dev/null +++ b/src/eval/prepareConfirmedReviewCaseScript.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +function writeCandidatePayload(dir: string) { + const inputPath = path.join(dir, "review-eval-candidates.json"); + writeFileSync( + inputPath, + JSON.stringify({ + summary: {}, + candidates: [ + { + id: "review-2026-07-08T15-02-39-765Z-152", + savedAt: "2026-07-08T15:02:39.765Z", + reason: "automatic:parser-notes:p1:r2c3", + resolution: "1920x1080", + ocr: { + "artifact-name": "Pristine Plume of the Blessed", + "artifact-slot": "Plume of Death", + "artifact-main-stat-label": "ATK", + "artifact-level": "+20", + "artifact-substats": "- Energy Recharge+10.4%", + }, + }, + ], + }), + "utf8", + ); + return inputPath; +} + +describe("prepare confirmed review case script", () => { + it("creates a confirmed corpus snippet from a candidate and explicit labels", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-")); + try { + const inputPath = writeCandidatePayload(dir); + const expectPath = path.join(dir, "expect.json"); + const outputPath = path.join(dir, "snippet.ts"); + writeFileSync( + expectPath, + `\uFEFF${JSON.stringify({ + name: "Pristine Plume of the Blessed", + slot: "Plume of Death", + level: 20, + mainStat: "ATK", + setName: "Silken Moon's Serenade", + })}`, + "utf8", + ); + + execFileSync( + "node", + [ + "scripts/prepare-confirmed-review-case.cjs", + `--input=${inputPath}`, + "--candidate=review-2026-07-08T15-02-39-765Z-152", + `--expect-file=${expectPath}`, + `--out=${outputPath}`, + ], + { cwd: process.cwd(), stdio: "pipe" }, + ); + + const snippet = readFileSync(outputPath, "utf8"); + expect(snippet).toContain("confirmed: true"); + expect(snippet).toContain("Pristine Plume of the Blessed"); + expect(snippet).toContain('"artifact-name": "Pristine Plume of the Blessed"'); + expect(snippet).toContain("sourceCandidate=review-2026-07-08T15-02-39-765Z-152"); + expect(snippet).not.toContain("confirmed: false"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects missing explicit labels", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-")); + try { + const inputPath = writeCandidatePayload(dir); + expect(() => + execFileSync( + "node", + ["scripts/prepare-confirmed-review-case.cjs", `--input=${inputPath}`, "--candidate=review-2026-07-08T15-02-39-765Z-152"], + { cwd: process.cwd(), stdio: "pipe" }, + ), + ).toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/eval/reviewEvalCandidatesScript.test.ts b/src/eval/reviewEvalCandidatesScript.test.ts new file mode 100644 index 0000000..ae5080c --- /dev/null +++ b/src/eval/reviewEvalCandidatesScript.test.ts @@ -0,0 +1,90 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +function reviewRecord(savedAt: string, reason: string, ocr: Array<{ id: string; text: string }>, locked?: boolean) { + return { + savedAt, + sample: { + reason, + capture: { + width: 1920, + height: 1080, + ocr, + locked, + }, + parsed: { + name: "Pristine Plume of the Blessed", + slot: ocr.some((entry) => entry.id === "artifact-slot") ? "Plume of Death" : "Unknown Slot", + level: 20, + mainStat: "ATK", + mainValue: "311", + setName: "Silken Moon's Serenade", + equipped: ocr.some((entry) => entry.id === "artifact-footer") ? "Aino" : "Not detected", + substats: ["Energy Recharge+10.4%"], + confidence: 0.9, + notes: [], + }, + }, + }; +} + +const completeOcr = [ + { id: "artifact-name", text: "Pristine Plume of the Blessed" }, + { id: "artifact-slot", text: "Plume of Death" }, + { id: "artifact-main-stat-label", text: "ATK" }, + { id: "artifact-level", text: "+20" }, + { id: "artifact-substats", text: "- Energy Recharge+10.4%" }, + { id: "artifact-footer", text: "Equipped: Aino" }, +]; + +describe("review eval candidate exporter", () => { + it("exports deduplicated candidates with stats and stale markers", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-review-candidates-")); + try { + const inputPath = path.join(dir, "review-samples.jsonl"); + const outDir = path.join(dir, "out"); + const staleOcr = completeOcr.filter((entry) => entry.id !== "artifact-slot"); + writeFileSync( + inputPath, + [ + JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)), + JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)), + JSON.stringify(reviewRecord("2026-07-08T15:01:00.000Z", "automatic:capture-rejected:p1:r0c1", staleOcr, true)), + "{not json", + ].join("\n"), + "utf8", + ); + + execFileSync("node", ["scripts/export-review-eval-candidates.cjs", `--input=${inputPath}`, `--out=${outDir}`, "--limit=10"], { + cwd: process.cwd(), + stdio: "pipe", + }); + + const payload = JSON.parse(readFileSync(path.join(outDir, "review-eval-candidates.json"), "utf8")); + const markdown = readFileSync(path.join(outDir, "review-eval-candidates.md"), "utf8"); + + expect(payload.summary.recordsRead).toBe(4); + expect(payload.summary.invalidRecords).toBe(1); + expect(payload.summary.uniqueCandidates).toBe(2); + expect(payload.summary.exportedCandidates).toBe(2); + expect(payload.summary.exportStats.completeFastFields).toBe(1); + expect(payload.summary.exportStats.likelyStaleCaptures).toBe(1); + expect(payload.summary.exportStats.equippedFooterCandidates).toBe(2); + expect(payload.summary.exportStats.lockedTrueCandidates).toBe(1); + expect(payload.candidates[0].missingFastFields).toEqual([]); + expect(payload.candidates[0].parsed.equipped).toBe("Aino"); + expect(payload.candidates[0].reviewPrompt.expectedFields.equipped).toBe("Aino"); + expect(payload.candidates[1].likelyStaleCapture).toBe(true); + expect(payload.candidates[1].locked).toBe(true); + expect(markdown).toContain("## Export stats"); + expect(markdown).toContain("- artifact-slot: 1"); + expect(markdown).toContain("equipped=Aino"); + expect(markdown).toContain("Locked=true candidates: 1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/eval/reviewSampleCorpus.ts b/src/eval/reviewSampleCorpus.ts index 4f17ee5..cefdc89 100644 --- a/src/eval/reviewSampleCorpus.ts +++ b/src/eval/reviewSampleCorpus.ts @@ -10,7 +10,7 @@ import type { OcrEvalCase } from "./ocrEvalHarness"; // itself). The intended flow is: // 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess. // 2. A human confirms or corrects `expect` in the produced case. -// 3. The corrected case is committed into src/eval/corpus/. +// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts. // The `confirmed` flag records whether step 2 happened. export interface ReviewSampleEvalCase extends OcrEvalCase { diff --git a/src/eval/scanAssessmentValidatorScript.test.ts b/src/eval/scanAssessmentValidatorScript.test.ts new file mode 100644 index 0000000..421955e --- /dev/null +++ b/src/eval/scanAssessmentValidatorScript.test.ts @@ -0,0 +1,355 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +function validAssessment() { + return { + createdAt: "2026-07-08T12:00:00.000Z", + goal100Decision: "qualified-comparison: winner=ik-traineddata", + goal100: { + limit: 100, + comparisonComplete: true, + winnerEngine: "ik-traineddata", + winnerQualified: true, + winnerActiveAverageMsPerParsed: 820, + winnerActiveProjectedMsFor100: 82000, + winnerMissRate: 0, + winnerReviewRate: 0.04, + engines: [ + { engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04 }, + { engine: "current", qualified: true, missRate: 0, reviewRate: 0.06 }, + ], + }, + limits: [ + { + limit: 20, + comparisonComplete: true, + winnerEngine: "current", + winnerQualified: true, + winnerActiveAverageMsPerParsed: 390, + winnerActiveProjectedMsFor100: 39000, + winnerMissRate: 0, + winnerReviewRate: 0.05, + engines: [ + { engine: "current", qualified: true, missRate: 0, reviewRate: 0.05 }, + { engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.1 }, + ], + }, + ], + }; +} + +function writeAssessment(dir: string, payload: unknown) { + const inputPath = path.join(dir, "scan-performance-assessment.json"); + writeFileSync(inputPath, JSON.stringify(payload), "utf8"); + return inputPath; +} + +describe("scan assessment validator", () => { + it("accepts a qualified complete 100-artifact comparison", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], { + cwd: process.cwd(), + encoding: "utf8", + }); + expect(JSON.parse(output).ok).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("accepts a matching expected winner", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + const output = execFileSync( + "node", + ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=ik-traineddata"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + expect(JSON.parse(output).ok).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints an opt-in human summary", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], { + cwd: process.cwd(), + encoding: "utf8", + }); + expect(output).toContain("scan assessment: PASS"); + expect(output).toContain(`input: ${inputPath}`); + expect(output).toContain("createdAt: 2026-07-08T12:00:00.000Z"); + expect(output).toContain("limit: 100"); + expect(output).toContain("winner: ik-traineddata"); + expect(output).toContain("activeAvg: 820ms/artifact"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("accepts a qualified complete 20-artifact comparison", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + const output = execFileSync( + "node", + ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + expect(output).toContain("scan assessment: PASS"); + expect(output).toContain("limit: 20"); + expect(output).toContain("winner: current"); + expect(output).toContain("activeAvg: 390ms/artifact"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects invalid requested limits", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=0"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("--limit must be a positive integer"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a requested limit that is missing from the assessment", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=45"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("Missing limit=45 assessment"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => { + const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")); + expect(packageJson.scripts["scan:goal:compare:validated"]).toBe( + "npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + ); + expect(packageJson.scripts["scan:goal:compare:validated:wait"]).toBe( + "npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + ); + expect(packageJson.scripts["scan:iterate:compare"]).toBe( + "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr", + ); + expect(packageJson.scripts["scan:iterate:compare:validated"]).toBe( + "npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + ); + expect(packageJson.scripts["scan:iterate:compare:validated:wait"]).toBe( + "npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + ); + }); + + it("rejects a mismatched expected winner", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + expect(() => + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], { + cwd: process.cwd(), + stdio: "pipe", + }), + ).toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints errors in summary mode for rejected assessments", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const inputPath = writeAssessment(dir, validAssessment()); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current", "--summary"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("Expected winner"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a single-engine 100-artifact run", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const payload = validAssessment(); + payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run"; + payload.goal100.comparisonComplete = false; + payload.goal100.engines = [{ engine: "current", qualified: true, missRate: 0, reviewRate: 0 }]; + const inputPath = writeAssessment(dir, payload); + expect(() => + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], { + cwd: process.cwd(), + stdio: "pipe", + }), + ).toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects an unqualified winner", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const payload = validAssessment(); + payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates"; + payload.goal100.winnerQualified = false; + payload.goal100.engines[0].qualified = false; + payload.goal100.engines[0].reviewRate = 0.3; + const inputPath = writeAssessment(dir, payload); + expect(() => + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], { + cwd: process.cwd(), + stdio: "pipe", + }), + ).toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a winner with missing quality rates", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const payload = validAssessment(); + delete (payload.goal100.engines[0] as { missRate?: number }).missRate; + delete (payload.goal100.engines[0] as { reviewRate?: number }).reviewRate; + const inputPath = writeAssessment(dir, payload); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("Winner missRate must be a finite number"); + expect(stdout).toContain("Winner reviewRate must be a finite number"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a winner with missing summary quality rates", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const payload = validAssessment(); + delete (payload.goal100 as { winnerMissRate?: number }).winnerMissRate; + delete (payload.goal100 as { winnerReviewRate?: number }).winnerReviewRate; + const inputPath = writeAssessment(dir, payload); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("Winner summary missRate must be a finite number"); + expect(stdout).toContain("Winner summary reviewRate must be a finite number"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a winner with missing speed timing", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const payload = validAssessment(); + delete (payload.goal100 as { winnerActiveAverageMsPerParsed?: number }).winnerActiveAverageMsPerParsed; + delete (payload.goal100 as { winnerActiveProjectedMsFor100?: number }).winnerActiveProjectedMsFor100; + const inputPath = writeAssessment(dir, payload); + let stdout = ""; + try { + execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: "pipe", + }); + } catch (error) { + stdout = String((error as { stdout?: string }).stdout || ""); + } + expect(stdout).toContain("scan assessment: FAIL"); + expect(stdout).toContain("Winner active average timing must be a positive finite number"); + expect(stdout).toContain("Winner projected100 timing must be a positive finite number"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("validates the latest assessment under a live-soak root", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); + try { + const older = path.join(dir, "2026-07-08T10-00-00"); + const newer = path.join(dir, "2026-07-08T11-00-00"); + mkdirSync(older); + mkdirSync(newer); + writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" }); + writeAssessment(newer, validAssessment()); + + const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", "--latest", `--root=${dir}`], { + cwd: process.cwd(), + encoding: "utf8", + }); + const parsed = JSON.parse(output); + expect(parsed.ok).toBe(true); + expect(parsed.inputPath).toContain("2026-07-08T11-00-00"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/features/app/services/appControllerService.ts b/src/features/app/services/appControllerService.ts index f5ed865..dbdb84d 100644 --- a/src/features/app/services/appControllerService.ts +++ b/src/features/app/services/appControllerService.ts @@ -144,13 +144,16 @@ export async function captureSelectedSourceAction( setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`); } const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options); - setLatestCapture(capture); - const ocrStatus = capture.ocrSkipped - ? "OCR skipped for fast scan." - : capture.ocrTimedOut - ? "OCR timed out; review sample needed." - : "OCR handoff is next."; - setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`); + const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame; + if (!quietArtifactScanCapture) { + setLatestCapture(capture); + const ocrStatus = capture.ocrSkipped + ? "OCR skipped for fast scan." + : capture.ocrTimedOut + ? "OCR timed out; review sample needed." + : "OCR handoff is next."; + setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`); + } return capture; } catch (error) { setTopbarStatus(error instanceof Error ? error.message : "Capture failed."); diff --git a/src/features/scan/components/DiagnosticsView.tsx b/src/features/scan/components/DiagnosticsView.tsx index 904ed48..57e6feb 100644 --- a/src/features/scan/components/DiagnosticsView.tsx +++ b/src/features/scan/components/DiagnosticsView.tsx @@ -23,7 +23,7 @@ const appDiagnosisSections = [ "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.", + "Review-Samples, lokale Lernregeln, GOOD Import/Export, Equipped-Footer und Lock-Status im Store nutzen.", ], }, { @@ -33,7 +33,7 @@ const appDiagnosisSections = [ 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.", + "Positive locked=true Probe und erneuter Equipped-Footer-Livebeweis an bekannten Artifacts fehlen.", "Empfehlungen bleiben Nebenfunktion, bis Scanner-Vertrauen und Review-Rate stabil genug sind.", ], }, @@ -54,6 +54,7 @@ const appDiagnosisSections = [ icon: Target, items: [ "Review-Corpus aus echten Samples vergroessern und mit `npm run eval` messbar halten.", + "Review-Export fuer Equipped-Footer und locked=true Kandidaten nutzen.", "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.", diff --git a/src/features/scan/components/hooks/useScanSummaryFooterModel.ts b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts index f333233..cc093f1 100644 --- a/src/features/scan/components/hooks/useScanSummaryFooterModel.ts +++ b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts @@ -23,7 +23,7 @@ export function useScanSummaryFooterModel({ : `${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 - ? `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)}` + ? `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 | roundtrip ${scanSummary.averageCaptureRoundTripMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}` : null; return { diff --git a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts index 770650c..e12dfa4 100644 --- a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts +++ b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts @@ -141,6 +141,8 @@ export function useScanDiagnosticsModalModel({ { label: "avgMs", value: controller.autoScanStats.averageMsPerParsed }, { label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed }, { label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs }, + { label: "avgCaptureRoundTripMs", value: controller.autoScanStats.averageCaptureRoundTripMs }, + { label: "avgCaptureRoundTripOverheadMs", value: controller.autoScanStats.averageCaptureRoundTripOverheadMs }, { label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms }, { label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms }, { label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs }, @@ -171,6 +173,8 @@ export function useScanDiagnosticsModalModel({ controller.autoScanStats.averageMsPerParsed, controller.autoScanStats.activeAverageMsPerParsed, controller.autoScanStats.averageCaptureMs, + controller.autoScanStats.averageCaptureRoundTripMs, + controller.autoScanStats.averageCaptureRoundTripOverheadMs, controller.autoScanStats.captureP50Ms, controller.autoScanStats.captureP90Ms, controller.autoScanStats.averageOcrMs, diff --git a/src/features/scan/hooks/scanViewControllerService.ts b/src/features/scan/hooks/scanViewControllerService.ts index 26a4096..c7c77ea 100644 --- a/src/features/scan/hooks/scanViewControllerService.ts +++ b/src/features/scan/hooks/scanViewControllerService.ts @@ -51,6 +51,14 @@ export interface ScanActionContextInput { source: string, needsReview: boolean, ) => Promise; + persistParsedArtifactsBatch?: ( + items: Array<{ + capture: CaptureResult | null; + parsed: ParsedArtifactCandidate; + source: string; + needsReview: boolean; + }>, + ) => Promise; saveReviewSample: ( capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, @@ -107,6 +115,7 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi appendDiagnosticEvent: input.appendDiagnosticEvent, parseArtifact: input.parseArtifact, persistParsedArtifact: input.persistParsedArtifact, + persistParsedArtifactsBatch: input.persistParsedArtifactsBatch, shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false), saveReviewSample: input.saveReviewSample, focusDashboard: input.focusDashboard, diff --git a/src/features/scan/hooks/scanViewEntryActions.ts b/src/features/scan/hooks/scanViewEntryActions.ts new file mode 100644 index 0000000..6a90a9f --- /dev/null +++ b/src/features/scan/hooks/scanViewEntryActions.ts @@ -0,0 +1,254 @@ +import { + artifactTabClickTarget, + keyPressBlocked, + validateAutoScanEntryPreflight, + type ScanEntryMode, +} from "../../../lib/autoScanEntry"; +import { wait } from "../../../lib/scanReviewUtils"; +import { + summarizeClickResult, + summarizeKeyPressResult, + type createScanDiagnosticEvent, +} from "../../../lib/scanDiagnosticsLog"; +import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories"; +import type { CaptureOptions, CaptureResult } from "../../../types/global"; + +export interface PrepareAutoScanEntryInput { + mode: ScanEntryMode; + automationRepo: AutomationRepositoryPort; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + appendAutomationLog: (line: string) => void; + appendDiagnosticEvent: (event: Omit[0], "includeFullScreenshot">) => void; +} + +export async function prepareAutoScanEntry({ + mode, + automationRepo, + captureFastSelectedSource, + appendAutomationLog, + appendDiagnosticEvent, +}: PrepareAutoScanEntryInput) { + if (mode === "visible-inventory") { + const capture = await waitForEntryCapture({ + captureFastSelectedSource, + timeoutMs: 900, + predicate: (candidate) => validateAutoScanEntryPreflight(candidate).ok, + }); + appendDiagnosticEvent({ + phase: "entry-visible", + severity: capture ? "ok" : "error", + message: capture && validateAutoScanEntryPreflight(capture).ok + ? "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; + appendAutomationLog: (line: string) => void; + appendDiagnosticEvent: (event: Omit[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; + 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; +} diff --git a/src/features/scan/hooks/scanViewReviewHelpers.ts b/src/features/scan/hooks/scanViewReviewHelpers.ts index be6dd1d..a752bb6 100644 --- a/src/features/scan/hooks/scanViewReviewHelpers.ts +++ b/src/features/scan/hooks/scanViewReviewHelpers.ts @@ -110,7 +110,7 @@ function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, inco } function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules { - return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } }; + return mergeLearningRulePayloads({}, loadedRules?.rules); } async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) { @@ -213,17 +213,47 @@ export async function mergeLearningRules( context: ReviewStateContext, ) { if (!nextRules || countScannerLearningRules(nextRules) === 0) return null; - const merged = { - textReplacements: { - ...currentRules.textReplacements, - ...(nextRules.textReplacements ?? {}), - }, - }; + const merged = mergeLearningRulePayloads(currentRules, nextRules); context.setScannerLearningRules(merged); const result = await context.learningRepo?.saveRules?.(merged).catch(() => null); return { result, merged }; } +function mergeLearningRulePayloads( + current: Partial | null | undefined, + next: Partial | null | undefined, +): ScannerLearningRules { + return { + textReplacements: { + ...(current?.textReplacements ?? {}), + ...(next?.textReplacements ?? {}), + }, + fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases), + constrainedFixes: { + ...(current?.constrainedFixes ?? {}), + ...(next?.constrainedFixes ?? {}), + }, + cropAdjustments: { + ...(current?.cropAdjustments ?? {}), + ...(next?.cropAdjustments ?? {}), + }, + uiProfileAdjustments: { + ...(current?.uiProfileAdjustments ?? {}), + ...(next?.uiProfileAdjustments ?? {}), + }, + }; +} + +function mergeNestedRuleMap( + current: Record> | undefined, + next: Record> | undefined, +) { + const merged: Record> = {}; + for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) }; + for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) }; + return merged; +} + export async function persistParsedArtifact( capture: CaptureResult | null, parsed: ParsedArtifactCandidate, @@ -256,6 +286,47 @@ export async function persistParsedArtifact( } } +export async function persistParsedArtifactsBatch( + items: Array<{ + capture: CaptureResult | null; + parsed: ParsedArtifactCandidate; + source: string; + needsReview: boolean; + }>, + context: ReviewStateContext, +) { + const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context; + if (!artifactRepo?.saveMany || items.length === 0) return 0; + + const records: StoredArtifactRecord[] = []; + for (const item of items) { + const rejection = captureRejectionReason(item.capture, item.parsed); + if (rejection) { + appendAutomationLog(`persist skip: ${rejection}`); + continue; + } + if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) { + appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`); + continue; + } + records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked)); + } + + if (records.length === 0) return 0; + + try { + const result = await artifactRepo.saveMany(records); + if (result?.ok) { + setStoredTotal(result.total); + void onStoredArtifactsChanged?.(); + return records.length; + } + return 0; + } catch { + return 0; + } +} + export async function saveReviewSample( capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, diff --git a/src/features/scan/hooks/scanViewScanActions.ts b/src/features/scan/hooks/scanViewScanActions.ts index 3815106..0068f17 100644 --- a/src/features/scan/hooks/scanViewScanActions.ts +++ b/src/features/scan/hooks/scanViewScanActions.ts @@ -1,11 +1,11 @@ import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner"; -import { artifactTabClickTarget, keyPressBlocked, validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry"; +import { validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry"; import { captureRejectionReason } from "../../../lib/scannerCaptureQuality"; import { runAutoScanLoop } from "../../../lib/autoScanLoop"; import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils"; -import { summarizeClickResult, summarizeKeyPressResult, type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; -import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories"; +import { type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; +import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { AutomationGuard, BooleanResult, @@ -17,8 +17,8 @@ import type { ScrollResult, } from "../../../types/global"; import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; -import type { MutableRefObject } from "react"; -import type { Dispatch, SetStateAction } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { prepareAutoScanEntry } from "./scanViewEntryActions"; export interface ScanActionContext { autoScanRunning: boolean; @@ -43,6 +43,7 @@ export interface ScanActionContext { captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise; + persistParsedArtifactsBatch?: (items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>) => Promise; saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise; shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean; focusDashboard: () => Promise; @@ -74,6 +75,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise Promise; - appendAutomationLog: (line: string) => void; - appendDiagnosticEvent: (event: Omit[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; - appendAutomationLog: (line: string) => void; - appendDiagnosticEvent: (event: Omit[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; - 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; -} diff --git a/src/features/scan/hooks/useScanGoodInterop.ts b/src/features/scan/hooks/useScanGoodInterop.ts new file mode 100644 index 0000000..9b173a1 --- /dev/null +++ b/src/features/scan/hooks/useScanGoodInterop.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop"; +import type { ArtifactRepositoryPort, ScanExportPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import type { StoredArtifactRecord } from "../../../types/storage"; + +interface UseScanGoodInteropInput { + artifactRepo?: ArtifactRepositoryPort; + exportRepo?: ScanExportPort; + onStoredArtifactsChanged?: () => Promise; + setStoredTotal: (value: number | null) => void; + bridgeReady: boolean; +} + +export function useScanGoodInterop({ + artifactRepo, + exportRepo, + onStoredArtifactsChanged, + setStoredTotal, + bridgeReady, +}: UseScanGoodInteropInput) { + const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany); + + const exportGoodFromStore = useCallback(async () => { + if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 }; + const loaded = await artifactRepo.loadAll(); + const records = loaded.artifacts ?? []; + const good = storedArtifactsToGood(records); + const result = await exportRepo.exportGood(good); + return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length }; + }, [artifactRepo, exportRepo]); + + const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => { + if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 }; + const result = await artifactRepo.saveMany(records); + if (typeof result.total === "number") setStoredTotal(result.total); + await onStoredArtifactsChanged?.(); + return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 }; + }, [artifactRepo, onStoredArtifactsChanged, setStoredTotal]); + + const importGoodFromFile = useCallback(async () => { + if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) { + return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." }; + } + const fileResult = await exportRepo.importGoodFile(); + if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true }; + if (!fileResult.ok) { + return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error }; + } + const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase); + if (records.length === 0) { + return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." }; + } + const saved = await importGoodArtifacts(records); + return { ...saved, count: records.length, path: fileResult.path }; + }, [artifactRepo, exportRepo, importGoodArtifacts]); + + return { + canGoodInterop, + exportGoodFromStore, + importGoodFromFile, + importGoodArtifacts, + }; +} diff --git a/src/features/scan/hooks/useScanViewActions.ts b/src/features/scan/hooks/useScanViewActions.ts index d8d1010..5b36eba 100644 --- a/src/features/scan/hooks/useScanViewActions.ts +++ b/src/features/scan/hooks/useScanViewActions.ts @@ -5,6 +5,7 @@ import { initializeLearningState, loadReviewQueue as loadReviewQueueFromRepo, persistParsedArtifact as persistParsedArtifactHelper, + persistParsedArtifactsBatch as persistParsedArtifactsBatchHelper, saveReviewSample as saveReviewSampleHelper, } from "./scanViewReviewHelpers"; import { createReviewContext, createScanActionContext } from "./scanViewControllerService"; @@ -186,6 +187,15 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe [reviewContext], ); + const parseArtifactsAndPersistBatch = useCallback( + async function parseArtifactsAndPersistBatch( + items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>, + ) { + return persistParsedArtifactsBatchHelper(items, reviewContext); + }, + [reviewContext], + ); + const handleSaveReviewSample = useCallback( async function handleSaveReviewSample( capture: CaptureResult | null = latestCapture, @@ -227,6 +237,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe appendDiagnosticEvent, parseArtifact, persistParsedArtifact: parseArtifactAndPersist, + persistParsedArtifactsBatch: parseArtifactsAndPersistBatch, saveReviewSample: handleSaveReviewSample, focusDashboard, captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, { @@ -265,6 +276,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe appendDiagnosticEvent, parseArtifact, parseArtifactAndPersist, + parseArtifactsAndPersistBatch, handleSaveReviewSample, focusDashboard, captureSelectedSource, @@ -317,13 +329,17 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe 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.", + : "Artifact detail view is not ready; guided scan waits for a visible artifact detail card instead of navigating.", capture: preflightCapture, }); + if (!visibleInventoryReady) { + setReviewStatus("Auto-Scan wartet: Bitte Artifact-Inventar mit sichtbarer Detailkarte oeffnen und erneut starten."); + return; + } await runVisibleGridScanAction(scanActionContext, { scanLimit: options.scanLimit, - scanEntryMode: visibleInventoryReady ? "visible-inventory" : "auto-entry", - processInitialSelection: visibleInventoryReady, + scanEntryMode: "visible-inventory", + processInitialSelection: true, ocrEngine: options.ocrEngine, }); }, [ diff --git a/src/features/scan/hooks/useScanViewController.ts b/src/features/scan/hooks/useScanViewController.ts index 6c41326..960350b 100644 --- a/src/features/scan/hooks/useScanViewController.ts +++ b/src/features/scan/hooks/useScanViewController.ts @@ -10,6 +10,7 @@ import { parseLearnedArtifact as parseLearnedArtifactHelper, } from "./scanViewReviewHelpers"; import { useScanRuntimeInfo } from "./useScanRuntimeInfo"; +import { useScanGoodInterop } from "./useScanGoodInterop"; import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher"; import { useScanViewActions } from "./useScanViewActions"; import { useScanViewStateSync } from "./useScanViewStateSync"; @@ -17,8 +18,6 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; import type { ScanViewProps, ScanViewControllerResult } from "../types"; import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; -import type { StoredArtifactRecord } from "../../../types/storage"; -import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop"; import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories"; export function useScanViewController({ @@ -79,7 +78,7 @@ export function useScanViewController({ const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen); const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]); const learningRuleCount = countScannerLearningRules(scannerLearningRules); - const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0; + const detectedInventoryCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0; const activeTargetCount = autoScanRunning ? resolveScanTargetCount(scanLimit, detectedInventoryCount) : scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount); @@ -175,41 +174,18 @@ export function useScanViewController({ setReviewQueueOpen, }); - const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany); - - const exportGoodFromStore = useCallback(async () => { - if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 }; - const loaded = await artifactRepo.loadAll(); - const records = loaded.artifacts ?? []; - const good = storedArtifactsToGood(records); - const result = await exportRepo.exportGood(good); - return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length }; - }, [artifactRepo, exportRepo]); - - const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => { - if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 }; - const result = await artifactRepo.saveMany(records); - if (typeof result.total === "number") setStoredTotal(result.total); - await onStoredArtifactsChanged?.(); - return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 }; - }, [artifactRepo, onStoredArtifactsChanged]); - - const importGoodFromFile = useCallback(async () => { - if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) { - return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." }; - } - const fileResult = await exportRepo.importGoodFile(); - if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true }; - if (!fileResult.ok) { - return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error }; - } - const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase); - if (records.length === 0) { - return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." }; - } - const saved = await importGoodArtifacts(records); - return { ...saved, count: records.length, path: fileResult.path }; - }, [artifactRepo, exportRepo, importGoodArtifacts]); + const { + canGoodInterop, + exportGoodFromStore, + importGoodFromFile, + importGoodArtifacts, + } = useScanGoodInterop({ + artifactRepo, + exportRepo, + onStoredArtifactsChanged, + setStoredTotal, + bridgeReady, + }); useScanViewStateSync({ artifactRepo, diff --git a/src/features/scan/hooks/useScanViewStateSync.ts b/src/features/scan/hooks/useScanViewStateSync.ts index 179a506..f530efb 100644 --- a/src/features/scan/hooks/useScanViewStateSync.ts +++ b/src/features/scan/hooks/useScanViewStateSync.ts @@ -20,7 +20,7 @@ export function useScanViewStateSync({ setStoredTotal, }: ScanViewStateSyncInput) { useEffect(() => { - const detectedCount = latestCapture?.inventoryCount?.current ?? 0; + const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0; if (!scanLimitTouched && detectedCount > 0) { setScanLimit(clampScanLimit(detectedCount)); } @@ -32,4 +32,3 @@ export function useScanViewStateSync({ }).catch(() => undefined); }, [artifactRepo, setStoredTotal]); } - diff --git a/src/lib/artifactOcrParser.test.ts b/src/lib/artifactOcrParser.test.ts index 8fad501..3006ede 100644 --- a/src/lib/artifactOcrParser.test.ts +++ b/src/lib/artifactOcrParser.test.ts @@ -140,6 +140,51 @@ describe("parseArtifactCandidate", () => { expect(parsed?.fields.mainValue.source).toBe("derived"); }); + it("derives percent main stat values when slot rules disallow flat HP ATK or DEF", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-name": "Moonlit Offering's Final Hour", + "artifact-main-stat-label": "HP", + "artifact-level": "+20", + "artifact-substats": "+ ATK+19\n- Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53", + })); + + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.mainStat).toBe("HP%"); + expect(parsed?.mainValue).toBe("46.6%"); + expect(parsed?.fields.mainStat.source).toBe("derived"); + expect(parsed?.fields.mainValue.source).toBe("derived"); + }); + + it("recovers a known piece from a truncated Viridescent Determination OCR tail", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-name": "Determmation oT", + "artifact-main-stat-label": "Energy Recharge", + "artifact-level": "+20", + "artifact-substats": "- ATK+5.8%\n- Elemental Mastery+37\nHP+11.7%\n+ ATK+54", + })); + + expect(parsed?.name).toBe("Viridescent Venerer's Determination"); + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.setName).toBe("Viridescent Venerer"); + expect(parsed?.mainStat).toBe("Energy Recharge"); + expect(parsed?.mainValue).toBe("51.8%"); + }); + + it("recovers long Disenchantment piece names from truncated OCR fragments", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-name": "Postintty That Ceased Upon", + "artifact-main-stat-label": "DEF", + "artifact-level": "+0", + "artifact-substats": "+ DEF+21\n+ Energy Recharge+4.5%\n+ CRIT Rate+3.1%\n- ATK+14", + })); + + expect(parsed?.name).toBe("Moment That Ceased Upon Waking From Grand Dreams"); + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.setName).toBe("Disenchantment in Deep Shadow"); + expect(parsed?.mainStat).toBe("DEF%"); + expect(parsed?.mainValue).toBe("8.7%"); + }); + 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", @@ -265,6 +310,46 @@ describe("parseArtifactCandidate", () => { expect(parsed?.equipped).toBe("Bennett"); }); + it("recognizes equipped characters when OCR splits the footer label and name", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Gladiator's Nostalgia\nFlower of Life", + "artifact-main-stat": "HP\n4,780", + "artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%", + "artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%", + "artifact-footer": "Equipped:\nBennett", + })); + + expect(parsed?.equipped).toBe("Bennett"); + expect(parsed?.fields.equipped.source).toBe("fallback"); + }); + + it("does not persist unknown one-letter equipped fragments as characters", () => { + 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?.equipped).toBe("Not detected"); + expect(parsed?.fields.equipped.source).toBe("missing"); + }); + + it("normalizes equipped footer trailing fragments to a known character", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-name": "Pristine Plume of the Blessed", + "artifact-slot": "Plume of Death", + "artifact-main-stat-label": "ATK", + "artifact-level": "+20", + "artifact-substats": "+ HP+16.9%\n- ATK+8.7%\n+ CRIT DMG+13.2%\n+ Elemental Mastery+21", + "artifact-set-effects": "2-Piece Set: Energy Recharge +20%.", + "artifact-footer": "Equipped: Linnea l", + })); + + expect(parsed?.equipped).toBe("Linnea"); + expect(parsed?.fields.equipped.source).toBe("fallback"); + }); + it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => { const sands = parseArtifactCandidate(captureFromOcr({ "artifact-title": "Myths of the Night Realm\nSands of Eon", diff --git a/src/lib/artifactOcrParser.ts b/src/lib/artifactOcrParser.ts index f5d9204..da7c024 100644 --- a/src/lib/artifactOcrParser.ts +++ b/src/lib/artifactOcrParser.ts @@ -82,6 +82,7 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null; const level = parsedLevel ?? 0; let mainStatField = inferMainStat(slotField.value, mainText); + mainStatField = promoteSlotPercentMainStat(slotField.value, mainStatField); let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel); if (!mainStatField.value && mainValueField.value) { const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel); @@ -227,6 +228,8 @@ function parseSetName(setText: string, artifactName: ParsedField): ParsedField { 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 (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived"); + const partialSetFromPiece = deriveSetFromPartialPieceName(artifactName.value); + if (partialSetFromPiece) return field(partialSetFromPiece, 72, "derived"); return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing"); } @@ -275,6 +278,22 @@ function firstUsefulLine(text: string, rejectIncludes: string[]) { .find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? ""; } +function deriveSetFromPartialPieceName(text: string) { + const words = cleanupOcrLabel(text) + .split(/\s+/) + .map((word) => simplifyForMatch(word)) + .filter((word) => word.length >= 5); + if (words.length < 2) return ""; + + const candidates = knownPieceNames.filter((piece) => { + const normalizedPiece = simplifyForMatch(piece); + const hits = words.filter((word) => normalizedPiece.includes(word)).length; + return hits >= 2; + }); + const sets = [...new Set(candidates.map((piece) => pieceToSet.get(piece)).filter((set): set is string => Boolean(set)))]; + return sets.length === 1 ? sets[0] : ""; +} + function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField { const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0"); const percentValue = extractPercentValue(cleaned); @@ -504,7 +523,7 @@ function parseEquippedCharacter(text: string): ParsedField { const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null; if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback"); - return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing"); + return field("Not detected", 45, "missing"); } function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField { @@ -520,6 +539,20 @@ function promotePercentVariant(stat: string, text: string) { return stat; } +function promoteSlotPercentMainStat(slot: string, mainStat: ParsedField): ParsedField { + if (!["ATK", "HP", "DEF"].includes(mainStat.value)) return mainStat; + const references = getSlotMainStatValueReferences(slot); + const hasFlat = references.some((candidate) => candidate.stat === mainStat.value); + const hasPercent = references.some((candidate) => candidate.stat === `${mainStat.value}%`); + if (hasFlat || !hasPercent) return mainStat; + return { + ...mainStat, + value: `${mainStat.value}%`, + confidence: Math.max(mainStat.confidence, 90), + source: "derived", + }; +} + function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] { const valueReferences = mainStatValueReferences[slot]; return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : []; @@ -607,6 +640,7 @@ function cleanupOcrLabel(line: string) { function cleanupCharacterNoise(text: string) { return text .replace(/^.*?equipped\s*:?\s*/i, "") + .replace(/^(?:by|to)\s+/i, "") .replace(/[^A-Za-z'\-\s]/g, " ") .replace(/\s+/g, " ") .trim(); diff --git a/src/lib/autoScanController.test.ts b/src/lib/autoScanController.test.ts index ba1f7bc..fa10f23 100644 --- a/src/lib/autoScanController.test.ts +++ b/src/lib/autoScanController.test.ts @@ -6,10 +6,10 @@ describe("autoScanController", () => { expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" }); }); - it("separates stuck detail views from duplicates", () => { + it("treats repeated readable signatures as duplicates", () => { const seen = new Set(["same"]); - expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" }); + expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "duplicate" }); expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" }); }); diff --git a/src/lib/autoScanController.ts b/src/lib/autoScanController.ts index 4f7a89d..ee58fb9 100644 --- a/src/lib/autoScanController.ts +++ b/src/lib/autoScanController.ts @@ -14,7 +14,6 @@ export function classifyAutoScanCapture({ seen: ReadonlySet; }): AutoScanCaptureDecision { if (!signature) return { kind: "unreadable", countAsMiss: true }; - if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature }; if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature }; return { kind: "new", countAsParsed: true, signature }; } diff --git a/src/lib/autoScanLoop.test.ts b/src/lib/autoScanLoop.test.ts index 9afcb00..defb408 100644 --- a/src/lib/autoScanLoop.test.ts +++ b/src/lib/autoScanLoop.test.ts @@ -265,9 +265,9 @@ describe("autoScanLoop fingerprints", () => { ocrProfile: "fast", ocrEngine: "ik-traineddata", omitFullFrame: true, + omitDetailPreview: true, omitInventoryPreview: true, omitCropImages: true, - omitEquippedOcr: true, skipOcrUnlessArtifactDetail: true, }); }); diff --git a/src/lib/autoScanLoop.ts b/src/lib/autoScanLoop.ts index 091ec12..0082367 100644 --- a/src/lib/autoScanLoop.ts +++ b/src/lib/autoScanLoop.ts @@ -32,6 +32,14 @@ export type AutoScanLoopDependencies = { source: string, needsReview: boolean, ) => Promise; + persistParsedArtifactsBatch?: ( + items: Array<{ + capture: CaptureResult | null; + parsed: ParsedArtifactCandidate; + source: string; + needsReview: boolean; + }>, + ) => Promise; saveReviewSample: ( capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, @@ -65,15 +73,17 @@ export type AutoScanLoopResult = { }; // Card-ready gating replaces a fixed settle delay: poll the detail fingerprint -// until it has changed and stabilized (or the budget is spent). See cardReadyGate. -const CARD_READY_MAX_MS = 420; -const CARD_READY_POLL_MS = 60; -const CARD_READY_STABLE_SAMPLES = 2; -const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 200; +// until it has changed, then read the artifact immediately. See cardReadyGate. +const CARD_READY_MAX_MS = 180; +const CARD_READY_POLL_MS = 25; +const CARD_READY_STABLE_SAMPLES = 1; +const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 0; 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 STATS_PUBLISH_INTERVAL_MS = 250; +const ROUTINE_CLICK_LOG_INTERVAL = 12; const MISS_ABORT_THRESHOLD = 3; const UNREADABLE_ABORT_THRESHOLD = 5; @@ -87,6 +97,7 @@ export async function runAutoScanLoop( captureFastSelectedSource, parseArtifact, persistParsedArtifact, + persistParsedArtifactsBatch, saveReviewSample, getAutoReviewReason, shouldFlagArtifactForReview, @@ -102,34 +113,56 @@ export async function runAutoScanLoop( const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount); const rowsToSkip = clampSkipRows(options.skipRows); const seen = new Set(); - const seenDetailFingerprints = new Set(); const seenPageFingerprints = new Set(); let page = 0; let blockedReason = ""; let aborted = false; let consecutiveMisses = 0; let rowsQueued = 0; - let writeQueue: Promise = Promise.resolve(); - function updateStats(preserveActiveScanMs = false) { - updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs }); - setAutoScanStats({ ...stats }); + let flushingWrites = false; + const writeQueue: Array<{ label: string; task: () => Promise }> = []; + const batchedPersistQueue: Array<{ + capture: CaptureResult | null; + parsed: ParsedArtifactCandidate; + source: string; + needsReview: boolean; + }> = []; + let lastStatsPublishAt = 0; + function updateStats(preserveActiveScanMs = false, forcePublish = false) { + updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs: preserveActiveScanMs || flushingWrites }); + const now = Date.now(); + if (forcePublish || now - lastStatsPublishAt >= STATS_PUBLISH_INTERVAL_MS) { + lastStatsPublishAt = now; + setAutoScanStats({ ...stats }); + } } function enqueueWrite(label: string, task: () => Promise) { - writeQueue = writeQueue - .catch(() => undefined) - .then(async () => { - try { - await task(); - } catch (error) { - appendAutomationLog(`write failed ${label}: ${error instanceof Error ? error.message : String(error)}`); - } - }); + writeQueue.push({ label, task }); } async function flushWrites() { - await writeQueue.catch(() => undefined); - updateStats(true); + flushingWrites = true; + try { + if (persistParsedArtifactsBatch && batchedPersistQueue.length > 0) { + const items = batchedPersistQueue.splice(0); + try { + stats.stored += await persistParsedArtifactsBatch(items); + } catch (error) { + appendAutomationLog(`write failed persist-batch:${items.length}: ${error instanceof Error ? error.message : String(error)}`); + } + } + for (const item of writeQueue.splice(0)) { + try { + await item.task(); + } catch (error) { + appendAutomationLog(`write failed ${item.label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } finally { + flushingWrites = false; + } + updateStats(true, true); } async function finish(result: AutoScanLoopResult) { @@ -137,7 +170,7 @@ export async function runAutoScanLoop( stats.activeScanMs = Math.max(0, flushStartedAt - startedAt); await flushWrites(); stats.writeFlushMs += Math.max(0, Date.now() - flushStartedAt); - updateStats(true); + updateStats(true, true); return { ...result, stats: { ...stats } }; } @@ -152,6 +185,10 @@ export async function runAutoScanLoop( } function persistArtifactLater(capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) { + if (persistParsedArtifactsBatch) { + batchedPersistQueue.push({ capture, parsed, source, needsReview }); + return; + } enqueueWrite(`persist:${source}:${parsed.name}`, async () => { if (await persistParsedArtifact(capture, parsed, source, needsReview)) { stats.stored++; @@ -192,9 +229,15 @@ export async function runAutoScanLoop( } async function clickTarget(target: GridTarget, label: string) { - appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`); + const clickNumber = stats.clicked + 1; + const routineLog = clickNumber <= 2 || clickNumber % ROUTINE_CLICK_LOG_INTERVAL === 0 || label !== "click"; + if (routineLog) appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`); + const clickStartedAt = Date.now(); const clickResult = await api.clickScreen(target.x, target.y); - appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`); + stats.clickMs += Math.max(0, Date.now() - clickStartedAt); + if (routineLog || reportedClickDeliveryFailure(clickResult) || clickResult.inputBlocked) { + appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`); + } stats.clicked++; stats.attempted = stats.clicked; updateStats(); @@ -236,7 +279,6 @@ export async function runAutoScanLoop( let lastDetailSignature = ""; let lastDetailViewFingerprint = detailFingerprint(currentCapture); - if (lastDetailViewFingerprint) seenDetailFingerprints.add(lastDetailViewFingerprint); const shouldSkipInitialGridTarget = Boolean(options.processInitialSelection && options.skipInitialGridTarget); let initialProcessedOffset = 0; @@ -247,9 +289,9 @@ export async function runAutoScanLoop( ocrProfile: "fast", ...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}), omitFullFrame: true, + omitDetailPreview: true, omitInventoryPreview: true, omitCropImages: true, - omitEquippedOcr: true, skipOcrUnlessArtifactDetail: true, }); const initialSurfaceRejection = validateAutoScanEntryPreflight(initialCapture); @@ -280,7 +322,7 @@ export async function runAutoScanLoop( saveAutomaticReviewSample(initialCapture, parsed, `automatic:initial-selection-rejected`); stats.verified++; stats.misses++; - addCaptureTiming(stats, initialCapture.timings); + addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs); initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0; lastDetailViewFingerprint = detailFingerprint(initialCapture); updateStats(); @@ -288,7 +330,7 @@ export async function runAutoScanLoop( } else { stats.verified++; stats.parsed++; - addCaptureTiming(stats, initialCapture.timings); + addCaptureTiming(stats, initialCapture.timings, initialCapture.elapsedMs); initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0; const signature = sessionSignature(parsed); seen.add(signature); @@ -376,6 +418,30 @@ export async function runAutoScanLoop( return { ready, capture: latestCapture }; } + async function captureArtifactAfterClick(): Promise<{ capture: CaptureResult | null; fingerprint: string; abortReason: string }> { + const capture = await captureSelectedSource(0, false, { + ocrMode: "artifact", + ocrProfile: "fast", + ...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}), + omitFullFrame: true, + omitDetailPreview: true, + omitInventoryPreview: true, + omitCropImages: true, + skipOcrUnlessArtifactDetail: true, + }); + return { capture, fingerprint: detailFingerprint(capture), abortReason: "" }; + } + + function validateHotArtifactCapture(capture: CaptureResult | null) { + const sourceRejection = captureSourceRejectionReason(capture); + if (sourceRejection) return { ok: false, reason: sourceRejection }; + 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 offen lassen.` }; + } + return { ok: true, reason: "" }; + } + try { while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) { page++; @@ -437,13 +503,15 @@ export async function runAutoScanLoop( appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`); } - let ready = await awaitCardReady(); - if (ready.abortReason) { - blockedReason = ready.abortReason; + let read = await captureArtifactAfterClick(); + if (read.abortReason) { + blockedReason = read.abortReason; aborted = true; break; } - let changedDetail = ready.changed; + let capture = read.capture; + let currentDetailFingerprint = read.fingerprint; + let changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint; if (!changedDetail) { if (options.processInitialSelection && !initialSelectionDuplicateSkipped && !reportedClickDeliveryFailure(clickResult)) { @@ -469,13 +537,15 @@ export async function runAutoScanLoop( if (reportedClickDeliveryFailure(clickResult)) { appendAutomationLog(`warn r${target.row} c${target.col}: retry helper reported cursor/click miss; verifying detail change`); } - ready = await awaitCardReady(); - if (ready.abortReason) { - blockedReason = ready.abortReason; + read = await captureArtifactAfterClick(); + if (read.abortReason) { + blockedReason = read.abortReason; aborted = true; break; } - changedDetail = ready.changed; + capture = read.capture; + currentDetailFingerprint = read.fingerprint; + changedDetail = Boolean(currentDetailFingerprint) && currentDetailFingerprint !== lastDetailViewFingerprint; } if (!changedDetail) { @@ -504,36 +574,28 @@ export async function runAutoScanLoop( stats.verified++; - 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; + let captureSurfaceRejection = validateHotArtifactCapture(capture); + for (let retry = 1; !captureSurfaceRejection.ok && retry <= 2; retry++) { + appendAutomationLog(`retry capture r${target.row} c${target.col}: ${captureSurfaceRejection.reason}`); + await wait(120); + read = await captureArtifactAfterClick(); + if (read.abortReason) { + blockedReason = read.abortReason; + aborted = true; + break; } - seenDetailFingerprints.add(ready.fingerprint); + capture = read.capture; + currentDetailFingerprint = read.fingerprint; + captureSurfaceRejection = validateHotArtifactCapture(capture); } - - 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 (aborted) break; if (!captureSurfaceRejection.ok) { blockedReason = captureSurfaceRejection.reason; appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`); break; } if (capture?.ocrTimedOut) { - addCaptureTiming(stats, capture.timings); + addCaptureTiming(stats, capture.timings, capture.elapsedMs); stats.misses++; consecutiveMisses++; lastDetailViewFingerprint = detailFingerprint(capture); @@ -546,9 +608,11 @@ export async function runAutoScanLoop( continue; } + const parseStartedAt = Date.now(); const parsed = parseArtifact(capture); + stats.parseMs += Math.max(0, Date.now() - parseStartedAt); const rejection = captureRejectionReason(capture, parsed); - addCaptureTiming(stats, capture?.timings); + addCaptureTiming(stats, capture?.timings, capture?.elapsedMs); if (rejection) { saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`); diff --git a/src/lib/lockDetection.test.ts b/src/lib/lockDetection.test.ts index 1066501..5306099 100644 --- a/src/lib/lockDetection.test.ts +++ b/src/lib/lockDetection.test.ts @@ -18,6 +18,18 @@ function bitmap(goldPixels: number, total: number): Bitmap { return { data, width: total, height: 1 }; } +function solidPixels(pixels: Array<{ b: number; g: number; r: number }>): Bitmap { + const data = Buffer.alloc(pixels.length * 4); + pixels.forEach((pixel, index) => { + const offset = index * 4; + data[offset] = pixel.b; + data[offset + 1] = pixel.g; + data[offset + 2] = pixel.r; + data[offset + 3] = 255; + }); + return { data, width: pixels.length, height: 1 }; +} + describe("lockDetection", () => { it("places the lock crop on the lock button in the substat panel", () => { const size = { width: 2560, height: 1440 }; @@ -41,4 +53,13 @@ describe("lockDetection", () => { expect(detectLockState(bitmap(20, 100))).toBe(true); expect(detectLockState(bitmap(1, 100))).toBe(false); }); + + it("counts the current red lock glyph but ignores grey unlocked button pixels", () => { + expect(lockSignalRatio(solidPixels([{ b: 90, g: 92, r: 235 }]))).toBe(1); + expect(lockSignalRatio({ data: Buffer.from([235, 92, 90, 255]), width: 1, height: 1 })).toBe(1); + expect(lockSignalRatio({ data: Buffer.from([255, 235, 92, 90]), width: 1, height: 1 })).toBe(1); + expect(lockSignalRatio(solidPixels([{ b: 235, g: 235, r: 235 }]))).toBe(0); + expect(lockSignalRatio({ data: Buffer.from([255, 235, 235, 235]), width: 1, height: 1 })).toBe(0); + expect(lockSignalRatio(solidPixels([{ b: 120, g: 122, r: 128 }]))).toBe(0); + }); }); diff --git a/src/lib/lockDetection.ts b/src/lib/lockDetection.ts index 279b4ee..4ecf04a 100644 --- a/src/lib/lockDetection.ts +++ b/src/lib/lockDetection.ts @@ -2,15 +2,15 @@ import { clampRect, type LayoutRect } from "./layoutProfile.js"; import type { Bitmap } from "./ocrPreprocess.js"; // EXPERIMENTAL, read-only lock-status detection (nice-to-have). Genshin shows a -// padlock at the top-right of the artifact detail card: a bright gold fill when -// locked, a dim outline when not. This estimates that icon region and measures -// the fraction of bright "lock-gold" pixels; above a threshold the piece is -// considered locked. +// padlock at the top-right of the artifact detail card: a highlighted red/pink +// lock in the current UI when locked, and a dim grey/white button when not. +// Older UI captures may still use gold highlights. This estimates that icon +// region and measures the fraction of active lock-colour pixels; above a +// threshold the piece is considered locked. // -// The crop position and threshold need calibration against a reference 16:9 -// screenshot before this is wired into the capture pipeline, so it ships pure and -// unit-tested but unused by main.ts. It never drives any in-game action - it only -// reads state for triage. +// The crop position and threshold were validated with unlocked=false and +// locked=true live samples on 2026-07-09. It never drives any in-game action - +// it only reads state for triage and export. export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect { return clampRect( @@ -29,14 +29,52 @@ function isLockGold(b: number, g: number, r: number): boolean { return r >= 180 && g >= 140 && b <= 120 && r > b + 40 && g > b + 20; } +// Current Genshin detail lock indicator: pink/red lock glyph and dark button +// when the selected artifact is locked. Unlocked buttons are mostly grey/white. +function isLockRed(b: number, g: number, r: number): boolean { + return r >= 180 && g <= 145 && b <= 145 && r > g + 35 && r > b + 35; +} + +function isActiveLockPixel(c0: number, c1: number, c2: number): boolean { + const colorMatches = (left: number, middle: number, right: number) => + isLockGold(left, middle, right) || + isLockRed(left, middle, right) || + isLockGold(right, middle, left) || + isLockRed(right, middle, left); + return colorMatches(c0, c1, c2); +} + +function inferAlphaChannel(data: Buffer | Uint8Array, pixels: number): number | null { + const highCounts = [0, 0, 0, 0]; + for (let pixel = 0; pixel < pixels; pixel++) { + const index = pixel * 4; + for (let channel = 0; channel < 4; channel++) { + if (data[index + channel] >= 245) highCounts[channel]++; + } + } + const ranked = highCounts + .map((count, channel) => ({ count, channel })) + .sort((left, right) => right.count - left.count); + const best = ranked[0]; + const second = ranked[1]; + if (best.count / pixels < 0.9) return null; + if (second && second.count / pixels > 0.8) return null; + return best.channel; +} + export function lockSignalRatio(bitmap: Bitmap): number { const { data, width, height } = bitmap; const pixels = width * height; if (pixels === 0) return 0; + const alphaChannel = inferAlphaChannel(data, pixels); let gold = 0; for (let pixel = 0; pixel < pixels; pixel++) { const index = pixel * 4; - if (isLockGold(data[index], data[index + 1], data[index + 2])) gold++; + const colorChannels = [0, 1, 2, 3] + .filter((channel) => channel !== alphaChannel) + .map((channel) => data[index + channel]) + .slice(0, 3); + if (colorChannels.length === 3 && isActiveLockPixel(colorChannels[0], colorChannels[1], colorChannels[2])) gold++; } return gold / pixels; } diff --git a/src/lib/scanReviewUtils.test.ts b/src/lib/scanReviewUtils.test.ts new file mode 100644 index 0000000..01b041e --- /dev/null +++ b/src/lib/scanReviewUtils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { CaptureResult } from "../types/global"; +import type { ParsedArtifactCandidate, ParsedField } from "./artifactOcrParser"; +import { getAutoReviewReason } from "./scanReviewUtils"; + +function field(value: string): ParsedField { + return { value, confidence: 95, source: "ocr" }; +} + +describe("getAutoReviewReason", () => { + it("does not require a detail preview image for clean automatic scan captures", () => { + const capture: CaptureResult = { + id: "window:test", + name: "Genshin", + width: 1920, + height: 1080, + dataUrl: "", + capturedAt: new Date(0).toISOString(), + crops: [{ id: "artifact-name", label: "Artifact name", rect: { x: 0, y: 0, width: 10, height: 10 } }], + ocr: [{ id: "artifact-name", label: "Artifact name", text: "Gladiator's Nostalgia", confidence: 95 }], + }; + const parsed: ParsedArtifactCandidate = { + name: "Gladiator's Nostalgia", + slot: "Flower of Life", + level: 20, + mainStat: "HP", + mainValue: "4780", + substats: ["CRIT Rate", "CRIT DMG", "Energy Recharge", "ATK%"], + setName: "Gladiator's Finale", + equipped: "", + confidence: 95, + notes: [], + fields: { + name: field("Gladiator's Nostalgia"), + slot: field("Flower of Life"), + level: field("20"), + mainStat: field("HP"), + mainValue: field("4780"), + setName: field("Gladiator's Finale"), + equipped: field(""), + substats: field("CRIT Rate, CRIT DMG, Energy Recharge, ATK%"), + }, + }; + + expect(getAutoReviewReason(capture, parsed)).toBe(""); + }); +}); diff --git a/src/lib/scanReviewUtils.ts b/src/lib/scanReviewUtils.ts index b91124c..d489cf6 100644 --- a/src/lib/scanReviewUtils.ts +++ b/src/lib/scanReviewUtils.ts @@ -8,7 +8,7 @@ export interface ReviewReasonInput { } export function getAutoReviewReason(capture: ReviewReasonInput["capture"], parsed: ReviewReasonInput["parsed"]) { - if (!capture.detailDataUrl || !capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr"; + if (!capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr"; if (!shouldSaveReviewSample(parsed)) return ""; const lowFields = Object.entries(parsed.fields) .filter(([, field]) => field.confidence < 70) diff --git a/src/lib/scannerLearning.test.ts b/src/lib/scannerLearning.test.ts index e3005c3..41e7893 100644 --- a/src/lib/scannerLearning.test.ts +++ b/src/lib/scannerLearning.test.ts @@ -23,6 +23,25 @@ describe("scannerLearning", () => { expect(learned?.ocr?.[0]?.text).toContain("Energy Recharge+6.5%"); }); + it("applies field aliases and constrained fixes before parsing", () => { + const learned = applyScannerLearningRules(capture("Equipped; Bennet\nAubade of Morningstar and Moor"), { + fieldAliases: { + equipped: { + Bennet: "Bennett", + }, + setName: { + Moor: "Moon", + }, + }, + constrainedFixes: { + "Equipped;": "Equipped:", + }, + }); + + expect(learned?.ocr?.[0]?.text).toContain("Equipped: Bennett"); + expect(learned?.ocr?.[0]?.text).toContain("Aubade of Morningstar and Moon"); + }); + it("marks low confidence or noted parses for review", () => { expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 95 } } })).toBe(false); expect(shouldSaveReviewSample({ confidence: 96, notes: ["Artifact name was fuzzy-matched"], fields: { name: { confidence: 95 } } })).toBe(false); @@ -100,7 +119,13 @@ describe("scannerLearning", () => { }); it("counts learned rules", () => { - expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2); + expect(countScannerLearningRules({ + textReplacements: { one: "1", two: "2" }, + fieldAliases: { equipped: { Bennet: "Bennett" } }, + cropAdjustments: { "artifact-footer": { dy: -2, approved: false } }, + uiProfileAdjustments: { "1080p-footer": { fieldId: "artifact-footer", dy: -2, approved: false } }, + constrainedFixes: { "Moor": "Moon" }, + })).toBe(6); }); it("does not flag DB review when only non-critical fields are weak", () => { diff --git a/src/lib/scannerLearning.ts b/src/lib/scannerLearning.ts index 4c185f8..a6f2308 100644 --- a/src/lib/scannerLearning.ts +++ b/src/lib/scannerLearning.ts @@ -15,21 +15,35 @@ export const DEFAULT_SCANNER_LEARNING_RULES: ScannerLearningRules = { "Elemental Masterv": "Elemental Mastery", "Equipped;": "Equipped:", }, + fieldAliases: {}, + constrainedFixes: {}, + cropAdjustments: {}, + uiProfileAdjustments: {}, }; export function mergeScannerLearningRules(...rules: Array | null | undefined>): ScannerLearningRules { return rules.reduce( (merged, rule) => ({ textReplacements: { ...merged.textReplacements, ...(rule?.textReplacements ?? {}) }, + fieldAliases: deepMergeRecord(merged.fieldAliases, rule?.fieldAliases), + constrainedFixes: { ...merged.constrainedFixes, ...(rule?.constrainedFixes ?? {}) }, + cropAdjustments: { ...merged.cropAdjustments, ...(rule?.cropAdjustments ?? {}) }, + uiProfileAdjustments: { ...merged.uiProfileAdjustments, ...(rule?.uiProfileAdjustments ?? {}) }, }), - { textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements } }, + { + textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements }, + fieldAliases: {}, + constrainedFixes: {}, + cropAdjustments: {}, + uiProfileAdjustments: {}, + }, ); } export function applyScannerLearningRules(capture: CaptureResult | null, rules?: Partial | null) { if (!capture?.ocr?.length) return capture; const merged = mergeScannerLearningRules(rules); - const replacements = Object.entries(merged.textReplacements ?? {}).filter(([from]) => from.length > 0); + const replacements = replacementEntriesFromLearningRules(merged); if (replacements.length === 0) return capture; return { @@ -90,7 +104,14 @@ function reviewRelevantConfidence(fields: Record } export function countScannerLearningRules(rules?: Partial | null) { - return Object.keys(rules?.textReplacements ?? {}).length; + const fieldAliasCount = Object.values(rules?.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0); + return ( + Object.keys(rules?.textReplacements ?? {}).length + + fieldAliasCount + + Object.keys(rules?.constrainedFixes ?? {}).length + + Object.keys(rules?.cropAdjustments ?? {}).length + + Object.keys(rules?.uiProfileAdjustments ?? {}).length + ); } export function deriveScannerLearningRules( @@ -149,6 +170,36 @@ function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate) } } +function replacementEntriesFromLearningRules(rules: ScannerLearningRules) { + const entries = new Map(); + for (const [from, to] of Object.entries(rules.textReplacements ?? {})) { + if (from.length > 0) entries.set(from, to); + } + for (const aliases of Object.values(rules.fieldAliases ?? {})) { + for (const [from, to] of Object.entries(aliases ?? {})) { + if (from.length > 0) entries.set(from, to); + } + } + for (const [from, to] of Object.entries(rules.constrainedFixes ?? {})) { + if (from.length > 0) entries.set(from, to); + } + return [...entries.entries()]; +} + +function deepMergeRecord( + left: Record> | undefined, + right: Record> | undefined, +) { + const merged: Record> = {}; + for (const [field, aliases] of Object.entries(left ?? {})) { + merged[field] = { ...(aliases ?? {}) }; + } + for (const [field, aliases] of Object.entries(right ?? {})) { + merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) }; + } + return merged; +} + function deriveReplacementPairs(rawText: string, expected: string) { if (!expected || expected.startsWith("Unknown")) return []; const normalizedExpected = normalizeLearningText(expected); diff --git a/src/lib/scannerSession.test.ts b/src/lib/scannerSession.test.ts index 2f357e1..06a548f 100644 --- a/src/lib/scannerSession.test.ts +++ b/src/lib/scannerSession.test.ts @@ -21,8 +21,8 @@ describe("scannerSession helpers", () => { 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 }); + addCaptureTiming(stats, { totalMs: 1200, ocrMs: 900 }, 1500); + addCaptureTiming(stats, { totalMs: 800, ocrMs: 500 }, 1000); updateScanTiming(stats, 1000, 9000); expect(stats.elapsedMs).toBe(8000); expect(stats.activeScanMs).toBe(8000); @@ -33,6 +33,10 @@ describe("scannerSession helpers", () => { expect(stats.projectedMsFor100).toBe(200000); expect(stats.activeProjectedMsFor100).toBe(200000); expect(stats.captureMs).toBe(2000); + expect(stats.captureRoundTripMs).toBe(2500); + expect(stats.averageCaptureRoundTripMs).toBe(1250); + expect(stats.captureRoundTripOverheadMs).toBe(500); + expect(stats.averageCaptureRoundTripOverheadMs).toBe(250); expect(stats.ocrMs).toBe(1400); expect(stats.averageCaptureMs).toBe(1000); expect(stats.averageOcrMs).toBe(700); diff --git a/src/lib/scannerSession.ts b/src/lib/scannerSession.ts index 94f0fd5..4e9cc2b 100644 --- a/src/lib/scannerSession.ts +++ b/src/lib/scannerSession.ts @@ -18,9 +18,19 @@ export type AutoScanStats = { projectedMsFor100: number; activeProjectedMsFor100: number; captureMs: number; + captureRoundTripMs: number; + captureRoundTripOverheadMs: number; ocrMs: number; averageCaptureMs: number; + averageCaptureRoundTripMs: number; + averageCaptureRoundTripOverheadMs: number; averageOcrMs: number; + clickMs: number; + averageClickMs: number; + parseMs: number; + averageParseMs: number; + loopOverheadMs: number; + averageLoopOverheadMs: number; captureP50Ms: number; captureP90Ms: number; ocrP50Ms: number; @@ -60,9 +70,19 @@ export const emptyAutoScanStats: AutoScanStats = { projectedMsFor100: 0, activeProjectedMsFor100: 0, captureMs: 0, + captureRoundTripMs: 0, + captureRoundTripOverheadMs: 0, ocrMs: 0, averageCaptureMs: 0, + averageCaptureRoundTripMs: 0, + averageCaptureRoundTripOverheadMs: 0, averageOcrMs: 0, + clickMs: 0, + averageClickMs: 0, + parseMs: 0, + averageParseMs: 0, + loopOverheadMs: 0, + averageLoopOverheadMs: 0, captureP50Ms: 0, captureP90Ms: 0, ocrP50Ms: 0, @@ -101,7 +121,14 @@ export function updateScanTiming( 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.averageCaptureRoundTripMs = stats.verified > 0 ? Math.round(stats.captureRoundTripMs / stats.verified) : 0; + stats.captureRoundTripOverheadMs = Math.max(0, stats.captureRoundTripMs - stats.captureMs); + stats.averageCaptureRoundTripOverheadMs = stats.verified > 0 ? Math.round(stats.captureRoundTripOverheadMs / stats.verified) : 0; stats.averageOcrMs = stats.verified > 0 ? Math.round(stats.ocrMs / stats.verified) : 0; + stats.averageClickMs = stats.clicked > 0 ? Math.round(stats.clickMs / stats.clicked) : 0; + stats.averageParseMs = stats.parsed > 0 ? Math.round(stats.parseMs / stats.parsed) : 0; + stats.loopOverheadMs = Math.max(0, stats.activeScanMs - stats.captureMs - stats.clickMs - stats.parseMs - stats.cardReadyMs - stats.scrollReadyMs); + stats.averageLoopOverheadMs = stats.parsed > 0 ? Math.round(stats.loopOverheadMs / stats.parsed) : 0; const samples = timingSamples.get(stats); stats.captureP50Ms = percentile(samples?.captureMs, 50); stats.captureP90Ms = percentile(samples?.captureMs, 90); @@ -115,11 +142,15 @@ export function updateScanTiming( export function addCaptureTiming( stats: AutoScanStats, timing?: { totalMs?: number; ocrMs?: number } | null, + elapsedMs?: 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; + if (typeof elapsedMs === "number" && Number.isFinite(elapsedMs)) { + stats.captureRoundTripMs += Math.max(0, Math.round(elapsedMs)); + } stats.ocrMs += ocrMs; const samples = timingSamples.get(stats) ?? { captureMs: [], ocrMs: [] }; samples.captureMs.push(captureMs); diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..b2756f9 --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,2471 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #090711; + color: #f4efff; + --surface: rgba(24, 18, 43, 0.68); + --surface-soft: rgba(18, 12, 35, 0.58); + --line: rgba(188, 154, 255, 0.18); + --line-strong: rgba(210, 184, 255, 0.32); + --text-muted: #b6aeca; + --text-soft: #8f86a8; + --cyan: #7ee7f2; + --gold: #f0c878; + --danger: #ff8c9d; + --button-hover-shift: -0.6px; + --button-hover-scale: 1.005; + --button-hover-shadow: 0 3px 9px rgba(184, 140, 255, 0.14); + --button-hover-brightness: 1.025; + --glass-shadow: 0 24px 70px rgba(0, 0, 0, 0.36); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + min-width: 1100px; + min-height: 720px; + background: + radial-gradient(1200px 760px at 18% -12%, rgba(102, 73, 176, 0.28), transparent 58%), + linear-gradient(145deg, #080610 0%, #130b24 42%, #080811 100%); +} + +button { + font: inherit; + position: relative; + overflow: hidden; + cursor: pointer; + transform: translateY(0); + will-change: transform; + transition: + transform 140ms ease, + box-shadow 140ms ease, + filter 140ms ease, + border-color 140ms ease, + background 140ms ease; + border-color: rgba(184, 140, 255, 0.28); +} + +button:enabled svg, +[role="button"]:enabled svg { + transition: transform 140ms ease; +} + +[role="button"], +button, +button.clickable, +button[data-clickable="true"] { + cursor: pointer; +} + +button:hover:not(:disabled):not([aria-disabled="true"]), +button:focus-visible:not(:disabled):not([aria-disabled="true"]), +[role="button"]:hover:not(:disabled):not([aria-disabled="true"]), +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), +button.clickable:hover:not(:disabled):not([aria-disabled="true"]), +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(var(--button-hover-brightness)) saturate(1.02); + box-shadow: var(--button-hover-shadow); + border-color: rgba(214, 183, 255, 0.58); + background-color: rgba(255, 255, 255, 0.03); +} + +button:hover:not(:disabled):not([aria-disabled="true"])::before, +button:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:hover:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before { + opacity: 0.35; + transform: scaleX(1); +} + +button:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + bottom: 8px; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(214, 183, 255, 0.38), transparent); + opacity: 0; + transform: scaleX(0); + transform-origin: center; + transition: + transform 160ms ease, + opacity 160ms ease; + pointer-events: none; +} + +.scan-cta:hover:not(:disabled):not([aria-disabled="true"]), +.scan-cta:focus-visible:not(:disabled):not([aria-disabled="true"]), +.review-button:hover:not(:disabled):not([aria-disabled="true"]), +.review-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(var(--button-hover-brightness)); + box-shadow: var(--button-hover-shadow); +} + +button:hover:not(:disabled):not([aria-disabled="true"]) svg, +button:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"]) svg, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +button.clickable:hover:not(:disabled):not([aria-disabled="true"]) svg, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]) svg, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg { + transform: translateX(0.6px); +} + +button:active:not(:disabled):not([aria-disabled="true"]), +[role="button"]:active:not(:disabled):not([aria-disabled="true"]), +button.clickable:active:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:active:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(0px) scale(0.999); + filter: brightness(0.985); + box-shadow: 0 2px 4px rgba(184, 140, 255, 0.12); +} + +button:hover:not(:disabled):not([aria-disabled="true"])::after, +button:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:hover:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after { + opacity: 1; + transform: translateX(120%); +} + +button:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-120%); + background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.12), transparent); + opacity: 0; + pointer-events: none; + transition: + transform 260ms ease, + opacity 260ms ease; +} + +button:focus-visible:not(:disabled):not([aria-disabled="true"]), +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { + outline: 0; + border-color: rgba(214, 183, 255, 0.6); +} + +.ghost-button, +.primary-button, +.scan-cta, +.stop-button, +.review-button, +.nav-item, +.mode-option { + transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.app-shell { + display: grid; + grid-template-columns: 260px 1fr; + height: 100vh; + overflow: hidden; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%), + linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--line); + background: rgba(13, 9, 25, 0.72); + box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(22px); + padding: 24px 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 1px solid rgba(216, 190, 255, 0.32); + border-radius: 8px; + background: + linear-gradient(145deg, rgba(222, 197, 255, 0.18), rgba(126, 231, 242, 0.08)), + rgba(23, 16, 43, 0.82); + color: #f5eaff; + box-shadow: 0 16px 38px rgba(68, 43, 142, 0.32); + font-weight: 800; +} + +.brand-title { + font-size: 15px; + font-weight: 800; +} + +.brand-subtitle, +.muted { + color: var(--text-soft); + font-size: 12px; +} + +.nav-list { + display: grid; + gap: 8px; +} + +.nav-item, +.ghost-button, +.primary-button, +.mode-option { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid transparent; + border-radius: 8px; + color: #eee8ff; + cursor: pointer; +} + +.nav-item { + width: 100%; + justify-content: flex-start; + padding: 11px 12px; + background: transparent; +} + +.nav-item:hover:not(:disabled), +.nav-item.active { + border-color: rgba(214, 183, 255, 0.28); + background: linear-gradient(135deg, rgba(184, 140, 255, 0.18), rgba(126, 231, 242, 0.05)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.nav-item:hover:not(:disabled), +.nav-item:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(1.002); +} + +.nav-item:disabled { + opacity: 0.45; + cursor: not-allowed; + color: #7f8ca0; +} + +.safety-card { + display: flex; + gap: 10px; + margin-top: auto; + border: 1px solid rgba(126, 231, 242, 0.24); + border-radius: 8px; + background: rgba(22, 28, 48, 0.62); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(18px); + padding: 14px; + color: var(--cyan); +} + +.safety-card span { + display: block; + margin-top: 4px; + color: #9eb6c3; + font-size: 12px; + line-height: 1.4; +} + +.main-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + min-height: 0; + min-width: 0; + overflow: hidden; + padding: 18px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.topbar h1 { + max-width: 780px; + margin: 2px 0 0; + color: #fbf8ff; + font-size: 22px; + letter-spacing: 0; + text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25); +} + +.eyebrow { + margin: 0; + color: var(--cyan); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.topbar-status { + max-width: 320px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ghost-button, +.primary-button { + height: 36px; + padding: 0 12px; +} + +.ghost-button { + border-color: var(--line); + background: rgba(28, 20, 51, 0.66); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + backdrop-filter: blur(14px); +} + +.ghost-button:hover:not(:disabled), +.ghost-button:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + border-color: rgba(214, 183, 255, 0.45); + box-shadow: + 0 2px 6px rgba(137, 91, 255, 0.09), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + background: rgba(56, 38, 103, 0.84); +} + +.ghost-button.success { + border-color: rgba(126, 242, 207, 0.54); + background: rgba(29, 88, 79, 0.42); + color: #bfffee; + box-shadow: + 0 10px 28px rgba(53, 220, 187, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.primary-button { + border-color: rgba(216, 183, 255, 0.62); + background: linear-gradient(135deg, #d9c0ff 0%, #a983ff 48%, #7ee7f2 100%); + box-shadow: + 0 16px 42px rgba(137, 91, 255, 0.34), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + color: #10091d; + font-weight: 800; +} + +.primary-button:hover:not(:disabled), +.primary-button:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(1.05); + box-shadow: + 0 3px 8px rgba(137, 91, 255, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.52); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 24px 0; +} + +.metric, +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + var(--surface); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(22px); +} + +.metric { + position: relative; + overflow: hidden; + padding: 16px; +} + +.metric::before { + content: ""; + position: absolute; + inset: 0; + border-top: 1px solid rgba(255, 255, 255, 0.12); + pointer-events: none; +} + +.metric span, +.metric small { + display: block; + color: var(--text-soft); + font-size: 12px; +} + +.metric strong { + display: block; + margin: 8px 0 4px; + color: #ffffff; + font-size: 28px; +} + +.content-grid { + display: grid; + gap: 14px; + min-height: 0; + overflow: hidden; +} + +.scan-layout { + grid-template-columns: 1.3fr 1fr; +} + +.panel { + min-height: 0; + overflow: hidden; + padding: 18px; +} + +.panel.wide { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 3px 0 0; + font-size: 18px; +} + +.scan-badge { + border: 1px solid var(--line-strong); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + padding: 6px 10px; + color: var(--text-muted); + font-size: 12px; +} + +.scan-badge.live { + border-color: var(--cyan); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); + color: var(--cyan); +} + +.timeline { + display: grid; + gap: 14px; +} + +.timeline-item { + display: grid; + grid-template-columns: 18px 1fr; + gap: 12px; +} + +.timeline-dot { + width: 10px; + height: 10px; + margin-top: 5px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 22px rgba(126, 231, 242, 0.72); +} + +.timeline-item p { + margin: 4px 0; + color: var(--text-muted); +} + +.timeline-item span { + color: var(--text-soft); + font-size: 12px; +} + +.mode-list { + display: grid; + gap: 10px; +} + +.mode-option { + align-items: flex-start; + flex-direction: column; + padding: 13px; + background: rgba(21, 15, 40, 0.66); + text-align: left; +} + +.mode-option:hover:not(.selected):not(:disabled), +.mode-option:focus-visible:not(.selected):not(:disabled) { + border-color: rgba(188, 154, 255, 0.32); + transform: translateY(var(--button-hover-shift)) scale(1.002); +} + +.mode-option.selected { + border-color: rgba(126, 231, 242, 0.5); + background: linear-gradient(135deg, rgba(126, 231, 242, 0.16), rgba(184, 140, 255, 0.16)); +} + +.mode-option span { + color: var(--text-muted); + font-size: 13px; + line-height: 1.4; +} + +.character-row, +.build-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} + +.character-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + padding: 14px; +} + +.character-card span, +.character-card small { + display: block; + margin-top: 6px; + color: var(--text-soft); +} + +.artifact-table { + display: grid; + gap: 8px; +} + +.artifact-row { + display: grid; + grid-template-columns: 230px 1fr 160px 120px; + gap: 12px; + align-items: center; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(13, 9, 26, 0.56); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 12px; +} + +.artifact-row span, +.reason span { + display: block; + color: var(--text-soft); + font-size: 12px; +} + +.substats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.substats span { + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 999px; + background: rgba(37, 27, 66, 0.68); + padding: 5px 8px; +} + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 30px; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 800; +} + +.keep, +.specific { + border: 1px solid rgba(157, 240, 212, 0.22); + background: rgba(53, 205, 156, 0.14); + color: #9df0d4; +} + +.maybe { + border: 1px solid rgba(240, 200, 120, 0.24); + background: rgba(240, 200, 120, 0.14); + color: var(--gold); +} + +.trash { + border: 1px solid rgba(255, 140, 157, 0.22); + background: rgba(255, 140, 157, 0.12); + color: var(--danger); +} + +.review { + border: 1px solid rgba(255, 184, 112, 0.24); + background: rgba(255, 184, 112, 0.13); + color: #ffbf82; +} + +.score { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border: 1px solid rgba(214, 183, 255, 0.42); + border-radius: 8px; + background: linear-gradient(145deg, rgba(184, 140, 255, 0.22), rgba(126, 231, 242, 0.08)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); + color: #f1e7ff; +} + +.build-card { + min-height: 330px; +} + +.build-copy, +.overlay-settings p, +.overlay-card p { + color: var(--text-muted); + line-height: 1.5; +} + +.build-pieces { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.build-pieces div { + display: grid; + grid-template-columns: 72px 1fr; + gap: 4px 8px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.46); + padding: 9px; +} + +.build-pieces small { + grid-column: 2; + color: var(--text-soft); +} + +.warning-list { + display: grid; + gap: 6px; + margin-top: 14px; +} + +.warning-list span { + display: flex; + align-items: center; + gap: 6px; + color: #ffbf82; + font-size: 13px; +} + +.overlay-settings { + display: grid; + max-width: 720px; + gap: 14px; +} + +.overlay-settings-copy { + display: grid; + gap: 4px; + border: 1px solid rgba(126, 231, 242, 0.16); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 12px; +} + +.overlay-settings-copy strong { + color: var(--text); +} + +.overlay-settings-copy span { + color: var(--muted); + font-size: 13px; +} + +.overlay-root { + display: flex; + justify-content: flex-end; + align-items: flex-start; + min-height: 100vh; + padding: 80px 48px; + background: transparent; +} + +.overlay-card { + width: 360px; + border: 1px solid rgba(214, 183, 255, 0.34); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), + rgba(17, 10, 34, 0.82); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.45); + padding: 18px; + backdrop-filter: blur(12px); +} + +.overlay-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.overlay-card-head h2 { + margin: 2px 0 0; + font-size: 19px; +} + +.overlay-card-head > strong { + display: grid; + min-width: 50px; + height: 50px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.34); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); + font-size: 18px; +} + +.overlay-artifact-mini, +.overlay-character-list { + display: grid; + gap: 4px; + margin-top: 12px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.38); + padding: 10px; +} + +.overlay-artifact-mini span, +.overlay-artifact-mini small, +.overlay-character-list span, +.overlay-card p { + color: var(--muted); + font-size: 12px; +} + +.overlay-artifact-mini strong { + color: var(--text); +} + +.overlay-character-list { + display: flex; + flex-wrap: wrap; +} + +.overlay-character-list span { + border: 1px solid rgba(126, 231, 242, 0.22); + border-radius: 999px; + background: rgba(126, 231, 242, 0.08); + padding: 5px 8px; +} + +@media (max-width: 1180px) { + .app-shell { + grid-template-columns: 220px 1fr; + } + + .artifact-row { + grid-template-columns: 1fr; + } +} +.capture-controls { + display: grid; + gap: 12px; +} + +.capture-controls select { + width: 100%; + min-height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 12px; + outline: none; +} + +.capture-controls select:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.capture-actions { + display: flex; + gap: 10px; +} + +.capture-controls p { + margin: 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.45; +} + +.capture-preview { + display: grid; + min-height: 158px; + place-items: center; + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(10, 7, 19, 0.64); +} + +.capture-preview img { + display: block; + width: 100%; + height: 100%; + max-height: 260px; + object-fit: contain; +} + +.capture-preview span { + color: var(--text-soft); + font-size: 13px; +} + +.bridge-status { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 800; +} + +.bridge-status.connected { + border: 1px solid rgba(157, 240, 212, 0.24); + background: rgba(53, 205, 156, 0.14); + color: #9df0d4; +} + +.bridge-status.missing { + border: 1px solid rgba(255, 140, 157, 0.24); + background: rgba(255, 140, 157, 0.12); + color: var(--danger); +} + +.crop-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.crop-card { + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.52); +} + +.crop-card img { + display: block; + width: 100%; + height: 82px; + object-fit: contain; + background: rgba(4, 3, 10, 0.74); + border-bottom: 1px solid rgba(188, 154, 255, 0.12); +} + +.crop-card div { + padding: 8px; +} + +.crop-card strong, +.crop-card span { + display: block; +} + +.crop-card strong { + font-size: 12px; +} + +.crop-card span { + margin-top: 3px; + color: var(--text-soft); + font-size: 11px; +} + +.capture-hint { + border-left: 2px solid rgba(240, 200, 120, 0.42); + padding-left: 10px; + color: #f0c878 !important; +} + +.ocr-panel { + display: grid; + gap: 8px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(8, 6, 18, 0.56); + padding: 12px; +} + +.ocr-row { + display: grid; + grid-template-columns: 132px 1fr; + gap: 10px; + border-top: 1px solid rgba(188, 154, 255, 0.12); + padding-top: 8px; +} + +.ocr-row span, +.ocr-row small { + display: block; +} + +.ocr-row span { + color: #f4efff; + font-size: 12px; + font-weight: 800; +} + +.ocr-row small { + margin-top: 3px; + color: var(--text-soft); + font-size: 11px; +} + +.ocr-row pre { + margin: 0; + white-space: pre-wrap; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + line-height: 1.35; +} + +.capture-debug { + color: var(--text-soft) !important; + font-size: 12px !important; +} + +.parsed-panel { + display: grid; + gap: 10px; + border: 1px solid rgba(126, 231, 242, 0.24); + border-radius: 8px; + background: rgba(126, 231, 242, 0.08); + padding: 12px; +} + +.parsed-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.parsed-heading span { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.parsed-grid { + display: grid; + grid-template-columns: 92px 1fr; + gap: 7px 12px; +} + +.parsed-grid span { + color: var(--text-soft); + font-size: 12px; +} + +.parsed-grid strong { + color: #f4efff; + font-size: 12px; +} + +.parsed-notes { + display: grid; + gap: 4px; + border-top: 1px solid rgba(188, 154, 255, 0.14); + padding-top: 8px; +} + +.parsed-notes span { + color: #f0c878; + font-size: 12px; +} + +.scanner-workbench { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 10px; + min-height: 0; + height: 100%; + width: 100%; + justify-self: stretch; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.018)), + rgba(18, 12, 35, 0.7); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(22px); + padding: 14px; +} + +.scanner-header, +.scanner-toolbar, +.scanner-status-row, +.result-heading, +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.scanner-header h2, +.modal-header h2 { + margin: 3px 0 0; + font-size: 20px; +} + +.scanner-subcopy { + margin: 8px 0 0; + max-width: 620px; + color: var(--text-soft); + font-size: 13px; + line-height: 1.5; +} + +.scanner-header-pills { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.scanner-toolbar { + align-items: flex-end; + display: grid; + grid-template-columns: minmax(320px, 1fr) auto; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.42); + padding: 12px; +} + +.source-select { + display: grid; + min-width: 0; + gap: 4px; +} + +.source-select span { + color: var(--text-soft); + font-size: 12px; + font-weight: 800; +} + +.source-select select { + width: 100%; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 12px; + outline: none; +} + +.source-select select:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.scanner-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.scanner-status-row { + align-items: flex-start; + color: var(--text-muted); + font-size: 12px; +} + +.scanner-status-row span:first-child { + color: var(--cyan); + font-weight: 800; +} + +.bridge-banner { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(255, 140, 157, 0.3); + border-radius: 10px; + background: rgba(255, 140, 157, 0.08); + color: var(--danger); + font-size: 13px; + font-weight: 700; + padding: 10px 12px; +} + +.dev-toggle { + opacity: 0.65; +} + +.dev-toggle.active { + opacity: 1; + border-color: rgba(126, 231, 242, 0.5); + color: var(--cyan); +} + +.player-scan-card { + display: grid; + gap: 8px; + min-height: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.6); + padding: 10px; +} + +.player-scan-row { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 8px; +} + +.player-scan-row .source-select { + min-width: 240px; + flex: 1; +} + +.mini-config { + display: grid; + gap: 6px; + width: 150px; +} + +.mini-config span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.mini-config input { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 10px; + outline: none; +} + +.mini-config input:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.player-scan-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.player-scan-lower { + display: grid; + grid-template-columns: minmax(360px, auto) minmax(360px, 1fr); + gap: 10px; + align-items: center; +} + +.learning-strip { + display: grid; + gap: 8px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 10px 12px; +} + +.learning-strip span { + display: grid; + gap: 3px; + min-width: 0; + color: var(--text-soft); + font-size: 11px; + font-weight: 700; +} + +.learning-strip strong { + color: #f4efff; + font-size: 13px; +} + +.learning-strip { + grid-template-columns: auto auto 1fr; + align-items: center; + border-color: rgba(126, 231, 242, 0.18); + background: rgba(126, 231, 242, 0.06); +} + +.learning-strip small { + color: var(--text-muted); + font-size: 11px; +} + +.runtime-pill { + min-height: 30px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + border: 1px solid rgba(188, 154, 255, 0.22); + background: rgba(15, 10, 29, 0.55); + color: var(--text-soft); + font-size: 12px; + font-weight: 900; +} + +.runtime-pill.elevated { + border-color: rgba(109, 244, 197, 0.36); + background: rgba(33, 214, 155, 0.13); + color: var(--mint); +} + +.runtime-pill.standard { + border-color: rgba(255, 207, 109, 0.3); + background: rgba(255, 207, 109, 0.11); + color: var(--warning); +} + +.scan-cta { + min-height: 36px; + padding: 0 16px; + font-size: 14px; +} + +.stop-button { + min-height: 36px; + padding: 0 18px; + border: 1px solid rgba(255, 140, 157, 0.5); + border-radius: 10px; + background: rgba(255, 140, 157, 0.14); + color: var(--danger); + font-size: 14px; + font-weight: 800; + cursor: pointer; +} + +.stop-button:hover:not(:disabled):not([aria-disabled="true"]), +.stop-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + box-shadow: 0 3px 8px rgba(255, 140, 157, 0.12); +} + +.player-status { + margin: 0; + color: var(--text-soft); + font-size: 12px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scanner-preflight { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.scanner-preflight div { + min-height: 52px; + display: grid; + align-content: center; + gap: 3px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.34); + padding: 9px 11px; +} + +.scanner-preflight span { + color: var(--text-muted); + font-size: 11px; + font-weight: 800; +} + +.scanner-preflight strong { + color: var(--text); + font-size: 13px; +} + +.scanner-preflight .ok { + border-color: rgba(126, 242, 207, 0.28); + background: rgba(33, 214, 155, 0.09); +} + +.scanner-preflight .ok strong { + color: var(--mint); +} + +.scanner-preflight .blocked { + border-color: rgba(255, 207, 109, 0.3); + background: rgba(255, 207, 109, 0.1); +} + +.scanner-preflight .blocked strong { + color: var(--warning); +} + +.player-progress { + display: grid; + gap: 7px; + min-width: 0; +} + +.player-progress-bar { + height: 7px; + border-radius: 999px; + background: rgba(188, 154, 255, 0.12); + overflow: hidden; +} + +.player-progress-bar div { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(126, 231, 242, 0.85), rgba(188, 154, 255, 0.9)); + transition: width 0.35s ease; +} + +.player-progress-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--text-muted); + 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 { + color: #f4efff; +} + +.player-progress-stats .collection { + margin-left: auto; +} + +.dev-section { + display: grid; + gap: 10px; + border: 1px dashed rgba(126, 231, 242, 0.25); + border-radius: 12px; + padding: 12px; +} + +.dev-section-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.scanner-diagnostics-modal { + width: min(1040px, 94vw); +} + +.scanner-settings-modal { + width: min(820px, 92vw); +} + +.diagnostics-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.diagnostics-preflight { + 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 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 10px 12px; +} + +.artifact-result-card { + display: grid; + gap: 10px; +} + +.artifact-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.artifact-result-head .artifact-set { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.quality-chip { + border-radius: 999px; + font-size: 11px; + font-weight: 800; + padding: 4px 10px; + white-space: nowrap; +} + +.quality-chip.good { + background: rgba(157, 240, 212, 0.14); + color: #9df0d4; +} + +.quality-chip.mid { + background: rgba(255, 214, 140, 0.14); + color: #ffd68c; +} + +.quality-chip.low { + background: rgba(255, 140, 157, 0.14); + color: var(--danger); +} + +.artifact-slot { + color: var(--text-muted); + font-size: 12px; +} + +.artifact-mainstat { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 10px; + background: rgba(8, 6, 18, 0.42); + padding: 10px 12px; +} + +.artifact-mainstat span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.artifact-mainstat strong { + color: #f4efff; + font-size: 15px; +} + +.artifact-mainstat em { + margin-left: auto; + color: var(--cyan); + font-size: 18px; + font-style: normal; + font-weight: 800; +} + +.artifact-substats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.artifact-substats span { + border: 1px solid rgba(188, 154, 255, 0.2); + border-radius: 999px; + background: rgba(188, 154, 255, 0.08); + color: #e8defc; + font-size: 12px; + padding: 4px 10px; +} + +.artifact-substats span.none { + border-style: dashed; + color: var(--text-muted); +} + +.artifact-equipped { + color: var(--text-muted); + font-size: 12px; +} + +.scan-summary-dev { + margin: 0; + color: var(--text-muted); + font-size: 11px; +} + +.auto-scan-strip { + display: grid; + grid-template-columns: 1fr repeat(8, auto auto); + gap: 6px 8px; + align-items: center; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.07); + padding: 10px 12px; +} + +.auto-scan-strip span { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.auto-scan-strip strong { + color: #f4efff; + font-size: 13px; +} + +.auto-scan-strip small { + color: var(--text-soft); + font-size: 11px; +} + +.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; + gap: 10px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 12px; +} + +.settings-stepper-head { + min-height: 42px; +} + +.settings-stepper-head div { + display: grid; + gap: 4px; +} + +.settings-stepper-head span, +.scan-settings-note strong { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.settings-stepper-head small, +.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-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 12px; + font-size: 18px; + font-weight: 800; + text-align: center; + outline: none; +} + +.settings-stepper-row input:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.settings-stepper-row input::-webkit-outer-spin-button, +.settings-stepper-row input::-webkit-inner-spin-button { + margin: 0; + 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; +} + +.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 { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + align-items: center; + border: 1px solid rgba(157, 240, 212, 0.2); + border-radius: 8px; + background: rgba(157, 240, 212, 0.07); + padding: 9px 12px; + box-shadow: 0 0 0 1px rgba(126, 231, 242, 0.035) inset; +} + +.grid-detection-strip span { + color: var(--text-soft); + font-size: 12px; + font-weight: 800; +} + +.grid-detection-strip strong { + color: #9df0d4; + font-size: 13px; +} + +.grid-detection-strip small { + color: var(--text-muted); + font-size: 11px; +} + +.grid-detection-strip.missing { + border-color: rgba(255, 140, 157, 0.24); + background: rgba(255, 140, 157, 0.06); +} + +.grid-detection-strip.missing strong { + color: var(--danger); +} + +.automation-log { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; + border: 1px solid rgba(126, 231, 242, 0.2); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 8px 12px; +} + +.automation-log-lines { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 118px; + overflow-y: auto; +} + +.automation-log span { + color: var(--cyan); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.automation-log strong { + color: #f4efff; + font-size: 12px; + 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 { + display: grid; + grid-template-columns: minmax(190px, 210px) minmax(520px, 1fr); + gap: 12px; + align-items: start; +} + +.capture-stage-shell { + display: grid; + gap: 8px; + min-width: 0; +} + +.capture-stage { + display: grid; + width: 100%; + aspect-ratio: 41 / 80; + min-height: 0; + max-height: 410px; + place-items: center; + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + 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 { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center top; +} + +.capture-stage-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.capture-stage-meta div { + display: grid; + gap: 3px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.34); + padding: 7px 8px; +} + +.capture-stage-meta span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.capture-stage-meta strong { + color: #f4efff; + font-size: 11px; + overflow-wrap: anywhere; +} + +.empty-stage { + display: grid; + place-items: center; + align-content: center; + gap: 8px; + padding: 18px; + color: var(--text-soft); + font-size: 12px; + line-height: 1.35; + text-align: center; +} + +.empty-stage strong { + color: #f4efff; + font-size: 14px; +} + +.empty-stage span { + max-width: 22ch; +} + +.scanner-result-panel { + display: grid; + align-content: start; + max-width: none; + gap: 10px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.07); + padding: 12px; +} + +.result-heading h3 { + margin: 3px 0 0; + font-size: 17px; +} + +.result-score { + display: grid; + width: 64px; + height: 64px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.4); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); + font-size: 20px; + font-weight: 900; +} + +.result-empty { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.scanner-result-brief { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.scanner-result-brief span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 9px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 999px; + background: rgba(8, 6, 18, 0.38); + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.scanner-result-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.scanner-result-caption { + margin: 0; + color: var(--text-soft); + font-size: 11px; + line-height: 1.45; +} + +.parsed-grid.compact { + grid-template-columns: 74px 1fr; +} + +.parsed-notes.compact { + border-top: 0; + padding-top: 0; +} + +.field-confidence-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.field-confidence { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 6px; + align-items: center; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.42); + padding: 7px 8px; +} + +.field-confidence span, +.field-confidence small { + color: var(--text-soft); + font-size: 10px; +} + +.field-confidence strong { + font-size: 12px; +} + +.field-confidence small { + grid-column: 1 / -1; + text-transform: uppercase; +} + +.field-confidence.high strong { + color: #9df0d4; +} + +.field-confidence.medium strong { + color: #f0c878; +} + +.field-confidence.low { + border-color: rgba(255, 140, 157, 0.28); +} + +.field-confidence.low strong { + color: var(--danger); +} + +.review-button { + justify-content: center; + width: 100%; +} + +.review-status { + margin: 0; + color: var(--text-soft); + font-size: 11px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + background: rgba(4, 3, 10, 0.72); + padding: 28px; +} + +.modal-panel { + display: grid; + width: min(980px, 94vw); + max-height: 88vh; + overflow: hidden; + border: 1px solid rgba(214, 183, 255, 0.3); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)), + rgba(14, 9, 29, 0.96); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.52); +} + +.modal-header { + border-bottom: 1px solid rgba(188, 154, 255, 0.14); + padding: 16px; +} + +.modal-body { + display: grid; + gap: 14px; + overflow: auto; + padding: 16px; +} + +.details-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.details-grid .crop-card img { + height: 132px; + object-fit: contain; +} + +.review-queue-modal { + width: min(860px, calc(100vw - 72px)); +} + +.review-queue-summary { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 10px 12px; +} + +.review-queue-summary strong { + color: var(--cyan); + font-size: 22px; +} + +.review-analysis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.review-analysis div { + display: grid; + gap: 4px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(15, 10, 29, 0.42); + padding: 10px; +} + +.review-analysis span { + color: var(--text-muted); + font-size: 11px; + font-weight: 800; +} + +.review-analysis strong { + min-width: 0; + color: var(--text); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-sample-list { + display: grid; + gap: 10px; +} + +.review-sample-card { + display: grid; + gap: 10px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(12, 8, 25, 0.72); + padding: 12px; +} + +.review-sample-head, +.review-sample-meta, +.review-sample-parsed, +.review-sample-ocr { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.review-sample-head { + justify-content: space-between; +} + +.review-sample-head div { + display: grid; + gap: 3px; +} + +.review-sample-head strong { + color: var(--text); +} + +.review-sample-head span, +.review-sample-head time, +.review-sample-meta span, +.review-sample-parsed span, +.review-sample-ocr span { + color: var(--muted); + font-size: 12px; +} + +.review-sample-parsed strong { + color: var(--text); + font-size: 13px; +} + +.empty-stage.compact { + min-height: 180px; +} + +@media (max-width: 1024px) { + .scanner-main-grid { + grid-template-columns: 1fr; + } + + .player-scan-lower { + grid-template-columns: 1fr; + } + + .scanner-toolbar { + align-items: stretch; + flex-direction: column; + } + + .scanner-actions { + justify-content: flex-start; + } + + .scan-settings-controls { + grid-template-columns: 1fr; + } + + .settings-preflight { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .capture-stage-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.capture-stage img { + max-height: 100%; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.modal-backdrop { + align-items: center; + justify-items: center; + overflow: hidden; +} + +.modal-panel { + width: min(900px, calc(100vw - 72px)); + max-height: min(760px, calc(100vh - 72px)); + grid-template-rows: auto minmax(0, 1fr); +} + +.scan-summary-backdrop { + position: fixed !important; + inset: 0 !important; + z-index: 999; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + padding: 24px; +} + +.scan-summary-modal { + display: grid; + gap: 16px; + width: min(520px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; + border: 1px solid rgba(214, 183, 255, 0.32); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), + rgba(15, 10, 30, 0.97); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); + padding: 22px; +} + +.scan-summary-icon { + display: grid; + width: 52px; + height: 52px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.34); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); +} + +.scan-summary-modal h2 { + margin: 4px 0 0; + color: #f4efff; + font-size: 24px; +} + +.scan-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.scan-summary-grid div { + display: grid; + gap: 4px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(8, 6, 18, 0.42); + padding: 10px; +} + +.scan-summary-grid strong { + color: var(--cyan); + font-size: 22px; +} + +.scan-summary-grid span, +.scan-summary-copy { + color: var(--text-soft); + font-size: 12px; + line-height: 1.45; +} + +.scan-summary-copy { + margin: 0; +} + +.modal-body { + min-height: 0; + overscroll-behavior: contain; +} + +.details-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 900px) { + .modal-panel { + width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + } + + .details-grid { + grid-template-columns: 1fr; + } +} + diff --git a/src/styles/diagnostics.css b/src/styles/diagnostics.css new file mode 100644 index 0000000..7203214 --- /dev/null +++ b/src/styles/diagnostics.css @@ -0,0 +1,164 @@ +/* Diagnose / Dev view - all developer info, separated from the Scan workspace. */ +.diagnose-view { + display: grid; + gap: 14px; + min-height: 0; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 6px; + width: 100%; +} + +.diagnose-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.diagnose-header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.diagnose-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.diagnose-card { + display: grid; + gap: 10px; + align-content: start; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)), + rgba(18, 12, 35, 0.7); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(18px); + padding: 16px; +} + +.diagnose-card-heading { + display: flex; + align-items: center; + justify-content: space-between; + 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) { + .diagnose-grid { + 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; + } +} + diff --git a/src/styles/global.css b/src/styles/global.css index 6e9c9b8..bd6fc60 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,2634 +1,2 @@ -:root { - color-scheme: dark; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #090711; - color: #f4efff; - --surface: rgba(24, 18, 43, 0.68); - --surface-soft: rgba(18, 12, 35, 0.58); - --line: rgba(188, 154, 255, 0.18); - --line-strong: rgba(210, 184, 255, 0.32); - --text-muted: #b6aeca; - --text-soft: #8f86a8; - --cyan: #7ee7f2; - --gold: #f0c878; - --danger: #ff8c9d; - --button-hover-shift: -0.6px; - --button-hover-scale: 1.005; - --button-hover-shadow: 0 3px 9px rgba(184, 140, 255, 0.14); - --button-hover-brightness: 1.025; - --glass-shadow: 0 24px 70px rgba(0, 0, 0, 0.36); -} - -* { - box-sizing: border-box; -} - -html, -body, -#root { - width: 100%; - height: 100%; - overflow: hidden; -} - -body { - margin: 0; - min-width: 1100px; - min-height: 720px; - background: - radial-gradient(1200px 760px at 18% -12%, rgba(102, 73, 176, 0.28), transparent 58%), - linear-gradient(145deg, #080610 0%, #130b24 42%, #080811 100%); -} - -button { - font: inherit; - position: relative; - overflow: hidden; - cursor: pointer; - transform: translateY(0); - will-change: transform; - transition: - transform 140ms ease, - box-shadow 140ms ease, - filter 140ms ease, - border-color 140ms ease, - background 140ms ease; - border-color: rgba(184, 140, 255, 0.28); -} - -button:enabled svg, -[role="button"]:enabled svg { - transition: transform 140ms ease; -} - -[role="button"], -button, -button.clickable, -button[data-clickable="true"] { - cursor: pointer; -} - -button:hover:not(:disabled):not([aria-disabled="true"]), -button:focus-visible:not(:disabled):not([aria-disabled="true"]), -[role="button"]:hover:not(:disabled):not([aria-disabled="true"]), -[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), -button.clickable:hover:not(:disabled):not([aria-disabled="true"]), -button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), -button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]), -button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { - transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); - filter: brightness(var(--button-hover-brightness)) saturate(1.02); - box-shadow: var(--button-hover-shadow); - border-color: rgba(214, 183, 255, 0.58); - background-color: rgba(255, 255, 255, 0.03); -} - -button:hover:not(:disabled):not([aria-disabled="true"])::before, -button:focus-visible:not(:disabled):not([aria-disabled="true"])::before, -[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::before, -[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before, -button.clickable:hover:not(:disabled):not([aria-disabled="true"])::before, -button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::before, -button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::before, -button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before { - opacity: 0.35; - transform: scaleX(1); -} - -button:not(:disabled):not([aria-disabled="true"])::before, -[role="button"]:not(:disabled):not([aria-disabled="true"])::before, -button.clickable:not(:disabled):not([aria-disabled="true"])::before, -button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::before { - content: ""; - position: absolute; - left: 14px; - right: 14px; - bottom: 8px; - height: 1px; - border-radius: 999px; - background: linear-gradient(90deg, transparent, rgba(214, 183, 255, 0.38), transparent); - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 160ms ease, - opacity 160ms ease; - pointer-events: none; -} - -.scan-cta:hover:not(:disabled):not([aria-disabled="true"]), -.scan-cta:focus-visible:not(:disabled):not([aria-disabled="true"]), -.review-button:hover:not(:disabled):not([aria-disabled="true"]), -.review-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { - transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); - filter: brightness(var(--button-hover-brightness)); - box-shadow: var(--button-hover-shadow); -} - -button:hover:not(:disabled):not([aria-disabled="true"]) svg, -button:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, -[role="button"]:hover:not(:disabled):not([aria-disabled="true"]) svg, -[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, -button.clickable:hover:not(:disabled):not([aria-disabled="true"]) svg, -button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, -button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]) svg, -button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg { - transform: translateX(0.6px); -} - -button:active:not(:disabled):not([aria-disabled="true"]), -[role="button"]:active:not(:disabled):not([aria-disabled="true"]), -button.clickable:active:not(:disabled):not([aria-disabled="true"]), -button[data-clickable="true"]:active:not(:disabled):not([aria-disabled="true"]) { - transform: translateY(0px) scale(0.999); - filter: brightness(0.985); - box-shadow: 0 2px 4px rgba(184, 140, 255, 0.12); -} - -button:hover:not(:disabled):not([aria-disabled="true"])::after, -button:focus-visible:not(:disabled):not([aria-disabled="true"])::after, -[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::after, -[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after, -button.clickable:hover:not(:disabled):not([aria-disabled="true"])::after, -button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::after, -button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::after, -button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after { - opacity: 1; - transform: translateX(120%); -} - -button:not(:disabled):not([aria-disabled="true"])::after, -[role="button"]:not(:disabled):not([aria-disabled="true"])::after, -button.clickable:not(:disabled):not([aria-disabled="true"])::after, -button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::after { - content: ""; - position: absolute; - inset: 0; - transform: translateX(-120%); - background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.12), transparent); - opacity: 0; - pointer-events: none; - transition: - transform 260ms ease, - opacity 260ms ease; -} - -button:focus-visible:not(:disabled):not([aria-disabled="true"]), -[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), -button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), -button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { - outline: 0; - border-color: rgba(214, 183, 255, 0.6); -} - -.ghost-button, -.primary-button, -.scan-cta, -.stop-button, -.review-button, -.nav-item, -.mode-option { - transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; -} - -.app-shell { - display: grid; - grid-template-columns: 260px 1fr; - height: 100vh; - overflow: hidden; - background: - linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%), - linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%); -} - -.sidebar { - display: flex; - flex-direction: column; - gap: 24px; - min-height: 0; - overflow: hidden; - border-right: 1px solid var(--line); - background: rgba(13, 9, 25, 0.72); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04); - backdrop-filter: blur(22px); - padding: 24px 18px; -} - -.brand { - display: flex; - align-items: center; - gap: 12px; -} - -.brand-mark { - display: grid; - width: 44px; - height: 44px; - place-items: center; - border: 1px solid rgba(216, 190, 255, 0.32); - border-radius: 8px; - background: - linear-gradient(145deg, rgba(222, 197, 255, 0.18), rgba(126, 231, 242, 0.08)), - rgba(23, 16, 43, 0.82); - color: #f5eaff; - box-shadow: 0 16px 38px rgba(68, 43, 142, 0.32); - font-weight: 800; -} - -.brand-title { - font-size: 15px; - font-weight: 800; -} - -.brand-subtitle, -.muted { - color: var(--text-soft); - font-size: 12px; -} - -.nav-list { - display: grid; - gap: 8px; -} - -.nav-item, -.ghost-button, -.primary-button, -.mode-option { - display: inline-flex; - align-items: center; - gap: 10px; - border: 1px solid transparent; - border-radius: 8px; - color: #eee8ff; - cursor: pointer; -} - -.nav-item { - width: 100%; - justify-content: flex-start; - padding: 11px 12px; - background: transparent; -} - -.nav-item:hover:not(:disabled), -.nav-item.active { - border-color: rgba(214, 183, 255, 0.28); - background: linear-gradient(135deg, rgba(184, 140, 255, 0.18), rgba(126, 231, 242, 0.05)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); -} - -.nav-item:hover:not(:disabled), -.nav-item:focus-visible:not(:disabled) { - transform: translateY(var(--button-hover-shift)) scale(1.002); -} - -.nav-item:disabled { - opacity: 0.45; - cursor: not-allowed; - color: #7f8ca0; -} - -.safety-card { - display: flex; - gap: 10px; - margin-top: auto; - border: 1px solid rgba(126, 231, 242, 0.24); - border-radius: 8px; - background: rgba(22, 28, 48, 0.62); - box-shadow: var(--glass-shadow); - backdrop-filter: blur(18px); - padding: 14px; - color: var(--cyan); -} - -.safety-card span { - display: block; - margin-top: 4px; - color: #9eb6c3; - font-size: 12px; - line-height: 1.4; -} - -.main-panel { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 12px; - min-height: 0; - min-width: 0; - overflow: hidden; - padding: 18px; -} - -.topbar { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; -} - -.topbar h1 { - max-width: 780px; - margin: 2px 0 0; - color: #fbf8ff; - font-size: 22px; - letter-spacing: 0; - text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25); -} - -.eyebrow { - margin: 0; - color: var(--cyan); - font-size: 12px; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; -} - -.topbar-actions { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.topbar-status { - max-width: 320px; - overflow: hidden; - color: var(--muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ghost-button, -.primary-button { - height: 36px; - padding: 0 12px; -} - -.ghost-button { - border-color: var(--line); - background: rgba(28, 20, 51, 0.66); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); - backdrop-filter: blur(14px); -} - -.ghost-button:hover:not(:disabled), -.ghost-button:focus-visible:not(:disabled) { - transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); - border-color: rgba(214, 183, 255, 0.45); - box-shadow: - 0 2px 6px rgba(137, 91, 255, 0.09), - inset 0 1px 0 rgba(255, 255, 255, 0.06); - background: rgba(56, 38, 103, 0.84); -} - -.ghost-button.success { - border-color: rgba(126, 242, 207, 0.54); - background: rgba(29, 88, 79, 0.42); - color: #bfffee; - box-shadow: - 0 10px 28px rgba(53, 220, 187, 0.16), - inset 0 1px 0 rgba(255, 255, 255, 0.08); -} - -.primary-button { - border-color: rgba(216, 183, 255, 0.62); - background: linear-gradient(135deg, #d9c0ff 0%, #a983ff 48%, #7ee7f2 100%); - box-shadow: - 0 16px 42px rgba(137, 91, 255, 0.34), - inset 0 1px 0 rgba(255, 255, 255, 0.42); - color: #10091d; - font-weight: 800; -} - -.primary-button:hover:not(:disabled), -.primary-button:focus-visible:not(:disabled) { - transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); - filter: brightness(1.05); - box-shadow: - 0 3px 8px rgba(137, 91, 255, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.52); -} - -.primary-button:disabled { - cursor: wait; - opacity: 0.65; -} - -.metrics-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin: 24px 0; -} - -.metric, -.panel { - border: 1px solid var(--line); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--surface); - box-shadow: var(--glass-shadow); - backdrop-filter: blur(22px); -} - -.metric { - position: relative; - overflow: hidden; - padding: 16px; -} - -.metric::before { - content: ""; - position: absolute; - inset: 0; - border-top: 1px solid rgba(255, 255, 255, 0.12); - pointer-events: none; -} - -.metric span, -.metric small { - display: block; - color: var(--text-soft); - font-size: 12px; -} - -.metric strong { - display: block; - margin: 8px 0 4px; - color: #ffffff; - font-size: 28px; -} - -.content-grid { - display: grid; - gap: 14px; - min-height: 0; - overflow: hidden; -} - -.scan-layout { - grid-template-columns: 1.3fr 1fr; -} - -.panel { - min-height: 0; - overflow: hidden; - padding: 18px; -} - -.panel.wide { - grid-column: 1 / -1; -} - -.panel-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-bottom: 16px; -} - -.panel-heading h2 { - margin: 3px 0 0; - font-size: 18px; -} - -.scan-badge { - border: 1px solid var(--line-strong); - border-radius: 999px; - background: rgba(255, 255, 255, 0.04); - padding: 6px 10px; - color: var(--text-muted); - font-size: 12px; -} - -.scan-badge.live { - border-color: var(--cyan); - box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); - color: var(--cyan); -} - -.timeline { - display: grid; - gap: 14px; -} - -.timeline-item { - display: grid; - grid-template-columns: 18px 1fr; - gap: 12px; -} - -.timeline-dot { - width: 10px; - height: 10px; - margin-top: 5px; - border-radius: 50%; - background: var(--cyan); - box-shadow: 0 0 22px rgba(126, 231, 242, 0.72); -} - -.timeline-item p { - margin: 4px 0; - color: var(--text-muted); -} - -.timeline-item span { - color: var(--text-soft); - font-size: 12px; -} - -.mode-list { - display: grid; - gap: 10px; -} - -.mode-option { - align-items: flex-start; - flex-direction: column; - padding: 13px; - background: rgba(21, 15, 40, 0.66); - text-align: left; -} - -.mode-option:hover:not(.selected):not(:disabled), -.mode-option:focus-visible:not(.selected):not(:disabled) { - border-color: rgba(188, 154, 255, 0.32); - transform: translateY(var(--button-hover-shift)) scale(1.002); -} - -.mode-option.selected { - border-color: rgba(126, 231, 242, 0.5); - background: linear-gradient(135deg, rgba(126, 231, 242, 0.16), rgba(184, 140, 255, 0.16)); -} - -.mode-option span { - color: var(--text-muted); - font-size: 13px; - line-height: 1.4; -} - -.character-row, -.build-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 12px; -} - -.character-card { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface-soft); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); - padding: 14px; -} - -.character-card span, -.character-card small { - display: block; - margin-top: 6px; - color: var(--text-soft); -} - -.artifact-table { - display: grid; - gap: 8px; -} - -.artifact-row { - display: grid; - grid-template-columns: 230px 1fr 160px 120px; - gap: 12px; - align-items: center; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(13, 9, 26, 0.56); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - padding: 12px; -} - -.artifact-row span, -.reason span { - display: block; - color: var(--text-soft); - font-size: 12px; -} - -.substats { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.substats span { - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 999px; - background: rgba(37, 27, 66, 0.68); - padding: 5px 8px; -} - -.pill { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 30px; - border-radius: 999px; - padding: 6px 10px; - font-size: 12px; - font-weight: 800; -} - -.keep, -.specific { - border: 1px solid rgba(157, 240, 212, 0.22); - background: rgba(53, 205, 156, 0.14); - color: #9df0d4; -} - -.maybe { - border: 1px solid rgba(240, 200, 120, 0.24); - background: rgba(240, 200, 120, 0.14); - color: var(--gold); -} - -.trash { - border: 1px solid rgba(255, 140, 157, 0.22); - background: rgba(255, 140, 157, 0.12); - color: var(--danger); -} - -.review { - border: 1px solid rgba(255, 184, 112, 0.24); - background: rgba(255, 184, 112, 0.13); - color: #ffbf82; -} - -.score { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border: 1px solid rgba(214, 183, 255, 0.42); - border-radius: 8px; - background: linear-gradient(145deg, rgba(184, 140, 255, 0.22), rgba(126, 231, 242, 0.08)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); - color: #f1e7ff; -} - -.build-card { - min-height: 330px; -} - -.build-copy, -.overlay-settings p, -.overlay-card p { - color: var(--text-muted); - line-height: 1.5; -} - -.build-pieces { - display: grid; - gap: 8px; - margin-top: 14px; -} - -.build-pieces div { - display: grid; - grid-template-columns: 72px 1fr; - gap: 4px 8px; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(15, 10, 29, 0.46); - padding: 9px; -} - -.build-pieces small { - grid-column: 2; - color: var(--text-soft); -} - -.warning-list { - display: grid; - gap: 6px; - margin-top: 14px; -} - -.warning-list span { - display: flex; - align-items: center; - gap: 6px; - color: #ffbf82; - font-size: 13px; -} - -.overlay-settings { - display: grid; - max-width: 720px; - gap: 14px; -} - -.overlay-settings-copy { - display: grid; - gap: 4px; - border: 1px solid rgba(126, 231, 242, 0.16); - border-radius: 8px; - background: rgba(126, 231, 242, 0.06); - padding: 12px; -} - -.overlay-settings-copy strong { - color: var(--text); -} - -.overlay-settings-copy span { - color: var(--muted); - font-size: 13px; -} - -.overlay-root { - display: flex; - justify-content: flex-end; - align-items: flex-start; - min-height: 100vh; - padding: 80px 48px; - background: transparent; -} - -.overlay-card { - width: 360px; - border: 1px solid rgba(214, 183, 255, 0.34); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), - rgba(17, 10, 34, 0.82); - box-shadow: 0 22px 60px rgba(0, 0, 0, 0.45); - padding: 18px; - backdrop-filter: blur(12px); -} - -.overlay-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.overlay-card-head h2 { - margin: 2px 0 0; - font-size: 19px; -} - -.overlay-card-head > strong { - display: grid; - min-width: 50px; - height: 50px; - place-items: center; - border: 1px solid rgba(126, 231, 242, 0.34); - border-radius: 8px; - background: rgba(126, 231, 242, 0.1); - color: var(--cyan); - font-size: 18px; -} - -.overlay-artifact-mini, -.overlay-character-list { - display: grid; - gap: 4px; - margin-top: 12px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(8, 6, 18, 0.38); - padding: 10px; -} - -.overlay-artifact-mini span, -.overlay-artifact-mini small, -.overlay-character-list span, -.overlay-card p { - color: var(--muted); - font-size: 12px; -} - -.overlay-artifact-mini strong { - color: var(--text); -} - -.overlay-character-list { - display: flex; - flex-wrap: wrap; -} - -.overlay-character-list span { - border: 1px solid rgba(126, 231, 242, 0.22); - border-radius: 999px; - background: rgba(126, 231, 242, 0.08); - padding: 5px 8px; -} - -@media (max-width: 1180px) { - .app-shell { - grid-template-columns: 220px 1fr; - } - - .artifact-row { - grid-template-columns: 1fr; - } -} -.capture-controls { - display: grid; - gap: 12px; -} - -.capture-controls select { - width: 100%; - min-height: 40px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(15, 10, 29, 0.78); - color: #f4efff; - padding: 0 12px; - outline: none; -} - -.capture-controls select:focus { - border-color: rgba(126, 231, 242, 0.52); - box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); -} - -.capture-actions { - display: flex; - gap: 10px; -} - -.capture-controls p { - margin: 0; - color: var(--text-muted); - font-size: 13px; - line-height: 1.45; -} - -.capture-preview { - display: grid; - min-height: 158px; - place-items: center; - overflow: hidden; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(10, 7, 19, 0.64); -} - -.capture-preview img { - display: block; - width: 100%; - height: 100%; - max-height: 260px; - object-fit: contain; -} - -.capture-preview span { - color: var(--text-soft); - font-size: 13px; -} - -.bridge-status { - display: inline-flex; - align-items: center; - width: fit-content; - border-radius: 999px; - padding: 6px 10px; - font-size: 12px; - font-weight: 800; -} - -.bridge-status.connected { - border: 1px solid rgba(157, 240, 212, 0.24); - background: rgba(53, 205, 156, 0.14); - color: #9df0d4; -} - -.bridge-status.missing { - border: 1px solid rgba(255, 140, 157, 0.24); - background: rgba(255, 140, 157, 0.12); - color: var(--danger); -} - -.crop-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.crop-card { - overflow: hidden; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(15, 10, 29, 0.52); -} - -.crop-card img { - display: block; - width: 100%; - height: 82px; - object-fit: contain; - background: rgba(4, 3, 10, 0.74); - border-bottom: 1px solid rgba(188, 154, 255, 0.12); -} - -.crop-card div { - padding: 8px; -} - -.crop-card strong, -.crop-card span { - display: block; -} - -.crop-card strong { - font-size: 12px; -} - -.crop-card span { - margin-top: 3px; - color: var(--text-soft); - font-size: 11px; -} - -.capture-hint { - border-left: 2px solid rgba(240, 200, 120, 0.42); - padding-left: 10px; - color: #f0c878 !important; -} - -.ocr-panel { - display: grid; - gap: 8px; - border: 1px solid rgba(126, 231, 242, 0.18); - border-radius: 8px; - background: rgba(8, 6, 18, 0.56); - padding: 12px; -} - -.ocr-row { - display: grid; - grid-template-columns: 132px 1fr; - gap: 10px; - border-top: 1px solid rgba(188, 154, 255, 0.12); - padding-top: 8px; -} - -.ocr-row span, -.ocr-row small { - display: block; -} - -.ocr-row span { - color: #f4efff; - font-size: 12px; - font-weight: 800; -} - -.ocr-row small { - margin-top: 3px; - color: var(--text-soft); - font-size: 11px; -} - -.ocr-row pre { - margin: 0; - white-space: pre-wrap; - color: var(--text-muted); - font-family: inherit; - font-size: 12px; - line-height: 1.35; -} - -.capture-debug { - color: var(--text-soft) !important; - font-size: 12px !important; -} - -.parsed-panel { - display: grid; - gap: 10px; - border: 1px solid rgba(126, 231, 242, 0.24); - border-radius: 8px; - background: rgba(126, 231, 242, 0.08); - padding: 12px; -} - -.parsed-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.parsed-heading span { - color: var(--cyan); - font-size: 12px; - font-weight: 800; -} - -.parsed-grid { - display: grid; - grid-template-columns: 92px 1fr; - gap: 7px 12px; -} - -.parsed-grid span { - color: var(--text-soft); - font-size: 12px; -} - -.parsed-grid strong { - color: #f4efff; - font-size: 12px; -} - -.parsed-notes { - display: grid; - gap: 4px; - border-top: 1px solid rgba(188, 154, 255, 0.14); - padding-top: 8px; -} - -.parsed-notes span { - color: #f0c878; - font-size: 12px; -} - -.scanner-workbench { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 10px; - min-height: 0; - height: 100%; - width: 100%; - justify-self: stretch; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.018)), - rgba(18, 12, 35, 0.7); - box-shadow: var(--glass-shadow); - backdrop-filter: blur(22px); - padding: 14px; -} - -.scanner-header, -.scanner-toolbar, -.scanner-status-row, -.result-heading, -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; -} - -.scanner-header h2, -.modal-header h2 { - margin: 3px 0 0; - font-size: 20px; -} - -.scanner-subcopy { - margin: 8px 0 0; - max-width: 620px; - color: var(--text-soft); - font-size: 13px; - line-height: 1.5; -} - -.scanner-header-pills { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.scanner-toolbar { - align-items: flex-end; - display: grid; - grid-template-columns: minmax(320px, 1fr) auto; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(10, 7, 20, 0.42); - padding: 12px; -} - -.source-select { - display: grid; - min-width: 0; - gap: 4px; -} - -.source-select span { - color: var(--text-soft); - font-size: 12px; - font-weight: 800; -} - -.source-select select { - width: 100%; - min-height: 34px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(15, 10, 29, 0.78); - color: #f4efff; - padding: 0 12px; - outline: none; -} - -.source-select select:focus { - border-color: rgba(126, 231, 242, 0.52); - box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); -} - -.scanner-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.scanner-status-row { - align-items: flex-start; - color: var(--text-muted); - font-size: 12px; -} - -.scanner-status-row span:first-child { - color: var(--cyan); - font-weight: 800; -} - -.bridge-banner { - display: flex; - align-items: center; - gap: 10px; - border: 1px solid rgba(255, 140, 157, 0.3); - border-radius: 10px; - background: rgba(255, 140, 157, 0.08); - color: var(--danger); - font-size: 13px; - font-weight: 700; - padding: 10px 12px; -} - -.dev-toggle { - opacity: 0.65; -} - -.dev-toggle.active { - opacity: 1; - border-color: rgba(126, 231, 242, 0.5); - color: var(--cyan); -} - -.player-scan-card { - display: grid; - gap: 8px; - min-height: 0; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(15, 10, 29, 0.6); - padding: 10px; -} - -.player-scan-row { - display: flex; - align-items: end; - flex-wrap: wrap; - gap: 8px; -} - -.player-scan-row .source-select { - min-width: 240px; - flex: 1; -} - -.mini-config { - display: grid; - gap: 6px; - width: 150px; -} - -.mini-config span { - color: var(--text-soft); - font-size: 11px; - font-weight: 800; -} - -.mini-config input { - min-height: 38px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(15, 10, 29, 0.78); - color: #f4efff; - padding: 0 10px; - outline: none; -} - -.mini-config input:focus { - border-color: rgba(126, 231, 242, 0.52); - box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); -} - -.player-scan-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.player-scan-lower { - display: grid; - grid-template-columns: minmax(360px, auto) minmax(360px, 1fr); - gap: 10px; - align-items: center; -} - -.learning-strip { - display: grid; - gap: 8px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(10, 7, 20, 0.36); - padding: 10px 12px; -} - -.learning-strip span { - display: grid; - gap: 3px; - min-width: 0; - color: var(--text-soft); - font-size: 11px; - font-weight: 700; -} - -.learning-strip strong { - color: #f4efff; - font-size: 13px; -} - -.learning-strip { - grid-template-columns: auto auto 1fr; - align-items: center; - border-color: rgba(126, 231, 242, 0.18); - background: rgba(126, 231, 242, 0.06); -} - -.learning-strip small { - color: var(--text-muted); - font-size: 11px; -} - -.runtime-pill { - min-height: 30px; - display: inline-flex; - align-items: center; - padding: 0 10px; - border-radius: 999px; - border: 1px solid rgba(188, 154, 255, 0.22); - background: rgba(15, 10, 29, 0.55); - color: var(--text-soft); - font-size: 12px; - font-weight: 900; -} - -.runtime-pill.elevated { - border-color: rgba(109, 244, 197, 0.36); - background: rgba(33, 214, 155, 0.13); - color: var(--mint); -} - -.runtime-pill.standard { - border-color: rgba(255, 207, 109, 0.3); - background: rgba(255, 207, 109, 0.11); - color: var(--warning); -} - -.scan-cta { - min-height: 36px; - padding: 0 16px; - font-size: 14px; -} - -.stop-button { - min-height: 36px; - padding: 0 18px; - border: 1px solid rgba(255, 140, 157, 0.5); - border-radius: 10px; - background: rgba(255, 140, 157, 0.14); - color: var(--danger); - font-size: 14px; - font-weight: 800; - cursor: pointer; -} - -.stop-button:hover:not(:disabled):not([aria-disabled="true"]), -.stop-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { - transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); - box-shadow: 0 3px 8px rgba(255, 140, 157, 0.12); -} - -.player-status { - margin: 0; - color: var(--text-soft); - font-size: 12px; - line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.scanner-preflight { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.scanner-preflight div { - min-height: 52px; - display: grid; - align-content: center; - gap: 3px; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(15, 10, 29, 0.34); - padding: 9px 11px; -} - -.scanner-preflight span { - color: var(--text-muted); - font-size: 11px; - font-weight: 800; -} - -.scanner-preflight strong { - color: var(--text); - font-size: 13px; -} - -.scanner-preflight .ok { - border-color: rgba(126, 242, 207, 0.28); - background: rgba(33, 214, 155, 0.09); -} - -.scanner-preflight .ok strong { - color: var(--mint); -} - -.scanner-preflight .blocked { - border-color: rgba(255, 207, 109, 0.3); - background: rgba(255, 207, 109, 0.1); -} - -.scanner-preflight .blocked strong { - color: var(--warning); -} - -.player-progress { - display: grid; - gap: 7px; - min-width: 0; -} - -.player-progress-bar { - height: 7px; - border-radius: 999px; - background: rgba(188, 154, 255, 0.12); - overflow: hidden; -} - -.player-progress-bar div { - height: 100%; - border-radius: 999px; - background: linear-gradient(90deg, rgba(126, 231, 242, 0.85), rgba(188, 154, 255, 0.9)); - transition: width 0.35s ease; -} - -.player-progress-stats { - display: flex; - flex-wrap: wrap; - gap: 6px; - color: var(--text-muted); - 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 { - color: #f4efff; -} - -.player-progress-stats .collection { - margin-left: auto; -} - -.dev-section { - display: grid; - gap: 10px; - border: 1px dashed rgba(126, 231, 242, 0.25); - border-radius: 12px; - padding: 12px; -} - -.dev-section-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.scanner-diagnostics-modal { - width: min(1040px, 94vw); -} - -.scanner-settings-modal { - width: min(820px, 92vw); -} - -.diagnostics-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.diagnostics-preflight { - 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 { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(10, 7, 20, 0.36); - padding: 10px 12px; -} - -.artifact-result-card { - display: grid; - gap: 10px; -} - -.artifact-result-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.artifact-result-head .artifact-set { - color: var(--cyan); - font-size: 12px; - font-weight: 800; -} - -.quality-chip { - border-radius: 999px; - font-size: 11px; - font-weight: 800; - padding: 4px 10px; - white-space: nowrap; -} - -.quality-chip.good { - background: rgba(157, 240, 212, 0.14); - color: #9df0d4; -} - -.quality-chip.mid { - background: rgba(255, 214, 140, 0.14); - color: #ffd68c; -} - -.quality-chip.low { - background: rgba(255, 140, 157, 0.14); - color: var(--danger); -} - -.artifact-slot { - color: var(--text-muted); - font-size: 12px; -} - -.artifact-mainstat { - display: flex; - align-items: baseline; - gap: 8px; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 10px; - background: rgba(8, 6, 18, 0.42); - padding: 10px 12px; -} - -.artifact-mainstat span { - color: var(--text-soft); - font-size: 11px; - font-weight: 800; -} - -.artifact-mainstat strong { - color: #f4efff; - font-size: 15px; -} - -.artifact-mainstat em { - margin-left: auto; - color: var(--cyan); - font-size: 18px; - font-style: normal; - font-weight: 800; -} - -.artifact-substats { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.artifact-substats span { - border: 1px solid rgba(188, 154, 255, 0.2); - border-radius: 999px; - background: rgba(188, 154, 255, 0.08); - color: #e8defc; - font-size: 12px; - padding: 4px 10px; -} - -.artifact-substats span.none { - border-style: dashed; - color: var(--text-muted); -} - -.artifact-equipped { - color: var(--text-muted); - font-size: 12px; -} - -.scan-summary-dev { - margin: 0; - color: var(--text-muted); - font-size: 11px; -} - -.auto-scan-strip { - display: grid; - grid-template-columns: 1fr repeat(8, auto auto); - gap: 6px 8px; - align-items: center; - border: 1px solid rgba(126, 231, 242, 0.18); - border-radius: 8px; - background: rgba(126, 231, 242, 0.07); - padding: 10px 12px; -} - -.auto-scan-strip span { - color: var(--cyan); - font-size: 12px; - font-weight: 800; -} - -.auto-scan-strip strong { - color: #f4efff; - font-size: 13px; -} - -.auto-scan-strip small { - color: var(--text-soft); - font-size: 11px; -} - -.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; - gap: 10px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(10, 7, 20, 0.36); - padding: 12px; -} - -.settings-stepper-head { - min-height: 42px; -} - -.settings-stepper-head div { - display: grid; - gap: 4px; -} - -.settings-stepper-head span, -.scan-settings-note strong { - color: var(--text-soft); - font-size: 11px; - font-weight: 800; -} - -.settings-stepper-head small, -.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-radius: 8px; - background: rgba(15, 10, 29, 0.78); - color: #f4efff; - padding: 0 12px; - font-size: 18px; - font-weight: 800; - text-align: center; - outline: none; -} - -.settings-stepper-row input:focus { - border-color: rgba(126, 231, 242, 0.52); - box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); -} - -.settings-stepper-row input::-webkit-outer-spin-button, -.settings-stepper-row input::-webkit-inner-spin-button { - margin: 0; - 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; -} - -.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 { - display: grid; - grid-template-columns: auto auto 1fr; - gap: 8px; - align-items: center; - border: 1px solid rgba(157, 240, 212, 0.2); - border-radius: 8px; - background: rgba(157, 240, 212, 0.07); - padding: 9px 12px; - box-shadow: 0 0 0 1px rgba(126, 231, 242, 0.035) inset; -} - -.grid-detection-strip span { - color: var(--text-soft); - font-size: 12px; - font-weight: 800; -} - -.grid-detection-strip strong { - color: #9df0d4; - font-size: 13px; -} - -.grid-detection-strip small { - color: var(--text-muted); - font-size: 11px; -} - -.grid-detection-strip.missing { - border-color: rgba(255, 140, 157, 0.24); - background: rgba(255, 140, 157, 0.06); -} - -.grid-detection-strip.missing strong { - color: var(--danger); -} - -.automation-log { - display: grid; - grid-template-columns: auto 1fr; - gap: 10px; - align-items: start; - border: 1px solid rgba(126, 231, 242, 0.2); - border-radius: 8px; - background: rgba(126, 231, 242, 0.06); - padding: 8px 12px; -} - -.automation-log-lines { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 118px; - overflow-y: auto; -} - -.automation-log span { - color: var(--cyan); - font-size: 11px; - font-weight: 900; - text-transform: uppercase; -} - -.automation-log strong { - color: #f4efff; - font-size: 12px; - 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 { - display: grid; - grid-template-columns: minmax(190px, 210px) minmax(520px, 1fr); - gap: 12px; - align-items: start; -} - -.capture-stage-shell { - display: grid; - gap: 8px; - min-width: 0; -} - -.capture-stage { - display: grid; - width: 100%; - aspect-ratio: 41 / 80; - min-height: 0; - max-height: 410px; - place-items: center; - overflow: hidden; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - 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 { - display: block; - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; - object-fit: contain; - object-position: center top; -} - -.capture-stage-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; -} - -.capture-stage-meta div { - display: grid; - gap: 3px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(8, 6, 18, 0.34); - padding: 7px 8px; -} - -.capture-stage-meta span { - color: var(--text-soft); - font-size: 11px; - font-weight: 800; -} - -.capture-stage-meta strong { - color: #f4efff; - font-size: 11px; - overflow-wrap: anywhere; -} - -.empty-stage { - display: grid; - place-items: center; - align-content: center; - gap: 8px; - padding: 18px; - color: var(--text-soft); - font-size: 12px; - line-height: 1.35; - text-align: center; -} - -.empty-stage strong { - color: #f4efff; - font-size: 14px; -} - -.empty-stage span { - max-width: 22ch; -} - -.scanner-result-panel { - display: grid; - align-content: start; - max-width: none; - gap: 10px; - border: 1px solid rgba(126, 231, 242, 0.18); - border-radius: 8px; - background: rgba(126, 231, 242, 0.07); - padding: 12px; -} - -.result-heading h3 { - margin: 3px 0 0; - font-size: 17px; -} - -.result-score { - display: grid; - width: 64px; - height: 64px; - place-items: center; - border: 1px solid rgba(126, 231, 242, 0.4); - border-radius: 8px; - background: rgba(126, 231, 242, 0.1); - color: var(--cyan); - font-size: 20px; - font-weight: 900; -} - -.result-empty { - margin: 0; - color: var(--text-muted); - line-height: 1.5; -} - -.scanner-result-brief { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.scanner-result-brief span { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 9px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 999px; - background: rgba(8, 6, 18, 0.38); - color: var(--text-soft); - font-size: 11px; - font-weight: 800; -} - -.scanner-result-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.scanner-result-caption { - margin: 0; - color: var(--text-soft); - font-size: 11px; - line-height: 1.45; -} - -.parsed-grid.compact { - grid-template-columns: 74px 1fr; -} - -.parsed-notes.compact { - border-top: 0; - padding-top: 0; -} - -.field-confidence-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; -} - -.field-confidence { - display: grid; - grid-template-columns: 1fr auto; - gap: 2px 6px; - align-items: center; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(8, 6, 18, 0.42); - padding: 7px 8px; -} - -.field-confidence span, -.field-confidence small { - color: var(--text-soft); - font-size: 10px; -} - -.field-confidence strong { - font-size: 12px; -} - -.field-confidence small { - grid-column: 1 / -1; - text-transform: uppercase; -} - -.field-confidence.high strong { - color: #9df0d4; -} - -.field-confidence.medium strong { - color: #f0c878; -} - -.field-confidence.low { - border-color: rgba(255, 140, 157, 0.28); -} - -.field-confidence.low strong { - color: var(--danger); -} - -.review-button { - justify-content: center; - width: 100%; -} - -.review-status { - margin: 0; - color: var(--text-soft); - font-size: 11px; - line-height: 1.4; - overflow-wrap: anywhere; -} - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 40; - display: grid; - place-items: center; - background: rgba(4, 3, 10, 0.72); - padding: 28px; -} - -.modal-panel { - display: grid; - width: min(980px, 94vw); - max-height: 88vh; - overflow: hidden; - border: 1px solid rgba(214, 183, 255, 0.3); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)), - rgba(14, 9, 29, 0.96); - box-shadow: 0 30px 90px rgba(0, 0, 0, 0.52); -} - -.modal-header { - border-bottom: 1px solid rgba(188, 154, 255, 0.14); - padding: 16px; -} - -.modal-body { - display: grid; - gap: 14px; - overflow: auto; - padding: 16px; -} - -.details-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.details-grid .crop-card img { - height: 132px; - object-fit: contain; -} - -.review-queue-modal { - width: min(860px, calc(100vw - 72px)); -} - -.review-queue-summary { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 10px; - border: 1px solid rgba(126, 231, 242, 0.18); - border-radius: 8px; - background: rgba(126, 231, 242, 0.06); - padding: 10px 12px; -} - -.review-queue-summary strong { - color: var(--cyan); - font-size: 22px; -} - -.review-analysis { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.review-analysis div { - display: grid; - gap: 4px; - border: 1px solid rgba(188, 154, 255, 0.14); - border-radius: 8px; - background: rgba(15, 10, 29, 0.42); - padding: 10px; -} - -.review-analysis span { - color: var(--text-muted); - font-size: 11px; - font-weight: 800; -} - -.review-analysis strong { - min-width: 0; - color: var(--text); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.review-sample-list { - display: grid; - gap: 10px; -} - -.review-sample-card { - display: grid; - gap: 10px; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(12, 8, 25, 0.72); - padding: 12px; -} - -.review-sample-head, -.review-sample-meta, -.review-sample-parsed, -.review-sample-ocr { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.review-sample-head { - justify-content: space-between; -} - -.review-sample-head div { - display: grid; - gap: 3px; -} - -.review-sample-head strong { - color: var(--text); -} - -.review-sample-head span, -.review-sample-head time, -.review-sample-meta span, -.review-sample-parsed span, -.review-sample-ocr span { - color: var(--muted); - font-size: 12px; -} - -.review-sample-parsed strong { - color: var(--text); - font-size: 13px; -} - -.empty-stage.compact { - min-height: 180px; -} - -@media (max-width: 1024px) { - .scanner-main-grid { - grid-template-columns: 1fr; - } - - .player-scan-lower { - grid-template-columns: 1fr; - } - - .scanner-toolbar { - align-items: stretch; - flex-direction: column; - } - - .scanner-actions { - justify-content: flex-start; - } - - .scan-settings-controls { - grid-template-columns: 1fr; - } - - .settings-preflight { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .capture-stage-meta { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.capture-stage img { - max-height: 100%; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.45; -} - -.modal-backdrop { - align-items: center; - justify-items: center; - overflow: hidden; -} - -.modal-panel { - width: min(900px, calc(100vw - 72px)); - max-height: min(760px, calc(100vh - 72px)); - grid-template-rows: auto minmax(0, 1fr); -} - -.scan-summary-backdrop { - position: fixed !important; - inset: 0 !important; - z-index: 999; - display: grid; - place-items: center; - width: 100vw; - height: 100vh; - padding: 24px; -} - -.scan-summary-modal { - display: grid; - gap: 16px; - width: min(520px, calc(100vw - 48px)); - max-height: calc(100vh - 48px); - overflow: auto; - border: 1px solid rgba(214, 183, 255, 0.32); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), - rgba(15, 10, 30, 0.97); - box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); - padding: 22px; -} - -.scan-summary-icon { - display: grid; - width: 52px; - height: 52px; - place-items: center; - border: 1px solid rgba(126, 231, 242, 0.34); - border-radius: 8px; - background: rgba(126, 231, 242, 0.1); - color: var(--cyan); -} - -.scan-summary-modal h2 { - margin: 4px 0 0; - color: #f4efff; - font-size: 24px; -} - -.scan-summary-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.scan-summary-grid div { - display: grid; - gap: 4px; - border: 1px solid rgba(188, 154, 255, 0.16); - border-radius: 8px; - background: rgba(8, 6, 18, 0.42); - padding: 10px; -} - -.scan-summary-grid strong { - color: var(--cyan); - font-size: 22px; -} - -.scan-summary-grid span, -.scan-summary-copy { - color: var(--text-soft); - font-size: 12px; - line-height: 1.45; -} - -.scan-summary-copy { - margin: 0; -} - -.modal-body { - min-height: 0; - overscroll-behavior: contain; -} - -.details-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -@media (max-width: 900px) { - .modal-panel { - width: calc(100vw - 32px); - max-height: calc(100vh - 32px); - } - - .details-grid { - grid-template-columns: 1fr; - } -} - -/* Diagnose / Dev view — all developer info, separated from the Scan workspace. */ -.diagnose-view { - display: grid; - gap: 14px; - min-height: 0; - height: 100%; - overflow-y: auto; - overscroll-behavior: contain; - padding-right: 6px; - width: 100%; -} - -.diagnose-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} - -.diagnose-header-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.diagnose-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; -} - -.diagnose-card { - display: grid; - gap: 10px; - align-content: start; - border: 1px solid var(--line); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015)), - rgba(18, 12, 35, 0.7); - box-shadow: var(--glass-shadow); - backdrop-filter: blur(18px); - padding: 16px; -} - -.diagnose-card-heading { - display: flex; - align-items: center; - justify-content: space-between; - 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) { - .diagnose-grid { - 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; - } -} +@import "./base.css"; +@import "./diagnostics.css"; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 4e198a5..9f5fb68 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -89,7 +89,18 @@ export interface CaptureResult { text: string; }; locked?: boolean; + lockSignal?: { + ratio: number; + threshold: number; + rect: { + x: number; + y: number; + width: number; + height: number; + }; + }; sanctified?: boolean; + elapsedMs?: number; layout?: { aspect: string; isSixteenNine: boolean; @@ -192,6 +203,25 @@ export type ScannerCommand = export interface ScannerLearningRulePayload { textReplacements?: Record; + fieldAliases?: Record>; + constrainedFixes?: Record; + cropAdjustments?: Record; + uiProfileAdjustments?: Record; } export interface LoadScannerLearningRulesResult {