feat(scanner): validate elevated live automation
This commit is contained in:
@@ -1,23 +0,0 @@
|
|||||||
@echo off
|
|
||||||
rem Startet den Dev-Modus mit Administratorrechten (ein UAC-Prompt erscheint).
|
|
||||||
rem Noetig, weil Genshin erhoeht laeuft: Windows (UIPI) verwirft sonst alle
|
|
||||||
rem simulierten Maus-Eingaben an das Spiel - SendInput meldet trotzdem Erfolg.
|
|
||||||
rem Der Punkt hinter %~dp0 verhindert, dass der abschliessende Backslash das
|
|
||||||
rem schliessende Anfuehrungszeichen escaped.
|
|
||||||
rem -NoExit haelt das erhoehte (innere) Fenster offen, selbst wenn das Skript
|
|
||||||
rem einen Fehler wirft oder npm run dev sofort wieder beendet.
|
|
||||||
rem
|
|
||||||
rem Dieses AEUSSERE Fenster (das du beim Doppelklick oder ueber
|
|
||||||
rem "npm run dev:admin" siehst) schloss sich frueher sofort, sobald
|
|
||||||
rem Start-Process zurueckkehrte - auch wenn UAC abgelehnt wurde oder die
|
|
||||||
rem Elevation ganz fehlschlug, ohne dass davon irgendetwas sichtbar war.
|
|
||||||
rem try/catch + timeout zeigen jetzt den Fehler und halten das Fenster kurz offen.
|
|
||||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$script='%~dp0scripts\dev-admin-start.ps1'; $project='%~dp0.'; try { Start-Process powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File',$script,'-ProjectRoot',$project) -Verb RunAs -ErrorAction Stop; Write-Host ''; Write-Host 'UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev.' -ForegroundColor Green } catch { Write-Host ''; Write-Host 'Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:' -ForegroundColor Red; Write-Host $_.Exception.Message -ForegroundColor Red }"
|
|
||||||
echo.
|
|
||||||
echo Dieses Fenster kannst du jetzt schliessen (Taste druecken oder 15s warten). Das eigentliche Programm laeuft im neuen Administrator-Fenster.
|
|
||||||
rem timeout statt pause/choice: pause und choice warten unter umgeleiteter
|
|
||||||
rem Standardeingabe (z.B. beim Testen ueber ein Skript) fuer immer, weil sie
|
|
||||||
rem auf ein echtes Konsolen-Handle angewiesen sind. timeout erkennt eine
|
|
||||||
rem umgeleitete Eingabe explizit und bricht sofort ab statt zu haengen, waehrend
|
|
||||||
rem es bei einem echten Doppelklick normal 15s wartet oder bei Tastendruck endet.
|
|
||||||
timeout /t 15 >nul 2>&1
|
|
||||||
+17
-2
@@ -49,7 +49,7 @@ flowchart LR
|
|||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, persistent PowerShell input/capture helper, JSON artifact store |
|
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, input/capture sidecar orchestration, JSON artifact store, dev-only scanner control endpoints |
|
||||||
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
| `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/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` | Main app shell, scan view, triage view, build view, overlay preview |
|
||||||
@@ -111,9 +111,24 @@ sequenceDiagram
|
|||||||
|
|
||||||
**Automatic grid scan** is user-triggered input automation limited to clicking detected inventory tiles and wheel-scrolling the inventory. Safety and reliability rules:
|
**Automatic grid scan** is user-triggered input automation limited to clicking detected inventory tiles and wheel-scrolling the inventory. Safety and reliability rules:
|
||||||
|
|
||||||
- All input goes through one persistent PowerShell helper process (`input-helper.ps1` in userData) that compiles the Win32 interop once and speaks JSON over stdin/stdout (ops: ping, focus, cursor, click, scroll, capture). Mouse movement is sent as iterated relative SendInput deltas (what a real mouse produces): Genshin tracks the cursor via raw input and snaps the OS cursor back to its own position every frame, so SetCursorPos/absolute moves silently stop working once the game owns the cursor. The helper verifies the cursor reached the target and refuses to click otherwise.
|
- All input goes through the helper service boundary (currently a C# sidecar with
|
||||||
|
fallback support behind the same JSON protocol). The helper owns focus, cursor
|
||||||
|
movement, click, scroll, guard-state polling, elevation detection, and GDI
|
||||||
|
capture. Mouse movement is sent as iterated relative input deltas instead of
|
||||||
|
relying on a single absolute cursor jump. The helper verifies the cursor
|
||||||
|
reached the target and refuses to click otherwise.
|
||||||
|
- `npm run dev:admin` is the validated dev path for automation when elevated
|
||||||
|
input is required. The elevated PowerShell startup is handled by
|
||||||
|
`scripts/dev-admin.ps1` and logged to `outputs/admin-start/admin-dev.log`.
|
||||||
|
The user must approve UAC manually; the app cannot approve the Secure Desktop
|
||||||
|
prompt itself.
|
||||||
- Failsafe: before every click and scroll the renderer polls cursor position and ESC state. Holding ESC or moving the mouse away from the last automated position aborts the scan immediately; the Stop button also aborts. Only the `GetAsyncKeyState` held-down bit (0x8000) is used - the "pressed since last call" bit fires for stale ESC presses from normal Genshin menu navigation and caused false aborts.
|
- Failsafe: before every click and scroll the renderer polls cursor position and ESC state. Holding ESC or moving the mouse away from the last automated position aborts the scan immediately; the Stop button also aborts. Only the `GetAsyncKeyState` held-down bit (0x8000) is used - the "pressed since last call" bit fires for stale ESC presses from normal Genshin menu navigation and caused false aborts.
|
||||||
- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing.
|
- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing.
|
||||||
|
- Dev-only probes under `http://127.0.0.1:17317` are used for live validation:
|
||||||
|
`/automation/probe-click?index=N` tests one read-only tile selection, and
|
||||||
|
`/scanner/start?limit=N` starts an auto-scan with a temporary limit payload.
|
||||||
|
The live known-good result on 2026-07-07 is documented in
|
||||||
|
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||||
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
|
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
|
||||||
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
|
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
|
||||||
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Automation Live Scan Runbook
|
||||||
|
|
||||||
|
This document is the durable reference for automatic artifact scanning, mouse
|
||||||
|
movement, click input, elevation, and live validation status.
|
||||||
|
|
||||||
|
## Current Known-Good State
|
||||||
|
|
||||||
|
Validated live on 2026-07-07 with Genshin open in the artifact inventory at
|
||||||
|
1920x1080, English UI:
|
||||||
|
|
||||||
|
- `npm run dev:admin` starts the app elevated after the user confirms UAC.
|
||||||
|
- Runtime status reported `isElevated: true`, `genshinFound: true`, and
|
||||||
|
`targetProcess: "GenshinImpact"`.
|
||||||
|
- The safe probe endpoint `/automation/probe-click?index=1` focused Genshin,
|
||||||
|
moved the cursor to the second visible inventory tile, clicked it, and changed
|
||||||
|
the artifact detail panel fingerprint.
|
||||||
|
- Probe result: `clicked: true`, `inputBlocked: false`,
|
||||||
|
`foregroundProcess: "GenshinImpact"`, and `changed: true`.
|
||||||
|
- A bounded live auto-scan via `/scanner/start?limit=2` completed with:
|
||||||
|
`clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`,
|
||||||
|
`review: 2`, `misses: 0`, `status: "done"`.
|
||||||
|
|
||||||
|
This proves that the current elevated app plus helper path can deliver mouse
|
||||||
|
movement and click input to the focused Genshin client in this environment.
|
||||||
|
|
||||||
|
## Elevation And UAC
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
The command runs `scripts/dev-admin.ps1`, which launches a new elevated
|
||||||
|
PowerShell window running `scripts/dev-admin-start.ps1`. The elevated start is
|
||||||
|
logged to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
outputs/admin-start/admin-dev.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The user must confirm the Windows UAC prompt. The app cannot and must not click
|
||||||
|
the Secure Desktop UAC prompt for itself. After confirmation, the app can verify
|
||||||
|
its own runtime through the dev status endpoint.
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod http://127.0.0.1:17317/health
|
||||||
|
Invoke-RestMethod http://127.0.0.1:17317/scanner/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected runtime facts before automatic scan:
|
||||||
|
|
||||||
|
- `isElevated: true`
|
||||||
|
- `genshinFound: true`
|
||||||
|
- `targetProcess: "GenshinImpact"`
|
||||||
|
- hotkeys registered
|
||||||
|
|
||||||
|
## Mouse And Click Validation
|
||||||
|
|
||||||
|
Use the probe before broad auto-scan work:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" |
|
||||||
|
ConvertTo-Json -Depth 12
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe performs one read-only inventory selection click. It does not delete,
|
||||||
|
feed, enhance, lock, unlock, spend, or modify game resources.
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
|
||||||
|
- `click.ok: true`, `clicked: true`, `inputBlocked: false` means Windows did not
|
||||||
|
block SendInput/UIPI in the current configuration.
|
||||||
|
- `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was
|
||||||
|
sent while Genshin was foreground.
|
||||||
|
- `changed: true` means the detail panel changed after the click.
|
||||||
|
- `changed: false` can be benign if the target tile was already selected or two
|
||||||
|
neighboring artifacts render identically; retry with another `index`, `row`,
|
||||||
|
or `col`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Second visible tile
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1"
|
||||||
|
|
||||||
|
# Specific grid cell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?row=0&col=3"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bounded Live Auto-Scan
|
||||||
|
|
||||||
|
For live validation, prefer a bounded scan first:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then poll:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
|
||||||
|
ConvertTo-Json -Depth 12
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/scanner/start?limit=N` endpoint sends a renderer command payload with a
|
||||||
|
temporary scan limit. It does not change the normal UI setting. The normal
|
||||||
|
hotkeys and buttons still use the UI's configured scan limit.
|
||||||
|
|
||||||
|
## Anti-Cheat And Safety Boundary
|
||||||
|
|
||||||
|
Do not describe the current implementation as bypassing anti-cheat. The app
|
||||||
|
does not read memory, hook the process, inject code, modify game files, inspect
|
||||||
|
packets, or interact with kernel drivers. It uses normal Windows screen capture,
|
||||||
|
focus, cursor movement, wheel, and click input.
|
||||||
|
|
||||||
|
The practical finding is narrower:
|
||||||
|
|
||||||
|
- A non-elevated app can be blocked by Windows integrity/UIPI when the target
|
||||||
|
process is elevated or protected.
|
||||||
|
- Running the app elevated fixed input delivery in the tested environment.
|
||||||
|
- Genshin's anti-cheat may still affect behavior on other machines, game modes,
|
||||||
|
overlays, or future versions. Re-run the probe before trusting broad scans.
|
||||||
|
|
||||||
|
Never add automation that deletes, feeds, enhances, locks/unlocks, spends
|
||||||
|
resources, reads memory, hooks, injects, or modifies Genshin.
|
||||||
|
|
||||||
|
## Live Layout Facts
|
||||||
|
|
||||||
|
The current 16:9 layout profile is calibrated from a 1920x1080 English
|
||||||
|
artifact-inventory capture:
|
||||||
|
|
||||||
|
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
|
||||||
|
- inventory grid: `8 x 5`
|
||||||
|
- first tile center: `x=179`, `y=254`, `row=0`, `col=0`
|
||||||
|
- second tile center: `x=325`, `y=254`, `row=0`, `col=1`
|
||||||
|
- inventory count crop successfully read `2059/2400` in the live session
|
||||||
|
|
||||||
|
The profile is resolution-scaled for 16:9. Off-profile setups should be treated
|
||||||
|
as higher risk and validated with Smart Capture plus the probe.
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Before marking an automation change done:
|
||||||
|
|
||||||
|
1. Run `npm run lint`.
|
||||||
|
2. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
|
||||||
|
3. Run `npm test`.
|
||||||
|
4. Run `npm run build`.
|
||||||
|
5. If Genshin is available, run `/automation/probe-click?index=1`.
|
||||||
|
6. For scan-loop changes, run `/scanner/start?limit=2` before any broader scan.
|
||||||
|
7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
||||||
@@ -15,6 +15,7 @@ This document contains Architecture Decision Records.
|
|||||||
| ADR-007 | Measure OCR accuracy with a labeled eval harness before reworking the scanner | Accepted | 2026-07-05 |
|
| ADR-007 | Measure OCR accuracy with a labeled eval harness before reworking the scanner | Accepted | 2026-07-05 |
|
||||||
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
|
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
|
||||||
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
|
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
|
||||||
|
| ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
|
||||||
|
|
||||||
## ADR-001: Build A Local Electron App First
|
## ADR-001: Build A Local Electron App First
|
||||||
|
|
||||||
@@ -235,3 +236,53 @@ validated against the ADR-007 eval harness.
|
|||||||
insufficient.
|
insufficient.
|
||||||
- Non-16:9 or non-borderless setups are explicitly unsupported for the auto
|
- Non-16:9 or non-borderless setups are explicitly unsupported for the auto
|
||||||
scanner; the app should detect and warn rather than silently misread.
|
scanner; the app should detect and warn rather than silently misread.
|
||||||
|
|
||||||
|
## ADR-010: Elevated Dev Runner And Bounded Live Automation Probes
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Automatic grid scanning needs read-only mouse movement, click, and wheel input
|
||||||
|
to reach the focused Genshin window. A lower-integrity app can fail to deliver
|
||||||
|
input to an elevated or protected target because of Windows UIPI/integrity
|
||||||
|
boundaries. During live testing, `npm run dev:admin` originally printed that a
|
||||||
|
new Administrator window was started, but the elevated PowerShell received no
|
||||||
|
arguments, so the intended dev process did not reliably start.
|
||||||
|
|
||||||
|
The project also needed a smaller live validation path than a full inventory
|
||||||
|
scan. A full scan is too risky as the first proof of input delivery because it
|
||||||
|
can click many tiles before a bad coordinate, focus issue, or blocked input is
|
||||||
|
understood.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Keep automatic scan input automation read-only and require an elevated runtime
|
||||||
|
when Windows reports that automation would otherwise be blocked. Replace the
|
||||||
|
old `dev-admin.cmd` entry with `scripts/dev-admin.ps1`, quote the elevated
|
||||||
|
PowerShell arguments explicitly, and log elevated startup to
|
||||||
|
`outputs/admin-start/admin-dev.log`.
|
||||||
|
|
||||||
|
Add dev-only HTTP checks:
|
||||||
|
|
||||||
|
- `/automation/probe-click?index=N` or `?row=R&col=C` performs one safe
|
||||||
|
inventory selection click and verifies whether the detail panel changed.
|
||||||
|
- `/scanner/start?limit=N` sends a temporary scan-limit payload to the renderer,
|
||||||
|
so live auto-scan validation can start with two items instead of the UI
|
||||||
|
default.
|
||||||
|
|
||||||
|
Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The user still has to approve Windows UAC manually; the app must not try to
|
||||||
|
click the Secure Desktop prompt.
|
||||||
|
- We can distinguish input delivery from OCR/parser quality with a one-click
|
||||||
|
probe before running any broader scan.
|
||||||
|
- Live validation now has a low-risk path: check elevation and Genshin
|
||||||
|
detection, run a single probe click, then run a bounded `limit=2` scan.
|
||||||
|
- The implementation remains inside the allowed safety boundary: no memory
|
||||||
|
reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
|
||||||
|
locking/unlocking, or spending resources.
|
||||||
|
|||||||
+9
-5
@@ -72,7 +72,7 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
||||||
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
||||||
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
||||||
| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path |
|
| Input automation | C# sidecar with elevated dev runner when needed | Live-validated for read-only inventory selection clicks; see `docs/AUTOMATION_LIVE_SCAN.md` |
|
||||||
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
||||||
| Packaging | electron-builder | Configured in `package.json` |
|
| Packaging | electron-builder | Configured in `package.json` |
|
||||||
|
|
||||||
@@ -95,11 +95,15 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
||||||
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
||||||
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
||||||
|
- Elevated live automation is validated in the current dev environment:
|
||||||
|
`/automation/probe-click?index=1` changed the selected artifact and
|
||||||
|
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
|
||||||
|
|
||||||
### What is still structurally weak
|
### What is still structurally weak
|
||||||
|
|
||||||
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
|
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
|
||||||
- The current PowerShell input sidecar is serviceable for experimentation but not a strong production base for long-running, low-jitter auto-scan.
|
- Broader scan soak testing still needs to increase the live limit gradually and
|
||||||
|
validate scroll/page transitions beyond the first visible row.
|
||||||
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
|
- 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.
|
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
||||||
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
|
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
|
||||||
@@ -196,7 +200,7 @@ Outcome:
|
|||||||
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
|
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
|
||||||
|
|
||||||
Status:
|
Status:
|
||||||
- Planned
|
- First live path validated; broader soak testing still needed
|
||||||
|
|
||||||
### Phase 5 - Learning loop that actually compounds
|
### Phase 5 - Learning loop that actually compounds
|
||||||
|
|
||||||
@@ -228,7 +232,7 @@ Status:
|
|||||||
1. Finish scan-page cleanup so the main operator view is no longer noisy.
|
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.
|
2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples.
|
||||||
3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
|
3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
|
||||||
4. Replace or wrap the current PowerShell sidecar with a more stable long-lived automation process.
|
4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions.
|
||||||
5. Extend the learning system from text-only fixes into crop/UI profile tuning.
|
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.
|
6. Resume recommendation work only when scan accuracy is consistently trustworthy.
|
||||||
|
|
||||||
@@ -236,7 +240,7 @@ Status:
|
|||||||
|
|
||||||
| Question | Status |
|
| Question | Status |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Should the production input sidecar be Rust/C++ first, or a transitional Node native addon, for the next iteration? | Open |
|
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
|
||||||
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
||||||
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
||||||
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Scanner rework status
|
# Scanner rework status
|
||||||
|
|
||||||
Progress on the approved scanner/OCR rework. See ADR-007/008/009 in
|
Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in
|
||||||
[DECISIONS.md](DECISIONS.md) for the decisions behind these.
|
[DECISIONS.md](DECISIONS.md) for the decisions behind these. For the current
|
||||||
|
live automation runbook, see
|
||||||
|
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||||
|
|
||||||
## Done (implemented, unit-tested, build green)
|
## Done (implemented, unit-tested, build green)
|
||||||
|
|
||||||
@@ -12,34 +14,44 @@ Progress on the approved scanner/OCR rework. See ADR-007/008/009 in
|
|||||||
fallback. Verified end-to-end (spawn, runtime, base64 capture).
|
fallback. Verified end-to-end (spawn, runtime, base64 capture).
|
||||||
- **Layout profiles + OCR preprocessing** — `src/lib/layoutProfile.ts` (pure
|
- **Layout profiles + OCR preprocessing** — `src/lib/layoutProfile.ts` (pure
|
||||||
geometry, 16:9 detection), `src/lib/ocrPreprocess.ts` (grayscale + Otsu
|
geometry, 16:9 detection), `src/lib/ocrPreprocess.ts` (grayscale + Otsu
|
||||||
binarize). main.ts crops via the profile and OCRs an upscaled + binarized copy.
|
binarize). main.ts now uses calibrated 16:9 detail/count/grid coordinates
|
||||||
|
first and OCRs an upscaled + binarized copy.
|
||||||
- **Card-ready gating** — `src/lib/cardReadyGate.ts` replaces the fixed 280 ms
|
- **Card-ready gating** — `src/lib/cardReadyGate.ts` replaces the fixed 280 ms
|
||||||
settle with change+stability polling; robust to animation.
|
settle with change+stability polling; robust to animation.
|
||||||
- **GOOD interop** — `src/lib/goodInterop.ts` (export + best-effort import for
|
- **GOOD interop** — `src/lib/goodInterop.ts` (export + best-effort import for
|
||||||
scanned records).
|
scanned records), Electron file-picker import/export, and store merge.
|
||||||
- **Rescan-merge** — `src/lib/artifactMerge.ts` collapses leveled re-scan
|
- **Rescan-merge** — `src/lib/artifactMerge.ts` collapses leveled re-scan
|
||||||
duplicates by a level-independent identity.
|
duplicates by a level-independent identity.
|
||||||
- **Data staleness warning** — `src/lib/dataPackageStatus.ts`, surfaced in the
|
- **Data staleness warning** — `src/lib/dataPackageStatus.ts`, surfaced in the
|
||||||
Scanner Diagnose data-package line.
|
Scanner Diagnose data-package line.
|
||||||
- **Lock detection (experimental)** — `src/lib/lockDetection.ts`, pure heuristic,
|
- **Lock detection (experimental)** — `src/lib/lockDetection.ts`, wired into
|
||||||
not yet wired into capture.
|
live capture as a read-only `locked` flag and persisted with scanned records.
|
||||||
|
- **Elevated live automation path** — `npm run dev:admin` now starts through
|
||||||
|
`scripts/dev-admin.ps1` and logs to `outputs/admin-start/admin-dev.log`.
|
||||||
|
Live status confirmed `isElevated: true`, `genshinFound: true`, and
|
||||||
|
`targetProcess: "GenshinImpact"`.
|
||||||
|
- **Read-only click probe** — `/automation/probe-click?index=1` verified that
|
||||||
|
the app can focus Genshin, move to a visible inventory tile, click it, and
|
||||||
|
observe a changed detail panel fingerprint (`clicked: true`,
|
||||||
|
`inputBlocked: false`, `changed: true`).
|
||||||
|
- **Bounded auto-scan validation** — `/scanner/start?limit=2` completed live
|
||||||
|
with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records,
|
||||||
|
2 review samples, and 0 misses.
|
||||||
|
|
||||||
## Remaining — needs the live environment or a UI pass
|
## Remaining — needs the live environment or a UI pass
|
||||||
|
|
||||||
These cannot be finished/validated without Genshin running at the user's
|
These cannot be finished/validated without Genshin running at the user's
|
||||||
resolution or without UI work best tested live:
|
resolution or without UI work best tested live:
|
||||||
|
|
||||||
1. **Calibrate IK-style fixed crop coordinates** (ADR-009). The layout module is
|
1. **Validate/tune OCR preprocessing** on more real captures — confirm invert +
|
||||||
the structure; the exact per-field fractions still come from a
|
|
||||||
colour-detected/fallback detail rect. A reference 16:9 screenshot of the
|
|
||||||
artifact screen lets us pin exact client-relative crop coordinates.
|
|
||||||
2. **Validate/tune OCR preprocessing** on real captures — confirm invert +
|
|
||||||
threshold + upscale factor help (not hurt) actual Tesseract reads. The
|
threshold + upscale factor help (not hurt) actual Tesseract reads. The
|
||||||
text-level eval harness cannot measure image preprocessing.
|
text-level eval harness cannot measure image preprocessing.
|
||||||
3. **Wire GOOD import** — file-picker IPC + merge imported records into the store
|
2. **Validate locked=true** against a known locked artifact — unlocked/grey lock
|
||||||
(the conversion engine is done and tested).
|
was live-checked; a gold locked icon still needs a positive sample.
|
||||||
4. **Wire live lock detection** — calibrate crop position/threshold against a
|
|
||||||
reference screenshot, then populate a `locked` flag during capture.
|
3. **Broader scan soak test** — after the bounded two-item live scan passed,
|
||||||
|
the next automation validation should increase the limit gradually and watch
|
||||||
|
for repeated pages, scroll behavior, duplicate handling, and OCR review rate.
|
||||||
|
|
||||||
## Grow the eval corpus
|
## Grow the eval corpus
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
SaveResultWithPath,
|
SaveResultWithPath,
|
||||||
SaveSnapshotResult,
|
SaveSnapshotResult,
|
||||||
GoodDatabase,
|
GoodDatabase,
|
||||||
|
GoodImportFileResult,
|
||||||
ScannerStatusPayload,
|
ScannerStatusPayload,
|
||||||
} from "../../src/types/global.js";
|
} from "../../src/types/global.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -51,6 +52,7 @@ interface PersistenceHandlersDependencies {
|
|||||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaptureHandlersDependencies {
|
interface CaptureHandlersDependencies {
|
||||||
@@ -86,6 +88,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
|||||||
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
||||||
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
||||||
exportGood: dependencies.exportGood,
|
exportGood: dependencies.exportGood,
|
||||||
|
importGoodFile: dependencies.importGoodFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerCaptureHandlers({
|
registerCaptureHandlers({
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import http, { type Server } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
CaptureOptions,
|
||||||
|
CaptureResult,
|
||||||
|
CaptureSourceInfo,
|
||||||
|
ClickResult,
|
||||||
|
ReviewSampleListResult,
|
||||||
|
ScannerCommand,
|
||||||
|
ScannerStatusPayload,
|
||||||
|
} from "../src/types/global.js";
|
||||||
|
|
||||||
|
interface DevControlServerDependencies {
|
||||||
|
registeredHotkeys: Record<string, boolean>;
|
||||||
|
hasMainWindow: () => boolean;
|
||||||
|
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||||
|
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||||
|
scannerStatus: () => ScannerStatusPayload;
|
||||||
|
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||||
|
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||||
|
captureSource: (
|
||||||
|
id: string,
|
||||||
|
delayMs?: number,
|
||||||
|
focusGenshin?: boolean,
|
||||||
|
options?: CaptureOptions,
|
||||||
|
) => Promise<CaptureResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlBase64(dataUrl: string) {
|
||||||
|
return dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function devCaptureOutputDir() {
|
||||||
|
return path.join(process.cwd(), "outputs", "live-capture");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDebugFilePart(value: string) {
|
||||||
|
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlFingerprint(dataUrl: string | undefined) {
|
||||||
|
if (!dataUrl) return "";
|
||||||
|
let hash = 2166136261;
|
||||||
|
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
|
||||||
|
for (let index = 0; index < dataUrl.length; index += stride) {
|
||||||
|
hash ^= dataUrl.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) {
|
||||||
|
if (!dataUrl) return null;
|
||||||
|
await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64"));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDevCaptureSnapshot(capture: CaptureResult) {
|
||||||
|
const outputDir = devCaptureOutputDir();
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "");
|
||||||
|
const prefix = safeDebugFilePart(`${stamp}-${capture.name}`);
|
||||||
|
const files = {
|
||||||
|
full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl),
|
||||||
|
detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl),
|
||||||
|
inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl),
|
||||||
|
crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const crop of capture.crops ?? []) {
|
||||||
|
const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`);
|
||||||
|
const written = await writeDevCaptureImage(cropPath, crop.dataUrl);
|
||||||
|
if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
id: capture.id,
|
||||||
|
name: capture.name,
|
||||||
|
width: capture.width,
|
||||||
|
height: capture.height,
|
||||||
|
capturedAt: capture.capturedAt,
|
||||||
|
captureTarget: capture.captureTarget,
|
||||||
|
ocrSkipped: capture.ocrSkipped,
|
||||||
|
ocrTimedOut: capture.ocrTimedOut,
|
||||||
|
layout: capture.layout,
|
||||||
|
inventoryGrid: capture.inventoryGrid
|
||||||
|
? {
|
||||||
|
rows: capture.inventoryGrid.rows,
|
||||||
|
cols: capture.inventoryGrid.cols,
|
||||||
|
confidence: capture.inventoryGrid.confidence,
|
||||||
|
source: capture.inventoryGrid.source,
|
||||||
|
firstCenter: capture.inventoryGrid.centers[0] ?? null,
|
||||||
|
lastCenter: capture.inventoryGrid.centers.at(-1) ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
inventoryCount: capture.inventoryCount ?? null,
|
||||||
|
locked: capture.locked,
|
||||||
|
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
|
||||||
|
ocr: capture.ocr ?? [],
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
const summaryPath = path.join(outputDir, `${prefix}-summary.json`);
|
||||||
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8");
|
||||||
|
return { ...summary, summaryPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) {
|
||||||
|
return sourceId
|
||||||
|
? sources.find((entry) => entry.id === sourceId)
|
||||||
|
: sources.find((entry) => entry.isGenshinCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceListForError(sources: CaptureSourceInfo[]) {
|
||||||
|
return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDevControlServer(deps: DevControlServerDependencies): Server {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
||||||
|
writeDevJson(res, 403, { ok: false, error: "local only" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/scanner/start") {
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||||
|
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||||
|
? { type: "start-auto", scanLimit: limit }
|
||||||
|
: "start-auto";
|
||||||
|
deps.sendScannerCommand(command);
|
||||||
|
writeDevJson(res, 200, { ok: true, command });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/scanner/stop") {
|
||||||
|
deps.sendScannerCommand("stop");
|
||||||
|
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/scanner/probe") {
|
||||||
|
deps.sendScannerCommand("probe-click");
|
||||||
|
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/automation/click") {
|
||||||
|
const x = Number(url.searchParams.get("x"));
|
||||||
|
const y = Number(url.searchParams.get("y"));
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||||
|
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.clickScreen(Math.round(x), Math.round(y))
|
||||||
|
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
||||||
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/scanner/status") {
|
||||||
|
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/review/samples") {
|
||||||
|
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||||
|
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||||
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/capture/smart") {
|
||||||
|
const sourceId = url.searchParams.get("sourceId");
|
||||||
|
const focus = url.searchParams.get("focus") !== "0";
|
||||||
|
const skipOcr = url.searchParams.get("skipOcr") === "1";
|
||||||
|
deps.listCaptureSources()
|
||||||
|
.then(async (sources) => {
|
||||||
|
const source = findGenshinSource(sources, sourceId);
|
||||||
|
if (!source) {
|
||||||
|
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const capture = await deps.captureSource(source.id, 250, focus, { skipOcr });
|
||||||
|
const summary = await writeDevCaptureSnapshot(capture);
|
||||||
|
writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/automation/probe-click") {
|
||||||
|
const sourceId = url.searchParams.get("sourceId");
|
||||||
|
const requestedIndex = Number(url.searchParams.get("index") ?? "1");
|
||||||
|
const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN);
|
||||||
|
const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN);
|
||||||
|
deps.listCaptureSources()
|
||||||
|
.then(async (sources) => {
|
||||||
|
const source = findGenshinSource(sources, sourceId);
|
||||||
|
if (!source) {
|
||||||
|
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true });
|
||||||
|
const centers = before.inventoryGrid?.centers ?? [];
|
||||||
|
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
|
||||||
|
? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
|
||||||
|
: centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))];
|
||||||
|
if (!target) {
|
||||||
|
writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl);
|
||||||
|
const click = await deps.clickScreen(target.x, target.y);
|
||||||
|
await wait(650);
|
||||||
|
const after = await deps.captureSource(source.id, 0, true, { skipOcr: true });
|
||||||
|
const afterFingerprint = dataUrlFingerprint(after.detailDataUrl);
|
||||||
|
const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint);
|
||||||
|
writeDevJson(res, 200, {
|
||||||
|
ok: Boolean(click.ok && click.clicked && changed),
|
||||||
|
changed,
|
||||||
|
target,
|
||||||
|
click,
|
||||||
|
before: {
|
||||||
|
captureTarget: before.captureTarget,
|
||||||
|
grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null,
|
||||||
|
detailFingerprint: beforeFingerprint,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
captureTarget: after.captureTarget,
|
||||||
|
grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null,
|
||||||
|
detailFingerprint: afterFingerprint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(17317, "127.0.0.1");
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
SaveScannerLearningRulesResult,
|
SaveScannerLearningRulesResult,
|
||||||
ScannerLearningRulePayload,
|
ScannerLearningRulePayload,
|
||||||
SaveResultWithPath,
|
SaveResultWithPath,
|
||||||
|
GoodImportFileResult,
|
||||||
} from "../../src/types/global.js";
|
} from "../../src/types/global.js";
|
||||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ interface PersistenceDependencies {
|
|||||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPersistenceHandlers({
|
export function registerPersistenceHandlers({
|
||||||
@@ -39,6 +41,7 @@ export function registerPersistenceHandlers({
|
|||||||
loadScannerLearningRules,
|
loadScannerLearningRules,
|
||||||
writeScannerLearningRules,
|
writeScannerLearningRules,
|
||||||
exportGood,
|
exportGood,
|
||||||
|
importGoodFile,
|
||||||
}: PersistenceDependencies) {
|
}: PersistenceDependencies) {
|
||||||
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
||||||
try {
|
try {
|
||||||
@@ -81,4 +84,8 @@ export function registerPersistenceHandlers({
|
|||||||
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||||
return exportGood(payload);
|
return exportGood(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("good:importFile", async () => {
|
||||||
|
return importGoodFile();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-65
@@ -1,19 +1,22 @@
|
|||||||
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import http, { type Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createWorker } from "tesseract.js";
|
import { createWorker } from "tesseract.js";
|
||||||
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
||||||
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
||||||
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
||||||
|
import { createDevControlServer } from "./devControlServer.js";
|
||||||
import type { AppSnapshot } from "../src/types/domain.js";
|
import type { AppSnapshot } from "../src/types/domain.js";
|
||||||
import type {
|
import type {
|
||||||
CaptureOptions,
|
CaptureOptions,
|
||||||
CaptureResult,
|
CaptureResult,
|
||||||
GoodDatabase,
|
GoodDatabase,
|
||||||
|
GoodImportFileResult,
|
||||||
SaveResultWithPath,
|
SaveResultWithPath,
|
||||||
|
ScannerCommand,
|
||||||
ScannerLearningRulePayload,
|
ScannerLearningRulePayload,
|
||||||
ScannerStatusPayload,
|
ScannerStatusPayload,
|
||||||
} from "../src/types/global.js";
|
} from "../src/types/global.js";
|
||||||
@@ -33,6 +36,7 @@ import {
|
|||||||
profileDetailRect,
|
profileDetailRect,
|
||||||
} from "../src/lib/layoutProfile.js";
|
} from "../src/lib/layoutProfile.js";
|
||||||
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
|
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
|
||||||
|
import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.js";
|
||||||
|
|
||||||
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
||||||
// crash the GPU/renderer process) when the hosting process runs with a full
|
// crash the GPU/renderer process) when the hosting process runs with a full
|
||||||
@@ -416,7 +420,7 @@ function focusMainWindow() {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendScannerCommand(command: "start-auto" | "stop" | "probe-click") {
|
function sendScannerCommand(command: ScannerCommand | "probe-click") {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
mainWindow.webContents.send("scanner:command", command);
|
mainWindow.webContents.send("scanner:command", command);
|
||||||
}
|
}
|
||||||
@@ -431,71 +435,18 @@ function registerScannerHotkeys() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
|
||||||
res.writeHead(statusCode, {
|
|
||||||
"content-type": "application/json; charset=utf-8",
|
|
||||||
"cache-control": "no-store",
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDevControlServer() {
|
function startDevControlServer() {
|
||||||
if (!isDev || devControlServer) return;
|
if (!isDev || devControlServer) return;
|
||||||
|
devControlServer = createDevControlServer({
|
||||||
devControlServer = http.createServer((req, res) => {
|
registeredHotkeys,
|
||||||
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||||
writeDevJson(res, 403, { ok: false, error: "local only" });
|
sendScannerCommand,
|
||||||
return;
|
clickScreen: clickScreenCommand,
|
||||||
}
|
scannerStatus: () => scannerDevStatus,
|
||||||
|
loadReviewSamples,
|
||||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
listCaptureSources,
|
||||||
if (url.pathname === "/health") {
|
captureSource,
|
||||||
writeDevJson(res, 200, { ok: true, hotkeys: registeredHotkeys, hasWindow: Boolean(mainWindow && !mainWindow.isDestroyed()) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/scanner/start") {
|
|
||||||
sendScannerCommand("start-auto");
|
|
||||||
writeDevJson(res, 200, { ok: true, command: "start-auto" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/scanner/stop") {
|
|
||||||
sendScannerCommand("stop");
|
|
||||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/scanner/probe") {
|
|
||||||
sendScannerCommand("probe-click");
|
|
||||||
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/automation/click") {
|
|
||||||
const x = Number(url.searchParams.get("x"));
|
|
||||||
const y = Number(url.searchParams.get("y"));
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
||||||
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getInputHelperService()
|
|
||||||
.clickScreen(Math.round(x), Math.round(y))
|
|
||||||
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
|
||||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/scanner/status") {
|
|
||||||
writeDevJson(res, 200, { ok: true, status: scannerDevStatus });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/review/samples") {
|
|
||||||
loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
|
||||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
|
||||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
devControlServer.listen(17317, "127.0.0.1");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverlayWindow() {
|
function createOverlayWindow() {
|
||||||
@@ -802,6 +753,10 @@ function createCrops(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
||||||
|
if (isSixteenNine(imageSize)) {
|
||||||
|
return profileDetailRect(imageSize);
|
||||||
|
}
|
||||||
|
|
||||||
const { width, height } = imageSize;
|
const { width, height } = imageSize;
|
||||||
const sampleStrideX = width > 2200 ? 4 : 3;
|
const sampleStrideX = width > 2200 ? 4 : 3;
|
||||||
const sampleStrideY = height > 1400 ? 4 : 3;
|
const sampleStrideY = height > 1400 ? 4 : 3;
|
||||||
@@ -869,6 +824,12 @@ async function buildCaptureResult(
|
|||||||
const detailRect = inferDetailRect(bitmap, size);
|
const detailRect = inferDetailRect(bitmap, size);
|
||||||
const inventoryRect = inferInventoryRect(size, detailRect);
|
const inventoryRect = inferInventoryRect(size, detailRect);
|
||||||
const crops = createCrops(sourceImage, size, detailRect, inventoryRect);
|
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) => ({
|
const croppedPayload = crops.map((crop) => ({
|
||||||
id: crop.id,
|
id: crop.id,
|
||||||
label: crop.label,
|
label: crop.label,
|
||||||
@@ -905,6 +866,7 @@ async function buildCaptureResult(
|
|||||||
})),
|
})),
|
||||||
inventoryGrid: inferInventoryGrid(size, detailRect),
|
inventoryGrid: inferInventoryGrid(size, detailRect),
|
||||||
inventoryCount: count,
|
inventoryCount: count,
|
||||||
|
locked,
|
||||||
layout: {
|
layout: {
|
||||||
aspect: aspectRatioLabel(size),
|
aspect: aspectRatioLabel(size),
|
||||||
isSixteenNine: isSixteenNine(size),
|
isSixteenNine: isSixteenNine(size),
|
||||||
@@ -965,6 +927,34 @@ async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importGoodFile(): Promise<GoodImportFileResult> {
|
||||||
|
const dialogOptions = {
|
||||||
|
title: "GOOD-Datei importieren",
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
|
||||||
|
} satisfies Electron.OpenDialogOptions;
|
||||||
|
const dialogResult = mainWindow && !mainWindow.isDestroyed()
|
||||||
|
? await dialog.showOpenDialog(mainWindow, dialogOptions)
|
||||||
|
: await dialog.showOpenDialog(dialogOptions);
|
||||||
|
|
||||||
|
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
|
||||||
|
return { ok: false, canceled: true, path: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = dialogResult.filePaths[0];
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(filePath, "utf8");
|
||||||
|
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
canceled: false,
|
||||||
|
path: filePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initializeAppLifecycle() {
|
function initializeAppLifecycle() {
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
const userDataPath = app.getPath("userData");
|
const userDataPath = app.getPath("userData");
|
||||||
@@ -993,6 +983,7 @@ function initializeAppLifecycle() {
|
|||||||
loadScannerLearningRules: () => loadScannerLearningRules(),
|
loadScannerLearningRules: () => loadScannerLearningRules(),
|
||||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
|
||||||
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
|
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
|
||||||
|
importGoodFile: () => importGoodFile(),
|
||||||
listSources: () => listCaptureSources(),
|
listSources: () => listCaptureSources(),
|
||||||
captureSource: (
|
captureSource: (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||||
|
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||||
import type { AppSnapshot } from "../src/types/domain.js";
|
import type { AppSnapshot } from "../src/types/domain.js";
|
||||||
|
|
||||||
@@ -23,11 +23,12 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||||
|
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||||
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => {
|
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command);
|
const listener = (_event: Electron.IpcRendererEvent, command: ScannerCommand) => callback(command);
|
||||||
ipcRenderer.on("scanner:command", listener);
|
ipcRenderer.on("scanner:command", listener);
|
||||||
return () => ipcRenderer.removeListener("scanner:command", listener);
|
return () => ipcRenderer.removeListener("scanner:command", listener);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
|
|||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
timesSeen: (existing.timesSeen ?? 1) + 1,
|
timesSeen: (existing.timesSeen ?? 1) + 1,
|
||||||
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
|
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
|
||||||
|
locked: typeof record.locked === "boolean" ? record.locked : existing.locked,
|
||||||
// A later confident scan clears the review flag; an uncertain rescan
|
// A later confident scan clears the review flag; an uncertain rescan
|
||||||
// must not downgrade an already confirmed artifact.
|
// must not downgrade an already confirmed artifact.
|
||||||
needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview),
|
needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview),
|
||||||
@@ -136,6 +137,7 @@ function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
|
|||||||
...record,
|
...record,
|
||||||
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
|
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
|
||||||
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
|
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
|
||||||
|
locked: typeof record.locked === "boolean" ? record.locked : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredAr
|
|||||||
substats: [...(preferredSubstats ?? [])],
|
substats: [...(preferredSubstats ?? [])],
|
||||||
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
|
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
|
||||||
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
|
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
|
||||||
|
locked: typeof incoming.locked === "boolean" ? incoming.locked : existing.locked,
|
||||||
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
|
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
|
||||||
source: resolveStoredArtifactSource(existing.source, incoming.source),
|
source: resolveStoredArtifactSource(existing.source, incoming.source),
|
||||||
firstSeenAt: existing.firstSeenAt ?? now,
|
firstSeenAt: existing.firstSeenAt ?? now,
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-electron\\electron\\preload.cjs",
|
"predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-electron\\electron\\preload.cjs",
|
||||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
|
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
|
||||||
"dev:web": "vite --host 127.0.0.1",
|
"dev:web": "vite --host 127.0.0.1",
|
||||||
"dev:admin": ".\\dev-admin.cmd",
|
"dev:admin": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\dev-admin.ps1 -ProjectRoot .",
|
||||||
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
|
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||||||
|
$logDir = Join-Path $project "outputs\admin-start"
|
||||||
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||||
|
$logPath = Join-Path $logDir "admin-dev.log"
|
||||||
|
try {
|
||||||
|
Start-Transcript -Path $logPath -Append | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host "WARNUNG: Admin-Start-Log konnte nicht geschrieben werden: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Projekt: $project"
|
Write-Host "Projekt: $project"
|
||||||
|
Write-Host "Admin-Log: $logPath"
|
||||||
|
|
||||||
# A UAC-elevated process gets its environment rebuilt fresh from the
|
# A UAC-elevated process gets its environment rebuilt fresh from the
|
||||||
# registry; it does NOT inherit PATH edits that only exist in the calling
|
# registry; it does NOT inherit PATH edits that only exist in the calling
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
param(
|
||||||
|
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Quote-ProcessArgument([string]$Value) {
|
||||||
|
return '"' + $Value.Replace('"', '\"') + '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||||||
|
$script = Join-Path $PSScriptRoot "dev-admin-start.ps1"
|
||||||
|
$powershellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
$arguments = @(
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy Bypass",
|
||||||
|
"-NoExit",
|
||||||
|
"-File $(Quote-ProcessArgument $script)",
|
||||||
|
"-ProjectRoot $(Quote-ProcessArgument $project)"
|
||||||
|
) -join " "
|
||||||
|
|
||||||
|
Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev." -ForegroundColor Green
|
||||||
|
Write-Host "Dieses Fenster kann geschlossen werden; das eigentliche Programm laeuft im neuen Administrator-Fenster."
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:" -ForegroundColor Red
|
||||||
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useRef, useState, type ChangeEvent } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
|
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
|
||||||
import type { CaptureResult } from "../../../types/global";
|
import type { CaptureResult } from "../../../types/global";
|
||||||
import type { ScanViewControllerResult } from "../types";
|
import type { ScanViewControllerResult } from "../types";
|
||||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase } from "../../../lib/goodInterop";
|
|
||||||
import { FieldConfidenceList } from "./ScanResultCards";
|
import { FieldConfidenceList } from "./ScanResultCards";
|
||||||
import { useScanDiagnosticsModalModel } from "./modals/hooks/useScanDiagnosticsModalModel";
|
import { useScanDiagnosticsModalModel } from "./modals/hooks/useScanDiagnosticsModalModel";
|
||||||
import { useScanDetailsModalModel } from "./modals/hooks/useScanDetailsModalModel";
|
import { useScanDetailsModalModel } from "./modals/hooks/useScanDetailsModalModel";
|
||||||
@@ -59,7 +58,6 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [interopStatus, setInteropStatus] = useState("");
|
const [interopStatus, setInteropStatus] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleExportGood = async () => {
|
const handleExportGood = async () => {
|
||||||
setInteropStatus("Exportiere GOOD...");
|
setInteropStatus("Exportiere GOOD...");
|
||||||
@@ -71,27 +69,20 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportGood = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleImportGood = async () => {
|
||||||
const file = event.target.files?.[0];
|
setInteropStatus("Waehle GOOD-Datei...");
|
||||||
event.target.value = "";
|
const result = await controller.importGoodFromFile();
|
||||||
if (!file) return;
|
if (result.canceled) {
|
||||||
try {
|
setInteropStatus("GOOD-Import abgebrochen.");
|
||||||
const database = JSON.parse(await file.text()) as GoodImportDatabase;
|
|
||||||
const records = goodDatabaseToStoredArtifacts(database);
|
|
||||||
if (records.length === 0) {
|
|
||||||
setInteropStatus("Keine gueltigen Artifacts in der Datei gefunden.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInteropStatus(`Importiere ${records.length} Artifacts...`);
|
|
||||||
const result = await controller.importGoodArtifacts(records);
|
|
||||||
setInteropStatus(
|
setInteropStatus(
|
||||||
result.ok
|
result.ok
|
||||||
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert.`
|
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert (${result.count} gelesen).`
|
||||||
: "Import fehlgeschlagen (App im Electron-Fenster oeffnen).",
|
: result.error === "No valid GOOD artifacts found."
|
||||||
|
? "Keine gueltigen Artifacts in der Datei gefunden."
|
||||||
|
: "Import fehlgeschlagen (Datei ist kein gueltiges GOOD/JSON oder Bridge fehlt).",
|
||||||
);
|
);
|
||||||
} catch {
|
|
||||||
setInteropStatus("Datei ist kein gueltiges GOOD/JSON.");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,11 +173,10 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
|||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
GOOD exportieren
|
GOOD exportieren
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" onClick={() => fileInputRef.current?.click()} disabled={!controller.canGoodInterop}>
|
<button className="ghost-button" onClick={handleImportGood} disabled={!controller.canGoodInterop}>
|
||||||
<Upload size={15} />
|
<Upload size={15} />
|
||||||
GOOD importieren
|
GOOD importieren
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileInputRef} type="file" accept="application/json,.json" hidden onChange={handleImportGood} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="scanner-subcopy">
|
<p className="scanner-subcopy">
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export async function persistParsedArtifact(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
|
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview, capture?.locked)]);
|
||||||
if (result?.ok) {
|
if (result?.ok) {
|
||||||
setStoredTotal(result.total);
|
setStoredTotal(result.total);
|
||||||
void onStoredArtifactsChanged?.();
|
void onStoredArtifactsChanged?.();
|
||||||
@@ -297,6 +297,7 @@ export async function saveReviewSample(
|
|||||||
})),
|
})),
|
||||||
inventoryGrid: capture.inventoryGrid,
|
inventoryGrid: capture.inventoryGrid,
|
||||||
inventoryCount: capture.inventoryCount,
|
inventoryCount: capture.inventoryCount,
|
||||||
|
locked: capture.locked,
|
||||||
ocr: capture.ocr,
|
ocr: capture.ocr,
|
||||||
},
|
},
|
||||||
parsed,
|
parsed,
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface ScanActionContext {
|
|||||||
focusDashboard: () => Promise<void>;
|
focusDashboard: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VisibleGridScanOptions {
|
||||||
|
scanLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||||
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
|
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
|
||||||
}
|
}
|
||||||
@@ -146,7 +150,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
|||||||
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
|
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
|
export async function runVisibleGridScan(context: ScanActionContext, options: VisibleGridScanOptions = {}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
autoScanRunning,
|
autoScanRunning,
|
||||||
bridgeReady,
|
bridgeReady,
|
||||||
@@ -167,11 +171,12 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
|
|||||||
persistParsedArtifact,
|
persistParsedArtifact,
|
||||||
saveReviewSample,
|
saveReviewSample,
|
||||||
shouldFlagArtifactForReview,
|
shouldFlagArtifactForReview,
|
||||||
scanLimit,
|
scanLimit: configuredScanLimit,
|
||||||
skipRows,
|
skipRows,
|
||||||
detectedInventoryCount,
|
detectedInventoryCount,
|
||||||
focusDashboard,
|
focusDashboard,
|
||||||
} = context;
|
} = context;
|
||||||
|
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
|
||||||
|
|
||||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { ScannerCommand } from "../../../types/global";
|
||||||
|
import type { VisibleGridScanOptions } from "./scanViewScanActions";
|
||||||
|
|
||||||
interface ScanCommandListenerInput {
|
interface ScanCommandListenerInput {
|
||||||
automationRepo?: AutomationRepositoryPort;
|
automationRepo?: AutomationRepositoryPort;
|
||||||
@@ -7,7 +9,7 @@ interface ScanCommandListenerInput {
|
|||||||
isScanning: boolean;
|
isScanning: boolean;
|
||||||
selectedSourceId: string;
|
selectedSourceId: string;
|
||||||
requestScanStop: (reason: string) => void;
|
requestScanStop: (reason: string) => void;
|
||||||
runVisibleGridScan: () => Promise<void>;
|
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScanCommandListener({
|
export function useScanCommandListener({
|
||||||
@@ -20,15 +22,16 @@ export function useScanCommandListener({
|
|||||||
}: ScanCommandListenerInput) {
|
}: ScanCommandListenerInput) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!automationRepo?.onCommand) return;
|
if (!automationRepo?.onCommand) return;
|
||||||
return automationRepo.onCommand((command: "start-auto" | "stop") => {
|
return automationRepo.onCommand((command: ScannerCommand) => {
|
||||||
if (command === "stop") {
|
if (command === "stop") {
|
||||||
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
|
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
const commandType = typeof command === "string" ? command : command.type;
|
||||||
void runVisibleGridScan();
|
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||||
|
const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit };
|
||||||
|
void runVisibleGridScan(options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
|
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
|
||||||
import {
|
import {
|
||||||
initializeLearningState,
|
initializeLearningState,
|
||||||
loadReviewQueue as loadReviewQueueFromRepo,
|
loadReviewQueue as loadReviewQueueFromRepo,
|
||||||
@@ -77,7 +77,7 @@ export interface ScanViewActionResult {
|
|||||||
loadReviewQueue: () => Promise<void>;
|
loadReviewQueue: () => Promise<void>;
|
||||||
openReviewQueue: () => Promise<void>;
|
openReviewQueue: () => Promise<void>;
|
||||||
runAutoReviewScan: () => Promise<void>;
|
runAutoReviewScan: () => Promise<void>;
|
||||||
runVisibleGridScan: () => Promise<void>;
|
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
|
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
|
||||||
@@ -262,11 +262,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
|||||||
await runAutoReviewScanAction(scanActionContext);
|
await runAutoReviewScanAction(scanActionContext);
|
||||||
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
|
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
|
||||||
|
|
||||||
const runVisibleGridScan = useCallback(async () => {
|
const runVisibleGridScan = useCallback(async (options: VisibleGridScanOptions = {}) => {
|
||||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runVisibleGridScanAction(scanActionContext);
|
await runVisibleGridScanAction(scanActionContext, options);
|
||||||
}, [
|
}, [
|
||||||
autoScanRunning,
|
autoScanRunning,
|
||||||
bridgeReady,
|
bridgeReady,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc
|
|||||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||||
import { storedArtifactsToGood } from "../../../lib/goodInterop";
|
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
|
||||||
export function useScanViewController({
|
export function useScanViewController({
|
||||||
@@ -174,6 +174,23 @@ export function useScanViewController({
|
|||||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||||
}, [artifactRepo, onStoredArtifactsChanged]);
|
}, [artifactRepo, onStoredArtifactsChanged]);
|
||||||
|
|
||||||
|
const importGoodFromFile = useCallback(async () => {
|
||||||
|
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||||
|
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||||
|
}
|
||||||
|
const fileResult = await exportRepo.importGoodFile();
|
||||||
|
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||||
|
if (!fileResult.ok) {
|
||||||
|
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||||
|
}
|
||||||
|
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||||
|
if (records.length === 0) {
|
||||||
|
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||||
|
}
|
||||||
|
const saved = await importGoodArtifacts(records);
|
||||||
|
return { ...saved, count: records.length, path: fileResult.path };
|
||||||
|
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||||
|
|
||||||
useScanViewStateSync({
|
useScanViewStateSync({
|
||||||
artifactRepo,
|
artifactRepo,
|
||||||
latestCapture,
|
latestCapture,
|
||||||
@@ -253,6 +270,7 @@ export function useScanViewController({
|
|||||||
runVisibleGridScan,
|
runVisibleGridScan,
|
||||||
canGoodInterop,
|
canGoodInterop,
|
||||||
exportGoodFromStore,
|
exportGoodFromStore,
|
||||||
|
importGoodFromFile,
|
||||||
importGoodArtifacts,
|
importGoodArtifacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,5 +82,6 @@ export interface ScanViewControllerResult {
|
|||||||
runVisibleGridScan: () => Promise<void>;
|
runVisibleGridScan: () => Promise<void>;
|
||||||
canGoodInterop: boolean;
|
canGoodInterop: boolean;
|
||||||
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
|
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
|
||||||
|
importGoodFromFile: () => Promise<{ ok: boolean; added: number; updated: number; count: number; canceled?: boolean; path?: string; error?: string }>;
|
||||||
importGoodArtifacts: (records: StoredArtifactRecord[]) => Promise<{ ok: boolean; added: number; updated: number }>;
|
importGoodArtifacts: (records: StoredArtifactRecord[]) => Promise<{ ok: boolean; added: number; updated: number }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
ClickResult,
|
ClickResult,
|
||||||
ReviewSampleListResult,
|
ReviewSampleListResult,
|
||||||
SaveScannerLearningRulesResult,
|
SaveScannerLearningRulesResult,
|
||||||
|
GoodImportFileResult,
|
||||||
} from "../../types/global";
|
} from "../../types/global";
|
||||||
|
|
||||||
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
|
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
|
||||||
@@ -67,6 +68,12 @@ const EMPTY_SAVE_RULES_RESULT: SaveScannerLearningRulesResult = {
|
|||||||
rules: {},
|
rules: {},
|
||||||
total: 0,
|
total: 0,
|
||||||
};
|
};
|
||||||
|
const EMPTY_GOOD_IMPORT_FILE_RESULT: GoodImportFileResult = {
|
||||||
|
ok: false,
|
||||||
|
canceled: false,
|
||||||
|
path: "",
|
||||||
|
error: "Electron bridge unavailable.",
|
||||||
|
};
|
||||||
|
|
||||||
async function createBridgeSafeCall<TResult>(
|
async function createBridgeSafeCall<TResult>(
|
||||||
callback: () => Promise<TResult> | TResult | null | undefined,
|
callback: () => Promise<TResult> | TResult | null | undefined,
|
||||||
@@ -205,6 +212,7 @@ export function createRendererRepositories(): RendererRepositories | null {
|
|||||||
|
|
||||||
const exportRepo: ScanExportPort = {
|
const exportRepo: ScanExportPort = {
|
||||||
exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT),
|
exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT),
|
||||||
|
importGoodFile: () => createBridgeSafeCall(() => bridge.importGoodFile(), EMPTY_GOOD_IMPORT_FILE_RESULT),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import type {
|
|||||||
ScannerStatusPayload,
|
ScannerStatusPayload,
|
||||||
ReviewSamplePayload,
|
ReviewSamplePayload,
|
||||||
GoodDatabase,
|
GoodDatabase,
|
||||||
|
GoodImportFileResult,
|
||||||
FocusGenshinResult,
|
FocusGenshinResult,
|
||||||
RuntimeInfo,
|
RuntimeInfo,
|
||||||
|
ScannerCommand,
|
||||||
LoadScannerLearningRulesResult,
|
LoadScannerLearningRulesResult,
|
||||||
SaveScannerLearningRulesResult,
|
SaveScannerLearningRulesResult,
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
@@ -62,7 +64,7 @@ export interface AutomationRepositoryPort {
|
|||||||
focusMainWindow(): Promise<BooleanResult>;
|
focusMainWindow(): Promise<BooleanResult>;
|
||||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||||
onCommand(callback: (command: "start-auto" | "stop") => void): () => void;
|
onCommand(callback: (command: ScannerCommand) => void): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayRepositoryPort {
|
export interface OverlayRepositoryPort {
|
||||||
@@ -71,6 +73,7 @@ export interface OverlayRepositoryPort {
|
|||||||
|
|
||||||
export interface ScanExportPort {
|
export interface ScanExportPort {
|
||||||
exportGood(payload: GoodDatabase): Promise<SaveResultWithPath>;
|
exportGood(payload: GoodDatabase): Promise<SaveResultWithPath>;
|
||||||
|
importGoodFile(): Promise<GoodImportFileResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererRepositories {
|
export interface RendererRepositories {
|
||||||
|
|||||||
@@ -264,6 +264,27 @@ describe("parseArtifactCandidate", () => {
|
|||||||
expect(parsed?.fields.substats.confidence).toBe(96);
|
expect(parsed?.fields.substats.confidence).toBe(96);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses the live calibrated 1080p Conductor circlet capture", () => {
|
||||||
|
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||||
|
"artifact-title": "Conductor's Top Hat",
|
||||||
|
"artifact-main-stat": "Circlet of Logos\nHP\n7. 0 % i",
|
||||||
|
"artifact-substats": "a +\n+ Energy Recharge+4.5%\n+ ATK+14\n- Elemental Mastery+19\n- ATK+5.3% (unactivated)",
|
||||||
|
"artifact-footer": "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(parsed?.name).toBe("Conductor's Top Hat");
|
||||||
|
expect(parsed?.slot).toBe("Circlet of Logos");
|
||||||
|
expect(parsed?.setName).toBe("Wanderer's Troupe");
|
||||||
|
expect(parsed?.mainStat).toBe("HP%");
|
||||||
|
expect(parsed?.mainValue).toBe("7.0%");
|
||||||
|
expect(parsed?.substats).toEqual([
|
||||||
|
"Energy Recharge+4.5%",
|
||||||
|
"ATK+14",
|
||||||
|
"Elemental Mastery+19",
|
||||||
|
"ATK%+5.3%",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a percent main value even when OCR misses the main stat label", () => {
|
it("keeps a percent main value even when OCR misses the main stat label", () => {
|
||||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||||
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
|
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
|
||||||
|
|||||||
@@ -276,14 +276,15 @@ function findMainValue(text: string, mainStat: string, slot: string, level: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractPercentValue(text: string) {
|
function extractPercentValue(text: string) {
|
||||||
|
const percentPattern = /([0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?)\s*%/;
|
||||||
const lineMatches = text
|
const lineMatches = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/))
|
.map((line) => line.match(percentPattern))
|
||||||
.filter((match): match is RegExpMatchArray => Boolean(match));
|
.filter((match): match is RegExpMatchArray => Boolean(match));
|
||||||
|
|
||||||
const preferred = lineMatches[0] ?? text.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/);
|
const preferred = lineMatches[0] ?? text.match(percentPattern);
|
||||||
if (!preferred?.[1]) return "";
|
if (!preferred?.[1]) return "";
|
||||||
return `${preferred[1].replace(/[:,\u00B7]/g, ".")}%`;
|
return `${normalizeMainValue(preferred[1])}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMainStat(slot: string, text: string): ParsedField {
|
function inferMainStat(slot: string, text: string): ParsedField {
|
||||||
@@ -302,7 +303,7 @@ function inferMainStat(slot: string, text: string): ParsedField {
|
|||||||
|
|
||||||
function findDirectMainStat(text: string) {
|
function findDirectMainStat(text: string) {
|
||||||
const compact = simplifyForMatch(text);
|
const compact = simplifyForMatch(text);
|
||||||
const hasPercentValue = /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text);
|
const hasPercentValue = /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text);
|
||||||
const priority = [
|
const priority = [
|
||||||
"Physical DMG Bonus",
|
"Physical DMG Bonus",
|
||||||
"Elemental Mastery",
|
"Elemental Mastery",
|
||||||
@@ -481,7 +482,7 @@ function isPercentMainStat(stat: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function promotePercentVariant(stat: string, text: string) {
|
function promotePercentVariant(stat: string, text: string) {
|
||||||
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text)) return `${stat}%`;
|
if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\s*[.,:\u00B7]\s*[0-9])?\s*%/.test(text)) return `${stat}%`;
|
||||||
return stat;
|
return stat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function hashId(value: string) {
|
|||||||
return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`;
|
return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean): StoredArtifactRecord {
|
export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean, locked?: boolean): StoredArtifactRecord {
|
||||||
return {
|
return {
|
||||||
id: hashId(storeSignature(parsed)),
|
id: hashId(storeSignature(parsed)),
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
@@ -58,6 +58,7 @@ export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string
|
|||||||
equipped: parsed.equipped,
|
equipped: parsed.equipped,
|
||||||
confidence: parsed.confidence,
|
confidence: parsed.confidence,
|
||||||
needsReview,
|
needsReview,
|
||||||
|
locked,
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ describe("layoutProfile", () => {
|
|||||||
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
|
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches the calibrated 1080p artifact detail panel", () => {
|
||||||
|
expect(profileDetailRect(HD)).toEqual({ x: 1308, y: 120, width: 492, height: 838 });
|
||||||
|
});
|
||||||
|
|
||||||
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
|
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
|
||||||
const detail = profileDetailRect(QHD);
|
const detail = profileDetailRect(QHD);
|
||||||
const crops = detailCropRects(detail, QHD);
|
const crops = detailCropRects(detail, QHD);
|
||||||
@@ -65,19 +69,28 @@ describe("layoutProfile", () => {
|
|||||||
const detail = profileDetailRect(QHD);
|
const detail = profileDetailRect(QHD);
|
||||||
const inv = inventoryRect(QHD, detail);
|
const inv = inventoryRect(QHD, detail);
|
||||||
const count = inventoryCountCropRect(inv, QHD);
|
const count = inventoryCountCropRect(inv, QHD);
|
||||||
expect(count.x).toBeGreaterThanOrEqual(inv.x);
|
expect(count.x).toBeGreaterThan(QHD.width * 0.75);
|
||||||
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
|
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds a 5-column inventory grid on the left", () => {
|
it("builds the calibrated 8-column inventory grid on the left", () => {
|
||||||
const detail = profileDetailRect(QHD);
|
const detail = profileDetailRect(QHD);
|
||||||
const grid = inventoryGrid(QHD, detail);
|
const grid = inventoryGrid(QHD, detail);
|
||||||
expect(grid.cols).toBe(5);
|
expect(grid.cols).toBe(8);
|
||||||
|
expect(grid.rows).toBe(5);
|
||||||
expect(grid.source).toBe("detected");
|
expect(grid.source).toBe("detected");
|
||||||
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
|
expect(grid.centers).toHaveLength(40);
|
||||||
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
|
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches the live 1080p artifact grid centers", () => {
|
||||||
|
const detail = profileDetailRect(HD);
|
||||||
|
const grid = inventoryGrid(HD, detail);
|
||||||
|
expect(grid.centers[0]).toEqual({ x: 179, y: 254, row: 0, col: 0 });
|
||||||
|
expect(grid.centers[7]).toEqual({ x: 1201, y: 254, row: 0, col: 7 });
|
||||||
|
expect(grid.centers.at(-1)).toEqual({ x: 1201, y: 958, row: 4, col: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a missing grid when the inventory panel is too small", () => {
|
it("reports a missing grid when the inventory panel is too small", () => {
|
||||||
const tiny = { width: 320, height: 180 };
|
const tiny = { width: 320, height: 180 };
|
||||||
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
|
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
|
||||||
|
|||||||
+36
-42
@@ -5,9 +5,9 @@
|
|||||||
// of that geometry; electron/main.ts consumes it for cropping and keeps a
|
// of that geometry; electron/main.ts consumes it for cropping and keeps a
|
||||||
// colour-based detail-rect detector only as a fallback for off-profile setups.
|
// colour-based detail-rect detector only as a fallback for off-profile setups.
|
||||||
//
|
//
|
||||||
// NOTE: the per-field detail crop fractions below are the current working values.
|
// Calibrated from a 1920x1080 English artifact-inventory screenshot and scaled
|
||||||
// True IK-style fixed coordinates need calibration against a reference 16:9
|
// by client size. This follows Inventory Kamera's stable approach: fixed
|
||||||
// screenshot; the structure here is what those calibrated numbers slot into.
|
// 16:9-relative UI regions first, visual detection only as a fallback.
|
||||||
|
|
||||||
export interface LayoutRect {
|
export interface LayoutRect {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -81,10 +81,10 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
|
|||||||
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
|
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
|
||||||
return clampRect(
|
return clampRect(
|
||||||
{
|
{
|
||||||
x: Math.round(width * 0.5),
|
x: Math.round(width * 0.681),
|
||||||
y: Math.round(height * 0.08),
|
y: Math.round(height * 0.111),
|
||||||
width: Math.round(width * 0.46),
|
width: Math.round(width * 0.256),
|
||||||
height: Math.round(height * 0.74),
|
height: Math.round(height * 0.776),
|
||||||
},
|
},
|
||||||
imageSize,
|
imageSize,
|
||||||
);
|
);
|
||||||
@@ -97,10 +97,10 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
|||||||
id: "artifact-title",
|
id: "artifact-title",
|
||||||
label: "Artifact title",
|
label: "Artifact title",
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
x: Math.round(detailRect.x),
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.05),
|
y: Math.round(detailRect.y),
|
||||||
width: Math.round(detailRect.width * 0.82),
|
width: Math.round(detailRect.width),
|
||||||
height: Math.round(detailRect.height * 0.16),
|
height: Math.round(detailRect.height * 0.07),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,9 +108,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
|||||||
label: "Main stat",
|
label: "Main stat",
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.2),
|
y: Math.round(detailRect.y + detailRect.height * 0.075),
|
||||||
width: Math.round(detailRect.width * 0.82),
|
width: Math.round(detailRect.width * 0.58),
|
||||||
height: Math.round(detailRect.height * 0.18),
|
height: Math.round(detailRect.height * 0.26),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,9 +118,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
|||||||
label: "Substats",
|
label: "Substats",
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.41),
|
y: Math.round(detailRect.y + detailRect.height * 0.34),
|
||||||
width: Math.round(detailRect.width * 0.82),
|
width: Math.round(detailRect.width * 0.86),
|
||||||
height: Math.round(detailRect.height * 0.25),
|
height: Math.round(detailRect.height * 0.27),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,9 +128,9 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
|||||||
label: "Footer",
|
label: "Footer",
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.78),
|
y: Math.round(detailRect.y + detailRect.height * 0.82),
|
||||||
width: Math.round(detailRect.width * 0.82),
|
width: Math.round(detailRect.width * 0.86),
|
||||||
height: Math.round(detailRect.height * 0.16),
|
height: Math.round(detailRect.height * 0.14),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -139,12 +139,13 @@ export function detailCropRects(detailRect: LayoutRect, imageSize: { width: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||||
|
const { width, height } = imageSize;
|
||||||
return clampRect(
|
return clampRect(
|
||||||
{
|
{
|
||||||
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
|
x: Math.round(width * 0.795),
|
||||||
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
|
y: Math.round(height * 0.02),
|
||||||
width: Math.round(inventoryRect.width * 0.34),
|
width: Math.round(width * 0.145),
|
||||||
height: Math.round(inventoryRect.height * 0.09),
|
height: Math.round(height * 0.055),
|
||||||
},
|
},
|
||||||
imageSize,
|
imageSize,
|
||||||
);
|
);
|
||||||
@@ -152,18 +153,12 @@ export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { w
|
|||||||
|
|
||||||
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
|
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
|
||||||
const { width, height } = imageSize;
|
const { width, height } = imageSize;
|
||||||
const preferredWidth = Math.max(140, Math.round(width * 0.48));
|
|
||||||
const x = Math.round(width * 0.03);
|
|
||||||
const y = Math.round(detailRect.y + detailRect.height * 0.09);
|
|
||||||
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
|
|
||||||
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
|
|
||||||
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
|
|
||||||
return clampRect(
|
return clampRect(
|
||||||
{
|
{
|
||||||
x,
|
x: Math.round(width * 0.055),
|
||||||
y,
|
y: Math.round(height * 0.155),
|
||||||
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
|
width: Math.max(140, Math.round(Math.min(detailRect.x - width * 0.07, width * 0.63))),
|
||||||
height: Math.max(140, Math.round(height * 0.7)),
|
height: Math.max(140, Math.round(height * 0.74)),
|
||||||
},
|
},
|
||||||
imageSize,
|
imageSize,
|
||||||
);
|
);
|
||||||
@@ -171,18 +166,17 @@ export function inventoryRect(imageSize: { width: number; height: number }, deta
|
|||||||
|
|
||||||
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
|
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
|
||||||
const rect = inventoryRect(imageSize, detailRect);
|
const rect = inventoryRect(imageSize, detailRect);
|
||||||
const cols = 5;
|
const cols = 8;
|
||||||
if (rect.width < 160 || rect.height < 140) {
|
if (imageSize.width < 800 || imageSize.height < 450 || rect.width < 160 || rect.height < 140) {
|
||||||
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
|
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellWidth = Math.max(56, Math.round(rect.width / cols));
|
const stepX = Math.round(imageSize.width * 0.076);
|
||||||
const stepX = Math.round(cellWidth * 0.96);
|
const stepY = Math.round(imageSize.height * 0.163);
|
||||||
const stepY = Math.round(cellWidth * 1.03);
|
const visibleRows = 5;
|
||||||
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
|
|
||||||
|
|
||||||
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
|
const startX = Math.round(imageSize.width * 0.093);
|
||||||
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
|
const startY = Math.round(imageSize.height * 0.235);
|
||||||
const centers: InventoryGridLayout["centers"] = [];
|
const centers: InventoryGridLayout["centers"] = [];
|
||||||
for (let row = 0; row < visibleRows; row++) {
|
for (let row = 0; row < visibleRows; row++) {
|
||||||
for (let col = 0; col < cols; col++) {
|
for (let col = 0; col < cols; col++) {
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ function bitmap(goldPixels: number, total: number): Bitmap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("lockDetection", () => {
|
describe("lockDetection", () => {
|
||||||
it("places the lock crop in the top-right of the detail card", () => {
|
it("places the lock crop on the lock button in the substat panel", () => {
|
||||||
const size = { width: 2560, height: 1440 };
|
const size = { width: 2560, height: 1440 };
|
||||||
const detail = profileDetailRect(size);
|
const detail = profileDetailRect(size);
|
||||||
const rect = lockIconCropRect(detail, size);
|
const rect = lockIconCropRect(detail, size);
|
||||||
expect(rect.x).toBeGreaterThan(detail.x + detail.width * 0.5);
|
expect(rect.x).toBeGreaterThan(detail.x + detail.width * 0.5);
|
||||||
expect(rect.x + rect.width).toBeLessThanOrEqual(size.width);
|
expect(rect.x + rect.width).toBeLessThanOrEqual(size.width);
|
||||||
expect(rect.y).toBeLessThan(detail.y + detail.height * 0.5);
|
expect(rect.y).toBeGreaterThan(detail.y + detail.height * 0.3);
|
||||||
|
expect(rect.y).toBeLessThan(detail.y + detail.height * 0.45);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("measures the gold-pixel ratio", () => {
|
it("measures the gold-pixel ratio", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clampRect, type LayoutRect } from "./layoutProfile";
|
import { clampRect, type LayoutRect } from "./layoutProfile.js";
|
||||||
import type { Bitmap } from "./ocrPreprocess";
|
import type { Bitmap } from "./ocrPreprocess.js";
|
||||||
|
|
||||||
// EXPERIMENTAL, read-only lock-status detection (nice-to-have). Genshin shows a
|
// 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
|
// padlock at the top-right of the artifact detail card: a bright gold fill when
|
||||||
@@ -15,10 +15,10 @@ import type { Bitmap } from "./ocrPreprocess";
|
|||||||
export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
export function lockIconCropRect(detailRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||||
return clampRect(
|
return clampRect(
|
||||||
{
|
{
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.8),
|
x: Math.round(detailRect.x + detailRect.width * 0.735),
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.03),
|
y: Math.round(detailRect.y + detailRect.height * 0.355),
|
||||||
width: Math.round(detailRect.width * 0.16),
|
width: Math.round(detailRect.width * 0.105),
|
||||||
height: Math.round(detailRect.height * 0.09),
|
height: Math.round(detailRect.height * 0.07),
|
||||||
},
|
},
|
||||||
imageSize,
|
imageSize,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function record(overrides: Partial<StoredArtifactRecord> = {}): StoredArtifactRe
|
|||||||
|
|
||||||
describe("storedArtifactAdapter", () => {
|
describe("storedArtifactAdapter", () => {
|
||||||
it("converts stored OCR artifacts into recommendation-domain artifacts", () => {
|
it("converts stored OCR artifacts into recommendation-domain artifacts", () => {
|
||||||
const [artifact] = storedArtifactsToDomain([record()]);
|
const [artifact] = storedArtifactsToDomain([record({ locked: true })]);
|
||||||
|
|
||||||
expect(artifact.slot).toBe("sands");
|
expect(artifact.slot).toBe("sands");
|
||||||
expect(artifact.setKey).toBe("viridescent_venerer");
|
expect(artifact.setKey).toBe("viridescent_venerer");
|
||||||
@@ -32,6 +32,13 @@ describe("storedArtifactAdapter", () => {
|
|||||||
expect(artifact.equipped).toBe("Sucrose");
|
expect(artifact.equipped).toBe("Sucrose");
|
||||||
expect(artifact.confidence).toBe(0.96);
|
expect(artifact.confidence).toBe(0.96);
|
||||||
expect(artifact.source).toBe("screen");
|
expect(artifact.source).toBe("screen");
|
||||||
|
expect(artifact.locked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent lock state from confidence", () => {
|
||||||
|
const [artifact] = storedArtifactsToDomain([record({ locked: undefined, confidence: 100 })]);
|
||||||
|
|
||||||
|
expect(artifact.locked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps flat and percent ATK substats distinct", () => {
|
it("keeps flat and percent ATK substats distinct", () => {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifa
|
|||||||
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
|
mainStat: normalizeMainStat(record.mainStat, record.mainValue),
|
||||||
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
|
substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[],
|
||||||
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
|
equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined,
|
||||||
locked: !record.needsReview && record.confidence >= 90,
|
locked: Boolean(record.locked),
|
||||||
source: toSource(record.source),
|
source: toSource(record.source),
|
||||||
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
|
confidence: Math.max(0, Math.min(1, record.confidence / 100)),
|
||||||
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
|
lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
SaveScannerLearningRulesResult,
|
SaveScannerLearningRulesResult,
|
||||||
SaveSnapshotResult,
|
SaveSnapshotResult,
|
||||||
GoodDatabase,
|
GoodDatabase,
|
||||||
|
GoodImportFileResult,
|
||||||
|
ScannerCommand,
|
||||||
ScannerStatusPayload,
|
ScannerStatusPayload,
|
||||||
ScannerLearningRulePayload,
|
ScannerLearningRulePayload,
|
||||||
} from "../types/global";
|
} from "../types/global";
|
||||||
@@ -39,6 +41,7 @@ export interface AssistantBridge {
|
|||||||
options?: CaptureOptions,
|
options?: CaptureOptions,
|
||||||
) => Promise<CaptureResult>;
|
) => Promise<CaptureResult>;
|
||||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||||
showOverlay: () => Promise<BooleanResult>;
|
showOverlay: () => Promise<BooleanResult>;
|
||||||
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
||||||
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
||||||
@@ -54,7 +57,7 @@ export interface AssistantBridge {
|
|||||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
|
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFunction(api: Record<string, unknown>, key: string): boolean {
|
function hasFunction(api: Record<string, unknown>, key: string): boolean {
|
||||||
@@ -81,6 +84,7 @@ export function getAssistantBridge(): AssistantBridge | null {
|
|||||||
listCaptureSources: () => api.listCaptureSources(),
|
listCaptureSources: () => api.listCaptureSources(),
|
||||||
captureSource: (sourceId, delayMs, focusGenshin, options) => api.captureSource(sourceId, delayMs, focusGenshin, options),
|
captureSource: (sourceId, delayMs, focusGenshin, options) => api.captureSource(sourceId, delayMs, focusGenshin, options),
|
||||||
exportGood: (payload) => api.exportGood(payload),
|
exportGood: (payload) => api.exportGood(payload),
|
||||||
|
importGoodFile: () => api.importGoodFile(),
|
||||||
loadArtifacts: () => api.loadArtifacts(),
|
loadArtifacts: () => api.loadArtifacts(),
|
||||||
saveArtifacts: (records) => api.saveArtifacts(records),
|
saveArtifacts: (records) => api.saveArtifacts(records),
|
||||||
loadReviewSamples: (limit = 50) => api.loadReviewSamples(limit),
|
loadReviewSamples: (limit = 50) => api.loadReviewSamples(limit),
|
||||||
|
|||||||
Vendored
+21
-1
@@ -56,6 +56,7 @@ export interface CaptureResult {
|
|||||||
source: "ocr" | "missing";
|
source: "ocr" | "missing";
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
locked?: boolean;
|
||||||
layout?: {
|
layout?: {
|
||||||
aspect: string;
|
aspect: string;
|
||||||
isSixteenNine: boolean;
|
isSixteenNine: boolean;
|
||||||
@@ -125,6 +126,14 @@ export interface SaveResultWithPath {
|
|||||||
|
|
||||||
export type SaveSnapshotResult = SaveResultWithPath;
|
export type SaveSnapshotResult = SaveResultWithPath;
|
||||||
|
|
||||||
|
export type ScannerCommand =
|
||||||
|
| "start-auto"
|
||||||
|
| "stop"
|
||||||
|
| {
|
||||||
|
type: "start-auto";
|
||||||
|
scanLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ScannerLearningRulePayload {
|
export interface ScannerLearningRulePayload {
|
||||||
textReplacements?: Record<string, string>;
|
textReplacements?: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -211,6 +220,7 @@ export interface ReviewSampleRecord {
|
|||||||
ocr?: OcrResult[];
|
ocr?: OcrResult[];
|
||||||
inventoryGrid?: CaptureResult["inventoryGrid"];
|
inventoryGrid?: CaptureResult["inventoryGrid"];
|
||||||
inventoryCount?: CaptureResult["inventoryCount"];
|
inventoryCount?: CaptureResult["inventoryCount"];
|
||||||
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,6 +249,7 @@ export interface ReviewSamplePayload {
|
|||||||
ocr?: OcrResult[];
|
ocr?: OcrResult[];
|
||||||
inventoryGrid?: CaptureResult["inventoryGrid"];
|
inventoryGrid?: CaptureResult["inventoryGrid"];
|
||||||
inventoryCount?: CaptureResult["inventoryCount"];
|
inventoryCount?: CaptureResult["inventoryCount"];
|
||||||
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -280,6 +291,14 @@ export interface GoodDatabase {
|
|||||||
artifacts: GoodExportArtifact[];
|
artifacts: GoodExportArtifact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoodImportFileResult {
|
||||||
|
ok: boolean;
|
||||||
|
canceled: boolean;
|
||||||
|
path: string;
|
||||||
|
database?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
assistantApi?: {
|
assistantApi?: {
|
||||||
@@ -302,10 +321,11 @@ declare global {
|
|||||||
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
loadArtifacts: () => Promise<ArtifactStoreLoadResult>;
|
||||||
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
saveArtifacts: (records: StoredArtifactRecord[]) => Promise<ArtifactStoreSaveResult>;
|
||||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||||
showOverlay: () => Promise<BooleanResult>;
|
showOverlay: () => Promise<BooleanResult>;
|
||||||
hideOverlay: () => Promise<BooleanResult>;
|
hideOverlay: () => Promise<BooleanResult>;
|
||||||
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void;
|
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface StoredArtifactRecord {
|
|||||||
equipped: string;
|
equipped: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
needsReview: boolean;
|
needsReview: boolean;
|
||||||
|
locked?: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
firstSeenAt?: string;
|
firstSeenAt?: string;
|
||||||
lastSeenAt?: string;
|
lastSeenAt?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user