chore: initialize repository baseline
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Normalize line endings: store LF in the repo, check out native on Windows.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary assets must never be line-ending converted.
|
||||||
|
*.traineddata binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Builds
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
outputs/dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Local runtime data
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Temporary work
|
||||||
|
work/
|
||||||
|
|
||||||
|
# Local session/runtime state
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file defines how AI coding agents should work in this repository.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. Correctness comes before speed.
|
||||||
|
2. Preserve the scanner-first product goal: reduce artifact decisions, guide hopping, and manual bookkeeping.
|
||||||
|
3. Prefer small, reviewable changes over broad rewrites.
|
||||||
|
4. Do not invent Genshin game data, API behavior, OCR confidence, or optimization rules without documenting the assumption.
|
||||||
|
5. Keep all game interaction read-only by default. Never add delete, feed, enhance, memory-read, hook, or game-modification behavior.
|
||||||
|
6. Keep user work and local app state intact.
|
||||||
|
7. Validate Electron main-process changes with a build and, when practical, a manual Smart Capture smoke test.
|
||||||
|
|
||||||
|
## Read Before Write
|
||||||
|
|
||||||
|
Before editing code, inspect the relevant source files and the project documents:
|
||||||
|
|
||||||
|
- `docs/PROJECT.md`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
- `docs/CONVENTIONS.md`
|
||||||
|
- `docs/DECISIONS.md`
|
||||||
|
- `docs/CHECKLISTS.md`
|
||||||
|
|
||||||
|
Use `rg` or `rg --files` for search.
|
||||||
|
|
||||||
|
## Product Safety
|
||||||
|
|
||||||
|
The app may capture the Genshin window or screen and may later support optional input automation. Treat those areas as high risk.
|
||||||
|
|
||||||
|
- Capture must not require memory reads, hooks, injection, or game file modification.
|
||||||
|
- Input automation, if added, must be opt-in, reversible, whitelisted, and stoppable.
|
||||||
|
- The app must never delete, feed, enhance, or spend in-game resources.
|
||||||
|
- Uncertain OCR data must remain reviewable instead of being silently trusted.
|
||||||
|
|
||||||
|
## Implementation Expectations
|
||||||
|
|
||||||
|
- Keep Electron main-process code focused on OS integration, capture, and IPC.
|
||||||
|
- Keep React code focused on UI state and presentation.
|
||||||
|
- Keep parsing, scoring, and recommendation logic in `src/lib`.
|
||||||
|
- Keep domain types in `src/types`.
|
||||||
|
- Prefer deterministic parsers and data packages over hardcoded one-off exceptions.
|
||||||
|
- When OCR requires heuristics, record the limitation in docs or code comments.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run the strongest practical checks for the change:
|
||||||
|
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm test`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
For scanner changes, also manually verify Smart Capture against an open artifact detail view when possible.
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
A task is done when:
|
||||||
|
|
||||||
|
- The requested behavior exists.
|
||||||
|
- The implementation follows the documented architecture and conventions.
|
||||||
|
- Relevant checks passed or skipped checks are explained.
|
||||||
|
- Scanner/OCR changes fail safely into review or uncertainty.
|
||||||
|
- The final summary names the changed areas and remaining risks.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Genshin Artifact Assistant
|
||||||
|
|
||||||
|
Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on Inventory Kamera or Genshin Optimizer as the main workflow.
|
||||||
|
|
||||||
|
## Current MVP
|
||||||
|
|
||||||
|
- Electron + React + TypeScript app shell.
|
||||||
|
- Dark purple fintech/glassmorphism UI direction.
|
||||||
|
- Genshin capture source discovery.
|
||||||
|
- Smart Capture that focuses Genshin, hides the app, captures the primary screen, and restores the app.
|
||||||
|
- Artifact detail-panel crop detection with focused OCR regions.
|
||||||
|
- OCR parser for artifact name, slot, main stat, substats, set, equipped character, confidence, and review notes.
|
||||||
|
- Demo triage and build suggestion views.
|
||||||
|
- Read-only overlay preview shell.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app:
|
||||||
|
|
||||||
|
- [AGENTS.md](AGENTS.md)
|
||||||
|
- [docs/PROJECT.md](docs/PROJECT.md)
|
||||||
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||||
|
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
|
||||||
|
- [docs/DECISIONS.md](docs/DECISIONS.md)
|
||||||
|
- [docs/CHECKLISTS.md](docs/CHECKLISTS.md)
|
||||||
|
- [docs/workflow.md](docs/workflow.md)
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
|
||||||
|
|
||||||
|
### Automatischer Scan: als Administrator starten
|
||||||
|
|
||||||
|
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
|
||||||
|
|
||||||
|
- `npm run dev:admin` (oder Doppelklick auf `dev-admin.cmd`) - öffnet aus einem normalen Terminal heraus einen UAC-Prompt und startet danach `npm run dev` in einem neuen Administrator-Fenster.
|
||||||
|
- Alternativ: Terminal per Rechtsklick "Als Administrator ausführen" öffnen und darin normal `npm run dev` starten.
|
||||||
|
|
||||||
|
Der UAC-Prompt lässt sich nicht dauerhaft abschalten; das ist Windows-Design. Die gepackte App fordert Admin-Rechte über `requestedExecutionLevel: requireAdministrator` selbst an. Die App zeigt im Scanner-Header und in der Scanner-Diagnose nur an, ob sie gerade erhöht läuft - sie startet sich nicht selbst neu.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety Boundaries
|
||||||
|
|
||||||
|
- No memory reads.
|
||||||
|
- No hooks or process injection.
|
||||||
|
- No game file modification.
|
||||||
|
- No automatic delete, enhance, feeding, or resource-spending actions.
|
||||||
|
- In-game lock/marking is a future opt-in module and remains disabled in this MVP.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"sets": {
|
||||||
|
"marechaussee_hunter": "Marechaussee Hunter",
|
||||||
|
"golden_troupe": "Golden Troupe",
|
||||||
|
"emblem_of_severed_fate": "Emblem of Severed Fate",
|
||||||
|
"deepwood_memories": "Deepwood Memories",
|
||||||
|
"gilded_dreams": "Gilded Dreams",
|
||||||
|
"viridescent_venerer": "Viridescent Venerer",
|
||||||
|
"noblesse_oblige": "Noblesse Oblige",
|
||||||
|
"heart_of_depth": "Heart of Depth"
|
||||||
|
},
|
||||||
|
"characters": [
|
||||||
|
{
|
||||||
|
"id": "neuvillette",
|
||||||
|
"name": "Neuvillette",
|
||||||
|
"role": "main_dps",
|
||||||
|
"recommendedSets": ["marechaussee_hunter"],
|
||||||
|
"alternativeSets": ["heart_of_depth"],
|
||||||
|
"mainStats": {
|
||||||
|
"sands": ["HP%"],
|
||||||
|
"goblet": ["Hydro DMG Bonus", "HP%"],
|
||||||
|
"circlet": ["CRIT Rate", "CRIT DMG", "HP%"]
|
||||||
|
},
|
||||||
|
"substatWeights": {
|
||||||
|
"CRIT Rate": 1.25,
|
||||||
|
"CRIT DMG": 1.2,
|
||||||
|
"HP%": 1,
|
||||||
|
"Energy Recharge": 0.55,
|
||||||
|
"Elemental Mastery": 0.15,
|
||||||
|
"ATK%": 0.05
|
||||||
|
},
|
||||||
|
"erTarget": 130,
|
||||||
|
"explanation": "HP scaling Hydro carry. Prioritize Marechaussee, HP%, crit, and enough ER for smooth rotations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "furina",
|
||||||
|
"name": "Furina",
|
||||||
|
"role": "sub_dps",
|
||||||
|
"recommendedSets": ["golden_troupe"],
|
||||||
|
"alternativeSets": ["emblem_of_severed_fate", "noblesse_oblige"],
|
||||||
|
"mainStats": {
|
||||||
|
"sands": ["HP%", "Energy Recharge"],
|
||||||
|
"goblet": ["HP%", "Hydro DMG Bonus"],
|
||||||
|
"circlet": ["CRIT Rate", "CRIT DMG", "HP%"]
|
||||||
|
},
|
||||||
|
"substatWeights": {
|
||||||
|
"Energy Recharge": 1.15,
|
||||||
|
"CRIT Rate": 1.05,
|
||||||
|
"CRIT DMG": 1,
|
||||||
|
"HP%": 0.95,
|
||||||
|
"Elemental Mastery": 0.1,
|
||||||
|
"ATK%": 0.05
|
||||||
|
},
|
||||||
|
"erTarget": 180,
|
||||||
|
"explanation": "Off-field HP scaling support/sub-DPS. Golden Troupe and ER comfort matter a lot."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raiden_shogun",
|
||||||
|
"name": "Raiden Shogun",
|
||||||
|
"role": "main_dps",
|
||||||
|
"recommendedSets": ["emblem_of_severed_fate"],
|
||||||
|
"alternativeSets": ["gilded_dreams"],
|
||||||
|
"mainStats": {
|
||||||
|
"sands": ["Energy Recharge", "ATK%"],
|
||||||
|
"goblet": ["Electro DMG Bonus", "ATK%"],
|
||||||
|
"circlet": ["CRIT Rate", "CRIT DMG"]
|
||||||
|
},
|
||||||
|
"substatWeights": {
|
||||||
|
"Energy Recharge": 1.2,
|
||||||
|
"CRIT Rate": 1.15,
|
||||||
|
"CRIT DMG": 1.1,
|
||||||
|
"ATK%": 0.75,
|
||||||
|
"Elemental Mastery": 0.25
|
||||||
|
},
|
||||||
|
"erTarget": 220,
|
||||||
|
"explanation": "Burst-focused carry. Emblem, ER, crit, and ATK are the safe default priorities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nahida",
|
||||||
|
"name": "Nahida",
|
||||||
|
"role": "support",
|
||||||
|
"recommendedSets": ["deepwood_memories"],
|
||||||
|
"alternativeSets": ["gilded_dreams"],
|
||||||
|
"mainStats": {
|
||||||
|
"sands": ["Elemental Mastery"],
|
||||||
|
"goblet": ["Elemental Mastery", "Dendro DMG Bonus"],
|
||||||
|
"circlet": ["Elemental Mastery", "CRIT Rate", "CRIT DMG"]
|
||||||
|
},
|
||||||
|
"substatWeights": {
|
||||||
|
"Elemental Mastery": 1.25,
|
||||||
|
"CRIT Rate": 0.85,
|
||||||
|
"CRIT DMG": 0.8,
|
||||||
|
"Energy Recharge": 0.45,
|
||||||
|
"ATK%": 0.1
|
||||||
|
},
|
||||||
|
"explanation": "Dendro enabler. Deepwood is the default team value; EM pieces are usually worth checking."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kazuha",
|
||||||
|
"name": "Kaedehara Kazuha",
|
||||||
|
"role": "support",
|
||||||
|
"recommendedSets": ["viridescent_venerer"],
|
||||||
|
"alternativeSets": ["gilded_dreams"],
|
||||||
|
"mainStats": {
|
||||||
|
"sands": ["Elemental Mastery", "Energy Recharge"],
|
||||||
|
"goblet": ["Elemental Mastery"],
|
||||||
|
"circlet": ["Elemental Mastery"]
|
||||||
|
},
|
||||||
|
"substatWeights": {
|
||||||
|
"Elemental Mastery": 1.2,
|
||||||
|
"Energy Recharge": 1,
|
||||||
|
"CRIT Rate": 0.15,
|
||||||
|
"CRIT DMG": 0.1,
|
||||||
|
"ATK%": 0.05
|
||||||
|
},
|
||||||
|
"erTarget": 160,
|
||||||
|
"explanation": "Swirl support. Viridescent and EM mainstats are the no-brainer target."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@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
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document describes the structure, boundaries, flows, and technical rules of the system.
|
||||||
|
|
||||||
|
## High-Level Overview
|
||||||
|
|
||||||
|
Genshin Artifact Assistant is a local desktop application. Electron owns OS integration, screen capture, IPC, and overlay windows. React owns the interactive UI. Domain logic for OCR parsing, scoring, scanner state, and recommendations lives in TypeScript modules under `src/lib`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User["User"]
|
||||||
|
Genshin["Genshin Impact Window"]
|
||||||
|
Electron["Electron Main Process"]
|
||||||
|
React["React Renderer"]
|
||||||
|
Parser["OCR Parser and Scoring"]
|
||||||
|
LocalData["Local Snapshot / Future SQLite"]
|
||||||
|
|
||||||
|
User --> React
|
||||||
|
React --> Electron
|
||||||
|
Electron --> Genshin
|
||||||
|
Electron --> React
|
||||||
|
React --> Parser
|
||||||
|
Parser --> React
|
||||||
|
React --> LocalData
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Style
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Style | Desktop modular monolith |
|
||||||
|
| Primary reason | One local app with tight UI, capture, and parsing feedback loops |
|
||||||
|
| Main trade-off | Faster iteration now, but scanner heuristics must stay isolated to avoid UI coupling |
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
electron/ Electron main process and preload bridge
|
||||||
|
src/ React app, domain types, parser/scoring logic
|
||||||
|
data/ Seed or package data
|
||||||
|
docs/ Project documentation and decisions
|
||||||
|
dist/ Generated renderer build
|
||||||
|
dist-electron/ Generated Electron build
|
||||||
|
outputs/ Packaged app output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
| 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/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
||||||
|
| `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store |
|
||||||
|
| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview |
|
||||||
|
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
|
||||||
|
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
|
||||||
|
| `src/lib/scoring.ts` | Recommendation and build scoring logic |
|
||||||
|
| `src/lib/demoData.ts` | Temporary local demo snapshot |
|
||||||
|
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
|
||||||
|
| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
|
||||||
|
| `src/types/*` | Shared app, capture, and domain contracts |
|
||||||
|
|
||||||
|
## Dependency Rules
|
||||||
|
|
||||||
|
- Renderer code calls Electron only through the preload bridge.
|
||||||
|
- Electron main process must not import React renderer modules.
|
||||||
|
- Pure parsing and scoring modules must not depend on Electron APIs.
|
||||||
|
- OCR uncertainty must be represented in data, not hidden in UI only.
|
||||||
|
- Generated folders must not be treated as source of truth.
|
||||||
|
|
||||||
|
## Smart Capture Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as React Scan UI
|
||||||
|
participant Bridge as Preload Bridge
|
||||||
|
participant Main as Electron Main
|
||||||
|
participant Game as Genshin Window
|
||||||
|
participant OCR as OCR Worker
|
||||||
|
|
||||||
|
UI->>Bridge: captureSource(sourceId, 0, focusGenshin=true)
|
||||||
|
Bridge->>Main: IPC capture:captureSource
|
||||||
|
Main->>Main: Hide app window
|
||||||
|
Main->>Game: Focus Genshin window
|
||||||
|
Main->>Main: Capture primary screen via GDI
|
||||||
|
Main->>Main: Detect artifact detail panel
|
||||||
|
Main->>OCR: OCR focused crops
|
||||||
|
OCR-->>Main: Text and confidence
|
||||||
|
Main->>Main: Restore app window
|
||||||
|
Main-->>UI: Full capture, detail preview, crops, OCR results
|
||||||
|
UI->>UI: Parse candidate and show result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
| Data | Source | Owner | Consumers |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Capture sources | Electron desktopCapturer | Electron main | Scan UI |
|
||||||
|
| Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview |
|
||||||
|
| OCR crops | Electron main | Electron main | Details modal, parser |
|
||||||
|
| Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser |
|
||||||
|
| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, future local DB |
|
||||||
|
| Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training |
|
||||||
|
| Stored artifacts | Manual/automatic scans | Electron userData `artifact-store.json` (dedupe by content signature) | Future triage, recommendations, SQLite migration |
|
||||||
|
| Recommendations | Scoring module | Renderer domain logic | Triage and builds views |
|
||||||
|
|
||||||
|
## Scan Modes
|
||||||
|
|
||||||
|
**Manual scan** is read-only: the user clicks artifacts in Genshin, the app repeatedly runs Smart Capture, deduplicates by content signature, persists new artifacts to the local store, and saves review samples when crops/OCR are missing, total confidence is low, field confidence is low, or parser notes indicate incomplete data.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- 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.
|
||||||
|
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
|
||||||
|
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
|
||||||
|
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
||||||
|
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them.
|
||||||
|
|
||||||
|
Parsed artifacts from both modes are persisted into `artifact-store.json` keyed by a content signature that excludes the equipped character, so re-equipping updates a record instead of duplicating it. Leveling an artifact currently creates a new record (documented limitation until rescan-merge exists).
|
||||||
|
|
||||||
|
## Security And Safety
|
||||||
|
|
||||||
|
| Area | Policy |
|
||||||
|
| --- | --- |
|
||||||
|
| Game access | Screen capture only; no memory reads, hooks, or process injection |
|
||||||
|
| Automation | Future feature only, opt-in and reversible |
|
||||||
|
| Data privacy | Local-first; no upload path in scanner MVP |
|
||||||
|
| Secrets | No cookies or API tokens required for scanner MVP |
|
||||||
|
| Unsafe actions | Never delete, feed, enhance, or spend resources |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Capture failures should surface in the scan status row.
|
||||||
|
- Missing Electron bridge should explain that browser preview cannot capture Genshin.
|
||||||
|
- OCR failures should leave the capture available and mark parsed fields as unknown.
|
||||||
|
- Low-confidence data should go to review instead of silent acceptance.
|
||||||
|
- Parsed artifact fields carry individual confidence and source metadata so the UI can show uncertainty per field.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Current OCR is prototype-grade and may be slower than the target scanner. Two batch-scan bottlenecks were removed: input/capture no longer spawn a PowerShell process (and recompile Win32 interop) per action, and the Tesseract worker is created once and reused across captures. The eventual batch scanner should still move expensive capture/OCR/build work into workers or a native sidecar.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Checklists
|
||||||
|
|
||||||
|
## Scanner Change
|
||||||
|
|
||||||
|
- [ ] The expected crop or capture behavior is clear.
|
||||||
|
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
|
||||||
|
- [ ] Capture failures are shown to the user.
|
||||||
|
- [ ] OCR uncertainty remains inspectable in Details.
|
||||||
|
- [ ] Parser output does not silently trust low-confidence text.
|
||||||
|
- [ ] `npm run lint` passes.
|
||||||
|
- [ ] `npm test` passes when parser/scoring logic changed.
|
||||||
|
- [ ] `npm run build` passes.
|
||||||
|
- [ ] Manual Smart Capture is tested when possible.
|
||||||
|
|
||||||
|
## UI Change
|
||||||
|
|
||||||
|
- [ ] The main workflow remains visible without unnecessary scrolling.
|
||||||
|
- [ ] Debug or secondary information is moved behind buttons/modals where appropriate.
|
||||||
|
- [ ] Disabled states prevent actions without required data.
|
||||||
|
- [ ] Text fits in controls and panels.
|
||||||
|
- [ ] Desktop viewport is checked manually.
|
||||||
|
- [ ] `npm run build` passes.
|
||||||
|
|
||||||
|
## Parser Or Scoring Change
|
||||||
|
|
||||||
|
- [ ] Known-good OCR samples still parse correctly.
|
||||||
|
- [ ] Ambiguous data becomes unknown or review, not false certainty.
|
||||||
|
- [ ] Character, set, stat, and slot dictionaries are updated deliberately.
|
||||||
|
- [ ] Tests cover changed behavior when practical.
|
||||||
|
|
||||||
|
## Release Or Packaging
|
||||||
|
|
||||||
|
- [ ] Production build succeeds.
|
||||||
|
- [ ] Electron preload bridge is copied to `dist-electron/preload.cjs`.
|
||||||
|
- [ ] App starts outside browser preview.
|
||||||
|
- [ ] Smart Capture bridge is available.
|
||||||
|
- [ ] No generated debug artifacts are included accidentally.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Conventions
|
||||||
|
|
||||||
|
This document defines project engineering standards.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- Use Genshin domain language where it makes behavior clearer: artifact, slot, set, main stat, substat, triage, build.
|
||||||
|
- Name booleans as predicates such as `isScanning`, `bridgeReady`, or `isGenshinCandidate`.
|
||||||
|
- Prefer explicit scanner names such as `createArtifactCrops` over generic names such as `processImage`.
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
- Keep Electron OS integration in `electron/`.
|
||||||
|
- Keep React components in `src/`, with extraction when `App.tsx` becomes hard to review.
|
||||||
|
- Keep pure domain logic in `src/lib/`.
|
||||||
|
- Keep shared contracts in `src/types/`.
|
||||||
|
- Keep generated outputs in `dist/`, `dist-electron/`, and `outputs/`.
|
||||||
|
|
||||||
|
## UI Rules
|
||||||
|
|
||||||
|
- The scan page should prioritize the capture workspace over secondary status content.
|
||||||
|
- Details and debug information belong in modals or secondary panels.
|
||||||
|
- Avoid long, overfilled cards on scanner pages.
|
||||||
|
- The design direction is dark purple fintech glassmorphism with premium, focused controls.
|
||||||
|
- Disable buttons when their required data does not exist.
|
||||||
|
|
||||||
|
## Scanner Rules
|
||||||
|
|
||||||
|
- Prefer focused crops over full-screen OCR.
|
||||||
|
- Confidence and raw OCR details must remain inspectable.
|
||||||
|
- Heuristics should fail safely into unknown fields or review notes.
|
||||||
|
- Do not add irreversible game actions.
|
||||||
|
|
||||||
|
## TypeScript Rules
|
||||||
|
|
||||||
|
- Keep strict type checks passing.
|
||||||
|
- Do not use `any` for capture, OCR, artifact, or recommendation contracts unless a boundary genuinely requires it.
|
||||||
|
- Parser functions should be deterministic and testable.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Use unit tests for parser and scoring logic.
|
||||||
|
- Use build/type checks for Electron IPC contract changes.
|
||||||
|
- Use manual Smart Capture smoke tests for crop and capture changes.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Update `docs/PROJECT.md` when product scope changes.
|
||||||
|
- Update `docs/ARCHITECTURE.md` when module boundaries or flows change.
|
||||||
|
- Add an ADR to `docs/DECISIONS.md` for durable technical trade-offs.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
This document contains Architecture Decision Records.
|
||||||
|
|
||||||
|
## ADR Index
|
||||||
|
|
||||||
|
| ID | Title | Status | Date |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ADR-001 | Build a local Electron app first | Accepted | 2026-07-04 |
|
||||||
|
| ADR-002 | Use screen capture as the primary scanner source | Accepted | 2026-07-04 |
|
||||||
|
| ADR-003 | Keep APIs and GOOD compatibility optional | Accepted | 2026-07-04 |
|
||||||
|
| ADR-004 | Treat in-game marking as a later opt-in feature | Accepted | 2026-07-04 |
|
||||||
|
| ADR-005 | Use a generated Genshin data package for OCR matching | Accepted | 2026-07-04 |
|
||||||
|
| ADR-006 | Persistent input helper and JSON artifact store before SQLite | Accepted | 2026-07-04 |
|
||||||
|
|
||||||
|
## ADR-001: Build A Local Electron App First
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The product needs a Windows desktop UI, local screen capture, possible overlay windows, and future optional input automation.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Use Electron with React and TypeScript for the MVP.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Fast UI iteration and easy local packaging.
|
||||||
|
- Electron main-process code must be treated as a separate boundary from renderer code.
|
||||||
|
- Native or Rust sidecars can be added later for high-performance capture/OCR work.
|
||||||
|
|
||||||
|
## ADR-002: Use Screen Capture As The Primary Scanner Source
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The user wants an app that works without Inventory Kamera, Genshin Optimizer, Enka, or HoYoLAB as core dependencies.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Use local screen capture as the primary source. Current Smart Capture focuses Genshin, hides the app, captures the primary screen through Windows GDI, detects the artifact detail panel, then OCRs focused crops.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The app remains offline-first.
|
||||||
|
- OCR and crop reliability are core product risks.
|
||||||
|
- UI language, resolution, HDR, and game layout changes need explicit test coverage.
|
||||||
|
|
||||||
|
## ADR-003: Keep APIs And GOOD Compatibility Optional
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
External APIs and existing optimizer formats can speed up setup, but should not define the main user workflow.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Keep Enka, HoYoLAB, Akasha, Genshin Optimizer, and GOOD import/export as optional future compatibility layers.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The app can work without external accounts or cookies.
|
||||||
|
- Data package and scanner quality become more important.
|
||||||
|
- Compatibility can be added when it helps testing, migration, or export.
|
||||||
|
|
||||||
|
## ADR-004: Treat In-Game Marking As A Later Opt-In Feature
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Locking or marking artifacts in game may save time, but input automation increases ToS and misclick risk.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Do not ship in-game marking in the scanner MVP. If implemented later, it must be off by default, reversible, whitelisted, previewed before execution, and stoppable with ESC or user mouse movement.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Early scanner work stays lower risk.
|
||||||
|
- App-internal triage remains the first decision layer.
|
||||||
|
- No delete, feed, enhance, or resource-spending automation is allowed.
|
||||||
|
|
||||||
|
## ADR-005: Use A Generated Genshin Data Package For OCR Matching
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Hardcoded arrays for characters and artifact sets caused repeated scanner failures whenever the user tested a newer character, set, or artifact name.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Generate `src/data/genshinGameData.json` from `genshin-db` and use it as the local matching dictionary for artifact sets, artifact piece names, characters, slots, main stats, and substats.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The scanner can recognize new characters and sets as soon as the local data package is regenerated from an updated `genshin-db`.
|
||||||
|
- Parser logic stays generic and testable instead of growing one-off fixes.
|
||||||
|
- OCR still needs good crops and text quality; the data package improves recognition but cannot solve unreadable screenshots by itself.
|
||||||
|
|
||||||
|
## ADR-006: Persistent Input Helper And JSON Artifact Store Before SQLite
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Per-action PowerShell scripts recompiled the Win32 interop for every click, scroll, and capture (1-2s each) and one `Marshal::SizeOf` call was broken in Windows PowerShell 5.1, so SendInput clicks silently never executed. Scan results were also not persisted anywhere; only review samples reached disk. Adding `better-sqlite3` (native module) was considered too heavy for this step.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Run one persistent PowerShell helper process (compiled once, JSON protocol over stdin/stdout) for focus, cursor/ESC state, click, scroll, and GDI capture. Persist parsed artifacts into `artifact-store.json` in userData, deduplicated by a content signature that excludes the equipped character. Keep SQLite as the planned future store; the JSON store is the migration source.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Batch scans become fast enough to be testable and the failsafe (ESC or user mouse movement aborts) can poll cheaply between actions.
|
||||||
|
- Automated clicks are verified by checking that the parsed detail signature changed; repeated failures abort with a diagnosis hint instead of clicking blindly.
|
||||||
|
- Leveling an artifact changes its signature and creates a new record; rescan-merge is an open follow-up.
|
||||||
|
- If the helper process dies it is respawned on the next request; pending requests fail loudly instead of hanging.
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
# Project
|
||||||
|
|
||||||
|
This document is the source of truth for project intent, scope, runtime facts, and operational expectations.
|
||||||
|
|
||||||
|
For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md).
|
||||||
|
|
||||||
|
## Project Identity
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Project name | Genshin Artifact Assistant |
|
||||||
|
| Status | Scanner rebuild in progress |
|
||||||
|
| Platform | Windows desktop |
|
||||||
|
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
|
||||||
|
| Runtime | Electron app with React UI and TypeScript |
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
Genshin Artifact Assistant should make artifact management feel like a no-brainer. The user opens Genshin, runs a scan, and receives simple decisions: what is good, who can use it, what can probably be marked as trash, and which builds are currently available.
|
||||||
|
|
||||||
|
The app is not intended to replace deep min-max tools. It prioritizes time savings, confidence, and understandable recommendations over perfect theorycrafting.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Make artifact scanning stable enough that a normal user can trust it without babysitting every click.
|
||||||
|
- Build one local canonical Genshin data package for artifact sets, pieces, slots, stats, and characters.
|
||||||
|
- Parse artifact name, slot, main stat, substats, set, equipped state, and confidence deterministically against that package.
|
||||||
|
- Save weak or failed reads automatically as review samples and turn corrections into reusable local fixes.
|
||||||
|
- Keep the app offline-first and usable without Genshin Optimizer, Inventory Kamera, Enka, or HoYoLAB.
|
||||||
|
- Re-introduce recommendations only after the scanner base is trustworthy.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No memory reads, process hooks, game modification, packet inspection, or anti-cheat bypassing.
|
||||||
|
- No automatic deleting, feeding, enhancing, or spending resources.
|
||||||
|
- No advanced formula editor or full power-user optimizer in the MVP.
|
||||||
|
- No cloud sync by default.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| FR-001 | List capture sources and automatically prefer the detected Genshin window when available. | Must | Prototype |
|
||||||
|
| FR-002 | Read one currently opened artifact reliably from the local screen and show its parsed result. | Must | Prototype |
|
||||||
|
| FR-003 | Generate and maintain a local canonical Genshin data package for sets, pieces, slots, stats, characters, aliases, and UI profiles. | Must | In progress |
|
||||||
|
| FR-004 | Parse artifact fields only through deterministic matching, validation, and derivation against the canonical package. | Must | In progress |
|
||||||
|
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Prototype |
|
||||||
|
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Prototype |
|
||||||
|
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Prototype |
|
||||||
|
| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | In progress |
|
||||||
|
| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending |
|
||||||
|
| FR-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending |
|
||||||
|
| FR-011 | Farming overlay for reward scans. | Later | Prototype shell |
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
| Category | Requirement | Measurement |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
|
||||||
|
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
|
||||||
|
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
|
||||||
|
| Reliability | Uncertain OCR must be visible to the user. | Confidence and details view |
|
||||||
|
| Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` |
|
||||||
|
| Maintainability | Scanner heuristics must be isolated and documented. | Parser tests, scan-loop tests, data generator, review sample pipeline |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Area | Choice | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Desktop shell | Electron | Windows local app and overlay windows |
|
||||||
|
| Frontend | React + TypeScript + Vite | UI and client state |
|
||||||
|
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
||||||
|
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
||||||
|
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
||||||
|
| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path |
|
||||||
|
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
||||||
|
| Packaging | electron-builder | Configured in `package.json` |
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
| Environment | Entry Point | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Local dev | `npm run dev` | Starts Vite and Electron |
|
||||||
|
| Local dev with automation | `npm run dev:admin` | Required when `GenshinImpact.exe` is elevated; Windows blocks lower-integrity cursor/click input |
|
||||||
|
| Production build | `npm run build` | Builds React and Electron main process |
|
||||||
|
| Preview | `npm run preview` | Browser preview only; capture bridge is unavailable |
|
||||||
|
|
||||||
|
## Current State Review
|
||||||
|
|
||||||
|
### What already works
|
||||||
|
|
||||||
|
- The app can enumerate capture sources and often identify the Genshin window automatically.
|
||||||
|
- Single-artifact capture is no longer blind full-screen OCR; it produces detail crops, OCR blocks, parsed fields, confidence, and notes.
|
||||||
|
- A local canonical data package already exists in `src/data/genshinGameData.json`, generated from `genshin-db`.
|
||||||
|
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
||||||
|
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
||||||
|
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
||||||
|
|
||||||
|
### What is still structurally weak
|
||||||
|
|
||||||
|
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
|
||||||
|
- The current PowerShell input sidecar is serviceable for experimentation but not a strong production base for long-running, low-jitter auto-scan.
|
||||||
|
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
|
||||||
|
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
||||||
|
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
|
||||||
|
- Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus.
|
||||||
|
|
||||||
|
### Current product conclusion
|
||||||
|
|
||||||
|
The app should stop behaving like an OCR demo with extra features around it. The next phase is a scanner product rebuild: canonical data first, scan engine second, learning loop third, recommendations later.
|
||||||
|
|
||||||
|
## Product Direction
|
||||||
|
|
||||||
|
- Artifact scanning is the first-class feature.
|
||||||
|
- Character optimization returns only after scan quality is trustworthy.
|
||||||
|
- Team building stays out of the critical path until artifact ingestion is stable.
|
||||||
|
- Inventory Kamera remains a reference for scan choreography and page movement, not a runtime dependency.
|
||||||
|
- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion.
|
||||||
|
|
||||||
|
## Execution Plan
|
||||||
|
|
||||||
|
### Phase 0 - Stabilize the operator surface
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Scan page reduced to source, main preview, result panel, primary scan actions, and compact status.
|
||||||
|
- Diagnostics, logs, crops, confidence breakdown, review queue, and learning internals moved behind modal or drawer entry points.
|
||||||
|
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- In progress
|
||||||
|
|
||||||
|
### Phase 1 - Canonical game data package
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- `scripts/generate-genshin-data.cjs` emits one stricter package contract for:
|
||||||
|
- artifact sets
|
||||||
|
- artifact pieces
|
||||||
|
- slot-by-piece mapping
|
||||||
|
- stats and allowed mains by slot
|
||||||
|
- characters
|
||||||
|
- aliases
|
||||||
|
- UI profiles
|
||||||
|
- source version metadata
|
||||||
|
- Parser regression tests run against saved review samples and known bad cases.
|
||||||
|
- Parser stops "free guessing" outside the canonical package.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- In progress
|
||||||
|
|
||||||
|
### Phase 2 - Deterministic parser hardening
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Name, slot, set, main stat, and equipped fields are parsed through layered validation:
|
||||||
|
1. direct OCR cleanup
|
||||||
|
2. alias normalization
|
||||||
|
3. exact package match
|
||||||
|
4. constrained fuzzy match
|
||||||
|
5. safe derivation from piece/slot/value references
|
||||||
|
- Main stat/value inference is tightened with slot constraints and reference tables.
|
||||||
|
- Bad parses automatically generate structured review reasons.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- In progress
|
||||||
|
|
||||||
|
### Phase 3 - Scanner core rebuild
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Auto-scan becomes a dedicated engine with explicit states:
|
||||||
|
- preflight
|
||||||
|
- grid detection
|
||||||
|
- click target
|
||||||
|
- wait stable
|
||||||
|
- detail verify
|
||||||
|
- parse
|
||||||
|
- store or review
|
||||||
|
- next tile
|
||||||
|
- row scroll
|
||||||
|
- resume or stop
|
||||||
|
- Progress counts only when a new verified artifact or duplicate signature is confirmed.
|
||||||
|
- Repeated pages, unchanged detail cards, blocked cursor movement, and scroll failures stop the scan with diagnosis instead of producing fake progress.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- In progress
|
||||||
|
|
||||||
|
### Phase 4 - Input automation replacement
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Replace the production automation path with a persistent Windows sidecar dedicated to:
|
||||||
|
- focus
|
||||||
|
- move
|
||||||
|
- click
|
||||||
|
- scroll
|
||||||
|
- capture
|
||||||
|
- probe
|
||||||
|
- Session probe decides which input mode works before auto-scan is unlocked.
|
||||||
|
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- Planned
|
||||||
|
|
||||||
|
### Phase 5 - Learning loop that actually compounds
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Weak scans save themselves as review samples automatically.
|
||||||
|
- User corrections update local:
|
||||||
|
- text replacements
|
||||||
|
- alias maps
|
||||||
|
- crop offsets
|
||||||
|
- UI profile adjustments
|
||||||
|
- constrained set/piece/slot fixes
|
||||||
|
- "Apply learned fixes" runs before every parse.
|
||||||
|
- Review samples become both parser regression fixtures and learning inputs.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- Planned
|
||||||
|
|
||||||
|
### Phase 6 - Recommendations come back on top of a trusted scanner
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Account snapshot and build suggestions are only promoted once scan quality is high enough to trust owned artifacts.
|
||||||
|
- Recommendations explain uncertainty and surface conflicts instead of pretending perfect certainty.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
- Deferred until scanner trust is acceptable
|
||||||
|
|
||||||
|
## Immediate Next Implementation Order
|
||||||
|
|
||||||
|
1. Finish scan-page cleanup so the main operator view is no longer noisy.
|
||||||
|
2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples.
|
||||||
|
3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
|
||||||
|
4. Replace or wrap the current PowerShell sidecar with a more stable long-lived automation process.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
| Question | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Should the production input sidecar be Rust/C++ first, or a transitional Node native addon, for the next iteration? | Open |
|
||||||
|
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
||||||
|
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
||||||
|
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Prompts
|
||||||
|
|
||||||
|
## Scanner Bug Report
|
||||||
|
|
||||||
|
Use this when reporting a capture/OCR issue:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Genshin resolution:
|
||||||
|
Display scaling:
|
||||||
|
Selected source:
|
||||||
|
Expected crop:
|
||||||
|
Actual crop:
|
||||||
|
Screenshot of scan page:
|
||||||
|
Screenshot of Details modal:
|
||||||
|
OCR candidates text:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Request
|
||||||
|
|
||||||
|
```text
|
||||||
|
Goal:
|
||||||
|
User workflow:
|
||||||
|
Must-have behavior:
|
||||||
|
Nice-to-have behavior:
|
||||||
|
Safety constraints:
|
||||||
|
Validation idea:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Request
|
||||||
|
|
||||||
|
```text
|
||||||
|
Please review this change for:
|
||||||
|
- scanner safety
|
||||||
|
- parser correctness
|
||||||
|
- UI clarity
|
||||||
|
- validation gaps
|
||||||
|
```
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Branching
|
||||||
|
|
||||||
|
This project is currently developed locally. When it becomes a Git repository, use focused branches:
|
||||||
|
|
||||||
|
- `feature/<short-name>` for new user-facing features.
|
||||||
|
- `fix/<short-name>` for bug fixes.
|
||||||
|
- `docs/<short-name>` for documentation-only work.
|
||||||
|
- `scanner/<short-name>` for capture, OCR, crop, or parser work.
|
||||||
|
|
||||||
|
Keep branches small enough to review and validate quickly.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Release Process
|
||||||
|
|
||||||
|
## Local Release Readiness
|
||||||
|
|
||||||
|
Before packaging:
|
||||||
|
|
||||||
|
1. Run `npm run lint`.
|
||||||
|
2. Run `npm test`.
|
||||||
|
3. Run `npm run build`.
|
||||||
|
4. Start the Electron app.
|
||||||
|
5. Run a manual Smart Capture smoke test.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
The package configuration lives in `package.json` under `build`.
|
||||||
|
|
||||||
|
Future packaging command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx electron-builder --win
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Until releases are formalized, rollback means returning to the last known working project folder or Git commit. Do not overwrite user data or local snapshots during rollback.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Repository Setup
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the Electron window for scanner work. Browser preview does not expose the capture bridge.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Genshin should be visible on the primary display for the current Smart Capture path.
|
||||||
|
- The app is Windows-first.
|
||||||
|
- Scanner OCR is still prototype-grade.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
## 1. Clarify Scope
|
||||||
|
|
||||||
|
Define whether the task affects scanner capture, OCR parsing, recommendations, UI, documentation, or packaging. Scanner and automation work require extra safety review.
|
||||||
|
|
||||||
|
## 2. Inspect Existing Code
|
||||||
|
|
||||||
|
Use `rg` and read the relevant files before editing. For this project, likely starting points are:
|
||||||
|
|
||||||
|
- `electron/main.ts`
|
||||||
|
- `electron/preload.cjs`
|
||||||
|
- `src/App.tsx`
|
||||||
|
- `src/lib/artifactOcrParser.ts`
|
||||||
|
- `src/lib/scoring.ts`
|
||||||
|
- `src/styles/global.css`
|
||||||
|
|
||||||
|
## 3. Implement Small Changes
|
||||||
|
|
||||||
|
Keep edits focused. Avoid mixing UI redesign, scanner logic, parser logic, and documentation unless the request explicitly spans them.
|
||||||
|
|
||||||
|
## 4. Validate
|
||||||
|
|
||||||
|
Default validation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For scanner changes, restart Electron after build or after changing `electron/main.ts`, because the main process does not hot reload reliably.
|
||||||
|
|
||||||
|
## 5. Manual Smoke Test
|
||||||
|
|
||||||
|
For Smart Capture:
|
||||||
|
|
||||||
|
1. Open Genshin in borderless/windowed mode.
|
||||||
|
2. Open the artifact inventory detail view.
|
||||||
|
3. Click `Sources` and confirm Genshin is selected or available.
|
||||||
|
4. Click `Smart Capture`.
|
||||||
|
5. Confirm the preview shows the artifact detail panel.
|
||||||
|
6. Open `Details` and inspect crops/OCR.
|
||||||
|
|
||||||
|
## 6. Review Outcome
|
||||||
|
|
||||||
|
Document important scanner heuristics, accepted limitations, and future fixes in `docs/DECISIONS.md` or this workflow when they become durable.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./ipcBootstrap.js";
|
||||||
|
export * from "./repositoryContext.js";
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { registerAppHandlers } from "../ipc/appHandlers.js";
|
||||||
|
import { registerCaptureHandlers } from "../ipc/captureHandlers.js";
|
||||||
|
import { registerPersistenceHandlers } from "../ipc/persistenceHandlers.js";
|
||||||
|
import type {
|
||||||
|
BooleanResult,
|
||||||
|
FocusGenshinResult,
|
||||||
|
RuntimeInfo,
|
||||||
|
LoadScannerLearningRulesResult,
|
||||||
|
SaveScannerLearningRulesResult,
|
||||||
|
ScannerLearningRulePayload,
|
||||||
|
CaptureOptions,
|
||||||
|
CaptureResult,
|
||||||
|
CaptureSourceInfo,
|
||||||
|
ClickResult,
|
||||||
|
AutomationGuard,
|
||||||
|
ScrollResult,
|
||||||
|
SaveResultWithPath,
|
||||||
|
SaveSnapshotResult,
|
||||||
|
GoodDatabase,
|
||||||
|
ScannerStatusPayload,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
import type {
|
||||||
|
ArtifactStoreRepositoryPort,
|
||||||
|
ReviewSamplesRepositoryPort,
|
||||||
|
ReviewSampleListResult,
|
||||||
|
} from "../repositories/contracts.js";
|
||||||
|
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||||
|
|
||||||
|
interface AppHandlersDependencies {
|
||||||
|
focusMainWindow: () => BooleanResult;
|
||||||
|
moveMainWindowOffGenshin: () => Promise<void>;
|
||||||
|
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||||
|
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||||
|
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||||
|
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
||||||
|
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||||
|
runMockScan: () => Promise<AppSnapshot | null>;
|
||||||
|
showOverlayWindow: () => Promise<BooleanResult>;
|
||||||
|
hideOverlayWindow: () => Promise<BooleanResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort;
|
||||||
|
type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort;
|
||||||
|
|
||||||
|
interface PersistenceHandlersDependencies {
|
||||||
|
getArtifactStoreRepository: ArtifactStoreAccessor;
|
||||||
|
getReviewSamplesRepository: ReviewSamplesAccessor;
|
||||||
|
artifactStorePath: () => string;
|
||||||
|
reviewSamplesPath: () => string;
|
||||||
|
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||||
|
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||||
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||||
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaptureHandlersDependencies {
|
||||||
|
listSources: () => Promise<CaptureSourceInfo[]>;
|
||||||
|
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||||
|
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||||
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||||
|
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpcBootstrapDependencies extends AppHandlersDependencies, PersistenceHandlersDependencies, CaptureHandlersDependencies {}
|
||||||
|
|
||||||
|
export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||||
|
registerAppHandlers({
|
||||||
|
focusMainWindow: dependencies.focusMainWindow,
|
||||||
|
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
|
||||||
|
focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
|
||||||
|
publishScannerStatus: dependencies.publishScannerStatus,
|
||||||
|
getRuntimeInfo: dependencies.readRuntimeInfo,
|
||||||
|
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
||||||
|
saveSnapshot: dependencies.saveSnapshotToDisk,
|
||||||
|
runMockScan: dependencies.runMockScan,
|
||||||
|
showOverlay: dependencies.showOverlayWindow,
|
||||||
|
hideOverlay: dependencies.hideOverlayWindow,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerPersistenceHandlers({
|
||||||
|
getArtifactStoreRepository: dependencies.getArtifactStoreRepository,
|
||||||
|
getReviewSamplesRepository: dependencies.getReviewSamplesRepository,
|
||||||
|
artifactStorePath: dependencies.artifactStorePath,
|
||||||
|
reviewSamplesPath: dependencies.reviewSamplesPath,
|
||||||
|
loadReviewSamples: dependencies.loadReviewSamples,
|
||||||
|
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
||||||
|
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
||||||
|
exportGood: dependencies.exportGood,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerCaptureHandlers({
|
||||||
|
listSources: dependencies.listSources,
|
||||||
|
captureSource: dependencies.captureSource,
|
||||||
|
clickScreen: dependencies.clickScreen,
|
||||||
|
scrollScreen: dependencies.scrollScreen,
|
||||||
|
getAutomationGuard: dependencies.getAutomationGuard,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
JsonArtifactStoreRepository,
|
||||||
|
JsonSnapshotRepository,
|
||||||
|
ReviewSamplesRepository,
|
||||||
|
ScannerLearningRepository,
|
||||||
|
type ArtifactStoreRepositoryPort,
|
||||||
|
type ReviewSamplesRepositoryPort,
|
||||||
|
type ScannerLearningRepositoryPort,
|
||||||
|
type SnapshotRepositoryPort,
|
||||||
|
} from "../repositories/index.js";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
export interface RepositoryContext {
|
||||||
|
artifactStorePath: string;
|
||||||
|
reviewSamplesPath: string;
|
||||||
|
scannerLearningPath: string;
|
||||||
|
snapshotPath: string;
|
||||||
|
artifactStoreRepository: ArtifactStoreRepositoryPort;
|
||||||
|
reviewSamplesRepository: ReviewSamplesRepositoryPort;
|
||||||
|
scannerLearningRepository: ScannerLearningRepositoryPort;
|
||||||
|
snapshotRepository: SnapshotRepositoryPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRepositoryContext(userDataPath: string): RepositoryContext {
|
||||||
|
const artifactStorePath = path.join(userDataPath, "artifact-store.json");
|
||||||
|
const reviewSamplesPath = path.join(userDataPath, "review-samples.jsonl");
|
||||||
|
const scannerLearningPath = path.join(userDataPath, "scanner-learning.json");
|
||||||
|
const snapshotPath = path.join(userDataPath, "snapshot.json");
|
||||||
|
|
||||||
|
return {
|
||||||
|
artifactStorePath,
|
||||||
|
reviewSamplesPath,
|
||||||
|
scannerLearningPath,
|
||||||
|
snapshotPath,
|
||||||
|
artifactStoreRepository: new JsonArtifactStoreRepository(userDataPath),
|
||||||
|
reviewSamplesRepository: new ReviewSamplesRepository(userDataPath),
|
||||||
|
scannerLearningRepository: new ScannerLearningRepository(userDataPath),
|
||||||
|
snapshotRepository: new JsonSnapshotRepository(userDataPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ipcMain } from "electron";
|
||||||
|
import type {
|
||||||
|
BooleanResult,
|
||||||
|
FocusGenshinResult,
|
||||||
|
RuntimeInfo,
|
||||||
|
SaveSnapshotResult,
|
||||||
|
ScannerStatusPayload,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||||
|
|
||||||
|
interface AppCommandDependencies {
|
||||||
|
focusMainWindow: () => BooleanResult;
|
||||||
|
moveMainWindowOffGenshin: () => Promise<void>;
|
||||||
|
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||||
|
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||||
|
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||||
|
loadSnapshot: () => Promise<AppSnapshot | null>;
|
||||||
|
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||||
|
runMockScan: () => Promise<AppSnapshot | null>;
|
||||||
|
showOverlay: () => Promise<BooleanResult>;
|
||||||
|
hideOverlay: () => Promise<BooleanResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAppHandlers({
|
||||||
|
focusMainWindow,
|
||||||
|
moveMainWindowOffGenshin,
|
||||||
|
focusGenshinForScanStart,
|
||||||
|
publishScannerStatus,
|
||||||
|
getRuntimeInfo,
|
||||||
|
loadSnapshot,
|
||||||
|
saveSnapshot,
|
||||||
|
runMockScan,
|
||||||
|
showOverlay,
|
||||||
|
hideOverlay,
|
||||||
|
}: AppCommandDependencies) {
|
||||||
|
ipcMain.handle("app:focusMainWindow", async () => focusMainWindow());
|
||||||
|
ipcMain.handle("automation:focusGenshin", async () => {
|
||||||
|
await moveMainWindowOffGenshin();
|
||||||
|
return focusGenshinForScanStart();
|
||||||
|
});
|
||||||
|
ipcMain.handle("scanner:publishStatus", async (_event, status: ScannerStatusPayload) => {
|
||||||
|
await publishScannerStatus(status);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
|
||||||
|
ipcMain.handle("snapshot:load", async () => loadSnapshot());
|
||||||
|
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
|
||||||
|
ipcMain.handle("scan:runMock", async () => runMockScan());
|
||||||
|
ipcMain.handle("overlay:show", () => showOverlay());
|
||||||
|
ipcMain.handle("overlay:hide", () => hideOverlay());
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ipcMain } from "electron";
|
||||||
|
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } from "../../src/types/global.js";
|
||||||
|
|
||||||
|
interface CaptureCommandDependencies {
|
||||||
|
listSources: () => Promise<CaptureSourceInfo[]>;
|
||||||
|
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||||
|
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||||
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||||
|
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCaptureHandlers({
|
||||||
|
listSources,
|
||||||
|
captureSource,
|
||||||
|
clickScreen,
|
||||||
|
scrollScreen,
|
||||||
|
getAutomationGuard,
|
||||||
|
}: CaptureCommandDependencies) {
|
||||||
|
ipcMain.handle("capture:listSources", async () => listSources());
|
||||||
|
ipcMain.handle("capture:captureSource", async (_event, sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
|
||||||
|
return captureSource(sourceId, delayMs, focusGenshin, options);
|
||||||
|
});
|
||||||
|
ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y));
|
||||||
|
ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY));
|
||||||
|
ipcMain.handle("automation:getGuard", async () => getAutomationGuard());
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { ipcMain } from "electron";
|
||||||
|
import type {
|
||||||
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreRepositoryPort,
|
||||||
|
ArtifactStoreSaveResult,
|
||||||
|
ReviewSampleListResult,
|
||||||
|
ReviewSamplePayload,
|
||||||
|
ReviewSamplesRepositoryPort,
|
||||||
|
} from "../repositories/contracts.js";
|
||||||
|
import type {
|
||||||
|
GoodDatabase,
|
||||||
|
LoadScannerLearningRulesResult,
|
||||||
|
SaveScannerLearningRulesResult,
|
||||||
|
ScannerLearningRulePayload,
|
||||||
|
SaveResultWithPath,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||||
|
|
||||||
|
type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort;
|
||||||
|
type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort;
|
||||||
|
|
||||||
|
interface PersistenceDependencies {
|
||||||
|
getArtifactStoreRepository: ArtifactStoreAccessor;
|
||||||
|
getReviewSamplesRepository: ReviewSamplesAccessor;
|
||||||
|
artifactStorePath: () => string;
|
||||||
|
reviewSamplesPath: () => string;
|
||||||
|
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||||
|
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||||
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||||
|
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPersistenceHandlers({
|
||||||
|
getArtifactStoreRepository,
|
||||||
|
getReviewSamplesRepository,
|
||||||
|
artifactStorePath,
|
||||||
|
reviewSamplesPath,
|
||||||
|
loadReviewSamples,
|
||||||
|
loadScannerLearningRules,
|
||||||
|
writeScannerLearningRules,
|
||||||
|
exportGood,
|
||||||
|
}: PersistenceDependencies) {
|
||||||
|
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
||||||
|
try {
|
||||||
|
return await getReviewSamplesRepository().append(sample);
|
||||||
|
} catch {
|
||||||
|
const filePath = reviewSamplesPath();
|
||||||
|
return { ok: false, path: filePath };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("review:loadSamples", async (_event, limit = 50) => {
|
||||||
|
return loadReviewSamples(Number(limit) || 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("scanner:loadLearningRules", async () => {
|
||||||
|
return loadScannerLearningRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("scanner:saveLearningRules", async (_event, rules: ScannerLearningRulePayload) => {
|
||||||
|
return writeScannerLearningRules(rules);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("artifacts:load", async () => {
|
||||||
|
try {
|
||||||
|
return (await getArtifactStoreRepository().loadAll()) as ArtifactStoreLoadResult;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, artifacts: [], total: 0, path: artifactStorePath() };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("artifacts:saveMany", async (_event, records: StoredArtifactRecord[]) => {
|
||||||
|
try {
|
||||||
|
const safeRecords = Array.isArray(records) ? records : [];
|
||||||
|
return (await getArtifactStoreRepository().saveMany(safeRecords)) as ArtifactStoreSaveResult;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, added: 0, updated: 0, total: 0, path: artifactStorePath() };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||||
|
return exportGood(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("assistantApi", {
|
||||||
|
loadSnapshot: () => ipcRenderer.invoke("snapshot:load"),
|
||||||
|
saveSnapshot: (snapshot) => ipcRenderer.invoke("snapshot:save", snapshot),
|
||||||
|
runMockScan: () => ipcRenderer.invoke("scan:runMock"),
|
||||||
|
listCaptureSources: () => ipcRenderer.invoke("capture:listSources"),
|
||||||
|
captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||||
|
clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||||
|
scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||||
|
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||||
|
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||||
|
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||||
|
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
|
||||||
|
saveReviewSample: (sample) => ipcRenderer.invoke("review:saveSample", sample),
|
||||||
|
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
|
||||||
|
loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"),
|
||||||
|
saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||||
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
|
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
|
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||||
|
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
|
onScannerCommand: (callback) => {
|
||||||
|
const listener = (_event, command) => callback(command);
|
||||||
|
ipcRenderer.on("scanner:command", listener);
|
||||||
|
return () => ipcRenderer.removeListener("scanner:command", listener);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||||
|
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||||
|
import type { AppSnapshot } from "../src/types/domain.js";
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("assistantApi", {
|
||||||
|
loadSnapshot: () => ipcRenderer.invoke("snapshot:load"),
|
||||||
|
saveSnapshot: (snapshot: AppSnapshot) => ipcRenderer.invoke("snapshot:save", snapshot),
|
||||||
|
runMockScan: () => ipcRenderer.invoke("scan:runMock"),
|
||||||
|
listCaptureSources: () => ipcRenderer.invoke("capture:listSources"),
|
||||||
|
captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||||
|
clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||||
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||||
|
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||||
|
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||||
|
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||||
|
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
|
||||||
|
saveReviewSample: (sample: ReviewSamplePayload) => ipcRenderer.invoke("review:saveSample", sample),
|
||||||
|
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
|
||||||
|
loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"),
|
||||||
|
saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||||
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
|
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
|
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||||
|
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
|
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command);
|
||||||
|
ipcRenderer.on("scanner:command", listener);
|
||||||
|
return () => ipcRenderer.removeListener("scanner:command", listener);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||||
|
import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js";
|
||||||
|
import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js";
|
||||||
|
interface ArtifactStoreFile {
|
||||||
|
version?: number;
|
||||||
|
artifacts?: StoredArtifactRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort {
|
||||||
|
private readonly filePath: string;
|
||||||
|
|
||||||
|
constructor(userDataPath: string, fileName = "artifact-store.json") {
|
||||||
|
this.filePath = path.join(userDataPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAll(): Promise<ArtifactStoreLoadResult> {
|
||||||
|
const records = await this.loadMap();
|
||||||
|
const artifacts = [...records.values()].sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? ""));
|
||||||
|
return { ok: true, artifacts, total: artifacts.length, path: this.filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMap(): Promise<Map<string, StoredArtifactRecord>> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await fs.readFile(this.filePath, "utf8")) as ArtifactStoreFile;
|
||||||
|
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
|
||||||
|
const cleaned = records
|
||||||
|
.filter((record) => record?.id && !isObviousGarbageRecord(record))
|
||||||
|
.map((record) => normalizeStoredArtifactRecordForLoad(record));
|
||||||
|
const store = new Map(cleaned.map((record) => [record.id, record]));
|
||||||
|
const storeChanged = cleaned.length !== records.length || cleaned.some((record, index) => JSON.stringify(record) !== JSON.stringify(records[index]));
|
||||||
|
if (storeChanged) await this.writeRecords([...store.values()]);
|
||||||
|
return store;
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult> {
|
||||||
|
const store = await this.loadMap();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let added = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const record of records ?? []) {
|
||||||
|
if (!record?.id || isObviousGarbageRecord(record)) continue;
|
||||||
|
const existing = store.get(record.id);
|
||||||
|
if (existing) {
|
||||||
|
store.set(record.id, {
|
||||||
|
...existing,
|
||||||
|
...record,
|
||||||
|
firstSeenAt: existing.firstSeenAt ?? now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
timesSeen: (existing.timesSeen ?? 1) + 1,
|
||||||
|
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
|
||||||
|
// A later confident scan clears the review flag; an uncertain rescan
|
||||||
|
// must not downgrade an already confirmed artifact.
|
||||||
|
needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview),
|
||||||
|
source: resolveStoredArtifactSource(existing.source, record.source),
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
const mergeCandidate = [...store.values()].find((candidate) => shouldMergeArtifactRecords(candidate, record));
|
||||||
|
if (mergeCandidate) {
|
||||||
|
const merged = mergeArtifactRecords(mergeCandidate, record, now);
|
||||||
|
if (mergeCandidate.id !== merged.id) store.delete(mergeCandidate.id);
|
||||||
|
store.set(merged.id, merged);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
store.set(record.id, { ...record, firstSeenAt: now, lastSeenAt: now, timesSeen: 1 });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.writeRecords([...store.values()]);
|
||||||
|
return { ok: true, added, updated, total: store.size, path: this.filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeRecords(records: StoredArtifactRecord[]) {
|
||||||
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactMergeKey(record: StoredArtifactRecord) {
|
||||||
|
return [record.name, record.slot, record.setName, record.mainStat, record.mainValue, record.level ?? ""]
|
||||||
|
.map((value) => `${value ?? ""}`.trim().toLowerCase())
|
||||||
|
.join("::");
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactFamilyKey(record: StoredArtifactRecord) {
|
||||||
|
return [record.name, record.slot, record.setName, record.mainStat]
|
||||||
|
.map((value) => `${value ?? ""}`.trim().toLowerCase())
|
||||||
|
.join("::");
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactQualityScore(record: StoredArtifactRecord) {
|
||||||
|
const corePenalty =
|
||||||
|
(record.name === "Unknown artifact" ? 40 : 0)
|
||||||
|
+ (record.slot === "Unknown slot" ? 35 : 0)
|
||||||
|
+ (record.setName === "Unknown set" ? 35 : 0)
|
||||||
|
+ (record.mainStat === "Unknown main stat" ? 40 : 0)
|
||||||
|
+ (record.mainValue === "?" ? 20 : 0);
|
||||||
|
|
||||||
|
return (record.confidence ?? 0)
|
||||||
|
+ Math.min(20, (record.substats?.length ?? 0) * 5)
|
||||||
|
+ (record.needsReview ? -12 : 8)
|
||||||
|
+ (record.equipped && record.equipped !== "Not detected" ? 2 : 0)
|
||||||
|
- corePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObviousGarbageRecord(record: StoredArtifactRecord) {
|
||||||
|
return (
|
||||||
|
!record?.id
|
||||||
|
|| record.name === "Unknown artifact"
|
||||||
|
|| record.slot === "Unknown slot"
|
||||||
|
|| record.setName === "Unknown set"
|
||||||
|
|| record.mainStat === "Unknown main stat"
|
||||||
|
|| record.mainValue === "?"
|
||||||
|
|| (record.substats?.length ?? 0) === 0
|
||||||
|
|| (record.confidence ?? 0) < 30
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArtifactNumericValue(value: string) {
|
||||||
|
const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, "").replace("%", "").trim());
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
|
||||||
|
const normalizedTimesSeen = Math.max(1, Math.round(record.timesSeen ?? 1));
|
||||||
|
const reviewOnly = isReviewOnlyArtifactSource(record.source);
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
|
||||||
|
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldMergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord) {
|
||||||
|
if (existing.id === incoming.id) return true;
|
||||||
|
|
||||||
|
const overlap = (incoming.substats ?? []).filter((substat: string) => (existing.substats ?? []).includes(substat)).length;
|
||||||
|
const overlapThreshold = Math.min(2, Math.min(existing.substats?.length ?? 0, incoming.substats?.length ?? 0));
|
||||||
|
const qualityGap = Math.abs(artifactQualityScore(existing) - artifactQualityScore(incoming)) >= 8;
|
||||||
|
|
||||||
|
if (artifactMergeKey(existing) === artifactMergeKey(incoming)) {
|
||||||
|
return (
|
||||||
|
overlap >= overlapThreshold
|
||||||
|
|| existing.needsReview
|
||||||
|
|| incoming.needsReview
|
||||||
|
|| (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0)
|
||||||
|
|| qualityGap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifactFamilyKey(existing) !== artifactFamilyKey(incoming)) return false;
|
||||||
|
|
||||||
|
const sameOrBetterLevel = (incoming.level ?? 0) >= (existing.level ?? 0);
|
||||||
|
const sameOrBetterMainValue = parseArtifactNumericValue(incoming.mainValue) >= parseArtifactNumericValue(existing.mainValue);
|
||||||
|
return sameOrBetterLevel && sameOrBetterMainValue && (
|
||||||
|
overlap >= overlapThreshold
|
||||||
|
|| existing.needsReview
|
||||||
|
|| incoming.needsReview
|
||||||
|
|| (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0)
|
||||||
|
|| qualityGap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord, now: string): StoredArtifactRecord {
|
||||||
|
const incomingPreferred = artifactQualityScore(incoming) >= artifactQualityScore(existing);
|
||||||
|
const preferred = incomingPreferred ? incoming : existing;
|
||||||
|
const secondary = incomingPreferred ? existing : incoming;
|
||||||
|
const preferredSubstats = (preferred.substats?.length ?? 0) >= (secondary.substats?.length ?? 0) ? preferred.substats : secondary.substats;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...secondary,
|
||||||
|
...preferred,
|
||||||
|
id: incomingPreferred ? incoming.id : existing.id,
|
||||||
|
level: Math.max(existing.level ?? 0, incoming.level ?? 0),
|
||||||
|
substats: [...(preferredSubstats ?? [])],
|
||||||
|
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
|
||||||
|
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
|
||||||
|
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
|
||||||
|
source: resolveStoredArtifactSource(existing.source, incoming.source),
|
||||||
|
firstSeenAt: existing.firstSeenAt ?? now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
timesSeen: (existing.timesSeen ?? 1) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type {
|
||||||
|
AutomationGuard,
|
||||||
|
CaptureOptions,
|
||||||
|
CaptureResult,
|
||||||
|
CaptureSourceInfo,
|
||||||
|
ClickResult,
|
||||||
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreSaveResult,
|
||||||
|
ReviewSampleListResult,
|
||||||
|
ReviewSamplePayload,
|
||||||
|
LoadScannerLearningRulesResult,
|
||||||
|
SaveScannerLearningRulesResult,
|
||||||
|
SaveResultWithPath,
|
||||||
|
FocusGenshinResult,
|
||||||
|
RuntimeInfo,
|
||||||
|
BooleanResult,
|
||||||
|
ScrollResult,
|
||||||
|
ScannerStatusPayload,
|
||||||
|
ScannerLearningRulePayload,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||||
|
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreSaveResult,
|
||||||
|
ReviewSampleListResult,
|
||||||
|
ReviewSamplePayload,
|
||||||
|
LoadScannerLearningRulesResult,
|
||||||
|
SaveScannerLearningRulesResult,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
|
||||||
|
export interface ArtifactStoreRepositoryPort {
|
||||||
|
loadAll(): Promise<ArtifactStoreLoadResult>;
|
||||||
|
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
|
||||||
|
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSamplesRepositoryPort {
|
||||||
|
list(limit?: number): Promise<ReviewSampleListResult>;
|
||||||
|
append(sample: ReviewSamplePayload): Promise<SaveResultWithPath>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScannerLearningRules = ScannerLearningRulePayload;
|
||||||
|
export interface ScannerLearningLoadResult extends LoadScannerLearningRulesResult {}
|
||||||
|
export interface ScannerLearningSaveResult extends SaveScannerLearningRulesResult {}
|
||||||
|
|
||||||
|
export interface ScannerLearningRepositoryPort {
|
||||||
|
load(): Promise<ScannerLearningLoadResult>;
|
||||||
|
save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotRepositoryPort {
|
||||||
|
load(): Promise<AppSnapshot | null>;
|
||||||
|
save(snapshot: AppSnapshot): Promise<SaveResultWithPath>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeRepositoryPort {
|
||||||
|
focusMainWindow(): Promise<BooleanResult>;
|
||||||
|
moveMainWindowOffGenshin(): Promise<void>;
|
||||||
|
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
|
||||||
|
publishScannerStatus(status: ScannerStatusPayload): Promise<BooleanResult>;
|
||||||
|
getRuntimeInfo(): Promise<RuntimeInfo>;
|
||||||
|
showOverlay: () => Promise<BooleanResult>;
|
||||||
|
hideOverlay: () => Promise<BooleanResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRepositoryPort {
|
||||||
|
getAutomationGuard(): Promise<AutomationGuard>;
|
||||||
|
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||||
|
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureRepositoryPort {
|
||||||
|
listSources(): Promise<CaptureSourceInfo[]>;
|
||||||
|
captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise<CaptureResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./contracts.js";
|
||||||
|
export { JsonArtifactStoreRepository } from "./artifactStoreRepository.js";
|
||||||
|
export { ReviewSamplesRepository } from "./reviewSamplesRepository.js";
|
||||||
|
export { ScannerLearningRepository } from "./scannerLearningRepository.js";
|
||||||
|
export { JsonSnapshotRepository } from "./snapshotRepository.js";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
|
||||||
|
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
|
||||||
|
|
||||||
|
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||||
|
private readonly filePath: string;
|
||||||
|
|
||||||
|
constructor(userDataPath: string, fileName = "review-samples.jsonl") {
|
||||||
|
this.filePath = path.join(userDataPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(limit = 50): Promise<ReviewSampleListResult> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.filePath, "utf8");
|
||||||
|
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||||
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||||
|
const samples = lines
|
||||||
|
.slice(-safeLimit)
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as ReviewSampleRecord;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ReviewSampleRecord[];
|
||||||
|
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath };
|
||||||
|
} catch {
|
||||||
|
return { ok: true, samples: [], total: 0, path: this.filePath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(sample: ReviewSamplePayload): Promise<SaveResultWithPath> {
|
||||||
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fs.appendFile(this.filePath, `${JSON.stringify({ savedAt: new Date().toISOString(), sample })}\n`, "utf8");
|
||||||
|
return { ok: true, path: this.filePath };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ScannerLearningLoadResult, ScannerLearningRepositoryPort, ScannerLearningRules, ScannerLearningSaveResult } from "./contracts.js";
|
||||||
|
|
||||||
|
export class ScannerLearningRepository implements ScannerLearningRepositoryPort {
|
||||||
|
private readonly filePath: string;
|
||||||
|
|
||||||
|
constructor(userDataPath: string, fileName = "scanner-learning.json") {
|
||||||
|
this.filePath = path.join(userDataPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<ScannerLearningLoadResult> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as ScannerLearningRules;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: this.filePath,
|
||||||
|
rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ok: true, path: this.filePath, rules: { textReplacements: {} } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult> {
|
||||||
|
const current = await this.load();
|
||||||
|
const nextTextReplacements = {
|
||||||
|
...((current.rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||||
|
...((rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||||
|
};
|
||||||
|
const payload: ScannerLearningRules = { textReplacements: nextTextReplacements };
|
||||||
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { SnapshotRepositoryPort } from "./contracts.js";
|
||||||
|
import type { SaveResultWithPath } from "../../src/types/global.js";
|
||||||
|
import type { AppSnapshot } from "../../src/types/domain.js";
|
||||||
|
|
||||||
|
export class JsonSnapshotRepository implements SnapshotRepositoryPort {
|
||||||
|
private readonly filePath: string;
|
||||||
|
|
||||||
|
constructor(userDataPath: string, fileName = "snapshot.json") {
|
||||||
|
this.filePath = path.join(userDataPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(this.filePath, "utf8")) as AppSnapshot;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(snapshot: AppSnapshot): Promise<SaveResultWithPath> {
|
||||||
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
await fs.writeFile(this.filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
||||||
|
return { ok: true, path: this.filePath };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
AutomationGuard,
|
||||||
|
ClickResult,
|
||||||
|
FocusGenshinResult,
|
||||||
|
GdiCaptureResult,
|
||||||
|
HelperOperationResponse,
|
||||||
|
WindowBounds,
|
||||||
|
RuntimeInfo,
|
||||||
|
ScrollResult,
|
||||||
|
} from "../../src/types/global.js";
|
||||||
|
|
||||||
|
const INPUT_HELPER_SCRIPT = String.raw`
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
|
||||||
|
$signature = @"
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetProcessDPIAware();
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
public static extern int SetProcessDpiAwareness(int value);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetCursorPos(int X, int Y);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)]
|
||||||
|
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)]
|
||||||
|
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern short GetAsyncKeyState(int vKey);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)]
|
||||||
|
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool IsWindow(IntPtr hWnd);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct POINT { public int X; public int Y; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct INPUT { public int type; public MOUSEINPUT mi; }
|
||||||
|
"@
|
||||||
|
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
|
||||||
|
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
|
||||||
|
# same symptom): the older SetProcessDPIAware() only applies a single,
|
||||||
|
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
|
||||||
|
# on one display, this app's window on a differently-scaled second display),
|
||||||
|
# that single scale factor is wrong for whichever monitor didn't set it,
|
||||||
|
# silently shifting every SetCursorPos/click coordinate off-target even
|
||||||
|
# though cursor readback still matches what we asked for (both go through the
|
||||||
|
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
|
||||||
|
try {
|
||||||
|
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
|
||||||
|
} catch {
|
||||||
|
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
|
||||||
|
}
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# SizeOf must receive a struct instance: passing the type object throws in
|
||||||
|
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
|
||||||
|
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
|
||||||
|
$genshinHwnd = [IntPtr]::Zero
|
||||||
|
|
||||||
|
function Send-MouseInput {
|
||||||
|
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
|
||||||
|
$mouseInput = New-Object Native.InputHelper+INPUT
|
||||||
|
$mouseInput.type = 0
|
||||||
|
$mouseInput.mi.dx = $dx
|
||||||
|
$mouseInput.mi.dy = $dy
|
||||||
|
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
|
||||||
|
$mouseInput.mi.dwFlags = $flags
|
||||||
|
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves
|
||||||
|
# with bare SetCursorPos, then clicks via the InputSimulator library's
|
||||||
|
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE
|
||||||
|
# SendInput call (two INPUT structs in the same array) - back-to-back with no
|
||||||
|
# artificial delay between them, unlike two separate SendInput calls with a
|
||||||
|
# Start-Sleep in between. Returns the number of injected events (2 = ok).
|
||||||
|
function Send-MouseClickBatch {
|
||||||
|
$down = New-Object Native.InputHelper+INPUT
|
||||||
|
$down.type = 0
|
||||||
|
$down.mi.dwFlags = 0x0002
|
||||||
|
$up = New-Object Native.InputHelper+INPUT
|
||||||
|
$up.type = 0
|
||||||
|
$up.mi.dwFlags = 0x0004
|
||||||
|
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CursorPoint {
|
||||||
|
$pt = New-Object Native.InputHelper+POINT
|
||||||
|
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||||
|
return $pt
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProcessNameFromHwnd {
|
||||||
|
param([IntPtr]$hwnd)
|
||||||
|
if ($hwnd -eq [IntPtr]::Zero) { return "" }
|
||||||
|
$pidValue = [uint32]0
|
||||||
|
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
|
||||||
|
if ($pidValue -eq 0) { return "" }
|
||||||
|
try {
|
||||||
|
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CurrentProcessElevation {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||||
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ForegroundInfo {
|
||||||
|
$hwnd = [Native.InputHelper]::GetForegroundWindow()
|
||||||
|
return @{
|
||||||
|
foregroundHwnd = $hwnd.ToInt64()
|
||||||
|
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CursorState {
|
||||||
|
$pt = New-Object Native.InputHelper+POINT
|
||||||
|
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||||
|
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
|
||||||
|
# call" bit is unreliable and fires for ESC presses that happened long
|
||||||
|
# before the scan (ESC is used constantly to navigate Genshin menus).
|
||||||
|
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
|
||||||
|
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
|
||||||
|
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
|
||||||
|
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GenshinClientBounds {
|
||||||
|
$hwnd = Find-GenshinWindow
|
||||||
|
if ($hwnd -eq [IntPtr]::Zero) { return $null }
|
||||||
|
|
||||||
|
$rect = New-Object Native.InputHelper+RECT
|
||||||
|
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
|
||||||
|
|
||||||
|
$topLeft = New-Object Native.InputHelper+POINT
|
||||||
|
$topLeft.X = 0
|
||||||
|
$topLeft.Y = 0
|
||||||
|
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
|
||||||
|
|
||||||
|
$width = $rect.Right - $rect.Left
|
||||||
|
$height = $rect.Bottom - $rect.Top
|
||||||
|
if ($width -le 0 -or $height -le 0) { return $null }
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Left = $topLeft.X
|
||||||
|
Top = $topLeft.Y
|
||||||
|
Width = $width
|
||||||
|
Height = $height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-GenshinWindow {
|
||||||
|
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
||||||
|
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||||
|
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||||
|
return $script:genshinHwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
function Focus-GenshinWindow {
|
||||||
|
$hwnd = Find-GenshinWindow
|
||||||
|
$info = @{
|
||||||
|
hwnd = $hwnd.ToInt64()
|
||||||
|
focused = $false
|
||||||
|
alreadyForeground = $false
|
||||||
|
foregroundProcess = ""
|
||||||
|
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||||
|
}
|
||||||
|
if ($hwnd -eq [IntPtr]::Zero) { return $info }
|
||||||
|
|
||||||
|
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||||
|
if (-not $info.alreadyForeground) {
|
||||||
|
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||||
|
# A previous version tapped ALT (keybd_event) right before this call to
|
||||||
|
# satisfy Windows' "who's allowed to change the foreground window"
|
||||||
|
# eligibility check. That tap has a side effect in most Win32 apps: a
|
||||||
|
# bare ALT press/release toggles menu-mnemonic navigation mode (verified
|
||||||
|
# live - it left a real app's menu bar highlighted after just this call),
|
||||||
|
# which then swallows the next several keyboard/mouse events as menu
|
||||||
|
# navigation instead of routing them to the app - looking exactly like
|
||||||
|
# "clicks/keys report success but do nothing". This app and Genshin run
|
||||||
|
# at the same (elevated) integrity level, so plain SetForegroundWindow
|
||||||
|
# already succeeds without the ALT tap - confirmed with a standalone
|
||||||
|
# compiled test against a live target window.
|
||||||
|
$info.setForegroundResult = [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||||
|
Start-Sleep -Milliseconds 140
|
||||||
|
}
|
||||||
|
|
||||||
|
$foreground = [Native.InputHelper]::GetForegroundWindow()
|
||||||
|
$info.focused = ($foreground -eq $hwnd)
|
||||||
|
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
|
||||||
|
return $info
|
||||||
|
}
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
$line = [Console]::In.ReadLine()
|
||||||
|
if ($null -eq $line) { break }
|
||||||
|
if ($line.Trim().Length -eq 0) { continue }
|
||||||
|
$response = @{ id = ""; ok = $true }
|
||||||
|
try {
|
||||||
|
$cmd = $line | ConvertFrom-Json
|
||||||
|
$response.id = "$($cmd.id)"
|
||||||
|
switch ("$($cmd.op)") {
|
||||||
|
"ping" {
|
||||||
|
$response.pong = $true
|
||||||
|
}
|
||||||
|
"cursor" {
|
||||||
|
$state = Get-CursorState
|
||||||
|
$response.cursorX = $state.cursorX
|
||||||
|
$response.cursorY = $state.cursorY
|
||||||
|
$response.escapePressed = $state.escapePressed
|
||||||
|
$response.enterPressed = $state.enterPressed
|
||||||
|
$response.f9Pressed = $state.f9Pressed
|
||||||
|
}
|
||||||
|
"runtime" {
|
||||||
|
$response.isElevated = Get-CurrentProcessElevation
|
||||||
|
$hwnd = Find-GenshinWindow
|
||||||
|
$foregroundInfo = Get-ForegroundInfo
|
||||||
|
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
|
||||||
|
$response.genshinHwnd = $hwnd.ToInt64()
|
||||||
|
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||||
|
$response.foregroundProcess = $foregroundInfo.foregroundProcess
|
||||||
|
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
|
||||||
|
$response.helperPid = $PID
|
||||||
|
}
|
||||||
|
"focus" {
|
||||||
|
$focusInfo = Focus-GenshinWindow
|
||||||
|
$response.focused = $focusInfo.focused
|
||||||
|
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||||
|
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||||
|
$response.targetProcess = $focusInfo.targetProcess
|
||||||
|
$response.genshinFound = ($focusInfo.hwnd -ne 0)
|
||||||
|
$response.setForegroundResult = $focusInfo.setForegroundResult
|
||||||
|
}
|
||||||
|
"click" {
|
||||||
|
$focusInfo = Focus-GenshinWindow
|
||||||
|
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
}
|
||||||
|
$targetX = [int]$cmd.x
|
||||||
|
$targetY = [int]$cmd.y
|
||||||
|
# Matches Inventory Kamera's verified-working sequence exactly: bare
|
||||||
|
# SetCursorPos immediately followed by a click, with NO extra move
|
||||||
|
# event and NO artificial delay between moving and clicking - IK's
|
||||||
|
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back,
|
||||||
|
# zero gap. Settling delays only happen after the click, in the scan
|
||||||
|
# loop. Down+up are sent as one SendInput call (see
|
||||||
|
# Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick().
|
||||||
|
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
|
||||||
|
$point = Get-CursorPoint
|
||||||
|
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
|
||||||
|
$clickEventsSent = 0
|
||||||
|
if ($onTarget) {
|
||||||
|
$clickEventsSent = Send-MouseClickBatch
|
||||||
|
}
|
||||||
|
$state = Get-CursorState
|
||||||
|
$response.cursorX = $state.cursorX
|
||||||
|
$response.cursorY = $state.cursorY
|
||||||
|
$response.escapePressed = $state.escapePressed
|
||||||
|
$response.enterPressed = $state.enterPressed
|
||||||
|
$response.f9Pressed = $state.f9Pressed
|
||||||
|
$response.moved = $onTarget
|
||||||
|
$response.focused = $focusInfo.focused
|
||||||
|
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||||
|
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||||
|
$response.targetProcess = $focusInfo.targetProcess
|
||||||
|
$response.isElevated = Get-CurrentProcessElevation
|
||||||
|
# Never report a click unless the cursor is verifiably on the target.
|
||||||
|
# Real acceptance is proven later by the detail-panel fingerprint.
|
||||||
|
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
|
||||||
|
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
|
||||||
|
}
|
||||||
|
"scroll" {
|
||||||
|
$focusInfo = Focus-GenshinWindow
|
||||||
|
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
}
|
||||||
|
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
|
||||||
|
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 30
|
||||||
|
}
|
||||||
|
$point = Get-CursorPoint
|
||||||
|
$response.cursorX = $point.X
|
||||||
|
$response.cursorY = $point.Y
|
||||||
|
$response.focused = $focusInfo.focused
|
||||||
|
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||||
|
$response.isElevated = Get-CurrentProcessElevation
|
||||||
|
$notches = [int]$cmd.notches
|
||||||
|
$stepDelta = 120
|
||||||
|
if ($notches -lt 0) { $stepDelta = -120 }
|
||||||
|
$count = [Math]::Abs($notches)
|
||||||
|
if ($count -gt 60) { $count = 60 }
|
||||||
|
$sentTotal = 0
|
||||||
|
for ($i = 0; $i -lt $count; $i++) {
|
||||||
|
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
|
||||||
|
Start-Sleep -Milliseconds 45
|
||||||
|
}
|
||||||
|
$response.notchesSent = $sentTotal
|
||||||
|
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
|
||||||
|
}
|
||||||
|
"bounds" {
|
||||||
|
$clientBounds = Get-GenshinClientBounds
|
||||||
|
if ($null -eq $clientBounds) {
|
||||||
|
$response.found = $false
|
||||||
|
} else {
|
||||||
|
$response.found = $true
|
||||||
|
$response.left = $clientBounds.Left
|
||||||
|
$response.top = $clientBounds.Top
|
||||||
|
$response.width = $clientBounds.Width
|
||||||
|
$response.height = $clientBounds.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"capture" {
|
||||||
|
$clientBounds = Get-GenshinClientBounds
|
||||||
|
if ($null -eq $clientBounds) {
|
||||||
|
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||||
|
$clientBounds = @{
|
||||||
|
Left = $screenBounds.Left
|
||||||
|
Top = $screenBounds.Top
|
||||||
|
Width = $screenBounds.Width
|
||||||
|
Height = $screenBounds.Height
|
||||||
|
}
|
||||||
|
$response.captureTarget = "primary-screen"
|
||||||
|
} else {
|
||||||
|
$response.captureTarget = "genshin-client"
|
||||||
|
}
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size)
|
||||||
|
$capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png")
|
||||||
|
$bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Dispose()
|
||||||
|
$response.path = $capturePath
|
||||||
|
$response.width = $clientBounds.Width
|
||||||
|
$response.height = $clientBounds.Height
|
||||||
|
$response.originX = $clientBounds.Left
|
||||||
|
$response.originY = $clientBounds.Top
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$response.ok = $false
|
||||||
|
$response.error = "unknown op"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$response.ok = $false
|
||||||
|
$response.error = $_.Exception.Message
|
||||||
|
}
|
||||||
|
Write-Output (ConvertTo-Json $response -Compress)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
class InputHelperClient {
|
||||||
|
private child: ChildProcessWithoutNullStreams | null = null;
|
||||||
|
private pending = new Map<string, { resolve: (value: HelperOperationResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private buffer = "";
|
||||||
|
private nextId = 1;
|
||||||
|
private starting: Promise<void> | null = null;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(private readonly scriptUserDataPath: string) {}
|
||||||
|
|
||||||
|
private async ensureStarted() {
|
||||||
|
if (this.child) return;
|
||||||
|
if (this.disposed) throw new Error("Input helper disposed");
|
||||||
|
if (!this.starting) {
|
||||||
|
this.starting = this.start().finally(() => {
|
||||||
|
this.starting = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.starting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async start() {
|
||||||
|
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
|
||||||
|
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||||
|
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
|
||||||
|
|
||||||
|
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stderr.on("data", () => undefined);
|
||||||
|
child.on("exit", () => {
|
||||||
|
this.child = null;
|
||||||
|
this.buffer = "";
|
||||||
|
for (const entry of this.pending.values()) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.reject(new Error("Input helper exited"));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
});
|
||||||
|
this.child = child;
|
||||||
|
|
||||||
|
// First request compiles the Win32 interop; give it extra time.
|
||||||
|
await this.send("ping", {}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStdout(chunk: string) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
let newlineIndex = this.buffer.indexOf("\n");
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const line = this.buffer.slice(0, newlineIndex).trim();
|
||||||
|
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||||
|
newlineIndex = this.buffer.indexOf("\n");
|
||||||
|
if (!line.startsWith("{")) continue;
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line) as HelperOperationResponse;
|
||||||
|
const entry = this.pending.get(String(message.id));
|
||||||
|
if (!entry) continue;
|
||||||
|
this.pending.delete(String(message.id));
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
if (message.ok) entry.resolve(message);
|
||||||
|
else entry.reject(new Error(message.error || "Input helper command failed"));
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON noise on stdout.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(op: string, params: Record<string, unknown>, timeoutMs: number) {
|
||||||
|
return new Promise<HelperOperationResponse>((resolve, reject) => {
|
||||||
|
const child = this.child;
|
||||||
|
if (!child?.stdin.writable) {
|
||||||
|
reject(new Error("Input helper is not running"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = String(this.nextId++);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`Input helper timed out on ${op}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
|
||||||
|
return this.ensureStarted().then(() => this.send(op, params, timeoutMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
this.child?.kill();
|
||||||
|
this.child = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputHelperService {
|
||||||
|
getRuntimeInfo(): Promise<RuntimeInfo>;
|
||||||
|
focusGenshinWindow(): Promise<FocusGenshinResult>;
|
||||||
|
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
|
||||||
|
getGenshinWindowBounds(): Promise<WindowBounds | null>;
|
||||||
|
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||||
|
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||||
|
getAutomationGuard(): Promise<AutomationGuard>;
|
||||||
|
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
|
||||||
|
const inputHelper = new InputHelperClient(options.userDataPath);
|
||||||
|
|
||||||
|
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
|
||||||
|
return inputHelper.request(op, params, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuntimeInfo() {
|
||||||
|
const result = await request("runtime", {}, 4000);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isElevated: Boolean(result.isElevated),
|
||||||
|
platform: process.platform,
|
||||||
|
genshinFound: Boolean(result.genshinFound),
|
||||||
|
genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined,
|
||||||
|
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||||
|
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||||
|
foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined,
|
||||||
|
helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusGenshinWindow() {
|
||||||
|
const result = await request("focus", {}, 6000);
|
||||||
|
return {
|
||||||
|
focused: Boolean(result.focused),
|
||||||
|
alreadyForeground: Boolean(result.alreadyForeground),
|
||||||
|
genshinFound: Boolean(result.genshinFound),
|
||||||
|
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||||
|
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||||
|
setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusGenshinForScanStart() {
|
||||||
|
let result = await focusGenshinWindow();
|
||||||
|
if (result.focused) return result;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||||
|
result = await focusGenshinWindow();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGenshinWindowBounds() {
|
||||||
|
const result = await request("bounds", {}, 4000);
|
||||||
|
if (!result.found) return null;
|
||||||
|
return {
|
||||||
|
x: Number(result.left),
|
||||||
|
y: Number(result.top),
|
||||||
|
width: Number(result.width),
|
||||||
|
height: Number(result.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickScreen(x: number, y: number) {
|
||||||
|
const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
||||||
|
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
||||||
|
escapePressed: Boolean(result.escapePressed),
|
||||||
|
enterPressed: Boolean(result.enterPressed),
|
||||||
|
f9Pressed: Boolean(result.f9Pressed),
|
||||||
|
moved: Boolean(result.moved),
|
||||||
|
clicked: Boolean(result.clicked),
|
||||||
|
inputBlocked: Boolean(result.inputBlocked),
|
||||||
|
focused: Boolean(result.focused),
|
||||||
|
alreadyForeground: Boolean(result.alreadyForeground),
|
||||||
|
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||||
|
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||||
|
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) {
|
||||||
|
const safeNotches = Math.max(-60, Math.min(60, Math.round(notches)));
|
||||||
|
const params: Record<string, unknown> = { notches: safeNotches };
|
||||||
|
if (typeof anchorX === "number" && typeof anchorY === "number") {
|
||||||
|
params.x = Math.round(anchorX);
|
||||||
|
params.y = Math.round(anchorY);
|
||||||
|
}
|
||||||
|
const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
notchesSent: Number(result.notchesSent ?? 0),
|
||||||
|
inputBlocked: Boolean(result.inputBlocked),
|
||||||
|
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAutomationGuard() {
|
||||||
|
const result = await request("cursor", {}, 4000);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
||||||
|
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
||||||
|
escapePressed: Boolean(result.escapePressed),
|
||||||
|
enterPressed: Boolean(result.enterPressed),
|
||||||
|
f9Pressed: Boolean(result.f9Pressed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capturePrimaryScreenViaGdi() {
|
||||||
|
const result = await request("capture", {}, 15000);
|
||||||
|
const capturePath = String(result.path);
|
||||||
|
const buffer = await fs.readFile(capturePath);
|
||||||
|
await fs.unlink(capturePath).catch(() => undefined);
|
||||||
|
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
|
||||||
|
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
|
||||||
|
? captureTargetRaw
|
||||||
|
: "primary-screen";
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
|
||||||
|
width: Number(result.width),
|
||||||
|
height: Number(result.height),
|
||||||
|
originX: Number(result.originX),
|
||||||
|
originY: Number(result.originY),
|
||||||
|
captureTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRuntimeInfo,
|
||||||
|
focusGenshinWindow,
|
||||||
|
focusGenshinForScanStart,
|
||||||
|
getGenshinWindowBounds,
|
||||||
|
clickScreen,
|
||||||
|
scrollScreen,
|
||||||
|
getAutomationGuard,
|
||||||
|
capturePrimaryScreenViaGdi,
|
||||||
|
dispose: () => inputHelper.dispose(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Genshin Artifact Assistant</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "genshin-artifact-assistant",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Local Windows assistant for scanning Genshin artifacts and suggesting no-brainer builds.",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-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:admin": ".\\dev-admin.cmd",
|
||||||
|
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\preload.cjs",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"start": "electron .",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"electron": "33.2.1",
|
||||||
|
"genshin-db": "^5.2.12",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^18.3.17",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"wait-on": "^8.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "local.genshin.artifact-assistant",
|
||||||
|
"productName": "Genshin Artifact Assistant",
|
||||||
|
"directories": {
|
||||||
|
"output": "outputs/dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"dist-electron/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"requestedExecutionLevel": "requireAdministrator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
param(
|
||||||
|
[string]$ProjectRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd),
|
||||||
|
# aber ein sauberer try/catch mit klarer Meldung ist trotzdem besser als ein
|
||||||
|
# roher Stacktrace, wenn z.B. der Dev-Port schon belegt ist.
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
|
||||||
|
|
||||||
|
Write-Host "Projekt: $project"
|
||||||
|
|
||||||
|
# A UAC-elevated process gets its environment rebuilt fresh from the
|
||||||
|
# registry; it does NOT inherit PATH edits that only exist in the calling
|
||||||
|
# (non-elevated) shell session (e.g. a version manager that only patched
|
||||||
|
# the current terminal). If node/npm resolve normally but not here, that
|
||||||
|
# is almost always the cause - fail with a clear message instead of a
|
||||||
|
# cryptic "npm is not recognized" a few lines down.
|
||||||
|
$npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||||||
|
if (-not $npmCommand) { $npmCommand = Get-Command npm -ErrorAction SilentlyContinue }
|
||||||
|
$nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $nodeCommand) { $nodeCommand = Get-Command node -ErrorAction SilentlyContinue }
|
||||||
|
if (-not $npmCommand -or -not $nodeCommand) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FEHLER: npm/node wurden in diesem administrativen Fenster nicht gefunden." -ForegroundColor Red
|
||||||
|
Write-Host "npm gefunden: $([bool]$npmCommand) node gefunden: $([bool]$nodeCommand)" -ForegroundColor Red
|
||||||
|
Write-Host "Ein 'Als Administrator ausfuehren'-Prozess bekommt seine Umgebung frisch aus der Registry -" -ForegroundColor Red
|
||||||
|
Write-Host "PATH-Aenderungen, die nur in deiner normalen (nicht-elevierten) Sitzung gelten (z.B. nvm/Volta" -ForegroundColor Red
|
||||||
|
Write-Host "ohne dauerhafte Registry-Eintragung), sind hier nicht sichtbar, obwohl 'npm run dev' normal funktioniert." -ForegroundColor Red
|
||||||
|
Write-Host "PATH in diesem Fenster:" -ForegroundColor DarkGray
|
||||||
|
Write-Host $env:PATH -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Loesung: node/npm dauerhaft im PATH eintragen (System-Umgebungsvariablen, nicht nur die Sitzung)," -ForegroundColor Yellow
|
||||||
|
Write-Host "z.B. ueber die Windows-Systemsteuerung 'Umgebungsvariablen bearbeiten' oder 'setx PATH ...'." -ForegroundColor Yellow
|
||||||
|
throw "npm/node nicht im administrativen PATH gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kill stale instances of THIS project BEFORE checking the port: a leftover
|
||||||
|
# electron/vite/node/input-helper cluster from a previous run (e.g. a window
|
||||||
|
# force-closed via Task Manager, which skips main.ts's graceful will-quit
|
||||||
|
# cleanup and orphans the spawned input-helper.ps1 child) is the most common
|
||||||
|
# reason a fresh start silently talks to an old instance instead of
|
||||||
|
# replacing it. Shared with "npm run dev" itself via the predev hook, so
|
||||||
|
# both start paths get the same guarantee.
|
||||||
|
& (Join-Path $PSScriptRoot "kill-stale-instances.ps1")
|
||||||
|
|
||||||
|
$devPort = 5173
|
||||||
|
$portInUse = $null
|
||||||
|
try {
|
||||||
|
$portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1
|
||||||
|
} catch {
|
||||||
|
# Get-NetTCPConnection kann auf manchen Systemen fehlen; das ist kein Fehler.
|
||||||
|
$portInUse = $null
|
||||||
|
}
|
||||||
|
if ($portInUse) {
|
||||||
|
$portOwner = Get-Process -Id $portInUse.OwningProcess -ErrorAction SilentlyContinue
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "WARNUNG: Port $devPort ist noch belegt (Prozess: $($portOwner.ProcessName), PID $($portInUse.OwningProcess)) - auch nach dem Beenden alter Instanzen dieses Projekts." -ForegroundColor Yellow
|
||||||
|
Write-Host "Das ist wahrscheinlich ein unabhaengiger Prozess (z.B. ein anderes Projekt auf Port $devPort)." -ForegroundColor Yellow
|
||||||
|
Write-Host "'npm run dev' weicht dann auf einen anderen Port aus oder verbindet sich mit dem falschen Server." -ForegroundColor Yellow
|
||||||
|
Write-Host "Falls noetig: Prozess PID $($portInUse.OwningProcess) manuell beenden und dieses Fenster neu starten." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location -LiteralPath $project
|
||||||
|
Write-Host "Starte npm run dev (Administrator)..."
|
||||||
|
npm run dev
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "npm run dev wurde mit Fehlercode $exitCode beendet." -ForegroundColor Red
|
||||||
|
Write-Host "Die Ausgabe oben zeigt die genaue Ursache (z.B. belegter Port, fehlende Abhaengigkeiten)." -ForegroundColor Red
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "npm run dev wurde beendet (Fenster schliessen oder Strg+C fuer Neustart)."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Fehler beim Start: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Dieses Fenster bleibt offen (siehe Meldungen oben). Schliessen mit Enter oder dem Fenster-X."
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const genshin = require('genshin-db');
|
||||||
|
|
||||||
|
const opts = { matchCategories: true, verboseCategories: true, resultLanguage: 'English', queryLanguages: ['English'] };
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const characters = asArray(genshin.characters('names', opts))
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
rarity: entry.rarity,
|
||||||
|
element: entry.elementText,
|
||||||
|
weapon: entry.weaponText,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const artifacts = asArray(genshin.artifacts('names', opts))
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
rarityList: entry.rarityList ?? [],
|
||||||
|
pieces: [entry.flower, entry.plume, entry.sands, entry.goblet, entry.circlet]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((piece) => ({ name: piece.name, relicType: piece.relicType })),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const artifactPieces = artifacts
|
||||||
|
.flatMap((set) =>
|
||||||
|
set.pieces.map((piece) => ({
|
||||||
|
name: piece.name,
|
||||||
|
setName: set.name,
|
||||||
|
slot: slotFromRelicType(piece.relicType),
|
||||||
|
relicType: piece.relicType,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot]));
|
||||||
|
const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName]));
|
||||||
|
|
||||||
|
const mainStats = [
|
||||||
|
'Elemental Mastery',
|
||||||
|
'Energy Recharge',
|
||||||
|
'CRIT Rate',
|
||||||
|
'CRIT DMG',
|
||||||
|
'Healing Bonus',
|
||||||
|
'ATK%',
|
||||||
|
'HP%',
|
||||||
|
'DEF%',
|
||||||
|
'ATK',
|
||||||
|
'HP',
|
||||||
|
'DEF',
|
||||||
|
'Hydro DMG Bonus',
|
||||||
|
'Pyro DMG Bonus',
|
||||||
|
'Electro DMG Bonus',
|
||||||
|
'Cryo DMG Bonus',
|
||||||
|
'Dendro DMG Bonus',
|
||||||
|
'Anemo DMG Bonus',
|
||||||
|
'Geo DMG Bonus',
|
||||||
|
'Physical DMG Bonus',
|
||||||
|
];
|
||||||
|
|
||||||
|
const substats = ['CRIT DMG', 'CRIT Rate', 'Energy Recharge', 'Elemental Mastery', 'ATK', 'ATK%', 'HP', 'HP%', 'DEF', 'DEF%'];
|
||||||
|
const mainStatsBySlot = {
|
||||||
|
'Flower of Life': ['HP'],
|
||||||
|
'Plume of Death': ['ATK'],
|
||||||
|
'Sands of Eon': ['HP%', 'ATK%', 'DEF%', 'Energy Recharge', 'Elemental Mastery'],
|
||||||
|
'Goblet of Eonothem': [
|
||||||
|
'HP%',
|
||||||
|
'ATK%',
|
||||||
|
'DEF%',
|
||||||
|
'Elemental Mastery',
|
||||||
|
'Hydro DMG Bonus',
|
||||||
|
'Pyro DMG Bonus',
|
||||||
|
'Electro DMG Bonus',
|
||||||
|
'Cryo DMG Bonus',
|
||||||
|
'Dendro DMG Bonus',
|
||||||
|
'Anemo DMG Bonus',
|
||||||
|
'Geo DMG Bonus',
|
||||||
|
'Physical DMG Bonus',
|
||||||
|
],
|
||||||
|
'Circlet of Logos': ['HP%', 'ATK%', 'DEF%', 'Elemental Mastery', 'CRIT Rate', 'CRIT DMG', 'Healing Bonus'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainStatValueReferences = {
|
||||||
|
'Flower of Life': [{ stat: 'HP', base: 717, max: 4780 }],
|
||||||
|
'Plume of Death': [{ stat: 'ATK', base: 47, max: 311 }],
|
||||||
|
'Sands of Eon': [
|
||||||
|
{ stat: 'HP%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'ATK%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'DEF%', base: 8.7, max: 58.3 },
|
||||||
|
{ stat: 'Energy Recharge', base: 7.8, max: 51.8 },
|
||||||
|
{ stat: 'Elemental Mastery', base: 28, max: 187 },
|
||||||
|
],
|
||||||
|
'Goblet of Eonothem': [
|
||||||
|
{ stat: 'HP%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'ATK%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'DEF%', base: 8.7, max: 58.3 },
|
||||||
|
{ stat: 'Elemental Mastery', base: 28, max: 187 },
|
||||||
|
{ stat: 'Hydro DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Pyro DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Electro DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Cryo DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Dendro DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Anemo DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Geo DMG Bonus', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'Physical DMG Bonus', base: 8.7, max: 58.3 },
|
||||||
|
],
|
||||||
|
'Circlet of Logos': [
|
||||||
|
{ stat: 'HP%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'ATK%', base: 7.0, max: 46.6 },
|
||||||
|
{ stat: 'DEF%', base: 8.7, max: 58.3 },
|
||||||
|
{ stat: 'Elemental Mastery', base: 28, max: 187 },
|
||||||
|
{ stat: 'CRIT Rate', base: 4.7, max: 31.1 },
|
||||||
|
{ stat: 'CRIT DMG', base: 9.3, max: 62.2 },
|
||||||
|
{ stat: 'Healing Bonus', base: 5.4, max: 35.9 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
package: 'genshin-db',
|
||||||
|
version: require('genshin-db/package.json').version,
|
||||||
|
resultLanguage: opts.resultLanguage,
|
||||||
|
},
|
||||||
|
sourceVersions: {
|
||||||
|
genshinDb: require('genshin-db/package.json').version,
|
||||||
|
resultLanguage: opts.resultLanguage,
|
||||||
|
},
|
||||||
|
sourceVersion: `genshin-db@${require('genshin-db/package.json').version}`,
|
||||||
|
characters,
|
||||||
|
artifactSets: artifacts,
|
||||||
|
artifactPieces,
|
||||||
|
slotByPiece,
|
||||||
|
setByPiece,
|
||||||
|
slots: ['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos'],
|
||||||
|
mainStats,
|
||||||
|
mainStatsBySlot,
|
||||||
|
mainStatValueReferences,
|
||||||
|
substats,
|
||||||
|
stats: {
|
||||||
|
main: mainStats,
|
||||||
|
mainBySlot: mainStatsBySlot,
|
||||||
|
sub: substats,
|
||||||
|
},
|
||||||
|
aliases: {
|
||||||
|
stats: {
|
||||||
|
'Crit Damage': 'CRIT DMG',
|
||||||
|
'Critical Damage': 'CRIT DMG',
|
||||||
|
'Crit Rate': 'CRIT Rate',
|
||||||
|
'Critical Rate': 'CRIT Rate',
|
||||||
|
'Energy Recharge %': 'Energy Recharge',
|
||||||
|
'Elemental Master': 'Elemental Mastery',
|
||||||
|
'Elemental Masterie': 'Elemental Mastery',
|
||||||
|
'Heal Bonus': 'Healing Bonus',
|
||||||
|
},
|
||||||
|
textReplacements: {
|
||||||
|
'CIT DMG': 'CRIT DMG',
|
||||||
|
'CRIT DMG+I': 'CRIT DMG+1',
|
||||||
|
'CRIT Rate+Z': 'CRIT Rate+2',
|
||||||
|
'Energv Recharge': 'Energy Recharge',
|
||||||
|
'Elemental Masterv': 'Elemental Mastery',
|
||||||
|
'Equipped;': 'Equipped:',
|
||||||
|
},
|
||||||
|
slotAliases: {
|
||||||
|
'Sands of Eon Vi': 'Sands of Eon',
|
||||||
|
'Sands of Eon V': 'Sands of Eon',
|
||||||
|
'Sands of Eon 2': 'Sands of Eon',
|
||||||
|
'Flower of Life 2': 'Flower of Life',
|
||||||
|
'Flower of Lif': 'Flower of Life',
|
||||||
|
'Goblet of Eonotherm': 'Goblet of Eonothem',
|
||||||
|
'Goblet of Eonothemn': 'Goblet of Eonothem',
|
||||||
|
'Circlet of Logas': 'Circlet of Logos',
|
||||||
|
'Circlet of Loges': 'Circlet of Logos',
|
||||||
|
},
|
||||||
|
setAliases: {
|
||||||
|
'Viridescent Venere': 'Viridescent Venerer',
|
||||||
|
'Maiden Beloved:': 'Maiden Beloved',
|
||||||
|
'Gladiators Finale': "Gladiator's Finale",
|
||||||
|
},
|
||||||
|
pieceAliases: {
|
||||||
|
'A Note in Springs Leich': "A Note in Spring's Leich",
|
||||||
|
'Viridescent Vencrers Vessel': "Viridescent Venerer's Vessel",
|
||||||
|
'Holy Crown of the Believer ': 'Holy Crown of the Believer',
|
||||||
|
},
|
||||||
|
characterAliases: {
|
||||||
|
'Citlall': 'Citlali',
|
||||||
|
'Sandrone ': 'Sandrone',
|
||||||
|
'Qiqi ': 'Qiqi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uiProfiles: {
|
||||||
|
artifactDetailEn: {
|
||||||
|
language: 'English',
|
||||||
|
supportedResolutions: ['1920x1080', '2560x1440', '3840x2160'],
|
||||||
|
detailPanel: { x: 0.5, y: 0.05, width: 0.45, height: 0.9 },
|
||||||
|
note: 'Relative profile used as scanner contract; runtime crops may tune offsets from review samples.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const outPath = path.join(process.cwd(), 'src', 'data', 'genshinGameData.json');
|
||||||
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(data, null, 2) + '\n');
|
||||||
|
console.log(`generated ${outPath}`);
|
||||||
|
console.log(`${characters.length} characters, ${artifacts.length} artifact sets`);
|
||||||
|
console.log(`${artifactPieces.length} artifact pieces, ${mainStats.length} main stats, ${substats.length} substats`);
|
||||||
|
|
||||||
|
function slotFromRelicType(relicType) {
|
||||||
|
switch (relicType) {
|
||||||
|
case 'EQUIP_BRACER':
|
||||||
|
return 'Flower of Life';
|
||||||
|
case 'EQUIP_NECKLACE':
|
||||||
|
return 'Plume of Death';
|
||||||
|
case 'EQUIP_SHOES':
|
||||||
|
return 'Sands of Eon';
|
||||||
|
case 'EQUIP_RING':
|
||||||
|
return 'Goblet of Eonothem';
|
||||||
|
case 'EQUIP_DRESS':
|
||||||
|
return 'Circlet of Logos';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Beendet alle laufenden Prozesse dieses Projekts (Electron, Node/Vite, und
|
||||||
|
# einen verwaisten lokalen Input-Helper-Kindprozess), bevor eine neue Instanz
|
||||||
|
# startet.
|
||||||
|
#
|
||||||
|
# Warum das noetig ist: Ein normales Schliessen der App (X-Button) raeumt
|
||||||
|
# sauber auf (main.ts: app.on("will-quit") beendet den gespawnten
|
||||||
|
# input-helper.ps1-Kindprozess). Wird die App aber gewaltsam beendet - z.B.
|
||||||
|
# ueber Task-Manager "Task beenden" oder ein Skript, das Stop-Process -Force
|
||||||
|
# auf den Electron-Prozess anwendet - greift dieser Cleanup-Handler NICHT,
|
||||||
|
# und der Kindprozess (PowerShell mit dem Input-Helper) bleibt als Waise
|
||||||
|
# aktiv, obwohl das Hauptfenster laengst weg ist. Dieses Skript faengt genau
|
||||||
|
# das ab: es wird vor JEDEM Start (npm run dev und npm run dev:admin, ueber
|
||||||
|
# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos
|
||||||
|
# weg, egal wie sie beendet wurden.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
||||||
|
|
||||||
|
$killed = 0
|
||||||
|
|
||||||
|
Get-CimInstance Win32_Process |
|
||||||
|
Where-Object {
|
||||||
|
($_.Name -eq "electron.exe" -and $_.ExecutablePath -eq $electronPath) -or
|
||||||
|
($_.Name -eq "node.exe" -and $_.CommandLine -like "*$project*") -or
|
||||||
|
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
|
||||||
|
} |
|
||||||
|
ForEach-Object {
|
||||||
|
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))"
|
||||||
|
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
$killed++
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($killed -gt 0) {
|
||||||
|
# Windows braucht einen Moment, um Ports/Handles wirklich freizugeben.
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Write-Host "$killed alte Prozess(e) beendet."
|
||||||
|
} else {
|
||||||
|
Write-Host "Keine alten Instanzen gefunden."
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { AppPage } from "./pages/AppPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <AppPage />;
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||||
|
import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
|
||||||
|
import type { AppControllerContext } from "../services/appControllerService";
|
||||||
|
|
||||||
|
interface AppControllerActionsInput {
|
||||||
|
appControllerContext: AppControllerContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppControllerActionsResult {
|
||||||
|
loadStoredArtifactSnapshot: () => Promise<void>;
|
||||||
|
refreshCaptureSources: () => Promise<void>;
|
||||||
|
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||||
|
runDemoScan: () => Promise<void>;
|
||||||
|
exportCurrentGood: () => Promise<void>;
|
||||||
|
showOverlay: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppControllerActions({
|
||||||
|
appControllerContext,
|
||||||
|
}: AppControllerActionsInput): AppControllerActionsResult {
|
||||||
|
const loadStoredArtifactSnapshot = useCallback(async () => {
|
||||||
|
await loadStoredArtifactSnapshotAction(appControllerContext);
|
||||||
|
}, [appControllerContext]);
|
||||||
|
|
||||||
|
const refreshCaptureSources = useCallback(async () => {
|
||||||
|
await refreshCaptureSourcesAction(appControllerContext);
|
||||||
|
}, [appControllerContext]);
|
||||||
|
|
||||||
|
const captureSelectedSource = useCallback(async (delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
|
||||||
|
return captureSelectedSourceAction(appControllerContext, delayMs, focusGenshin, options);
|
||||||
|
}, [appControllerContext]);
|
||||||
|
|
||||||
|
const runDemoScan = useCallback(async () => {
|
||||||
|
await runDemoScanAction(appControllerContext, captureSelectedSource);
|
||||||
|
}, [appControllerContext, captureSelectedSource]);
|
||||||
|
|
||||||
|
const exportCurrentGood = useCallback(async () => {
|
||||||
|
await exportCurrentGoodAction(appControllerContext);
|
||||||
|
}, [appControllerContext]);
|
||||||
|
|
||||||
|
const showOverlay = useCallback(async () => {
|
||||||
|
await showOverlayAction(appControllerContext);
|
||||||
|
}, [appControllerContext]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadStoredArtifactSnapshot,
|
||||||
|
refreshCaptureSources,
|
||||||
|
captureSelectedSource,
|
||||||
|
runDemoScan,
|
||||||
|
exportCurrentGood,
|
||||||
|
showOverlay,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { initializeAppControllerStateAction } from "../services/appControllerService";
|
||||||
|
import type { AppControllerContext } from "../services/appControllerService";
|
||||||
|
|
||||||
|
interface AppControllerLifecycleInput {
|
||||||
|
appControllerContext: AppControllerContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppControllerLifecycle({ appControllerContext }: AppControllerLifecycleInput) {
|
||||||
|
useEffect(() => {
|
||||||
|
void initializeAppControllerStateAction(appControllerContext);
|
||||||
|
}, [appControllerContext.artifactRepo, appControllerContext.captureRepo, appControllerContext.snapshotRepo, appControllerContext.isOverlay]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { AppSnapshot } from "../../../types/domain";
|
||||||
|
import type { CaptureResult, CaptureSourceInfo } from "../../../types/global";
|
||||||
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
import { createAppMetrics } from "../services/appMetricsService";
|
||||||
|
import type { AppControllerResult } from "../types";
|
||||||
|
import { createInitialSnapshot, createAppControllerContext } from "../services/appControllerService";
|
||||||
|
import { useAppControllerActions } from "./useAppControllerActions";
|
||||||
|
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
|
||||||
|
import { type NavigationId } from "../../layout/types";
|
||||||
|
|
||||||
|
export function useAppControllerState(): AppControllerResult {
|
||||||
|
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
|
||||||
|
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||||
|
|
||||||
|
const [snapshot, setSnapshot] = useState<AppSnapshot>(createInitialSnapshot);
|
||||||
|
const [activeView, setActiveView] = useState<NavigationId>("scan");
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
|
||||||
|
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||||
|
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
|
||||||
|
const [topbarStatus, setTopbarStatus] = useState(
|
||||||
|
repositories
|
||||||
|
? "Electron bridge connected. Refresh sources after Genshin is open."
|
||||||
|
: "Electron bridge missing. Open the Electron app window, not the browser preview URL.",
|
||||||
|
);
|
||||||
|
const appControllerContext = useMemo(
|
||||||
|
() =>
|
||||||
|
createAppControllerContext(repositories, {
|
||||||
|
isOverlay,
|
||||||
|
selectedSourceId,
|
||||||
|
snapshot,
|
||||||
|
setCaptureSources,
|
||||||
|
setSelectedSourceId,
|
||||||
|
setTopbarStatus,
|
||||||
|
setSnapshot,
|
||||||
|
setLatestCapture,
|
||||||
|
setIsScanning,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
repositories,
|
||||||
|
isOverlay,
|
||||||
|
selectedSourceId,
|
||||||
|
snapshot,
|
||||||
|
setCaptureSources,
|
||||||
|
setSelectedSourceId,
|
||||||
|
setTopbarStatus,
|
||||||
|
setSnapshot,
|
||||||
|
setLatestCapture,
|
||||||
|
setIsScanning,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bridgeReady = Boolean(repositories?.isAvailable);
|
||||||
|
const canExportGood = Boolean(repositories?.canExportGood);
|
||||||
|
const canShowOverlay = Boolean(repositories?.canShowOverlay);
|
||||||
|
const { cards: metricCards } = useMemo(() => createAppMetrics(snapshot), [snapshot]);
|
||||||
|
|
||||||
|
useAppControllerLifecycle({ appControllerContext });
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadStoredArtifactSnapshot,
|
||||||
|
refreshCaptureSources,
|
||||||
|
captureSelectedSource,
|
||||||
|
runDemoScan,
|
||||||
|
exportCurrentGood,
|
||||||
|
showOverlay,
|
||||||
|
} = useAppControllerActions({ appControllerContext });
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOverlay,
|
||||||
|
snapshot,
|
||||||
|
activeView,
|
||||||
|
setActiveView,
|
||||||
|
isScanning,
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
setSelectedSourceId,
|
||||||
|
latestCapture,
|
||||||
|
topbarStatus,
|
||||||
|
bridgeReady,
|
||||||
|
canExportGood,
|
||||||
|
canShowOverlay,
|
||||||
|
metricCards,
|
||||||
|
loadStoredArtifactSnapshot,
|
||||||
|
refreshCaptureSources,
|
||||||
|
captureSelectedSource,
|
||||||
|
runDemoScan,
|
||||||
|
exportCurrentGood,
|
||||||
|
showOverlay,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { createDemoSnapshot, getPresets } from "../../../lib/demoData";
|
||||||
|
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
|
||||||
|
import { exportGood } from "../../../lib/goodFormat";
|
||||||
|
import { runAccountScan } from "../../../lib/scanner";
|
||||||
|
import type { AppSnapshot } from "../../../types/domain";
|
||||||
|
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
|
||||||
|
import type {
|
||||||
|
ArtifactRepositoryPort,
|
||||||
|
CaptureRepositoryPort,
|
||||||
|
OverlayRepositoryPort,
|
||||||
|
RendererRepositories,
|
||||||
|
ScanExportPort,
|
||||||
|
SnapshotRepositoryPort,
|
||||||
|
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export interface AppControllerContext {
|
||||||
|
artifactRepo?: ArtifactRepositoryPort;
|
||||||
|
captureRepo?: CaptureRepositoryPort;
|
||||||
|
exportRepo?: ScanExportPort;
|
||||||
|
overlayRepo?: OverlayRepositoryPort;
|
||||||
|
snapshotRepo?: SnapshotRepositoryPort;
|
||||||
|
isOverlay: boolean;
|
||||||
|
selectedSourceId: string;
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
setTopbarStatus: (value: string) => void;
|
||||||
|
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
|
||||||
|
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
|
||||||
|
setIsScanning?: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppControllerUIState {
|
||||||
|
isOverlay: boolean;
|
||||||
|
selectedSourceId: string;
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
setTopbarStatus: (value: string) => void;
|
||||||
|
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
|
||||||
|
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
|
||||||
|
setIsScanning?: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppControllerContext(
|
||||||
|
repositories: RendererRepositories | null,
|
||||||
|
uiState: AppControllerUIState,
|
||||||
|
): AppControllerContext {
|
||||||
|
return {
|
||||||
|
artifactRepo: repositories?.artifacts,
|
||||||
|
captureRepo: repositories?.capture,
|
||||||
|
exportRepo: repositories?.export,
|
||||||
|
overlayRepo: repositories?.overlay,
|
||||||
|
snapshotRepo: repositories?.snapshot,
|
||||||
|
isOverlay: uiState.isOverlay,
|
||||||
|
selectedSourceId: uiState.selectedSourceId,
|
||||||
|
snapshot: uiState.snapshot,
|
||||||
|
setCaptureSources: uiState.setCaptureSources,
|
||||||
|
setSelectedSourceId: uiState.setSelectedSourceId,
|
||||||
|
setTopbarStatus: uiState.setTopbarStatus,
|
||||||
|
setSnapshot: uiState.setSnapshot,
|
||||||
|
setLatestCapture: uiState.setLatestCapture,
|
||||||
|
setIsScanning: uiState.setIsScanning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoredArtifactSnapshotAction(context: AppControllerContext): Promise<void> {
|
||||||
|
const { artifactRepo, setSnapshot } = context;
|
||||||
|
if (!artifactRepo?.loadAll) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return artifactRepo.loadAll().then((result: ArtifactStoreLoadResult) => {
|
||||||
|
if (!result?.ok || result.artifacts.length === 0) return;
|
||||||
|
const nextSnapshot = createLocalAccountSnapshot(result.artifacts, getPresets());
|
||||||
|
if (!nextSnapshot) return;
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeAppControllerStateAction(context: AppControllerContext): Promise<void> {
|
||||||
|
const { snapshotRepo, isOverlay, setTopbarStatus, setSnapshot } = context;
|
||||||
|
try {
|
||||||
|
await loadStoredArtifactSnapshotAction(context);
|
||||||
|
if (isOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await snapshotRepo?.load();
|
||||||
|
if (stored) {
|
||||||
|
setSnapshot((current) =>
|
||||||
|
current.artifacts.length > 0 && current.scanEvents[0]?.id === "local-db-loaded" ? current : stored,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await refreshCaptureSourcesAction(context);
|
||||||
|
} catch (error) {
|
||||||
|
setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshCaptureSourcesAction(context: AppControllerContext): Promise<void> {
|
||||||
|
const { captureRepo, selectedSourceId, setCaptureSources, setSelectedSourceId, setTopbarStatus } = context;
|
||||||
|
if (!captureRepo?.listSources) {
|
||||||
|
setTopbarStatus("Electron bridge missing. Die Browser-Vorschau kann Genshin nicht scannen - Electron-App verwenden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sources = await captureRepo.listSources();
|
||||||
|
setCaptureSources(sources);
|
||||||
|
const currentStillExists = sources.some((source: CaptureSourceInfo) => source.id === selectedSourceId);
|
||||||
|
const genshinCandidate = sources.find((source: CaptureSourceInfo) => source.isGenshinCandidate);
|
||||||
|
const current = currentStillExists ? sources.find((source: CaptureSourceInfo) => source.id === selectedSourceId) : null;
|
||||||
|
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
|
||||||
|
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
|
||||||
|
setTopbarStatus(
|
||||||
|
`${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureSelectedSourceAction(
|
||||||
|
context: AppControllerContext,
|
||||||
|
delayMs = 0,
|
||||||
|
focusGenshin = false,
|
||||||
|
options?: CaptureOptions,
|
||||||
|
): Promise<CaptureResult | null> {
|
||||||
|
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
|
||||||
|
if (!captureRepo?.captureSource) {
|
||||||
|
setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!selectedSourceId) {
|
||||||
|
setTopbarStatus("No capture source selected. Press Sources after Genshin is open.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!setLatestCapture) {
|
||||||
|
setTopbarStatus("Capture state cannot be updated.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (delayMs > 0) {
|
||||||
|
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
||||||
|
}
|
||||||
|
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||||
|
setLatestCapture(capture);
|
||||||
|
const ocrStatus = capture.ocrSkipped
|
||||||
|
? "OCR skipped for fast scan."
|
||||||
|
: capture.ocrTimedOut
|
||||||
|
? "OCR timed out; review sample needed."
|
||||||
|
: "OCR handoff is next.";
|
||||||
|
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||||
|
return capture;
|
||||||
|
} catch (error) {
|
||||||
|
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDemoScanAction(
|
||||||
|
context: AppControllerContext,
|
||||||
|
captureSelectedSource: () => Promise<CaptureResult | null>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { snapshotRepo, setSnapshot, setIsScanning, setTopbarStatus } = context;
|
||||||
|
if (!setIsScanning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsScanning(true);
|
||||||
|
try {
|
||||||
|
await captureSelectedSource();
|
||||||
|
const next = await (snapshotRepo?.runMockScan() ?? Promise.resolve(null));
|
||||||
|
const resolved = next ?? (await runAccountScan());
|
||||||
|
setSnapshot(resolved);
|
||||||
|
await snapshotRepo?.save(resolved);
|
||||||
|
} catch (error) {
|
||||||
|
setTopbarStatus(error instanceof Error ? error.message : "Demo scan failed.");
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialSnapshot(): AppSnapshot {
|
||||||
|
return createDemoSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
|
||||||
|
const { exportRepo, snapshot, setTopbarStatus } = context;
|
||||||
|
if (!exportRepo?.exportGood) {
|
||||||
|
setTopbarStatus("GOOD-Export ist nur in der Electron-App verfuegbar.");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (snapshot.artifacts.length === 0) {
|
||||||
|
setTopbarStatus("Keine Artifacts zum Exportieren vorhanden.");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return exportRepo
|
||||||
|
.exportGood(exportGood(snapshot.artifacts))
|
||||||
|
.then((result: SaveResultWithPath) => {
|
||||||
|
setTopbarStatus(result.ok ? `GOOD exportiert: ${result.path}` : "GOOD-Export fehlgeschlagen.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showOverlayAction(context: AppControllerContext): Promise<void> {
|
||||||
|
const { overlayRepo, setTopbarStatus } = context;
|
||||||
|
if (!overlayRepo?.show) {
|
||||||
|
setTopbarStatus("Overlay braucht die Electron-Bridge.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await overlayRepo.show();
|
||||||
|
} catch {
|
||||||
|
setTopbarStatus("Overlay konnte nicht gestartet werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ArtifactVerdict, AppSnapshot } from "../../../types/domain";
|
||||||
|
import { summarizeSnapshot } from "../../../lib/snapshotSummary";
|
||||||
|
import type { AppMetricCard } from "../../layout/types";
|
||||||
|
|
||||||
|
const verdictLabelByType: Record<ArtifactVerdict, string> = {
|
||||||
|
keep: "Keep",
|
||||||
|
maybe_level: "Test level",
|
||||||
|
character_specific: "Character-specific",
|
||||||
|
trash_candidate: "Trash",
|
||||||
|
needs_review: "Needs review",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppTopbarSafetyCounts {
|
||||||
|
keep: number;
|
||||||
|
maybe: number;
|
||||||
|
trash: number;
|
||||||
|
review: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMetricsModel {
|
||||||
|
topbarSafety: AppTopbarSafetyCounts;
|
||||||
|
cards: AppMetricCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppMetrics(snapshot: AppSnapshot): AppMetricsModel {
|
||||||
|
const summary = summarizeSnapshot(snapshot);
|
||||||
|
const recommendations = snapshot.recommendations;
|
||||||
|
const topbarSafety: AppTopbarSafetyCounts = {
|
||||||
|
keep: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "keep").length,
|
||||||
|
maybe: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "maybe_level").length,
|
||||||
|
trash: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length,
|
||||||
|
review: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cards: AppMetricCard[] = [
|
||||||
|
{ label: verdictLabelByType.keep, value: summary.artifacts, detail: `${topbarSafety.review} brauchen Review` },
|
||||||
|
{ label: "Behalten", value: summary.useful, detail: `${topbarSafety.keep + topbarSafety.maybe} / ${snapshot.artifacts.length} usable` },
|
||||||
|
{ label: "Trash-Kandidaten", value: summary.trash, detail: "wird nie automatisch geloescht" },
|
||||||
|
{ label: "Build-Optionen", value: summary.builds, detail: "Top-Vorschlaege verfuegbar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { topbarSafety, cards };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AppSnapshot } from "../../types/domain";
|
||||||
|
import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global";
|
||||||
|
import { type AppMetricCard, type NavigationId } from "../layout/types";
|
||||||
|
|
||||||
|
export interface AppControllerResult {
|
||||||
|
activeView: NavigationId;
|
||||||
|
setActiveView: (value: NavigationId) => void;
|
||||||
|
isOverlay: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
captureSources: CaptureSourceInfo[];
|
||||||
|
selectedSourceId: string;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
latestCapture: CaptureResult | null;
|
||||||
|
topbarStatus: string;
|
||||||
|
bridgeReady: boolean;
|
||||||
|
canExportGood: boolean;
|
||||||
|
canShowOverlay: boolean;
|
||||||
|
metricCards: AppMetricCard[];
|
||||||
|
refreshCaptureSources: () => Promise<void>;
|
||||||
|
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||||
|
runDemoScan: () => Promise<void>;
|
||||||
|
exportCurrentGood: () => Promise<void>;
|
||||||
|
loadStoredArtifactSnapshot: () => Promise<void>;
|
||||||
|
showOverlay: () => Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { AppControllerResult } from "./types";
|
||||||
|
import { useAppControllerState } from "./hooks/useAppControllerState";
|
||||||
|
|
||||||
|
export function useAppController(): AppControllerResult {
|
||||||
|
return useAppControllerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { useBuildCardModel } from "./hooks/useBuildCardModel";
|
||||||
|
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
||||||
|
import type { BuildCardProps, BuildsViewProps } from "./types";
|
||||||
|
|
||||||
|
function BuildCard({ build, snapshot }: BuildCardProps) {
|
||||||
|
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
|
||||||
|
const {
|
||||||
|
characterName,
|
||||||
|
qualityLabel,
|
||||||
|
roundedScore,
|
||||||
|
explanation,
|
||||||
|
artifactRows,
|
||||||
|
warnings,
|
||||||
|
hasWarnings,
|
||||||
|
} = useBuildCardModel({
|
||||||
|
build,
|
||||||
|
characters: snapshot.characters,
|
||||||
|
artifactLookup,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel build-card">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{qualityLabel}</p>
|
||||||
|
<h2>{characterName}</h2>
|
||||||
|
</div>
|
||||||
|
<strong className="score">{roundedScore}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="build-copy">{explanation}</p>
|
||||||
|
<div className="build-pieces">
|
||||||
|
{artifactRows.map((artifact) => (
|
||||||
|
<div key={artifact.id}>
|
||||||
|
<span>{artifact.slot}</span>
|
||||||
|
<strong>{artifact.setName}</strong>
|
||||||
|
<small>{artifact.details}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasWarnings && (
|
||||||
|
<div className="warning-list">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<span key={warning}><AlertTriangle size={14} />{warning}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BuildsView({ snapshot }: BuildsViewProps) {
|
||||||
|
const { visibleBuilds } = useBuildsViewModel({ snapshot });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="build-grid">
|
||||||
|
{visibleBuilds.map(({ build }) => (
|
||||||
|
<BuildCard key={build.id} build={build} snapshot={snapshot} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BuildSuggestion } from "../../../types/domain";
|
||||||
|
import type { Artifact, Character } from "../../../types/domain";
|
||||||
|
|
||||||
|
export interface BuildCardModel {
|
||||||
|
characterName: string;
|
||||||
|
qualityLabel: string;
|
||||||
|
roundedScore: number;
|
||||||
|
explanation: string;
|
||||||
|
hasWarnings: boolean;
|
||||||
|
artifactRows: Array<{
|
||||||
|
id: string;
|
||||||
|
slot: string;
|
||||||
|
setName: string;
|
||||||
|
details: string;
|
||||||
|
}>;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBuildCardModelInput {
|
||||||
|
build: BuildSuggestion;
|
||||||
|
characters: Character[];
|
||||||
|
artifactLookup: Map<string, Artifact>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBuildCardModel({
|
||||||
|
build,
|
||||||
|
characters,
|
||||||
|
artifactLookup,
|
||||||
|
}: UseBuildCardModelInput): BuildCardModel {
|
||||||
|
const character = characters.find((entry) => entry.id === build.characterId);
|
||||||
|
const artifacts = build.artifactIds
|
||||||
|
.map((id) => artifactLookup.get(id))
|
||||||
|
.filter((artifact): artifact is Artifact => Boolean(artifact));
|
||||||
|
|
||||||
|
return {
|
||||||
|
characterName: character?.name ?? build.characterId,
|
||||||
|
qualityLabel: build.quality.replace("_", " "),
|
||||||
|
roundedScore: Math.round(build.score),
|
||||||
|
explanation: build.explanation,
|
||||||
|
hasWarnings: build.warnings.length > 0,
|
||||||
|
artifactRows: artifacts.map((artifact) => ({
|
||||||
|
id: artifact.id,
|
||||||
|
slot: artifact.slot,
|
||||||
|
setName: artifact.setName,
|
||||||
|
details: `+${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
|
||||||
|
})),
|
||||||
|
warnings: build.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppSnapshot } from "../../../types/domain";
|
||||||
|
import type { BuildSuggestion } from "../../../types/domain";
|
||||||
|
|
||||||
|
interface UseBuildsViewModelInput {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildsViewModel {
|
||||||
|
visibleBuilds: Array<{
|
||||||
|
build: BuildSuggestion;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel {
|
||||||
|
return {
|
||||||
|
visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
|
||||||
|
|
||||||
|
export interface BuildsViewProps {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildCardProps {
|
||||||
|
build: BuildSuggestion;
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AlertTriangle, BadgeCheck, Lock, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
import type { ArtifactVerdict } from "../../types/domain";
|
||||||
|
|
||||||
|
export const verdictMeta: Record<ArtifactVerdict, { label: string; className: string; icon: JSX.Element }> = {
|
||||||
|
keep: { label: "Keep", className: "pill keep", icon: <Lock size={14} /> },
|
||||||
|
maybe_level: { label: "Test level", className: "pill maybe", icon: <Sparkles size={14} /> },
|
||||||
|
character_specific: { label: "Character-specific", className: "pill specific", icon: <BadgeCheck size={14} /> },
|
||||||
|
trash_candidate: { label: "Trash candidate", className: "pill trash", icon: <Trash2 size={14} /> },
|
||||||
|
needs_review: { label: "Needs review", className: "pill review", icon: <AlertTriangle size={14} /> },
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
AppMetricsProps,
|
||||||
|
AppShellProps,
|
||||||
|
AppSidebarProps,
|
||||||
|
AppTopbarProps,
|
||||||
|
} from "./types";
|
||||||
|
import { useAppSidebarModel } from "./hooks/useAppSidebarModel";
|
||||||
|
import { useAppTopbarModel } from "./hooks/useAppTopbarModel";
|
||||||
|
|
||||||
|
export function AppSidebar({ activeView, items, onSelect }: AppSidebarProps) {
|
||||||
|
const { navigationItems } = useAppSidebarModel({ items, onSelect });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark">GA</div>
|
||||||
|
<div>
|
||||||
|
<div className="brand-title">Artifact Assistant</div>
|
||||||
|
<div className="brand-subtitle">Scanner-first MVP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="nav-list">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
||||||
|
disabled={Boolean(item.disabled)}
|
||||||
|
onClick={item.onSelect}
|
||||||
|
title={item.disabled ? item.disabledReason : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="safety-card">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>Sicherheits-Grenzen</strong>
|
||||||
|
<span>Keine Eingriffe am Spielinventar, keine Ressourcen-Aktionen, nur Analyse und Scan.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppTopbar({
|
||||||
|
topbarStatus,
|
||||||
|
canExportGood,
|
||||||
|
canShowOverlay,
|
||||||
|
isScanning,
|
||||||
|
artifactCount,
|
||||||
|
onShowOverlay,
|
||||||
|
onDemoScan,
|
||||||
|
onExportGood,
|
||||||
|
exportIcon,
|
||||||
|
overlayIcon,
|
||||||
|
demoIcon,
|
||||||
|
}: AppTopbarProps) {
|
||||||
|
const {
|
||||||
|
handleShowOverlay,
|
||||||
|
handleExportGood,
|
||||||
|
handleDemoScan,
|
||||||
|
isExportDisabled,
|
||||||
|
isDemoDisabled,
|
||||||
|
isOverlayDisabled,
|
||||||
|
exportButtonLabel,
|
||||||
|
exportButtonTitle,
|
||||||
|
overlayButtonLabel,
|
||||||
|
overlayButtonTitle,
|
||||||
|
demoButtonLabel,
|
||||||
|
demoButtonTitle,
|
||||||
|
} = useAppTopbarModel({
|
||||||
|
onExportGood,
|
||||||
|
onShowOverlay,
|
||||||
|
onDemoScan,
|
||||||
|
canExportGood,
|
||||||
|
canShowOverlay,
|
||||||
|
isScanning,
|
||||||
|
artifactCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Lokaler Windows-Assistent</p>
|
||||||
|
<h1>Scanne dein Inventar, triff einfache Artifact-Entscheidungen.</h1>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
{topbarStatus && <span className="topbar-status">{topbarStatus}</span>}
|
||||||
|
<button className="ghost-button" onClick={handleExportGood} disabled={isExportDisabled} title={exportButtonTitle}>
|
||||||
|
{exportIcon}
|
||||||
|
<span>{exportButtonLabel}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={handleShowOverlay}
|
||||||
|
disabled={isOverlayDisabled}
|
||||||
|
title={overlayButtonTitle}
|
||||||
|
>
|
||||||
|
{overlayIcon}
|
||||||
|
<span>{overlayButtonLabel}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={handleDemoScan}
|
||||||
|
disabled={isDemoDisabled}
|
||||||
|
title={demoButtonTitle}
|
||||||
|
>
|
||||||
|
{demoIcon}
|
||||||
|
{demoButtonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppMetrics({ metricCards }: AppMetricsProps) {
|
||||||
|
return (
|
||||||
|
<section className="metrics-grid">
|
||||||
|
{metricCards.map((metric) => (
|
||||||
|
<div className="metric" key={metric.label}>
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<strong>{metric.value}</strong>
|
||||||
|
<small>{metric.detail}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
{sidebar}
|
||||||
|
<main className="main-panel">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { NavigationId } from "../types";
|
||||||
|
import type { AppNavigationItem, AppSidebarItemAction } from "../types";
|
||||||
|
|
||||||
|
export interface AppSidebarModel {
|
||||||
|
navigationItems: AppSidebarItemAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAppSidebarModelInput {
|
||||||
|
items: AppNavigationItem[];
|
||||||
|
onSelect: (id: NavigationId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppSidebarModel({ items, onSelect }: UseAppSidebarModelInput): AppSidebarModel {
|
||||||
|
const navigationItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
onSelect: () => onSelect(item.id),
|
||||||
|
})),
|
||||||
|
[items, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigationItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export interface AppTopbarModel {
|
||||||
|
handleShowOverlay: () => void;
|
||||||
|
handleExportGood: () => void;
|
||||||
|
handleDemoScan: () => void;
|
||||||
|
isExportDisabled: boolean;
|
||||||
|
isDemoDisabled: boolean;
|
||||||
|
isOverlayDisabled: boolean;
|
||||||
|
exportButtonLabel: string;
|
||||||
|
exportButtonTitle: string;
|
||||||
|
overlayButtonLabel: string;
|
||||||
|
overlayButtonTitle: string;
|
||||||
|
demoButtonLabel: string;
|
||||||
|
demoButtonTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAppTopbarModelInput {
|
||||||
|
onExportGood: () => Promise<void> | void;
|
||||||
|
onShowOverlay: () => Promise<void>;
|
||||||
|
onDemoScan: () => void;
|
||||||
|
canExportGood: boolean;
|
||||||
|
canShowOverlay: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
artifactCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppTopbarModel({
|
||||||
|
onExportGood,
|
||||||
|
onShowOverlay,
|
||||||
|
onDemoScan,
|
||||||
|
canExportGood,
|
||||||
|
canShowOverlay,
|
||||||
|
isScanning,
|
||||||
|
artifactCount,
|
||||||
|
}: UseAppTopbarModelInput): AppTopbarModel {
|
||||||
|
const handleShowOverlay = useCallback(() => {
|
||||||
|
void onShowOverlay();
|
||||||
|
}, [onShowOverlay]);
|
||||||
|
|
||||||
|
const handleExportGood = useCallback(() => {
|
||||||
|
void onExportGood();
|
||||||
|
}, [onExportGood]);
|
||||||
|
|
||||||
|
const handleDemoScan = useCallback(() => {
|
||||||
|
onDemoScan();
|
||||||
|
}, [onDemoScan]);
|
||||||
|
|
||||||
|
const isExportDisabled = !canExportGood || artifactCount === 0;
|
||||||
|
const isDemoDisabled = isScanning;
|
||||||
|
const isOverlayDisabled = !canShowOverlay;
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleShowOverlay,
|
||||||
|
handleExportGood,
|
||||||
|
handleDemoScan,
|
||||||
|
isExportDisabled,
|
||||||
|
isDemoDisabled,
|
||||||
|
isOverlayDisabled,
|
||||||
|
exportButtonLabel: "GOOD Export",
|
||||||
|
exportButtonTitle: canExportGood ? "GOOD-Export starten." : "GOOD-Export ist nur in der Electron-App verfuegbar.",
|
||||||
|
overlayButtonLabel: "Overlay",
|
||||||
|
overlayButtonTitle: canShowOverlay ? "Overlay oeffnen." : "Overlay benoetigt die Electron-Bridge.",
|
||||||
|
demoButtonLabel: isScanning ? "Laedt..." : "Demo-Daten",
|
||||||
|
demoButtonTitle: "Laedt Beispieldaten fuer die Triage- und Build-Ansicht.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { AppShell, AppMetrics, AppSidebar, AppTopbar } from "./AppLayout";
|
||||||
|
export {
|
||||||
|
type AppMetricCard,
|
||||||
|
type AppNavigationItem,
|
||||||
|
type AppBarAction,
|
||||||
|
type NavigationId,
|
||||||
|
type AppSidebarProps,
|
||||||
|
type AppTopbarProps,
|
||||||
|
type AppMetricsProps,
|
||||||
|
type AppShellProps,
|
||||||
|
} from "./types";
|
||||||
|
export { appNavigationItems } from "./navigation";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Layers3, Radar, Wand2, Eye } from "lucide-react";
|
||||||
|
import type { AppNavigationItem } from "./types";
|
||||||
|
|
||||||
|
export const appNavigationItems: AppNavigationItem[] = [
|
||||||
|
{ id: "scan", label: "Scan", icon: Radar },
|
||||||
|
{ id: "triage", label: "Triage", icon: Layers3 },
|
||||||
|
{ id: "builds", label: "Builds", icon: Wand2 },
|
||||||
|
{ id: "overlay", label: "Overlay", icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type ComponentType, type ReactNode } from "react";
|
||||||
|
import type { LucideProps } from "lucide-react";
|
||||||
|
|
||||||
|
export type NavigationId = "scan" | "triage" | "builds" | "overlay";
|
||||||
|
|
||||||
|
export interface AppNavigationItem {
|
||||||
|
id: NavigationId;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSidebarItemAction extends AppNavigationItem {
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMetricCard {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppBarAction {
|
||||||
|
id: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAction: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSidebarProps {
|
||||||
|
activeView: NavigationId;
|
||||||
|
items: AppNavigationItem[];
|
||||||
|
onSelect: (id: NavigationId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTopbarProps {
|
||||||
|
topbarStatus: string;
|
||||||
|
canExportGood: boolean;
|
||||||
|
canShowOverlay: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
artifactCount: number;
|
||||||
|
onExportGood: () => Promise<void> | void;
|
||||||
|
onShowOverlay: () => Promise<void>;
|
||||||
|
onDemoScan: () => void;
|
||||||
|
exportIcon?: ReactNode;
|
||||||
|
overlayIcon?: ReactNode;
|
||||||
|
demoIcon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMetricsProps {
|
||||||
|
metricCards: AppMetricCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppShellProps {
|
||||||
|
sidebar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Eye } from "lucide-react";
|
||||||
|
import type { OverlayPreviewProps, OverlaySettingsProps } from "./types";
|
||||||
|
import { useOverlayPreviewModel } from "./hooks/useOverlayPreviewModel";
|
||||||
|
import { useOverlaySettingsModel } from "./hooks/useOverlaySettingsModel";
|
||||||
|
|
||||||
|
export function OverlaySettings({
|
||||||
|
canShowOverlay,
|
||||||
|
onShowOverlay,
|
||||||
|
}: OverlaySettingsProps) {
|
||||||
|
const {
|
||||||
|
handleShowOverlay,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
statusHeadline,
|
||||||
|
statusText,
|
||||||
|
buttonLabel,
|
||||||
|
buttonTitle,
|
||||||
|
} = useOverlaySettingsModel({ onShowOverlay });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
<Eye size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="overlay-settings">
|
||||||
|
<div className="overlay-settings-copy">
|
||||||
|
<strong>{statusHeadline}</strong>
|
||||||
|
<span>{statusText}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={handleShowOverlay}
|
||||||
|
disabled={!canShowOverlay}
|
||||||
|
title={canShowOverlay ? buttonTitle : "Overlay benoetigt die Electron-Bridge."}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverlayPreview({ snapshot }: OverlayPreviewProps) {
|
||||||
|
const {
|
||||||
|
artifactName,
|
||||||
|
artifactSlotLine,
|
||||||
|
artifactSubstatsText,
|
||||||
|
artifactMainStat,
|
||||||
|
characterNames,
|
||||||
|
scoreText,
|
||||||
|
metaClassName,
|
||||||
|
metaIcon,
|
||||||
|
metaLabel,
|
||||||
|
reason,
|
||||||
|
hasArtifact,
|
||||||
|
} = useOverlayPreviewModel({ snapshot });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay-root">
|
||||||
|
<div className="overlay-card">
|
||||||
|
<div className="overlay-card-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Reward scan</p>
|
||||||
|
<h2>{artifactName}</h2>
|
||||||
|
</div>
|
||||||
|
<strong>{scoreText}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={metaClassName}>
|
||||||
|
{metaIcon}
|
||||||
|
{metaLabel}
|
||||||
|
</div>
|
||||||
|
{hasArtifact ? (
|
||||||
|
<div className="overlay-artifact-mini">
|
||||||
|
<span>{artifactSlotLine}</span>
|
||||||
|
<strong>{artifactMainStat}</strong>
|
||||||
|
<small>{artifactSubstatsText}</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="overlay-character-list">
|
||||||
|
{characterNames.map((name) => (
|
||||||
|
<span key={name}>{name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p>{reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { AppSnapshot, Recommendation } from "../../../types/domain";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { verdictMeta } from "../../common/verdictMeta";
|
||||||
|
|
||||||
|
export interface OverlayPreviewModel {
|
||||||
|
candidate: Recommendation | null;
|
||||||
|
artifactName: string;
|
||||||
|
scoreText: string;
|
||||||
|
metaClassName: string;
|
||||||
|
metaLabel: string;
|
||||||
|
metaIcon: ReactNode;
|
||||||
|
artifactSlotLine: string;
|
||||||
|
artifactSubstatsText: string;
|
||||||
|
artifactMainStat: string;
|
||||||
|
characterNames: string[];
|
||||||
|
reason: string;
|
||||||
|
hasArtifact: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOverlayPreviewModelInput {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverlayPreviewModel({ snapshot }: UseOverlayPreviewModelInput): OverlayPreviewModel {
|
||||||
|
const candidate = snapshot.recommendations.find((entry) => entry.verdict === "keep")
|
||||||
|
?? snapshot.recommendations.find((entry) => entry.verdict === "character_specific")
|
||||||
|
?? snapshot.recommendations.find((entry) => entry.verdict === "maybe_level")
|
||||||
|
?? snapshot.recommendations[0]
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
const artifact = snapshot.artifacts.find((entry) => entry.id === candidate?.artifactId);
|
||||||
|
const meta = verdictMeta[candidate?.verdict ?? "needs_review"];
|
||||||
|
const characterNames =
|
||||||
|
candidate?.bestCharacters.map((id) => snapshot.characters.find((character) => character.id === id)?.name ?? id).slice(0, 3) ?? [];
|
||||||
|
const characterDisplayNames = characterNames.length > 0 ? characterNames : ["Review first"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidate,
|
||||||
|
artifactName: artifact?.setName ?? "Artifact detected",
|
||||||
|
scoreText: candidate ? `${Math.round(candidate.score)}` : "--",
|
||||||
|
metaClassName: meta.className,
|
||||||
|
metaLabel: meta.label,
|
||||||
|
metaIcon: meta.icon,
|
||||||
|
artifactSlotLine: artifact ? `${artifact.slot} | +${artifact.level}` : "No artifact",
|
||||||
|
artifactSubstatsText: artifact?.substats
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`)
|
||||||
|
.join(" | ") ?? "",
|
||||||
|
artifactMainStat: artifact?.mainStat ?? "No main stat",
|
||||||
|
characterNames: characterDisplayNames,
|
||||||
|
reason: candidate?.reason ?? "Waiting for artifact detail view.",
|
||||||
|
hasArtifact: Boolean(artifact),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export interface OverlaySettingsModel {
|
||||||
|
handleShowOverlay: () => void;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
statusHeadline: string;
|
||||||
|
statusText: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
buttonTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOverlaySettingsModelInput {
|
||||||
|
onShowOverlay: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverlaySettingsModel({ onShowOverlay }: UseOverlaySettingsModelInput): OverlaySettingsModel {
|
||||||
|
const handleShowOverlay = useCallback(() => {
|
||||||
|
void onShowOverlay();
|
||||||
|
}, [onShowOverlay]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleShowOverlay,
|
||||||
|
eyebrow: "Farming overlay",
|
||||||
|
title: "Read-only reward assistant",
|
||||||
|
statusHeadline: "Aktueller MVP-Status",
|
||||||
|
statusText:
|
||||||
|
"Click-through Preview mit echten lokalen Recommendations. Live Reward Scan und Auto-DB-Sync kommen danach.",
|
||||||
|
buttonLabel: "Show overlay preview",
|
||||||
|
buttonTitle: "Overlay oeffnen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { AppSnapshot } from "../../types/domain";
|
||||||
|
|
||||||
|
export interface OverlaySettingsProps {
|
||||||
|
canShowOverlay: boolean;
|
||||||
|
onShowOverlay: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlayPreviewProps {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ScanViewLayout } from "./components/ScanViewLayout";
|
||||||
|
import { useScanViewController } from "./hooks/useScanViewController";
|
||||||
|
import type { ScanViewProps } from "./types";
|
||||||
|
|
||||||
|
export function ScanView(props: ScanViewProps) {
|
||||||
|
const controller = useScanViewController(props);
|
||||||
|
|
||||||
|
return <ScanViewLayout {...props} controller={controller} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { AlertTriangle, Camera, Eye } from "lucide-react";
|
||||||
|
import { ArtifactResultCard } from "./ScanResultCards";
|
||||||
|
import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel";
|
||||||
|
import type { ScanMainSectionProps } from "./types";
|
||||||
|
|
||||||
|
export function ScanMainSection({
|
||||||
|
latestCapture,
|
||||||
|
captureStatus,
|
||||||
|
parsedArtifact,
|
||||||
|
sourceLabel,
|
||||||
|
gridLabel,
|
||||||
|
inventoryLabel,
|
||||||
|
activeTargetCount,
|
||||||
|
storedTotal,
|
||||||
|
reviewSampleTotal,
|
||||||
|
learningRulesLoaded,
|
||||||
|
learningRuleCount,
|
||||||
|
setDetailsOpen,
|
||||||
|
autoScanRunning,
|
||||||
|
canOpenReviewQueue,
|
||||||
|
openReviewQueue,
|
||||||
|
}: ScanMainSectionProps) {
|
||||||
|
const {
|
||||||
|
canOpenDetails,
|
||||||
|
handleOpenDetails,
|
||||||
|
handleOpenReviewQueue,
|
||||||
|
captureImageSrc,
|
||||||
|
captureImageAlt,
|
||||||
|
hasCapture,
|
||||||
|
captureModeText,
|
||||||
|
resultHeading,
|
||||||
|
noArtifactText,
|
||||||
|
noCaptureMessage,
|
||||||
|
targetLabel,
|
||||||
|
dbLabel,
|
||||||
|
reviewLabel,
|
||||||
|
rulesLabel,
|
||||||
|
} = useScanMainSectionModel({
|
||||||
|
latestCapture,
|
||||||
|
parsedArtifact,
|
||||||
|
activeTargetCount,
|
||||||
|
storedTotal,
|
||||||
|
reviewSampleTotal,
|
||||||
|
learningRulesLoaded,
|
||||||
|
learningRuleCount,
|
||||||
|
setDetailsOpen,
|
||||||
|
openReviewQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scanner-main-grid">
|
||||||
|
<div className="capture-stage-shell">
|
||||||
|
<div className="capture-stage">
|
||||||
|
{hasCapture ? (
|
||||||
|
<img src={captureImageSrc} alt={captureImageAlt} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-stage">
|
||||||
|
<Camera size={28} />
|
||||||
|
<strong>Noch kein Bild</strong>
|
||||||
|
<span>{noCaptureMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="capture-stage-meta">
|
||||||
|
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
|
||||||
|
<div><span>Grid</span><strong>{gridLabel}</strong></div>
|
||||||
|
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
|
||||||
|
<div><span>Modus</span><strong>{captureModeText}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="scanner-result-panel">
|
||||||
|
<div className="result-heading">
|
||||||
|
<p className="eyebrow">Zuletzt gelesen</p>
|
||||||
|
<h3>{resultHeading}</h3>
|
||||||
|
</div>
|
||||||
|
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
|
||||||
|
<p className="result-empty">{noArtifactText}</p>
|
||||||
|
)}
|
||||||
|
<div className="scanner-result-brief">
|
||||||
|
<span>{targetLabel}</span>
|
||||||
|
<span>{dbLabel}</span>
|
||||||
|
<span>{reviewLabel}</span>
|
||||||
|
<span>{rulesLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scanner-result-actions">
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={handleOpenDetails}
|
||||||
|
disabled={!canOpenDetails}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
Review Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="scanner-result-caption">{captureStatus}</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ScanDiagnosticsModal } from "./modals/ScanDiagnosticsModal";
|
||||||
|
import { ScanSettingsModal } from "./modals/ScanSettingsModal";
|
||||||
|
import { ScanDetailsModal } from "./modals/ScanDetailsModal";
|
||||||
|
import { ScanReviewQueueModal } from "./modals/ScanReviewQueueModal";
|
||||||
|
import { ScanSummaryModal } from "./modals/ScanSummaryModal";
|
||||||
|
import type { ScanModalsSectionProps } from "./types";
|
||||||
|
|
||||||
|
export function ScanModalsSection({
|
||||||
|
captureStatus,
|
||||||
|
latestCapture,
|
||||||
|
diagnosticsOpen,
|
||||||
|
settingsOpen,
|
||||||
|
detailsOpen,
|
||||||
|
reviewQueueOpen,
|
||||||
|
controller,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
setSettingsOpen,
|
||||||
|
setDetailsOpen,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
setScanSummary,
|
||||||
|
}: ScanModalsSectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScanDiagnosticsModal
|
||||||
|
open={diagnosticsOpen}
|
||||||
|
captureStatus={captureStatus}
|
||||||
|
latestCapture={latestCapture}
|
||||||
|
controller={controller}
|
||||||
|
setDetailsOpen={setDetailsOpen}
|
||||||
|
setDiagnosticsOpen={setDiagnosticsOpen}
|
||||||
|
/>
|
||||||
|
<ScanSettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
latestCapture={latestCapture}
|
||||||
|
controller={controller}
|
||||||
|
setSettingsOpen={setSettingsOpen}
|
||||||
|
/>
|
||||||
|
<ScanDetailsModal
|
||||||
|
open={detailsOpen}
|
||||||
|
latestCapture={latestCapture}
|
||||||
|
controller={controller}
|
||||||
|
setDetailsOpen={setDetailsOpen}
|
||||||
|
/>
|
||||||
|
<ScanReviewQueueModal
|
||||||
|
open={reviewQueueOpen}
|
||||||
|
controller={controller}
|
||||||
|
setReviewQueueOpen={setReviewQueueOpen}
|
||||||
|
/>
|
||||||
|
<ScanSummaryModal
|
||||||
|
open={Boolean(controller.scanSummary)}
|
||||||
|
controller={controller}
|
||||||
|
setScanSummary={setScanSummary}
|
||||||
|
devMode={controller.devMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ArtifactResultCardProps, FieldConfidenceListProps, ReviewSampleCardProps, ScanSummaryFooterProps } from "./types";
|
||||||
|
import { useFieldConfidenceRowsModel, useReviewSampleCardModel, useScanResultCardModel } from "./hooks/useScanResultCardsModel";
|
||||||
|
import { useScanSummaryFooterModel } from "./hooks/useScanSummaryFooterModel";
|
||||||
|
|
||||||
|
export function FieldConfidenceList({ parsedArtifact }: FieldConfidenceListProps) {
|
||||||
|
const rows = useFieldConfidenceRowsModel({ parsedArtifact });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field-confidence-list">
|
||||||
|
{rows.map(({ label, field, confidenceClassName }) => (
|
||||||
|
<div className={`field-confidence ${confidenceClassName}`} key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{field.confidence}%</strong>
|
||||||
|
<small>{field.source}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactResultCard({ parsed }: ArtifactResultCardProps) {
|
||||||
|
const { quality, levelText, equippedText, substats, noSubstatsText } = useScanResultCardModel({ parsed });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="artifact-result-card">
|
||||||
|
<div className="artifact-result-head">
|
||||||
|
<span className="artifact-set">{parsed.setName}</span>
|
||||||
|
<span className={`quality-chip ${quality.className}`}>{quality.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="artifact-slot">{parsed.slot}</span>
|
||||||
|
<div className="artifact-mainstat">
|
||||||
|
<span>Hauptwert</span>
|
||||||
|
<strong>{parsed.mainStat}</strong>
|
||||||
|
<em>{parsed.mainValue}</em>
|
||||||
|
</div>
|
||||||
|
<div className="artifact-equipped">
|
||||||
|
{levelText}
|
||||||
|
</div>
|
||||||
|
<div className="artifact-substats">
|
||||||
|
{substats.length > 0 ? substats.map((substat) => <span key={substat}>{substat}</span>) : <span className="none">{noSubstatsText}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="artifact-equipped">
|
||||||
|
{equippedText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewSampleCard({ entry }: ReviewSampleCardProps) {
|
||||||
|
const model = useReviewSampleCardModel({ entry });
|
||||||
|
const {
|
||||||
|
artifactTitle,
|
||||||
|
reasonText,
|
||||||
|
sourceText,
|
||||||
|
captureTargetText,
|
||||||
|
resolutionText,
|
||||||
|
gridText,
|
||||||
|
ocrCountText,
|
||||||
|
parsedSlotText,
|
||||||
|
parsedMainText,
|
||||||
|
parsedSetText,
|
||||||
|
parsedEquippedText,
|
||||||
|
savedAtText,
|
||||||
|
showParsed,
|
||||||
|
ocrRows,
|
||||||
|
} = model;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="review-sample-card">
|
||||||
|
<div className="review-sample-head">
|
||||||
|
<div>
|
||||||
|
<strong>{artifactTitle}</strong>
|
||||||
|
<span>{reasonText}</span>
|
||||||
|
</div>
|
||||||
|
<time>{savedAtText}</time>
|
||||||
|
</div>
|
||||||
|
<div className="review-sample-meta">
|
||||||
|
<span>{sourceText}</span>
|
||||||
|
{captureTargetText && <span>{captureTargetText}</span>}
|
||||||
|
<span>{resolutionText}</span>
|
||||||
|
<span>{gridText}</span>
|
||||||
|
<span>{ocrCountText}</span>
|
||||||
|
</div>
|
||||||
|
{showParsed && (
|
||||||
|
<div className="review-sample-parsed">
|
||||||
|
<span>{parsedSlotText}</span>
|
||||||
|
<strong>{parsedMainText}</strong>
|
||||||
|
<span>{parsedSetText}</span>
|
||||||
|
<span>{parsedEquippedText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ocrRows.length > 0 && (
|
||||||
|
<div className="review-sample-ocr">
|
||||||
|
{ocrRows.map((ocrRow) => (
|
||||||
|
<span key={ocrRow.id}>{ocrRow.text}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanSummaryFooter({ devMode, scanSummary, storedTotal }: ScanSummaryFooterProps) {
|
||||||
|
const { summaryCopy, devCopy } = useScanSummaryFooterModel({
|
||||||
|
devMode,
|
||||||
|
scanSummary,
|
||||||
|
storedTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="scan-summary-copy">{summaryCopy}</p>
|
||||||
|
{devCopy && <p className="scan-summary-dev">{devCopy}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Camera,
|
||||||
|
Play,
|
||||||
|
Radar,
|
||||||
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ScanTopControlsSectionProps } from "./types";
|
||||||
|
import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel";
|
||||||
|
|
||||||
|
export function ScanTopControlsSection({
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
setSelectedSourceId,
|
||||||
|
refreshCaptureSources,
|
||||||
|
captureSelectedSource,
|
||||||
|
bridgeReady,
|
||||||
|
isScanning,
|
||||||
|
controller,
|
||||||
|
}: ScanTopControlsSectionProps) {
|
||||||
|
const {
|
||||||
|
shouldShowGenshinSourceButton,
|
||||||
|
bridgeDisabled,
|
||||||
|
canStartAutoScan,
|
||||||
|
canStartManualScan,
|
||||||
|
canCaptureSingle,
|
||||||
|
autoScanRunning,
|
||||||
|
handleSourceChange,
|
||||||
|
selectGenshinSource,
|
||||||
|
openSettings,
|
||||||
|
openDiagnostics,
|
||||||
|
captureSingleArtifact,
|
||||||
|
stopScan,
|
||||||
|
runVisibleGridScan,
|
||||||
|
runAutoReviewScan,
|
||||||
|
bridgeStatusText,
|
||||||
|
bridgePillClass,
|
||||||
|
runtimeStatusText,
|
||||||
|
runtimePillClass,
|
||||||
|
playerStatusText,
|
||||||
|
autoScanButtonTitle,
|
||||||
|
autoScanButtonLabel,
|
||||||
|
manualScanButtonTitle,
|
||||||
|
captureSingleButtonTitle,
|
||||||
|
refreshCaptureSourcesTitle,
|
||||||
|
diagnosticsButtonTitle,
|
||||||
|
scanSetupButtonTitle,
|
||||||
|
showPlayerProgress,
|
||||||
|
progressWidth,
|
||||||
|
progressStats,
|
||||||
|
} = useScanTopControlsModel({
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
setSelectedSourceId,
|
||||||
|
captureSelectedSource,
|
||||||
|
bridgeReady,
|
||||||
|
isScanning,
|
||||||
|
controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="scanner-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Scanner</p>
|
||||||
|
<h2>Artifact capture workspace</h2>
|
||||||
|
<p className="scanner-subcopy">Grosse Vorschau vorn, klare Aktionen oben, Diagnose und Review nur bei Bedarf.</p>
|
||||||
|
</div>
|
||||||
|
<div className="scanner-header-pills">
|
||||||
|
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
|
||||||
|
<span className={`runtime-pill ${runtimePillClass}`}>{runtimeStatusText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!bridgeReady && (
|
||||||
|
<div className="bridge-banner">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
Die Capture-Verbindung fehlt. Bitte das Electron-App-Fenster verwenden - die Browser-Vorschau kann Genshin nicht scannen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="player-scan-card">
|
||||||
|
<div className="player-scan-row">
|
||||||
|
<label className="source-select">
|
||||||
|
<span>Quelle</span>
|
||||||
|
<select value={selectedSourceId} onChange={handleSourceChange} disabled={bridgeDisabled}>
|
||||||
|
{captureSources.length === 0 && <option value="">Keine Quellen gefunden</option>}
|
||||||
|
{captureSources.map((source) => (
|
||||||
|
<option key={source.id} value={source.id}>
|
||||||
|
{source.isGenshinCandidate ? "Genshin - " : ""}{source.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={refreshCaptureSources}
|
||||||
|
title={refreshCaptureSourcesTitle}
|
||||||
|
disabled={!bridgeReady}
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
Neu suchen
|
||||||
|
</button>
|
||||||
|
{shouldShowGenshinSourceButton && (
|
||||||
|
<button className="ghost-button" onClick={selectGenshinSource} disabled={!bridgeReady}>
|
||||||
|
<Radar size={15} />
|
||||||
|
Genshin waehlen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={openSettings}
|
||||||
|
disabled={bridgeDisabled}
|
||||||
|
title={scanSetupButtonTitle}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={15} />
|
||||||
|
Scan-Setup
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button dev-toggle"
|
||||||
|
onClick={openDiagnostics}
|
||||||
|
disabled={!bridgeReady}
|
||||||
|
title={diagnosticsButtonTitle}
|
||||||
|
>
|
||||||
|
<Wrench size={15} />
|
||||||
|
Scanner Diagnose
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="player-scan-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button scan-cta"
|
||||||
|
onClick={runVisibleGridScan}
|
||||||
|
disabled={!canStartAutoScan}
|
||||||
|
title={autoScanButtonTitle}
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{autoScanButtonLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={runAutoReviewScan}
|
||||||
|
disabled={!canStartManualScan}
|
||||||
|
title={manualScanButtonTitle}
|
||||||
|
>
|
||||||
|
<Radar size={15} />
|
||||||
|
Manueller Scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={captureSingleArtifact}
|
||||||
|
disabled={!canCaptureSingle}
|
||||||
|
title={captureSingleButtonTitle}
|
||||||
|
>
|
||||||
|
<Camera size={15} />
|
||||||
|
Einzelnes Artifact lesen
|
||||||
|
</button>
|
||||||
|
{autoScanRunning && (
|
||||||
|
<button className="stop-button" onClick={stopScan}>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="player-status">
|
||||||
|
{playerStatusText}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showPlayerProgress && (
|
||||||
|
<div className="player-progress">
|
||||||
|
<div className="player-progress-bar">
|
||||||
|
<div style={{ width: `${progressWidth}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="player-progress-stats">
|
||||||
|
{progressStats.map((entry) => (
|
||||||
|
<span key={entry.label} className={entry.extraClass}>
|
||||||
|
<strong>{entry.value}</strong> {entry.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ScanMainSection } from "./ScanMainSection";
|
||||||
|
import { ScanModalsSection } from "./ScanModalsSection";
|
||||||
|
import { ScanTopControlsSection } from "./ScanTopControlsSection";
|
||||||
|
import type { ScanViewLayoutProps } from "./types";
|
||||||
|
|
||||||
|
export function ScanViewLayout({
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
setSelectedSourceId,
|
||||||
|
latestCapture,
|
||||||
|
captureStatus,
|
||||||
|
refreshCaptureSources,
|
||||||
|
captureSelectedSource,
|
||||||
|
bridgeReady,
|
||||||
|
controller,
|
||||||
|
isScanning,
|
||||||
|
}: ScanViewLayoutProps) {
|
||||||
|
const {
|
||||||
|
setDetailsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
setSettingsOpen,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
setScanSummary,
|
||||||
|
reviewSampleTotal,
|
||||||
|
autoScanRunning,
|
||||||
|
storedTotal,
|
||||||
|
learningRulesLoaded,
|
||||||
|
parsedArtifact,
|
||||||
|
learningRuleCount,
|
||||||
|
activeTargetCount,
|
||||||
|
canReadReviewQueue,
|
||||||
|
sourceLabel,
|
||||||
|
gridLabel,
|
||||||
|
inventoryLabel,
|
||||||
|
openReviewQueue,
|
||||||
|
} = controller;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="scanner-workbench">
|
||||||
|
<ScanTopControlsSection
|
||||||
|
captureSources={captureSources}
|
||||||
|
selectedSourceId={selectedSourceId}
|
||||||
|
setSelectedSourceId={setSelectedSourceId}
|
||||||
|
refreshCaptureSources={refreshCaptureSources}
|
||||||
|
captureSelectedSource={captureSelectedSource}
|
||||||
|
bridgeReady={bridgeReady}
|
||||||
|
isScanning={isScanning}
|
||||||
|
controller={controller}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScanMainSection
|
||||||
|
latestCapture={latestCapture}
|
||||||
|
captureStatus={captureStatus}
|
||||||
|
parsedArtifact={parsedArtifact}
|
||||||
|
sourceLabel={sourceLabel}
|
||||||
|
gridLabel={gridLabel}
|
||||||
|
inventoryLabel={inventoryLabel}
|
||||||
|
activeTargetCount={activeTargetCount}
|
||||||
|
storedTotal={storedTotal}
|
||||||
|
reviewSampleTotal={reviewSampleTotal}
|
||||||
|
learningRulesLoaded={learningRulesLoaded}
|
||||||
|
learningRuleCount={learningRuleCount}
|
||||||
|
canOpenReviewQueue={!autoScanRunning && canReadReviewQueue}
|
||||||
|
openReviewQueue={openReviewQueue}
|
||||||
|
autoScanRunning={autoScanRunning}
|
||||||
|
setDetailsOpen={setDetailsOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScanModalsSection
|
||||||
|
captureStatus={captureStatus}
|
||||||
|
latestCapture={latestCapture}
|
||||||
|
diagnosticsOpen={Boolean(controller.diagnosticsOpen)}
|
||||||
|
settingsOpen={Boolean(controller.settingsOpen)}
|
||||||
|
detailsOpen={Boolean(controller.detailsOpen)}
|
||||||
|
reviewQueueOpen={Boolean(controller.reviewQueueOpen)}
|
||||||
|
controller={controller}
|
||||||
|
setDiagnosticsOpen={setDiagnosticsOpen}
|
||||||
|
setSettingsOpen={setSettingsOpen}
|
||||||
|
setDetailsOpen={setDetailsOpen}
|
||||||
|
setReviewQueueOpen={setReviewQueueOpen}
|
||||||
|
setScanSummary={setScanSummary}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { ScanMainSectionProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanMainSectionModel {
|
||||||
|
canOpenDetails: boolean;
|
||||||
|
handleOpenDetails: () => void;
|
||||||
|
handleOpenReviewQueue: () => void;
|
||||||
|
captureImageSrc: string;
|
||||||
|
captureImageAlt: string;
|
||||||
|
hasCapture: boolean;
|
||||||
|
captureModeText: string;
|
||||||
|
resultHeading: string;
|
||||||
|
noArtifactText: string;
|
||||||
|
noCaptureMessage: string;
|
||||||
|
targetLabel: string;
|
||||||
|
dbLabel: string;
|
||||||
|
reviewLabel: string;
|
||||||
|
rulesLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseScanMainSectionModelProps = Pick<
|
||||||
|
ScanMainSectionProps,
|
||||||
|
| "latestCapture"
|
||||||
|
| "parsedArtifact"
|
||||||
|
| "activeTargetCount"
|
||||||
|
| "storedTotal"
|
||||||
|
| "reviewSampleTotal"
|
||||||
|
| "learningRulesLoaded"
|
||||||
|
| "learningRuleCount"
|
||||||
|
| "setDetailsOpen"
|
||||||
|
| "openReviewQueue"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useScanMainSectionModel({
|
||||||
|
latestCapture,
|
||||||
|
parsedArtifact,
|
||||||
|
activeTargetCount,
|
||||||
|
storedTotal,
|
||||||
|
reviewSampleTotal,
|
||||||
|
learningRulesLoaded,
|
||||||
|
learningRuleCount,
|
||||||
|
setDetailsOpen,
|
||||||
|
openReviewQueue,
|
||||||
|
}: UseScanMainSectionModelProps): ScanMainSectionModel {
|
||||||
|
const handleOpenDetails = useCallback(() => {
|
||||||
|
setDetailsOpen(true);
|
||||||
|
}, [setDetailsOpen]);
|
||||||
|
const handleOpenReviewQueue = useCallback(() => {
|
||||||
|
void openReviewQueue();
|
||||||
|
}, [openReviewQueue]);
|
||||||
|
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
|
||||||
|
const captureImageSrc = latestCapture ? latestCapture.detailDataUrl ?? latestCapture.dataUrl : "";
|
||||||
|
const captureImageAlt = latestCapture
|
||||||
|
? `Latest capture from ${latestCapture.name}`
|
||||||
|
: "Latest capture is not available yet";
|
||||||
|
const captureModeText = latestCapture ? "Erkannt" : "Warte";
|
||||||
|
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
|
||||||
|
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
|
||||||
|
const noCaptureMessage = noArtifactText;
|
||||||
|
const targetLabel = `Ziel ${activeTargetCount}`;
|
||||||
|
const dbLabel = `DB ${storedTotal ?? "-"}`;
|
||||||
|
const reviewLabel = `Review ${reviewSampleTotal}`;
|
||||||
|
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canOpenDetails,
|
||||||
|
handleOpenDetails,
|
||||||
|
handleOpenReviewQueue,
|
||||||
|
captureImageSrc,
|
||||||
|
captureImageAlt,
|
||||||
|
hasCapture: Boolean(latestCapture),
|
||||||
|
captureModeText,
|
||||||
|
resultHeading,
|
||||||
|
noArtifactText,
|
||||||
|
noCaptureMessage,
|
||||||
|
targetLabel,
|
||||||
|
dbLabel,
|
||||||
|
reviewLabel,
|
||||||
|
rulesLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { ArtifactResultCardProps, FieldConfidenceListProps } from "../types";
|
||||||
|
import type { ParsedArtifactCandidate, ParsedField } from "../../../../lib/artifactOcrParser";
|
||||||
|
import type { ReviewSampleRecord } from "../../../../types/global";
|
||||||
|
|
||||||
|
export interface FieldConfidenceRowModel {
|
||||||
|
label: string;
|
||||||
|
field: ParsedField;
|
||||||
|
confidenceClassName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResultCardModel {
|
||||||
|
levelField: ParsedField;
|
||||||
|
quality: {
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
};
|
||||||
|
fieldRows: FieldConfidenceRowModel[];
|
||||||
|
levelText: string;
|
||||||
|
equippedText: string;
|
||||||
|
substats: string[];
|
||||||
|
noSubstatsText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSampleCardModel {
|
||||||
|
artifactTitle: string;
|
||||||
|
reasonText: string;
|
||||||
|
sourceText: string;
|
||||||
|
captureTargetText: string | null;
|
||||||
|
resolutionText: string;
|
||||||
|
gridText: string;
|
||||||
|
ocrCountText: string;
|
||||||
|
parsedSlotText: string;
|
||||||
|
parsedMainText: string;
|
||||||
|
parsedSetText: string;
|
||||||
|
parsedEquippedText: string;
|
||||||
|
savedAtText: string;
|
||||||
|
showParsed: boolean;
|
||||||
|
ocrRows: Array<{ id: string; label: string; text: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFieldConfidenceRowsModel({ parsedArtifact }: FieldConfidenceListProps): FieldConfidenceRowModel[] {
|
||||||
|
const rows: Array<[string, ParsedField]> = [
|
||||||
|
["Name", parsedArtifact.fields.name],
|
||||||
|
["Slot", parsedArtifact.fields.slot],
|
||||||
|
["Level", getLevelField(parsedArtifact)],
|
||||||
|
["Main", mergeField(parsedArtifact.fields.mainStat, parsedArtifact.fields.mainValue)],
|
||||||
|
["Set", parsedArtifact.fields.setName],
|
||||||
|
["Equipped", parsedArtifact.fields.equipped],
|
||||||
|
["Substats", parsedArtifact.fields.substats],
|
||||||
|
];
|
||||||
|
|
||||||
|
return rows.map(([label, field]) => ({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
confidenceClassName: resolveFieldConfidenceClass(field),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanResultCardModel({
|
||||||
|
parsed,
|
||||||
|
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
|
||||||
|
const substats = parsed.substats;
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelField: getLevelField(parsed),
|
||||||
|
quality: resolveQuality(parsed.confidence),
|
||||||
|
fieldRows: [
|
||||||
|
["Name", parsed.fields.name],
|
||||||
|
["Slot", parsed.fields.slot],
|
||||||
|
["Level", getLevelField(parsed)],
|
||||||
|
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
|
||||||
|
["Set", parsed.fields.setName],
|
||||||
|
["Equipped", parsed.fields.equipped],
|
||||||
|
["Substats", parsed.fields.substats],
|
||||||
|
].map(([label, field]) => ({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
confidenceClassName: resolveFieldConfidenceClass(field),
|
||||||
|
})),
|
||||||
|
levelText: resolveLevelText(parsed),
|
||||||
|
equippedText: resolveEquippedText(parsed.equipped),
|
||||||
|
substats,
|
||||||
|
noSubstatsText: "Keine Substats gelesen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviewSampleCardModel({ entry }: { entry: ReviewSampleRecord }): ReviewSampleCardModel {
|
||||||
|
const parsed = entry.sample?.parsed as Partial<ParsedArtifactCandidate> | undefined;
|
||||||
|
const capture = entry.sample?.capture;
|
||||||
|
const ocr = capture?.ocr ?? [];
|
||||||
|
const grid = capture?.inventoryGrid;
|
||||||
|
|
||||||
|
const date = new Date(entry.savedAt);
|
||||||
|
const savedAtText = Number.isNaN(date.getTime()) ? "Unbekannter Zeitpunkt" : date.toLocaleString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
artifactTitle: parsed?.name || "Unparsed capture",
|
||||||
|
reasonText: entry.sample?.reason || "manual",
|
||||||
|
sourceText: capture?.name || "Unknown source",
|
||||||
|
captureTargetText: capture?.captureTarget || null,
|
||||||
|
resolutionText: `${capture?.width ?? "?"}x${capture?.height ?? "?"}`,
|
||||||
|
gridText: grid ? `${grid.cols}x${grid.rows} grid ${grid.confidence}%` : "no grid",
|
||||||
|
ocrCountText: `${ocr.length} OCR fields`,
|
||||||
|
parsedSlotText: parsed?.slot || "Unknown slot",
|
||||||
|
parsedMainText: `${parsed?.mainStat || "Unknown main"} ${parsed?.mainValue || ""}`,
|
||||||
|
parsedSetText: parsed?.setName || "Unknown set",
|
||||||
|
parsedEquippedText: parsed?.equipped || "Not detected",
|
||||||
|
savedAtText,
|
||||||
|
showParsed: Boolean(parsed),
|
||||||
|
ocrRows: ocr.slice(0, 4).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
text: `${item.label}: ${item.confidence}%`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelField(parsedArtifact: ParsedArtifactCandidate): ParsedField {
|
||||||
|
const fieldLevel = parsedArtifact.fields.level;
|
||||||
|
if (fieldLevel?.value) {
|
||||||
|
return fieldLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedLevel = parsedArtifact.level;
|
||||||
|
return {
|
||||||
|
value: `${derivedLevel}`,
|
||||||
|
confidence: derivedLevel > 0 ? 80 : 0,
|
||||||
|
source: derivedLevel > 0 ? "derived" : "missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuality(confidence: number): { label: string; className: string } {
|
||||||
|
return confidence >= 85
|
||||||
|
? { label: "Sauber gelesen", className: "good" }
|
||||||
|
: confidence >= 70
|
||||||
|
? { label: "Etwas unsicher", className: "mid" }
|
||||||
|
: { label: "Unsicher - bitte prüfen", className: "low" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeField(primary: ParsedField, secondary: ParsedField): ParsedField {
|
||||||
|
const confidence = primary.value && secondary.value
|
||||||
|
? Math.round((primary.confidence + secondary.confidence) / 2)
|
||||||
|
: Math.min(primary.confidence, secondary.confidence);
|
||||||
|
const source = primary.source === secondary.source
|
||||||
|
? primary.source
|
||||||
|
: primary.source === "missing"
|
||||||
|
? secondary.source
|
||||||
|
: primary.source;
|
||||||
|
return {
|
||||||
|
value: `${primary.value} ${secondary.value}`.trim(),
|
||||||
|
confidence,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldConfidenceClass(field: ParsedField): string {
|
||||||
|
return field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLevelText(parsed: ParsedArtifactCandidate): string {
|
||||||
|
return parsed.level > 0 ? `Level +${parsed.level}` : "Level nicht erkannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEquippedText(equipped: string | null | undefined): string {
|
||||||
|
return equipped && equipped !== "Not detected" ? `Ausgerüstet: ${equipped}` : "Nicht Ausgerüstet / nicht erkannt";
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ScanSummaryFooterProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanSummaryFooterModel {
|
||||||
|
summaryCopy: string;
|
||||||
|
devCopy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanSummaryFooterModel({
|
||||||
|
devMode,
|
||||||
|
scanSummary,
|
||||||
|
storedTotal,
|
||||||
|
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
|
||||||
|
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
|
||||||
|
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
|
||||||
|
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||||
|
|
||||||
|
const devCopy = devMode
|
||||||
|
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summaryCopy,
|
||||||
|
devCopy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import type { ScanTopControlsSectionProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanTopControlsModel {
|
||||||
|
shouldShowGenshinSourceButton: boolean;
|
||||||
|
bridgeDisabled: boolean;
|
||||||
|
canStartAutoScan: boolean;
|
||||||
|
canStartManualScan: boolean;
|
||||||
|
canCaptureSingle: boolean;
|
||||||
|
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
selectGenshinSource: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
|
openDiagnostics: () => void;
|
||||||
|
captureSingleArtifact: () => void;
|
||||||
|
stopScan: () => void;
|
||||||
|
runVisibleGridScan: () => void;
|
||||||
|
runAutoReviewScan: () => void;
|
||||||
|
bridgeStatusText: string;
|
||||||
|
bridgePillClass: string;
|
||||||
|
runtimeStatusText: string;
|
||||||
|
runtimePillClass: string;
|
||||||
|
playerStatusText: string;
|
||||||
|
autoScanButtonTitle: string;
|
||||||
|
autoScanButtonLabel: string;
|
||||||
|
manualScanButtonTitle: string;
|
||||||
|
captureSingleButtonTitle: string;
|
||||||
|
refreshCaptureSourcesTitle: string;
|
||||||
|
diagnosticsButtonTitle: string;
|
||||||
|
scanSetupButtonTitle: string;
|
||||||
|
showPlayerProgress: boolean;
|
||||||
|
progressWidth: number;
|
||||||
|
progressStats: Array<{ label: string; value: number | string; extraClass?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanTopControlsModel({
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
setSelectedSourceId,
|
||||||
|
captureSelectedSource,
|
||||||
|
bridgeReady,
|
||||||
|
isScanning,
|
||||||
|
controller,
|
||||||
|
}: ScanTopControlsSectionProps): ScanTopControlsModel {
|
||||||
|
const {
|
||||||
|
setSettingsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
requestScanStop,
|
||||||
|
runVisibleGridScan,
|
||||||
|
runAutoReviewScan,
|
||||||
|
autoScanRunning,
|
||||||
|
canCaptureSource,
|
||||||
|
canAutoScan,
|
||||||
|
hasSourceSelected,
|
||||||
|
requiresAdminForAutoScan,
|
||||||
|
reviewStatus,
|
||||||
|
runtimeInfo,
|
||||||
|
autoScanStats,
|
||||||
|
storedTotal,
|
||||||
|
scanProgressPercent,
|
||||||
|
genshinSource,
|
||||||
|
} = controller;
|
||||||
|
|
||||||
|
const handleSourceChange = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLSelectElement>) => setSelectedSourceId(event.target.value),
|
||||||
|
[setSelectedSourceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectGenshinSource = useCallback(() => {
|
||||||
|
if (genshinSource) {
|
||||||
|
setSelectedSourceId(genshinSource.id);
|
||||||
|
}
|
||||||
|
}, [genshinSource, setSelectedSourceId]);
|
||||||
|
|
||||||
|
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
|
||||||
|
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
|
||||||
|
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
|
||||||
|
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
|
||||||
|
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
|
||||||
|
const bridgePillClass = bridgeReady ? "elevated" : "standard";
|
||||||
|
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
|
||||||
|
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
|
||||||
|
const playerStatusText = reviewStatus
|
||||||
|
|| (runtimeInfo?.isElevated
|
||||||
|
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
|
||||||
|
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
|
||||||
|
const autoScanButtonTitle = requiresAdminForAutoScan
|
||||||
|
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
|
||||||
|
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
|
||||||
|
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
|
||||||
|
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
|
||||||
|
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
|
||||||
|
const refreshCaptureSourcesTitle = "Fenster- und Bildschirmquellen neu suchen";
|
||||||
|
const diagnosticsButtonTitle = bridgeReady
|
||||||
|
? "Scanner Diagnose oeffnen: Rechte, Grid-Erkennung und Logs."
|
||||||
|
: "Scanner Diagnose ist nur in der Electron-App vollstaendig nutzbar.";
|
||||||
|
const scanSetupButtonTitle = bridgeReady
|
||||||
|
? "Scan-Ziel, Skip-Zeilen und Operator-Optionen anpassen."
|
||||||
|
: "Scan-Setup ist nur in der Electron-App vollstaendig nutzbar.";
|
||||||
|
const showPlayerProgress = autoScanRunning || autoScanStats.clicked > 0 || autoScanStats.parsed > 0;
|
||||||
|
const progressStats = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Klicks", value: autoScanStats.clicked },
|
||||||
|
{ label: "Positionen", value: autoScanStats.attempted },
|
||||||
|
{ label: "Verifiziert", value: autoScanStats.verified },
|
||||||
|
{ label: "Gespeichert", value: autoScanStats.stored },
|
||||||
|
{ label: "Review", value: autoScanStats.review },
|
||||||
|
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
|
||||||
|
],
|
||||||
|
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldShowGenshinSourceButton: Boolean(genshinSource && selectedSourceId !== genshinSource.id),
|
||||||
|
bridgeDisabled: !bridgeReady || captureSources.length === 0,
|
||||||
|
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
|
||||||
|
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||||
|
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||||
|
handleSourceChange,
|
||||||
|
selectGenshinSource,
|
||||||
|
openSettings,
|
||||||
|
openDiagnostics,
|
||||||
|
captureSingleArtifact,
|
||||||
|
stopScan,
|
||||||
|
runVisibleGridScan,
|
||||||
|
runAutoReviewScan,
|
||||||
|
bridgeStatusText,
|
||||||
|
bridgePillClass,
|
||||||
|
runtimeStatusText,
|
||||||
|
runtimePillClass,
|
||||||
|
playerStatusText,
|
||||||
|
autoScanButtonTitle,
|
||||||
|
autoScanButtonLabel,
|
||||||
|
manualScanButtonTitle,
|
||||||
|
captureSingleButtonTitle,
|
||||||
|
refreshCaptureSourcesTitle,
|
||||||
|
diagnosticsButtonTitle,
|
||||||
|
scanSetupButtonTitle,
|
||||||
|
showPlayerProgress,
|
||||||
|
progressWidth: scanProgressPercent,
|
||||||
|
progressStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { FieldConfidenceList } from "../ScanResultCards";
|
||||||
|
import type { ScanDetailsModalProps } from "./types";
|
||||||
|
import { useScanDetailsModalModel } from "./hooks/useScanDetailsModalModel";
|
||||||
|
|
||||||
|
export function ScanDetailsModal({
|
||||||
|
open,
|
||||||
|
latestCapture,
|
||||||
|
controller,
|
||||||
|
setDetailsOpen,
|
||||||
|
}: ScanDetailsModalProps) {
|
||||||
|
const { parsedArtifact } = controller;
|
||||||
|
const {
|
||||||
|
closeDetails,
|
||||||
|
stopPropagation,
|
||||||
|
parsedNotes,
|
||||||
|
showParsedNotes,
|
||||||
|
cropRows,
|
||||||
|
ocrRows,
|
||||||
|
debugText,
|
||||||
|
showCrops,
|
||||||
|
showOcr,
|
||||||
|
} = useScanDetailsModalModel({
|
||||||
|
setDetailsOpen,
|
||||||
|
parsedArtifact,
|
||||||
|
latestCapture,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open || !latestCapture) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={closeDetails}>
|
||||||
|
<div className="modal-panel" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Dev</p>
|
||||||
|
<h2>Crops, OCR & Confidence</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button" onClick={closeDetails}>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{parsedArtifact && (
|
||||||
|
<>
|
||||||
|
<FieldConfidenceList parsedArtifact={parsedArtifact} />
|
||||||
|
{showParsedNotes && (
|
||||||
|
<div className="parsed-notes compact">
|
||||||
|
{parsedNotes.map((note) => <span key={note}>{note}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showCrops && (
|
||||||
|
<div className="crop-grid details-grid">
|
||||||
|
{cropRows.map((crop) => (
|
||||||
|
<div className="crop-card" key={crop.id}>
|
||||||
|
<img src={crop.dataUrl} alt={crop.label} />
|
||||||
|
<div>
|
||||||
|
<strong>{crop.label}</strong>
|
||||||
|
<span>{crop.x},{crop.y} - {crop.width}x{crop.height}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showOcr && (
|
||||||
|
<div className="ocr-panel">
|
||||||
|
<strong>OCR candidates</strong>
|
||||||
|
{ocrRows.map((entry) => (
|
||||||
|
<div className="ocr-row" key={entry.id}>
|
||||||
|
<div>
|
||||||
|
<span>{entry.label}</span>
|
||||||
|
<small>{entry.confidence}% confidence</small>
|
||||||
|
</div>
|
||||||
|
<pre>{entry.text}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="capture-debug">{debugText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { AlertTriangle, Eye, Wrench } from "lucide-react";
|
||||||
|
import type { ScanDiagnosticsModalProps } from "./types";
|
||||||
|
import { useScanDiagnosticsModalModel } from "./hooks/useScanDiagnosticsModalModel";
|
||||||
|
|
||||||
|
export function ScanDiagnosticsModal({
|
||||||
|
open,
|
||||||
|
captureStatus,
|
||||||
|
latestCapture,
|
||||||
|
controller,
|
||||||
|
setDetailsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
}: ScanDiagnosticsModalProps) {
|
||||||
|
const {
|
||||||
|
toggleDevMode,
|
||||||
|
} = controller;
|
||||||
|
|
||||||
|
const {
|
||||||
|
closeDiagnostics,
|
||||||
|
openDetails,
|
||||||
|
handleSaveReviewSample,
|
||||||
|
stopPropagation,
|
||||||
|
canOpenDetails,
|
||||||
|
statusTitle,
|
||||||
|
rightsClassName,
|
||||||
|
rightsValue,
|
||||||
|
genshinClassName,
|
||||||
|
genshinValue,
|
||||||
|
shouldShowAdminBanner,
|
||||||
|
gridSourceClass,
|
||||||
|
gridMainValue,
|
||||||
|
gridMetaValue,
|
||||||
|
learningRulesText,
|
||||||
|
learningRulesSubtext,
|
||||||
|
autoScanModeLabel,
|
||||||
|
autoScanRunning,
|
||||||
|
fingerprintText,
|
||||||
|
runtimeRows,
|
||||||
|
scanLimitText,
|
||||||
|
scanTipText,
|
||||||
|
autoScanStatsLines,
|
||||||
|
playerProgress,
|
||||||
|
showDevRows,
|
||||||
|
reviewStatus,
|
||||||
|
automationLogLines,
|
||||||
|
canSaveReviewSample,
|
||||||
|
} = useScanDiagnosticsModalModel({
|
||||||
|
setDetailsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
saveReviewSample: controller.saveReviewSample,
|
||||||
|
canSaveReviewSample: controller.canSaveReviewSample,
|
||||||
|
latestCapture,
|
||||||
|
controller,
|
||||||
|
captureStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={closeDiagnostics}>
|
||||||
|
<div className="modal-panel scanner-diagnostics-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Scanner Diagnose</p>
|
||||||
|
<h2>Input, Grid & Lernstatus</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button" onClick={closeDiagnostics}>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="diagnostics-actions">
|
||||||
|
<button className="ghost-button" onClick={toggleDevMode}>
|
||||||
|
<Wrench size={15} />
|
||||||
|
{statusTitle}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scanner-preflight diagnostics-preflight">
|
||||||
|
<div className={rightsClassName}>
|
||||||
|
<span>App-Rechte</span>
|
||||||
|
<strong>{rightsValue}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={genshinClassName}>
|
||||||
|
<span>Genshin</span>
|
||||||
|
<strong>{genshinValue}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shouldShowAdminBanner && (
|
||||||
|
<p className="scanner-subcopy">
|
||||||
|
App laeuft im Standard-Modus. Auto-Scan braucht Administrator-Rechte: App schliessen und als Administrator neu starten (z.B. Terminal per Rechtsklick "Als Administrator ausfuehren" und darin "npm run dev").
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`grid-detection-strip ${gridSourceClass}`}>
|
||||||
|
<span>Tile grid</span>
|
||||||
|
<strong>{gridMainValue}</strong>
|
||||||
|
<small>{gridMetaValue}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="learning-strip">
|
||||||
|
<span>Learning</span>
|
||||||
|
<strong>{learningRulesText}</strong>
|
||||||
|
<small>{learningRulesSubtext}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playerProgress.show && (
|
||||||
|
<div className="auto-scan-strip">
|
||||||
|
<span>{autoScanModeLabel}</span>
|
||||||
|
{autoScanStatsLines.map((entry) => (
|
||||||
|
<span className="auto-scan-stat" key={entry.label}>
|
||||||
|
<strong>{entry.value}</strong>
|
||||||
|
<small>{entry.label}</small>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="learning-strip">
|
||||||
|
<span>Fingerprint</span>
|
||||||
|
<strong>{fingerprintText}</strong>
|
||||||
|
<small>Active capture fingerprint used for deterministic duplicate guard checks.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDevRows && (
|
||||||
|
<div className="scanner-status-row diagnostics-status">
|
||||||
|
{runtimeRows.map((row) => (
|
||||||
|
<span key={row}>{row}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{autoScanRunning && playerProgress.show && (
|
||||||
|
<div className="player-progress">
|
||||||
|
<div className="player-progress-bar">
|
||||||
|
<div style={{ width: `${playerProgress.width}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewStatus && (
|
||||||
|
<p className="review-status">{reviewStatus}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="automation-log">
|
||||||
|
<span>Automation</span>
|
||||||
|
<div className="automation-log-lines">
|
||||||
|
{automationLogLines.length > 0 ? (
|
||||||
|
automationLogLines.map((line, index) => (
|
||||||
|
<span key={`${index}-${line}`}>{line}</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<strong>No scan activity yet.</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dev-section-actions">
|
||||||
|
<button className="ghost-button" onClick={openDetails} disabled={!canOpenDetails}>
|
||||||
|
<Eye size={15} />
|
||||||
|
Crops, OCR & Confidence
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={handleSaveReviewSample} disabled={!canSaveReviewSample}>
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
Review-Sample speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="scanner-result-caption">{scanLimitText}</p>
|
||||||
|
<p className="scan-summary-copy">{scanTipText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { ReviewSampleCard } from "../ScanResultCards";
|
||||||
|
import type { ScanReviewQueueModalProps } from "./types";
|
||||||
|
import { useScanReviewQueueModalModel } from "./hooks/useScanReviewQueueModalModel";
|
||||||
|
|
||||||
|
export function ScanReviewQueueModal({
|
||||||
|
open,
|
||||||
|
controller,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
}: ScanReviewQueueModalProps) {
|
||||||
|
const { reviewSamples, reviewSampleTotal, reviewAnalysis, canReadReviewQueue, loadReviewQueue } = controller;
|
||||||
|
const {
|
||||||
|
closeReviewQueue,
|
||||||
|
refreshReviewQueue,
|
||||||
|
stopPropagation,
|
||||||
|
emptyText,
|
||||||
|
reviewAnalysisRows,
|
||||||
|
showReviewAnalysis,
|
||||||
|
} = useScanReviewQueueModalModel({
|
||||||
|
setReviewQueueOpen,
|
||||||
|
loadReviewQueue,
|
||||||
|
reviewAnalysis,
|
||||||
|
reviewSampleTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={closeReviewQueue}>
|
||||||
|
<div className="modal-panel review-queue-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Review Queue</p>
|
||||||
|
<h2>Unsichere Scanner-Faelle</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button" onClick={closeReviewQueue}>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="review-queue-summary">
|
||||||
|
<strong>{reviewSampleTotal}</strong>
|
||||||
|
<span>Samples gespeichert</span>
|
||||||
|
<button className="ghost-button" onClick={refreshReviewQueue} disabled={!canReadReviewQueue}>Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
{showReviewAnalysis && (
|
||||||
|
<div className="review-analysis">
|
||||||
|
{reviewAnalysisRows.map((row) => (
|
||||||
|
<div key={row.label}>
|
||||||
|
<span>{row.label}</span><strong>{row.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reviewSamples.length === 0 ? (
|
||||||
|
<div className="empty-stage compact">
|
||||||
|
<AlertTriangle size={22} />
|
||||||
|
<strong>{emptyText}</strong>
|
||||||
|
<span>Unsichere Auto-Scan- oder OCR-Faelle erscheinen hier automatisch.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="review-sample-list">
|
||||||
|
{reviewSamples.map((entry, index) => (
|
||||||
|
<ReviewSampleCard entry={entry} key={`${entry.savedAt}-${index}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ScanSettingsModalProps } from "./types";
|
||||||
|
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
|
||||||
|
|
||||||
|
export function ScanSettingsModal({
|
||||||
|
open,
|
||||||
|
latestCapture,
|
||||||
|
controller,
|
||||||
|
setSettingsOpen,
|
||||||
|
}: ScanSettingsModalProps) {
|
||||||
|
const {
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
runtimeInfo,
|
||||||
|
activeTargetCount,
|
||||||
|
} = controller;
|
||||||
|
|
||||||
|
const {
|
||||||
|
closeSettings,
|
||||||
|
handleScanLimitChange,
|
||||||
|
handleSkipRowsChange,
|
||||||
|
stopPropagation,
|
||||||
|
inventoryCountText,
|
||||||
|
inventoryClassName,
|
||||||
|
scanLimitClassName,
|
||||||
|
skipRowsClassName,
|
||||||
|
activeTargetText,
|
||||||
|
scanSummaryText,
|
||||||
|
} = useScanSettingsModalModel({
|
||||||
|
setSettingsOpen,
|
||||||
|
setScanLimit: controller.setScanLimit,
|
||||||
|
setScanLimitTouched: controller.setScanLimitTouched,
|
||||||
|
setSkipRows: controller.setSkipRows,
|
||||||
|
latestCapture,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
activeTargetCount,
|
||||||
|
runtimeInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={closeSettings}>
|
||||||
|
<div className="modal-panel scanner-settings-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Scan-Setup</p>
|
||||||
|
<h2>Operator-Einstellungen</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button" onClick={closeSettings}>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="scan-config-strip">
|
||||||
|
<label>
|
||||||
|
<span>Anzahl Artifacts</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1800}
|
||||||
|
value={scanLimit}
|
||||||
|
onChange={handleScanLimitChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Zeilen ueberspringen</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={8}
|
||||||
|
value={skipRows}
|
||||||
|
onChange={handleSkipRowsChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p>
|
||||||
|
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
|
||||||
|
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="scanner-preflight diagnostics-preflight">
|
||||||
|
<div className={inventoryClassName}>
|
||||||
|
<span>Inventarzaehler</span>
|
||||||
|
<strong>{inventoryCountText}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={scanLimitClassName}>
|
||||||
|
<span>Limit</span>
|
||||||
|
<strong>{scanLimit}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={skipRowsClassName}>
|
||||||
|
<span>Skip</span>
|
||||||
|
<strong>{skipRows}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{activeTargetText}</p>
|
||||||
|
</div>
|
||||||
|
<p className="scan-summary-copy">{scanSummaryText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Play, AlertTriangle } from "lucide-react";
|
||||||
|
import { ScanSummaryFooter } from "../ScanResultCards";
|
||||||
|
import type { ScanSummaryModalProps } from "./types";
|
||||||
|
import { useScanSummaryModalModel } from "./hooks/useScanSummaryModalModel";
|
||||||
|
|
||||||
|
export function ScanSummaryModal({
|
||||||
|
open,
|
||||||
|
controller,
|
||||||
|
setScanSummary,
|
||||||
|
devMode,
|
||||||
|
}: ScanSummaryModalProps) {
|
||||||
|
const { closeSummary, stopPropagation, iconKind, summaryTitle, gridSummaryText } = useScanSummaryModalModel({
|
||||||
|
setScanSummary,
|
||||||
|
scanSummary: controller.scanSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open || !controller.scanSummary) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop scan-summary-backdrop" role="presentation" onClick={closeSummary}>
|
||||||
|
<div className="scan-summary-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||||
|
<div className="scan-summary-icon">
|
||||||
|
{iconKind === "blocked" ? <AlertTriangle size={24} /> : <Play size={24} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Scan-Ergebnis</p>
|
||||||
|
<h2>{summaryTitle}</h2>
|
||||||
|
{gridSummaryText && <p className="scan-summary-copy">{gridSummaryText}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="scan-summary-grid player">
|
||||||
|
<div><strong>{controller.scanSummary.stored}</strong><span>neu gespeichert</span></div>
|
||||||
|
<div><strong>{controller.scanSummary.review}</strong><span>unsicher (Review)</span></div>
|
||||||
|
<div><strong>{controller.scanSummary.duplicates}</strong><span>Duplikate</span></div>
|
||||||
|
<div><strong>{controller.scanSummary.verified}</strong><span>verifizierte Ansichten</span></div>
|
||||||
|
</div>
|
||||||
|
<ScanSummaryFooter devMode={devMode} scanSummary={controller.scanSummary} storedTotal={controller.storedTotal} />
|
||||||
|
<button className="primary-button" onClick={closeSummary}>Ok</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, type MouseEvent } from "react";
|
||||||
|
import type { ScanDetailsModalProps } from "../types";
|
||||||
|
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
|
||||||
|
|
||||||
|
export interface ScanDetailsModalModel {
|
||||||
|
closeDetails: () => void;
|
||||||
|
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
parsedNotes: string[];
|
||||||
|
showParsedNotes: boolean;
|
||||||
|
cropRows: Array<{ id: string; dataUrl: string; label: string; x: number; y: number; width: number; height: number }>;
|
||||||
|
ocrRows: Array<{ id: string; label: string; confidence: number; text: string }>;
|
||||||
|
debugText: string;
|
||||||
|
showCrops: boolean;
|
||||||
|
showOcr: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScanDetailsModalModelInput {
|
||||||
|
setDetailsOpen: ScanDetailsModalProps["setDetailsOpen"];
|
||||||
|
parsedArtifact: ScanDetailsModalProps["controller"]["parsedArtifact"];
|
||||||
|
latestCapture: ScanDetailsModalProps["latestCapture"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanDetailsModalModel({
|
||||||
|
setDetailsOpen,
|
||||||
|
parsedArtifact,
|
||||||
|
latestCapture,
|
||||||
|
}: UseScanDetailsModalModelInput): ScanDetailsModalModel {
|
||||||
|
const closeDetails = useCallback(() => setDetailsOpen(false), [setDetailsOpen]);
|
||||||
|
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
const parsedNotes = (parsedArtifact?.notes ?? []) as ParsedArtifactCandidate["notes"];
|
||||||
|
|
||||||
|
const crops = latestCapture?.crops ?? [];
|
||||||
|
const ocr = latestCapture?.ocr ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeDetails,
|
||||||
|
stopPropagation,
|
||||||
|
parsedNotes,
|
||||||
|
showParsedNotes: parsedNotes.length > 0,
|
||||||
|
cropRows: crops.map((crop) => ({
|
||||||
|
id: crop.id,
|
||||||
|
dataUrl: crop.dataUrl,
|
||||||
|
label: crop.label,
|
||||||
|
x: crop.rect.x,
|
||||||
|
y: crop.rect.y,
|
||||||
|
width: crop.rect.width,
|
||||||
|
height: crop.rect.height,
|
||||||
|
})),
|
||||||
|
ocrRows: ocr.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
confidence: entry.confidence,
|
||||||
|
text: entry.text || "No text detected",
|
||||||
|
})),
|
||||||
|
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
|
||||||
|
showCrops: crops.length > 0,
|
||||||
|
showOcr: ocr.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { detailFingerprint } from "../../../../lib/autoScanLoop";
|
||||||
|
import { sourceVersion } from "../../../../lib/genshinData";
|
||||||
|
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
|
import type { ScanDiagnosticsModalProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanDiagnosticsModelProgress {
|
||||||
|
width: number;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanDiagnosticsModalModel {
|
||||||
|
closeDiagnostics: () => void;
|
||||||
|
openDetails: () => void;
|
||||||
|
handleSaveReviewSample: () => void;
|
||||||
|
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
canOpenDetails: boolean;
|
||||||
|
statusTitle: string;
|
||||||
|
rightsClassName: string;
|
||||||
|
rightsValue: string;
|
||||||
|
genshinClassName: string;
|
||||||
|
genshinValue: string;
|
||||||
|
shouldShowAdminBanner: boolean;
|
||||||
|
gridSourceClass: string;
|
||||||
|
gridMainValue: string;
|
||||||
|
gridMetaValue: string;
|
||||||
|
learningRulesText: string;
|
||||||
|
learningRulesSubtext: string;
|
||||||
|
autoScanModeLabel: string;
|
||||||
|
fingerprintText: string;
|
||||||
|
runtimeRows: string[];
|
||||||
|
scanLimitText: string;
|
||||||
|
scanTipText: string;
|
||||||
|
autoScanStatsLines: Array<{ label: string; value: number }>;
|
||||||
|
playerProgress: ScanDiagnosticsModelProgress;
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
showDevRows: boolean;
|
||||||
|
reviewStatus: string;
|
||||||
|
automationLogLines: string[];
|
||||||
|
canSaveReviewSample: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScanDiagnosticsModalModelInput extends Pick<
|
||||||
|
ScanDiagnosticsModalProps,
|
||||||
|
| "setDetailsOpen"
|
||||||
|
| "setDiagnosticsOpen"
|
||||||
|
| "saveReviewSample"
|
||||||
|
| "canSaveReviewSample"
|
||||||
|
| "latestCapture"
|
||||||
|
| "controller"
|
||||||
|
> {
|
||||||
|
captureStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanDiagnosticsModalModel({
|
||||||
|
setDetailsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
saveReviewSample,
|
||||||
|
canSaveReviewSample,
|
||||||
|
latestCapture,
|
||||||
|
controller,
|
||||||
|
captureStatus,
|
||||||
|
}: UseScanDiagnosticsModalModelInput): ScanDiagnosticsModalModel {
|
||||||
|
const closeDiagnostics = useCallback(() => setDiagnosticsOpen(false), [setDiagnosticsOpen]);
|
||||||
|
const openDetails = useCallback(() => setDetailsOpen(true), [setDetailsOpen]);
|
||||||
|
const handleSaveReviewSample = useCallback(() => {
|
||||||
|
if (canSaveReviewSample && controller.parsedArtifact) {
|
||||||
|
void saveReviewSample();
|
||||||
|
}
|
||||||
|
}, [canSaveReviewSample, controller.parsedArtifact, saveReviewSample]);
|
||||||
|
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
|
||||||
|
const shouldShowAdminBanner = !controller.runtimeInfo?.isElevated;
|
||||||
|
const rightsClassName = controller.runtimeInfo?.isElevated ? "ok" : "blocked";
|
||||||
|
const rightsValue = controller.runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||||
|
const genshinClassName = controller.runtimeInfo?.genshinFound ? "ok" : "neutral";
|
||||||
|
const genshinValue = controller.runtimeInfo?.genshinFound ? "Gefunden" : "Nicht gefunden";
|
||||||
|
const statusTitle = controller.devMode ? "Dev-Ausgabe kompakt" : "Dev-Ausgabe erweitern";
|
||||||
|
const autoScanModeLabel = controller.autoScanRunning ? "Scan active" : "Last scan";
|
||||||
|
|
||||||
|
const inventoryGrid = latestCapture?.inventoryGrid;
|
||||||
|
const gridSourceClass = inventoryGrid?.source ?? "missing";
|
||||||
|
const gridMainValue = inventoryGrid
|
||||||
|
? inventoryGrid?.source === "missing"
|
||||||
|
? "not detected"
|
||||||
|
: `${inventoryGrid?.cols} x ${inventoryGrid?.rows}`
|
||||||
|
: "waiting";
|
||||||
|
const gridMetaValue = inventoryGrid
|
||||||
|
? `${inventoryGrid?.confidence}% confidence - ${inventoryGrid?.centers.length} click targets`
|
||||||
|
: "Run a capture once while the artifact inventory is visible.";
|
||||||
|
|
||||||
|
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
|
||||||
|
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`;
|
||||||
|
|
||||||
|
const playerProgress = useMemo(() => {
|
||||||
|
const width = Math.min(
|
||||||
|
100,
|
||||||
|
controller.autoScanStats.attempted > 0
|
||||||
|
? Math.round((controller.autoScanStats.verified / Math.max(1, controller.autoScanStats.attempted)) * 100)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
const show = controller.autoScanRunning || controller.autoScanStats.clicked > 0 || controller.autoScanStats.parsed > 0;
|
||||||
|
return { width, show };
|
||||||
|
}, [
|
||||||
|
controller.autoScanRunning,
|
||||||
|
controller.autoScanStats.attempted,
|
||||||
|
controller.autoScanStats.clicked,
|
||||||
|
controller.autoScanStats.parsed,
|
||||||
|
controller.autoScanStats.verified,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const autoScanStatsLines = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "clicked", value: controller.autoScanStats.clicked },
|
||||||
|
{ label: "attempted", value: controller.autoScanStats.attempted },
|
||||||
|
{ label: "verified", value: controller.autoScanStats.verified },
|
||||||
|
{ label: "parsed", value: controller.autoScanStats.parsed },
|
||||||
|
{ label: "stored", value: controller.autoScanStats.stored },
|
||||||
|
{ label: "review", value: controller.autoScanStats.review },
|
||||||
|
{ label: "duplicates", value: controller.autoScanStats.duplicates },
|
||||||
|
{ label: "misses", value: controller.autoScanStats.misses },
|
||||||
|
{ label: "pages", value: controller.autoScanStats.pages },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
controller.autoScanStats.clicked,
|
||||||
|
controller.autoScanStats.attempted,
|
||||||
|
controller.autoScanStats.verified,
|
||||||
|
controller.autoScanStats.parsed,
|
||||||
|
controller.autoScanStats.stored,
|
||||||
|
controller.autoScanStats.review,
|
||||||
|
controller.autoScanStats.duplicates,
|
||||||
|
controller.autoScanStats.misses,
|
||||||
|
controller.autoScanStats.pages,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeRows = useMemo(() => {
|
||||||
|
const selectedSourceLabel = controller.selectedSource
|
||||||
|
? `Selected: ${controller.selectedSource.name}`
|
||||||
|
: "No source selected";
|
||||||
|
const storedTotalLabel = controller.storedTotal !== null ? `DB: ${controller.storedTotal} Artifacts` : "DB: -";
|
||||||
|
const runtime = controller.runtimeInfo
|
||||||
|
? `${controller.runtimeInfo?.isElevated ? "admin" : "standard"} - target ${controller.runtimeInfo?.genshinFound ? controller.runtimeInfo?.targetProcess || "Genshin" : "missing"} - fg ${controller.runtimeInfo?.foregroundProcess || "unknown"}`
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
return [selectedSourceLabel, storedTotalLabel, runtime, captureStatus, controller.reviewStatus].filter(Boolean);
|
||||||
|
}, [controller.selectedSource, controller.storedTotal, controller.runtimeInfo, captureStatus, controller.reviewStatus]);
|
||||||
|
|
||||||
|
const fingerprintText = latestCapture ? detailFingerprint(latestCapture) : "--";
|
||||||
|
const scanLimitText = controller.scanLimit ? `Scan-Limit: ${controller.scanLimit}` : "Keine Limitinfo.";
|
||||||
|
const scanTipText = "Tip: Die Auto-Scan Logik bleibt aktivierbar, aber Dev-Ansicht ist rein informativ.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeDiagnostics,
|
||||||
|
openDetails,
|
||||||
|
handleSaveReviewSample,
|
||||||
|
stopPropagation,
|
||||||
|
canOpenDetails,
|
||||||
|
statusTitle,
|
||||||
|
rightsClassName,
|
||||||
|
rightsValue,
|
||||||
|
genshinClassName,
|
||||||
|
genshinValue,
|
||||||
|
shouldShowAdminBanner,
|
||||||
|
gridSourceClass,
|
||||||
|
gridMainValue,
|
||||||
|
gridMetaValue,
|
||||||
|
learningRulesText,
|
||||||
|
learningRulesSubtext,
|
||||||
|
autoScanModeLabel,
|
||||||
|
fingerprintText,
|
||||||
|
runtimeRows,
|
||||||
|
scanLimitText,
|
||||||
|
scanTipText,
|
||||||
|
autoScanStatsLines,
|
||||||
|
playerProgress,
|
||||||
|
autoScanRunning: controller.autoScanRunning,
|
||||||
|
showDevRows: controller.devMode,
|
||||||
|
reviewStatus: controller.reviewStatus,
|
||||||
|
automationLogLines: controller.automationLog,
|
||||||
|
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
|
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
|
||||||
|
import type { ScanReviewQueueModalProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanReviewQueueRow {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanReviewQueueModalModel {
|
||||||
|
closeReviewQueue: () => void;
|
||||||
|
refreshReviewQueue: () => void;
|
||||||
|
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
emptyText: string;
|
||||||
|
reviewAnalysisRows: ScanReviewQueueRow[];
|
||||||
|
showReviewAnalysis: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScanReviewQueueModalModelInput extends Pick<
|
||||||
|
ScanReviewQueueModalProps,
|
||||||
|
"setReviewQueueOpen" | "loadReviewQueue"
|
||||||
|
> {
|
||||||
|
reviewAnalysis: ReviewSampleAnalysis;
|
||||||
|
reviewSampleTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanReviewQueueModalModel({
|
||||||
|
setReviewQueueOpen,
|
||||||
|
loadReviewQueue,
|
||||||
|
reviewAnalysis,
|
||||||
|
reviewSampleTotal,
|
||||||
|
}: UseScanReviewQueueModalModelInput): ScanReviewQueueModalModel {
|
||||||
|
const closeReviewQueue = useCallback(() => setReviewQueueOpen(false), [setReviewQueueOpen]);
|
||||||
|
const refreshReviewQueue = useCallback(() => {
|
||||||
|
void loadReviewQueue();
|
||||||
|
}, [loadReviewQueue]);
|
||||||
|
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const weakFieldsSummary = useMemo(
|
||||||
|
() => reviewAnalysis.weakFields.slice(0, 3).map((entry) => `${entry.field} ${entry.count}`).join(", "),
|
||||||
|
[reviewAnalysis.weakFields],
|
||||||
|
);
|
||||||
|
const topReasonsSummary = useMemo(
|
||||||
|
() => reviewAnalysis.reasons.slice(0, 2).map((entry) => `${entry.reason} ${entry.count}`).join(", "),
|
||||||
|
[reviewAnalysis.reasons],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewAnalysisRows = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Parsed", value: `${reviewAnalysis.withParsed}/${reviewAnalysis.total}` },
|
||||||
|
{ label: "Avg confidence", value: `${reviewAnalysis.averageConfidence}%` },
|
||||||
|
{ label: "Weak fields", value: weakFieldsSummary || "none" },
|
||||||
|
{ label: "Top reasons", value: topReasonsSummary || "none" },
|
||||||
|
],
|
||||||
|
[reviewAnalysis.averageConfidence, reviewAnalysis.total, reviewAnalysis.withParsed, topReasonsSummary, weakFieldsSummary],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeReviewQueue,
|
||||||
|
refreshReviewQueue,
|
||||||
|
stopPropagation,
|
||||||
|
emptyText: reviewSampleTotal > 0 ? "none" : "Keine Review-Samples",
|
||||||
|
reviewAnalysisRows,
|
||||||
|
showReviewAnalysis: reviewSampleTotal > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
||||||
|
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
||||||
|
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
|
||||||
|
|
||||||
|
export interface ScanSettingsModalModel {
|
||||||
|
closeSettings: () => void;
|
||||||
|
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
inventoryCountText: string;
|
||||||
|
inventoryClassName: string;
|
||||||
|
scanLimitClassName: string;
|
||||||
|
skipRowsClassName: string;
|
||||||
|
activeTargetText: string;
|
||||||
|
scanSummaryText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanSettingsModalModel({
|
||||||
|
setSettingsOpen,
|
||||||
|
setScanLimit,
|
||||||
|
setScanLimitTouched,
|
||||||
|
setSkipRows,
|
||||||
|
latestCapture,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
activeTargetCount,
|
||||||
|
runtimeInfo,
|
||||||
|
}: {
|
||||||
|
latestCapture: CaptureResult | null;
|
||||||
|
scanLimit: number;
|
||||||
|
skipRows: number;
|
||||||
|
activeTargetCount: number;
|
||||||
|
runtimeInfo: RuntimeInfo | null;
|
||||||
|
setSettingsOpen: (open: boolean) => void;
|
||||||
|
setScanLimit: (value: number) => void;
|
||||||
|
setScanLimitTouched: (touched: boolean) => void;
|
||||||
|
setSkipRows: (rows: number) => void;
|
||||||
|
}): ScanSettingsModalModel {
|
||||||
|
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
||||||
|
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setScanLimitTouched(true);
|
||||||
|
setScanLimit(clampScanLimit(Number(event.target.value)));
|
||||||
|
}, [setScanLimit, setScanLimitTouched]);
|
||||||
|
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSkipRows(clampSkipRows(Number(event.target.value)));
|
||||||
|
}, [setSkipRows]);
|
||||||
|
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
|
||||||
|
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
|
||||||
|
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
|
||||||
|
const inventoryCountText = detectedInventoryCount
|
||||||
|
? `${detectedInventoryCount}/${detectedInventoryTotal || "?"}`
|
||||||
|
: "Noch nicht erkannt";
|
||||||
|
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
|
||||||
|
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
|
||||||
|
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||||
|
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
|
||||||
|
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeSettings,
|
||||||
|
handleScanLimitChange,
|
||||||
|
handleSkipRowsChange,
|
||||||
|
stopPropagation,
|
||||||
|
inventoryCountText,
|
||||||
|
inventoryClassName,
|
||||||
|
scanLimitClassName,
|
||||||
|
skipRowsClassName,
|
||||||
|
activeTargetText,
|
||||||
|
scanSummaryText,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, type MouseEvent } from "react";
|
||||||
|
import type { ScanSummaryModalProps } from "../types";
|
||||||
|
|
||||||
|
export interface ScanSummaryModalModel {
|
||||||
|
closeSummary: () => void;
|
||||||
|
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
iconKind: "running" | "blocked" | "stopped" | "finished";
|
||||||
|
summaryTitle: string;
|
||||||
|
gridSummaryText: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScanSummaryModalModelInput extends Pick<ScanSummaryModalProps, "setScanSummary"> {
|
||||||
|
scanSummary: ScanSummaryModalProps["controller"]["scanSummary"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanSummaryModalModel({
|
||||||
|
setScanSummary,
|
||||||
|
scanSummary,
|
||||||
|
}: UseScanSummaryModalModelInput): ScanSummaryModalModel {
|
||||||
|
const closeSummary = useCallback(() => setScanSummary(null), [setScanSummary]);
|
||||||
|
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
const isBlocked = scanSummary?.status === "blocked";
|
||||||
|
const isStopped = scanSummary?.status === "stopped";
|
||||||
|
const summaryTitle = isStopped ? "Scan gestoppt" : isBlocked ? "Scan abgebrochen" : "Scan fertig";
|
||||||
|
const iconKind = isBlocked ? "blocked" : isStopped ? "stopped" : "finished";
|
||||||
|
const gridSummaryText = scanSummary?.gridLabel ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeSummary,
|
||||||
|
stopPropagation,
|
||||||
|
iconKind,
|
||||||
|
summaryTitle,
|
||||||
|
gridSummaryText,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { ScanViewProps, ScanViewControllerResult } from "../../types";
|
||||||
|
|
||||||
|
export interface ScanDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
controller: Pick<ScanViewControllerResult, "parsedArtifact">;
|
||||||
|
setDetailsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanDiagnosticsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
captureStatus: string;
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
setDetailsOpen: (open: boolean) => void;
|
||||||
|
setDiagnosticsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanReviewQueueModalProps {
|
||||||
|
open: boolean;
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
setReviewQueueOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
setSettingsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSummaryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
controller: Pick<ScanViewControllerResult, "scanSummary" | "storedTotal">;
|
||||||
|
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
|
||||||
|
devMode: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import type { ReviewSampleRecord } from "../../../types/global";
|
||||||
|
import type { ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import type { ScanViewControllerResult, ScanViewProps } from "../types";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export interface FieldConfidenceListProps {
|
||||||
|
parsedArtifact: ParsedArtifactCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactResultCardProps {
|
||||||
|
parsed: ParsedArtifactCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSampleCardProps {
|
||||||
|
entry: ReviewSampleRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSummaryFooterProps {
|
||||||
|
devMode: boolean;
|
||||||
|
scanSummary: ScanSummary;
|
||||||
|
storedTotal: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanViewLayoutProps {
|
||||||
|
captureSources: ScanViewProps["captureSources"];
|
||||||
|
selectedSourceId: string;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
captureStatus: string;
|
||||||
|
refreshCaptureSources: () => Promise<void>;
|
||||||
|
captureSelectedSource: ScanViewProps["captureSelectedSource"];
|
||||||
|
bridgeReady: boolean;
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
isScanning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanMainSectionProps {
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
captureStatus: string;
|
||||||
|
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
|
||||||
|
sourceLabel: string;
|
||||||
|
gridLabel: string;
|
||||||
|
inventoryLabel: string;
|
||||||
|
activeTargetCount: number;
|
||||||
|
storedTotal: ScanViewControllerResult["storedTotal"];
|
||||||
|
reviewSampleTotal: number;
|
||||||
|
learningRulesLoaded: ScanViewControllerResult["learningRulesLoaded"];
|
||||||
|
learningRuleCount: number;
|
||||||
|
canOpenReviewQueue: boolean;
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
openReviewQueue: () => void;
|
||||||
|
setDetailsOpen: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanTopControlsSectionProps {
|
||||||
|
captureSources: ScanViewProps["captureSources"];
|
||||||
|
selectedSourceId: string;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
refreshCaptureSources: () => Promise<void>;
|
||||||
|
captureSelectedSource: ScanViewProps["captureSelectedSource"];
|
||||||
|
bridgeReady: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanModalsSectionProps {
|
||||||
|
captureStatus: string;
|
||||||
|
latestCapture: ScanViewProps["latestCapture"];
|
||||||
|
diagnosticsOpen: boolean;
|
||||||
|
settingsOpen: boolean;
|
||||||
|
detailsOpen: boolean;
|
||||||
|
reviewQueueOpen: boolean;
|
||||||
|
controller: ScanViewControllerResult;
|
||||||
|
setDiagnosticsOpen: (open: boolean) => void;
|
||||||
|
setSettingsOpen: (open: boolean) => void;
|
||||||
|
setDetailsOpen: (open: boolean) => void;
|
||||||
|
setReviewQueueOpen: (open: boolean) => void;
|
||||||
|
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { shouldFlagArtifactForReview } from "../../../lib/scannerLearning";
|
||||||
|
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||||
|
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import type { ScanActionContext } from "./scanViewScanActions";
|
||||||
|
import type {
|
||||||
|
ReviewStateContext,
|
||||||
|
} from "./scanViewReviewHelpers";
|
||||||
|
import type { ArtifactRepositoryPort, ReviewSampleRepositoryPort, LearningRepositoryPort, AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord, RuntimeInfo } from "../../../types/global";
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export interface ReviewStateContextInput {
|
||||||
|
artifactRepo?: ArtifactRepositoryPort;
|
||||||
|
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||||
|
learningRepo?: LearningRepositoryPort;
|
||||||
|
onStoredArtifactsChanged?: () => Promise<void> | void;
|
||||||
|
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
|
||||||
|
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||||
|
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||||
|
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||||
|
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||||
|
appendAutomationLog: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanActionContextInput {
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
|
||||||
|
isScanning: boolean;
|
||||||
|
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||||
|
selectedSourceId: string;
|
||||||
|
bridgeReady: boolean;
|
||||||
|
automationRepo?: AutomationRepositoryPort;
|
||||||
|
runtimeInfo?: RuntimeInfo | null;
|
||||||
|
runtimeRepo?: RuntimeRepositoryPort;
|
||||||
|
scanLimit: number;
|
||||||
|
skipRows: number;
|
||||||
|
detectedInventoryCount: number;
|
||||||
|
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||||
|
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
|
||||||
|
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||||
|
appendAutomationLog: (line: string) => void;
|
||||||
|
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||||
|
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||||
|
persistParsedArtifact: (
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
parsed: ParsedArtifactCandidate,
|
||||||
|
source: string,
|
||||||
|
needsReview: boolean,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
saveReviewSample: (
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
parsed: ParsedArtifactCandidate | null,
|
||||||
|
reason?: string,
|
||||||
|
) => Promise<BooleanResult | null>;
|
||||||
|
focusDashboard: () => Promise<void>;
|
||||||
|
captureSelectedSource: (
|
||||||
|
delayMs?: number,
|
||||||
|
focusGenshin?: boolean,
|
||||||
|
options?: CaptureOptions,
|
||||||
|
) => Promise<CaptureResult | null>;
|
||||||
|
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
|
||||||
|
return {
|
||||||
|
artifactRepo: input.artifactRepo,
|
||||||
|
reviewSamplesRepo: input.reviewSamplesRepo,
|
||||||
|
learningRepo: input.learningRepo,
|
||||||
|
onStoredArtifactsChanged: input.onStoredArtifactsChanged,
|
||||||
|
setReviewSampleTotal: input.setReviewSampleTotal,
|
||||||
|
setReviewSamples: input.setReviewSamples,
|
||||||
|
setLearningRulesLoaded: input.setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules: input.setScannerLearningRules,
|
||||||
|
setReviewStatus: input.setReviewStatus,
|
||||||
|
setStoredTotal: input.setStoredTotal,
|
||||||
|
appendAutomationLog: input.appendAutomationLog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScanActionContext(input: ScanActionContextInput): ScanActionContext {
|
||||||
|
return {
|
||||||
|
autoScanRunning: input.autoScanRunning,
|
||||||
|
setAutoScanRunning: input.setAutoScanRunning,
|
||||||
|
isScanning: input.isScanning,
|
||||||
|
stopVisibleScanRef: input.stopVisibleScanRef,
|
||||||
|
selectedSourceId: input.selectedSourceId,
|
||||||
|
bridgeReady: input.bridgeReady,
|
||||||
|
automationRepo: input.automationRepo,
|
||||||
|
runtimeInfo: input.runtimeInfo,
|
||||||
|
runtimeRepo: input.runtimeRepo,
|
||||||
|
scanLimit: input.scanLimit,
|
||||||
|
skipRows: input.skipRows,
|
||||||
|
detectedInventoryCount: input.detectedInventoryCount,
|
||||||
|
setScanSummary: input.setScanSummary,
|
||||||
|
setAutoScanStats: input.setAutoScanStats,
|
||||||
|
setReviewStatus: input.setReviewStatus,
|
||||||
|
appendAutomationLog: input.appendAutomationLog,
|
||||||
|
appendClickDiagnostics: input.appendClickDiagnostics,
|
||||||
|
parseArtifact: input.parseArtifact,
|
||||||
|
persistParsedArtifact: input.persistParsedArtifact,
|
||||||
|
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
|
||||||
|
saveReviewSample: input.saveReviewSample,
|
||||||
|
focusDashboard: input.focusDashboard,
|
||||||
|
captureSelectedSource: input.captureSelectedSource,
|
||||||
|
captureFastSelectedSource: input.captureFastSelectedSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
applyScannerLearningRules,
|
||||||
|
countScannerLearningRules,
|
||||||
|
deriveScannerLearningRules,
|
||||||
|
deriveScannerLearningRulesFromReviewSamples,
|
||||||
|
shouldFlagArtifactForReview,
|
||||||
|
type ScannerLearningRules,
|
||||||
|
} from "../../../lib/scannerLearning";
|
||||||
|
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality";
|
||||||
|
import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore";
|
||||||
|
import {
|
||||||
|
type ArtifactRepositoryPort,
|
||||||
|
type LearningRepositoryPort,
|
||||||
|
type ReviewSampleRepositoryPort,
|
||||||
|
} from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global";
|
||||||
|
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||||
|
|
||||||
|
const REVIEW_SAMPLE_LIMIT_INITIAL = 120;
|
||||||
|
const REVIEW_SAMPLE_LIMIT_QUEUE = 80;
|
||||||
|
|
||||||
|
export interface ReviewStateContext {
|
||||||
|
artifactRepo?: ArtifactRepositoryPort;
|
||||||
|
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||||
|
learningRepo?: LearningRepositoryPort;
|
||||||
|
onStoredArtifactsChanged?: () => Promise<void> | void;
|
||||||
|
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
|
||||||
|
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||||
|
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||||
|
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||||
|
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||||
|
appendAutomationLog: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReviewSamplesForContext(
|
||||||
|
context: ReviewStateContext,
|
||||||
|
limit: number,
|
||||||
|
): Promise<ReturnType<ReviewSampleRepositoryPort["loadSamples"]> | null> {
|
||||||
|
return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) {
|
||||||
|
const result = await loadReviewSamplesForContext(context, limit);
|
||||||
|
if (result?.ok) {
|
||||||
|
context.setReviewSampleTotal(result.total);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLearnedArtifact(
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
scannerLearningRules: ScannerLearningRules,
|
||||||
|
): ParsedArtifactCandidate | null {
|
||||||
|
return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null {
|
||||||
|
const capture = entry.sample?.capture;
|
||||||
|
if (!capture?.ocr) return null;
|
||||||
|
return {
|
||||||
|
id: `review-${entry.savedAt}`,
|
||||||
|
name: capture.name ?? "review-sample",
|
||||||
|
width: capture.width ?? 0,
|
||||||
|
height: capture.height ?? 0,
|
||||||
|
dataUrl: "",
|
||||||
|
capturedAt: capture.capturedAt ?? entry.savedAt,
|
||||||
|
captureTarget: capture.captureTarget,
|
||||||
|
crops: [],
|
||||||
|
inventoryDataUrl: capture.inventoryDataUrl,
|
||||||
|
ocr: capture.ocr,
|
||||||
|
inventoryGrid: capture.inventoryGrid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) {
|
||||||
|
if (!record) return "";
|
||||||
|
return [
|
||||||
|
record.name,
|
||||||
|
record.slot,
|
||||||
|
record.level ?? 0,
|
||||||
|
record.mainStat,
|
||||||
|
record.mainValue,
|
||||||
|
record.setName,
|
||||||
|
record.equipped,
|
||||||
|
(record.substats ?? []).join("|"),
|
||||||
|
record.needsReview ? "review" : "clean",
|
||||||
|
].join("::");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) {
|
||||||
|
if (!existing) return true;
|
||||||
|
if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false;
|
||||||
|
|
||||||
|
const existingStrength = storedArtifactStrength(existing);
|
||||||
|
const incomingStrength = storedArtifactStrength(incoming);
|
||||||
|
const existingSubstats = existing.substats?.length ?? 0;
|
||||||
|
const incomingSubstats = incoming.substats?.length ?? 0;
|
||||||
|
const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped));
|
||||||
|
const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped));
|
||||||
|
|
||||||
|
if (existing.needsReview && !incoming.needsReview) return true;
|
||||||
|
if (incomingSubstats > existingSubstats) return true;
|
||||||
|
if (!existingEquippedKnown && incomingEquippedKnown) return true;
|
||||||
|
if (incomingStrength >= existingStrength + 6) return true;
|
||||||
|
if (isReviewOnlyArtifactSource(existing.source)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
|
||||||
|
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
|
||||||
|
const reviewResult = await loadReviewSamplesAndSetTotal(context, limit);
|
||||||
|
if (reviewResult?.ok) {
|
||||||
|
await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context);
|
||||||
|
}
|
||||||
|
return reviewResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverArtifactsFromReviewSamples(
|
||||||
|
samples: ReviewSampleRecord[],
|
||||||
|
rules: ScannerLearningRules,
|
||||||
|
context: ReviewStateContext,
|
||||||
|
): Promise<number> {
|
||||||
|
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||||
|
if (!artifactRepo?.saveMany || samples.length === 0) return 0;
|
||||||
|
|
||||||
|
const existingResult = await artifactRepo.loadAll().catch(() => null);
|
||||||
|
const existingById = new Map(
|
||||||
|
(existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const recoveredCandidates = samples.flatMap((entry) => {
|
||||||
|
const capture = createCaptureFromReviewSample(entry);
|
||||||
|
const parsed = parseLearnedArtifact(capture, rules);
|
||||||
|
if (!capture || !parsed) return [];
|
||||||
|
if (captureSourceRejectionReason(capture)) return [];
|
||||||
|
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||||
|
if (!shouldPersistParsedArtifact(parsed, needsReview)) return [];
|
||||||
|
const stored = toStoredArtifact(parsed, "review-reprocess", needsReview);
|
||||||
|
return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveredById = new Map<string, StoredArtifactRecord>();
|
||||||
|
for (const record of recoveredCandidates) {
|
||||||
|
const existing = recoveredById.get(record.id);
|
||||||
|
if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) {
|
||||||
|
recoveredById.set(record.id, record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recovered = [...recoveredById.values()];
|
||||||
|
|
||||||
|
if (recovered.length === 0) return 0;
|
||||||
|
const result = await artifactRepo.saveMany(recovered).catch(() => null);
|
||||||
|
if (result?.ok) {
|
||||||
|
setStoredTotal(result.total);
|
||||||
|
void onStoredArtifactsChanged?.();
|
||||||
|
appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`);
|
||||||
|
return result.added + result.updated;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeLearningState(context: ReviewStateContext): Promise<void> {
|
||||||
|
const {
|
||||||
|
learningRepo,
|
||||||
|
setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadedRules = await learningRepo?.loadRules().catch(() => null);
|
||||||
|
const currentRules = getDefaultScannerRules(loadedRules);
|
||||||
|
|
||||||
|
if (countScannerLearningRules(currentRules) > 0) {
|
||||||
|
setScannerLearningRules(currentRules);
|
||||||
|
await loadReviewSamplesAndRecover(context, currentRules);
|
||||||
|
setLearningRulesLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL);
|
||||||
|
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
|
||||||
|
const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules;
|
||||||
|
if (countScannerLearningRules(derived) > 0) {
|
||||||
|
setScannerLearningRules(derived);
|
||||||
|
await learningRepo?.saveRules?.(derived).catch(() => null);
|
||||||
|
} else {
|
||||||
|
setScannerLearningRules(currentRules);
|
||||||
|
}
|
||||||
|
setLearningRulesLoaded(true);
|
||||||
|
} catch {
|
||||||
|
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL);
|
||||||
|
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
|
||||||
|
setScannerLearningRules(derived);
|
||||||
|
if (countScannerLearningRules(derived) > 0) {
|
||||||
|
await learningRepo?.saveRules?.(derived).catch(() => null);
|
||||||
|
}
|
||||||
|
if (reviewResult?.ok) {
|
||||||
|
await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context);
|
||||||
|
}
|
||||||
|
setLearningRulesLoaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeLearningRules(
|
||||||
|
nextRules: Partial<ScannerLearningRules> | null | undefined,
|
||||||
|
currentRules: ScannerLearningRules,
|
||||||
|
context: ReviewStateContext,
|
||||||
|
) {
|
||||||
|
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
|
||||||
|
const merged = {
|
||||||
|
textReplacements: {
|
||||||
|
...currentRules.textReplacements,
|
||||||
|
...(nextRules.textReplacements ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
context.setScannerLearningRules(merged);
|
||||||
|
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||||
|
return { result, merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistParsedArtifact(
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
parsed: ParsedArtifactCandidate,
|
||||||
|
source: string,
|
||||||
|
needsReview: boolean,
|
||||||
|
context: ReviewStateContext,
|
||||||
|
) {
|
||||||
|
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||||
|
if (!artifactRepo?.saveMany) return false;
|
||||||
|
|
||||||
|
const rejection = captureRejectionReason(capture, parsed);
|
||||||
|
if (rejection) {
|
||||||
|
appendAutomationLog(`persist skip: ${rejection}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!shouldPersistParsedArtifact(parsed, needsReview)) {
|
||||||
|
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
|
||||||
|
if (result?.ok) {
|
||||||
|
setStoredTotal(result.total);
|
||||||
|
void onStoredArtifactsChanged?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveReviewSample(
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
parsed: ParsedArtifactCandidate | null,
|
||||||
|
reason: string,
|
||||||
|
scannerLearningRules: ScannerLearningRules,
|
||||||
|
context: ReviewStateContext,
|
||||||
|
): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> {
|
||||||
|
const {
|
||||||
|
reviewSamplesRepo,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
setReviewSamples,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
if (!reviewSamplesRepo?.saveSample) {
|
||||||
|
setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar.");
|
||||||
|
return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||||
|
}
|
||||||
|
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||||
|
|
||||||
|
const result = await reviewSamplesRepo.saveSample({
|
||||||
|
reason,
|
||||||
|
capture: {
|
||||||
|
id: capture.id,
|
||||||
|
name: capture.name,
|
||||||
|
width: capture.width,
|
||||||
|
height: capture.height,
|
||||||
|
dataUrl: capture.dataUrl,
|
||||||
|
detailDataUrl: capture.detailDataUrl,
|
||||||
|
inventoryDataUrl: capture.inventoryDataUrl,
|
||||||
|
captureTarget: capture.captureTarget,
|
||||||
|
capturedAt: capture.capturedAt,
|
||||||
|
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||||
|
id: crop.id,
|
||||||
|
label: crop.label,
|
||||||
|
rect: crop.rect,
|
||||||
|
dataUrl: crop.dataUrl,
|
||||||
|
})),
|
||||||
|
inventoryGrid: capture.inventoryGrid,
|
||||||
|
inventoryCount: capture.inventoryCount,
|
||||||
|
ocr: capture.ocr,
|
||||||
|
},
|
||||||
|
parsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const learned = deriveScannerLearningRules(capture, parsed);
|
||||||
|
const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context);
|
||||||
|
const learnedCount = countScannerLearningRules(learned);
|
||||||
|
const activeRules = learnedResult?.merged ?? scannerLearningRules;
|
||||||
|
const recoveredParsed = parseLearnedArtifact(capture, activeRules);
|
||||||
|
const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed);
|
||||||
|
let recoveredToDb = false;
|
||||||
|
if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) {
|
||||||
|
const extendedContext: ReviewStateContext = {
|
||||||
|
...context,
|
||||||
|
artifactRepo: context.artifactRepo,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
};
|
||||||
|
recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReviewStatus(
|
||||||
|
result?.path
|
||||||
|
? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}`
|
||||||
|
: "Review-Sample konnte nicht gespeichert werden.",
|
||||||
|
);
|
||||||
|
if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`);
|
||||||
|
if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`);
|
||||||
|
|
||||||
|
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
|
||||||
|
if (reviewResult?.ok) {
|
||||||
|
setReviewSamples(reviewResult.samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, recoveredParsed, recoveredToDb };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadReviewQueue(context: ReviewStateContext): Promise<void> {
|
||||||
|
const { setReviewSamples } = context;
|
||||||
|
const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
|
||||||
|
if (!result?.ok) return;
|
||||||
|
setReviewSamples(result.samples);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||||
|
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
|
||||||
|
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||||
|
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||||
|
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
|
||||||
|
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export interface ScanActionContext {
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
|
||||||
|
isScanning: boolean;
|
||||||
|
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||||
|
selectedSourceId: string;
|
||||||
|
bridgeReady: boolean;
|
||||||
|
automationRepo?: AutomationRepositoryPort;
|
||||||
|
runtimeRepo?: RuntimeRepositoryPort;
|
||||||
|
runtimeInfo?: RuntimeInfo | null;
|
||||||
|
scanLimit: number;
|
||||||
|
skipRows: number;
|
||||||
|
detectedInventoryCount: number;
|
||||||
|
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||||
|
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
|
||||||
|
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||||
|
appendAutomationLog: (line: string) => void;
|
||||||
|
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||||
|
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||||
|
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||||
|
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||||
|
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
|
||||||
|
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
|
||||||
|
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
|
||||||
|
focusDashboard: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||||
|
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAutoReviewScan(context: ScanActionContext): Promise<void> {
|
||||||
|
const {
|
||||||
|
autoScanRunning,
|
||||||
|
selectedSourceId,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
setAutoScanRunning,
|
||||||
|
setReviewStatus,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
appendAutomationLog,
|
||||||
|
scanLimit,
|
||||||
|
detectedInventoryCount,
|
||||||
|
captureSelectedSource,
|
||||||
|
parseArtifact,
|
||||||
|
persistParsedArtifact,
|
||||||
|
saveReviewSample,
|
||||||
|
shouldFlagArtifactForReview,
|
||||||
|
focusDashboard,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
if (autoScanRunning || !selectedSourceId) return;
|
||||||
|
|
||||||
|
setAutoScanRunning(true);
|
||||||
|
stopVisibleScanRef.current = false;
|
||||||
|
setScanSummary(null);
|
||||||
|
setAutoScanStats(emptyAutoScanStats);
|
||||||
|
setReviewStatus("Manueller Scan laeuft. Klicke in Genshin auf ein anderes Artifact; nur neue Artifacts werden verarbeitet.");
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
|
||||||
|
let idleTicks = 0;
|
||||||
|
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||||
|
const maxIdleTicks = 90;
|
||||||
|
|
||||||
|
while (!stopVisibleScanRef.current && stats.parsed < maxArtifacts && idleTicks < maxIdleTicks) {
|
||||||
|
const capture = await captureSelectedSource(0, true);
|
||||||
|
const rejection = captureRejectionReason(capture, parseArtifact(capture));
|
||||||
|
const parsed = parseArtifact(capture);
|
||||||
|
|
||||||
|
if (!capture || !parsed || rejection) {
|
||||||
|
if (capture && rejection) {
|
||||||
|
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
|
||||||
|
stats.review++;
|
||||||
|
setAutoScanStats({ ...stats });
|
||||||
|
}
|
||||||
|
idleTicks++;
|
||||||
|
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
|
||||||
|
await wait(700);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = buildScanSignature(parsed);
|
||||||
|
if (seen.has(signature)) {
|
||||||
|
idleTicks++;
|
||||||
|
setReviewStatus(`Manueller Scan wartet auf ein neues Artifact... (${stats.parsed}/${maxArtifacts})`);
|
||||||
|
await wait(700);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(signature);
|
||||||
|
idleTicks = 0;
|
||||||
|
stats.attempted++;
|
||||||
|
stats.verified++;
|
||||||
|
stats.parsed++;
|
||||||
|
|
||||||
|
const reason = getAutoReviewReason(capture, parsed);
|
||||||
|
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||||
|
if (reason) {
|
||||||
|
await saveReviewSample(capture, parsed, `manual:${reason}`);
|
||||||
|
stats.review++;
|
||||||
|
}
|
||||||
|
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
|
||||||
|
stats.stored++;
|
||||||
|
}
|
||||||
|
setAutoScanStats({ ...stats });
|
||||||
|
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
|
||||||
|
await wait(700);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoScanRunning(false);
|
||||||
|
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
|
||||||
|
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
|
||||||
|
await focusDashboard();
|
||||||
|
setReviewStatus(
|
||||||
|
`Manueller Scan ${status}. ${stats.verified} neue Ansichten verifiziert, ${stats.parsed} Artifacts gelesen, ${stats.stored} in der Datenbank gespeichert, ${stats.review} Review-Samples.${idleSuffix}`,
|
||||||
|
);
|
||||||
|
setScanSummary({
|
||||||
|
mode: "Manueller Scan",
|
||||||
|
status,
|
||||||
|
...stats,
|
||||||
|
targetCount: maxArtifacts,
|
||||||
|
gridLabel: "Nur neue, vom User angeklickte Artifacts wurden verarbeitet.",
|
||||||
|
});
|
||||||
|
|
||||||
|
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
|
||||||
|
const {
|
||||||
|
autoScanRunning,
|
||||||
|
bridgeReady,
|
||||||
|
selectedSourceId,
|
||||||
|
runtimeInfo,
|
||||||
|
automationRepo,
|
||||||
|
runtimeRepo,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
setAutoScanRunning,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
captureSelectedSource,
|
||||||
|
captureFastSelectedSource,
|
||||||
|
parseArtifact,
|
||||||
|
persistParsedArtifact,
|
||||||
|
saveReviewSample,
|
||||||
|
shouldFlagArtifactForReview,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
focusDashboard,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||||
|
|
||||||
|
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||||
|
if (requiresAdminForAutoScan) {
|
||||||
|
setReviewStatus(
|
||||||
|
"App laeuft nicht als Administrator. Bitte die App schliessen und als Administrator neu starten - Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.",
|
||||||
|
);
|
||||||
|
setScanSummary({
|
||||||
|
mode: "Automatischer Scan",
|
||||||
|
status: "blocked",
|
||||||
|
...emptyAutoScanStats,
|
||||||
|
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||||
|
gridLabel: "App laeuft nicht als Administrator. Neustart als Administrator noetig.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoScanRunning(true);
|
||||||
|
stopVisibleScanRef.current = false;
|
||||||
|
setScanSummary(null);
|
||||||
|
setAutoScanStats(emptyAutoScanStats);
|
||||||
|
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||||
|
|
||||||
|
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
|
||||||
|
const adminBlockReason = automationBlockReason(freshRuntime);
|
||||||
|
if (adminBlockReason) {
|
||||||
|
setAutoScanRunning(false);
|
||||||
|
setReviewStatus(adminBlockReason);
|
||||||
|
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
|
||||||
|
setScanSummary({
|
||||||
|
mode: "Automatischer Scan",
|
||||||
|
status: "blocked",
|
||||||
|
...emptyAutoScanStats,
|
||||||
|
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||||
|
gridLabel: adminBlockReason,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freshRuntime) {
|
||||||
|
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
|
||||||
|
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReviewStatus("Genshin wird in den Vordergrund geholt...");
|
||||||
|
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
|
||||||
|
if (focusResult) {
|
||||||
|
appendAutomationLog(
|
||||||
|
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!focusResult?.focused) {
|
||||||
|
setAutoScanRunning(false);
|
||||||
|
const reason = !focusResult?.genshinFound
|
||||||
|
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
|
||||||
|
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
|
||||||
|
setReviewStatus(reason);
|
||||||
|
setScanSummary({
|
||||||
|
mode: "Automatischer Scan",
|
||||||
|
status: "blocked",
|
||||||
|
...emptyAutoScanStats,
|
||||||
|
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||||
|
gridLabel: reason,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||||
|
|
||||||
|
const result = await runAutoScanLoop(
|
||||||
|
{
|
||||||
|
api: {
|
||||||
|
clickScreen: (x: number, y: number) => {
|
||||||
|
if (!automationRepo?.clickScreen) {
|
||||||
|
return Promise.resolve<ClickResult>({
|
||||||
|
ok: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
clicked: false,
|
||||||
|
moved: false,
|
||||||
|
focused: false,
|
||||||
|
inputBlocked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return automationRepo.clickScreen(x, y);
|
||||||
|
},
|
||||||
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => {
|
||||||
|
if (!automationRepo?.scrollScreen) {
|
||||||
|
return Promise.resolve<ScrollResult>({ ok: false, notchesSent: 0, inputBlocked: false });
|
||||||
|
}
|
||||||
|
return automationRepo.scrollScreen(notches, anchorX, anchorY);
|
||||||
|
},
|
||||||
|
getAutomationGuard: () =>
|
||||||
|
automationRepo?.getAutomationGuard?.() ??
|
||||||
|
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||||
|
},
|
||||||
|
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
|
||||||
|
captureFastSelectedSource,
|
||||||
|
parseArtifact,
|
||||||
|
persistParsedArtifact,
|
||||||
|
saveReviewSample,
|
||||||
|
getAutoReviewReason,
|
||||||
|
shouldFlagArtifactForReview,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
setReviewStatus,
|
||||||
|
setAutoScanStats,
|
||||||
|
shouldStop: () => stopVisibleScanRef.current,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setAutoScanRunning(false);
|
||||||
|
if (result.blockedReason) appendAutomationLog(`stop: ${result.blockedReason}`);
|
||||||
|
await focusDashboard();
|
||||||
|
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
|
||||||
|
setScanSummary({
|
||||||
|
mode: "Automatischer Scan",
|
||||||
|
status: result.status,
|
||||||
|
...result.stats,
|
||||||
|
targetCount: result.targetCount,
|
||||||
|
gridLabel: result.gridLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
|
||||||
|
interface ScanCommandListenerInput {
|
||||||
|
automationRepo?: AutomationRepositoryPort;
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
selectedSourceId: string;
|
||||||
|
requestScanStop: (reason: string) => void;
|
||||||
|
runVisibleGridScan: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanCommandListener({
|
||||||
|
automationRepo,
|
||||||
|
autoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
selectedSourceId,
|
||||||
|
requestScanStop,
|
||||||
|
runVisibleGridScan,
|
||||||
|
}: ScanCommandListenerInput) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!automationRepo?.onCommand) return;
|
||||||
|
return automationRepo.onCommand((command: "start-auto" | "stop") => {
|
||||||
|
if (command === "stop") {
|
||||||
|
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||||
|
void runVisibleGridScan();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { RuntimeInfo } from "../../../types/global";
|
||||||
|
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
|
||||||
|
export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
|
||||||
|
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
runtimeRepo?.getRuntimeInfo().then((result) => {
|
||||||
|
if (mounted) {
|
||||||
|
setRuntimeInfo(result);
|
||||||
|
}
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
runtimeRepo?.getRuntimeInfo().then((result) => {
|
||||||
|
if (mounted) setRuntimeInfo(result);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [runtimeRepo]);
|
||||||
|
|
||||||
|
return runtimeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { type AppSnapshot } from "../../../types/domain";
|
||||||
|
import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import type { RuntimeInfo } from "../../../types/global";
|
||||||
|
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { CaptureResult } from "../../../types/global";
|
||||||
|
|
||||||
|
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
|
||||||
|
|
||||||
|
interface ScanSnapshotPublisherInput {
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
reviewStatus: string;
|
||||||
|
captureStatus: string;
|
||||||
|
selectedSourceName: string | null;
|
||||||
|
autoScanStats: AutoScanStats;
|
||||||
|
scanSummary: ScanSummary | null;
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
latestInventoryGrid: InventoryGrid | null | undefined;
|
||||||
|
automationLog: string[];
|
||||||
|
runtimeInfo: RuntimeInfo | null;
|
||||||
|
storedTotal: number | null;
|
||||||
|
learningRuleCount: number;
|
||||||
|
snapshotRepo?: SnapshotRepositoryPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanSnapshotPublisher({
|
||||||
|
autoScanRunning,
|
||||||
|
reviewStatus,
|
||||||
|
captureStatus,
|
||||||
|
selectedSourceName,
|
||||||
|
autoScanStats,
|
||||||
|
scanSummary,
|
||||||
|
snapshot,
|
||||||
|
latestInventoryGrid,
|
||||||
|
automationLog,
|
||||||
|
runtimeInfo,
|
||||||
|
storedTotal,
|
||||||
|
learningRuleCount,
|
||||||
|
snapshotRepo,
|
||||||
|
}: ScanSnapshotPublisherInput) {
|
||||||
|
useEffect(() => {
|
||||||
|
void snapshotRepo?.publishScannerStatus({
|
||||||
|
running: autoScanRunning,
|
||||||
|
reviewStatus,
|
||||||
|
captureStatus,
|
||||||
|
selectedSource: selectedSourceName,
|
||||||
|
stats: autoScanStats,
|
||||||
|
summary: scanSummary,
|
||||||
|
snapshotArtifacts: snapshot.artifacts.length,
|
||||||
|
snapshotCharacters: snapshot.characters.filter((character) => character.owned).length,
|
||||||
|
snapshotRecommendations: snapshot.recommendations.length,
|
||||||
|
snapshotBuilds: snapshot.builds.length,
|
||||||
|
grid: latestInventoryGrid ?? null,
|
||||||
|
automationLog: automationLog.slice(-12),
|
||||||
|
runtimeInfo,
|
||||||
|
storedTotal,
|
||||||
|
learningRuleCount,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, [
|
||||||
|
autoScanRunning,
|
||||||
|
reviewStatus,
|
||||||
|
captureStatus,
|
||||||
|
selectedSourceName,
|
||||||
|
autoScanStats,
|
||||||
|
scanSummary,
|
||||||
|
snapshot,
|
||||||
|
latestInventoryGrid,
|
||||||
|
automationLog,
|
||||||
|
runtimeInfo,
|
||||||
|
storedTotal,
|
||||||
|
learningRuleCount,
|
||||||
|
snapshotRepo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
|
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
|
||||||
|
import {
|
||||||
|
initializeLearningState,
|
||||||
|
loadReviewQueue as loadReviewQueueFromRepo,
|
||||||
|
persistParsedArtifact as persistParsedArtifactHelper,
|
||||||
|
saveReviewSample as saveReviewSampleHelper,
|
||||||
|
} from "./scanViewReviewHelpers";
|
||||||
|
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
|
||||||
|
import { useScanCommandListener } from "./useScanCommandListener";
|
||||||
|
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||||
|
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||||
|
import type {
|
||||||
|
ArtifactRepositoryPort,
|
||||||
|
ReviewSampleRepositoryPort,
|
||||||
|
LearningRepositoryPort,
|
||||||
|
RuntimeRepositoryPort,
|
||||||
|
AutomationRepositoryPort,
|
||||||
|
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import type { RuntimeInfo } from "../../../types/global";
|
||||||
|
|
||||||
|
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
|
||||||
|
type NumberSetter = Dispatch<SetStateAction<number>>;
|
||||||
|
type StringSetter = Dispatch<SetStateAction<string>>;
|
||||||
|
type NumberOrNullSetter = Dispatch<SetStateAction<number | null>>;
|
||||||
|
type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||||
|
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||||
|
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
|
||||||
|
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>;
|
||||||
|
|
||||||
|
interface ScanViewActionInput {
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
setAutoScanRunning: BooleanSetter;
|
||||||
|
isScanning: boolean;
|
||||||
|
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||||
|
selectedSourceId: string;
|
||||||
|
bridgeReady: boolean;
|
||||||
|
automationRepo?: AutomationRepositoryPort;
|
||||||
|
runtimeInfo?: RuntimeInfo | null;
|
||||||
|
runtimeRepo?: RuntimeRepositoryPort;
|
||||||
|
scanLimit: number;
|
||||||
|
skipRows: number;
|
||||||
|
detectedInventoryCount: number;
|
||||||
|
setScanSummary: ScanSummarySetter;
|
||||||
|
setAutoScanStats: AutoScanStatsSetter;
|
||||||
|
setReviewStatus: StringSetter;
|
||||||
|
appendAutomationLog: (line: string) => void;
|
||||||
|
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||||
|
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||||
|
artifactRepo?: ArtifactRepositoryPort;
|
||||||
|
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||||
|
learningRepo?: LearningRepositoryPort;
|
||||||
|
onStoredArtifactsChanged?: (() => Promise<void>) | (() => void);
|
||||||
|
setReviewSampleTotal: NumberSetter;
|
||||||
|
setReviewSamples: ReviewSamplesSetter;
|
||||||
|
setLearningRulesLoaded: BooleanSetter;
|
||||||
|
setScannerLearningRules: ScannerRulesSetter;
|
||||||
|
setStoredTotal: NumberOrNullSetter;
|
||||||
|
scannerLearningRules: ScannerLearningRules;
|
||||||
|
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||||
|
latestCapture: CaptureResult | null;
|
||||||
|
parsedArtifact: ParsedArtifactCandidate | null;
|
||||||
|
canCaptureSource: boolean;
|
||||||
|
setReviewQueueOpen: BooleanSetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanViewActionResult {
|
||||||
|
requestScanStop: (reason?: string) => void;
|
||||||
|
saveReviewSample: (
|
||||||
|
capture?: CaptureResult | null,
|
||||||
|
parsed?: ParsedArtifactCandidate | null,
|
||||||
|
reason?: string,
|
||||||
|
) => Promise<BooleanResult | null>;
|
||||||
|
loadReviewQueue: () => Promise<void>;
|
||||||
|
openReviewQueue: () => Promise<void>;
|
||||||
|
runAutoReviewScan: () => Promise<void>;
|
||||||
|
runVisibleGridScan: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
|
||||||
|
const {
|
||||||
|
autoScanRunning,
|
||||||
|
setAutoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
selectedSourceId,
|
||||||
|
bridgeReady,
|
||||||
|
automationRepo,
|
||||||
|
runtimeInfo,
|
||||||
|
runtimeRepo,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
parseArtifact,
|
||||||
|
artifactRepo,
|
||||||
|
reviewSamplesRepo,
|
||||||
|
learningRepo,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
setReviewSampleTotal,
|
||||||
|
setReviewSamples,
|
||||||
|
setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules,
|
||||||
|
setStoredTotal,
|
||||||
|
scannerLearningRules,
|
||||||
|
captureSelectedSource,
|
||||||
|
latestCapture,
|
||||||
|
parsedArtifact,
|
||||||
|
canCaptureSource,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
|
||||||
|
stopVisibleScanRef.current = true;
|
||||||
|
appendAutomationLog(`stop requested: ${reason}`);
|
||||||
|
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
|
||||||
|
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]);
|
||||||
|
|
||||||
|
const reviewContext = useMemo(
|
||||||
|
() =>
|
||||||
|
createReviewContext({
|
||||||
|
artifactRepo,
|
||||||
|
reviewSamplesRepo,
|
||||||
|
learningRepo,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
setReviewSampleTotal,
|
||||||
|
setReviewSamples,
|
||||||
|
setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules,
|
||||||
|
setReviewStatus,
|
||||||
|
setStoredTotal,
|
||||||
|
appendAutomationLog,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
artifactRepo,
|
||||||
|
reviewSamplesRepo,
|
||||||
|
learningRepo,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
setReviewSampleTotal,
|
||||||
|
setReviewSamples,
|
||||||
|
setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules,
|
||||||
|
setReviewStatus,
|
||||||
|
setStoredTotal,
|
||||||
|
appendAutomationLog,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initializeLearningState(reviewContext);
|
||||||
|
}, [reviewContext]);
|
||||||
|
|
||||||
|
const focusDashboard = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await automationRepo?.focusMainWindow?.();
|
||||||
|
} catch {
|
||||||
|
// Best-effort fallback; state remains in renderer.
|
||||||
|
}
|
||||||
|
}, [automationRepo?.focusMainWindow]);
|
||||||
|
|
||||||
|
const parseArtifactAndPersist = useCallback(
|
||||||
|
async function parseArtifactAndPersist(
|
||||||
|
capture: CaptureResult | null,
|
||||||
|
parsed: ParsedArtifactCandidate,
|
||||||
|
source: string,
|
||||||
|
needsReview: boolean,
|
||||||
|
) {
|
||||||
|
return persistParsedArtifactHelper(capture, parsed, source, needsReview, reviewContext);
|
||||||
|
},
|
||||||
|
[reviewContext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveReviewSample = useCallback(
|
||||||
|
async function handleSaveReviewSample(
|
||||||
|
capture: CaptureResult | null = latestCapture,
|
||||||
|
parsed: ParsedArtifactCandidate | null = parsedArtifact,
|
||||||
|
reason = "manual",
|
||||||
|
): Promise<BooleanResult | null> {
|
||||||
|
const result = await saveReviewSampleHelper(
|
||||||
|
capture,
|
||||||
|
parsed,
|
||||||
|
reason,
|
||||||
|
scannerLearningRules,
|
||||||
|
reviewContext,
|
||||||
|
);
|
||||||
|
return result.result ? { ok: result.result.ok } : null;
|
||||||
|
},
|
||||||
|
[latestCapture, parsedArtifact, reviewContext, scannerLearningRules],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanActionContext = useMemo(
|
||||||
|
() =>
|
||||||
|
createScanActionContext({
|
||||||
|
autoScanRunning,
|
||||||
|
setAutoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
selectedSourceId,
|
||||||
|
bridgeReady,
|
||||||
|
automationRepo,
|
||||||
|
runtimeInfo,
|
||||||
|
runtimeRepo,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
parseArtifact,
|
||||||
|
persistParsedArtifact: parseArtifactAndPersist,
|
||||||
|
saveReviewSample: handleSaveReviewSample,
|
||||||
|
focusDashboard,
|
||||||
|
captureSelectedSource,
|
||||||
|
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
autoScanRunning,
|
||||||
|
setAutoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
selectedSourceId,
|
||||||
|
bridgeReady,
|
||||||
|
automationRepo,
|
||||||
|
runtimeInfo,
|
||||||
|
runtimeRepo,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
parseArtifact,
|
||||||
|
parseArtifactAndPersist,
|
||||||
|
handleSaveReviewSample,
|
||||||
|
focusDashboard,
|
||||||
|
captureSelectedSource,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadReviewQueueAction = useCallback(async () => {
|
||||||
|
await loadReviewQueueFromRepo(reviewContext);
|
||||||
|
}, [reviewContext]);
|
||||||
|
|
||||||
|
const openReviewQueueModal = useCallback(async () => {
|
||||||
|
await loadReviewQueueAction();
|
||||||
|
setReviewQueueOpen(true);
|
||||||
|
}, [loadReviewQueueAction, setReviewQueueOpen]);
|
||||||
|
|
||||||
|
const runAutoReviewScan = useCallback(async () => {
|
||||||
|
if (autoScanRunning || !selectedSourceId || !canCaptureSource) return;
|
||||||
|
await runAutoReviewScanAction(scanActionContext);
|
||||||
|
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
|
||||||
|
|
||||||
|
const runVisibleGridScan = useCallback(async () => {
|
||||||
|
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runVisibleGridScanAction(scanActionContext);
|
||||||
|
}, [
|
||||||
|
autoScanRunning,
|
||||||
|
bridgeReady,
|
||||||
|
selectedSourceId,
|
||||||
|
automationRepo?.clickScreen,
|
||||||
|
automationRepo?.scrollScreen,
|
||||||
|
scanActionContext,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useScanCommandListener({
|
||||||
|
automationRepo,
|
||||||
|
autoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
selectedSourceId,
|
||||||
|
requestScanStop,
|
||||||
|
runVisibleGridScan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestScanStop,
|
||||||
|
saveReviewSample: handleSaveReviewSample,
|
||||||
|
loadReviewQueue: loadReviewQueueAction,
|
||||||
|
openReviewQueue: openReviewQueueModal,
|
||||||
|
runAutoReviewScan,
|
||||||
|
runVisibleGridScan,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||||
|
import {
|
||||||
|
countScannerLearningRules,
|
||||||
|
type ScannerLearningRules,
|
||||||
|
} from "../../../lib/scannerLearning";
|
||||||
|
import { type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||||
|
import { analyzeReviewSamples } from "../../../lib/reviewSampleAnalysis";
|
||||||
|
import {
|
||||||
|
parseLearnedArtifact as parseLearnedArtifactHelper,
|
||||||
|
} from "./scanViewReviewHelpers";
|
||||||
|
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
|
||||||
|
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
|
||||||
|
import { useScanViewActions } from "./useScanViewActions";
|
||||||
|
import { useScanViewStateSync } from "./useScanViewStateSync";
|
||||||
|
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||||
|
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||||
|
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||||
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
|
||||||
|
export function useScanViewController({
|
||||||
|
snapshot,
|
||||||
|
isScanning,
|
||||||
|
captureSources,
|
||||||
|
selectedSourceId,
|
||||||
|
latestCapture,
|
||||||
|
captureStatus,
|
||||||
|
captureSelectedSource,
|
||||||
|
bridgeReady,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
}: ScanViewProps): ScanViewControllerResult {
|
||||||
|
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||||
|
const artifactRepo = repositories?.artifacts;
|
||||||
|
const runtimeRepo = repositories?.runtime;
|
||||||
|
const reviewSamplesRepo = repositories?.reviewSamples;
|
||||||
|
const learningRepo = repositories?.learning;
|
||||||
|
const snapshotRepo = repositories?.snapshot;
|
||||||
|
const automationRepo = repositories?.automation;
|
||||||
|
const captureRepo = repositories?.capture;
|
||||||
|
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [reviewQueueOpen, setReviewQueueOpen] = useState(false);
|
||||||
|
const [reviewSamples, setReviewSamples] = useState<ReviewSampleRecord[]>([]);
|
||||||
|
const [reviewSampleTotal, setReviewSampleTotal] = useState(0);
|
||||||
|
const [reviewStatus, setReviewStatus] = useState("");
|
||||||
|
const [autoScanRunning, setAutoScanRunning] = useState(false);
|
||||||
|
const stopVisibleScanRef = useRef(false);
|
||||||
|
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
|
||||||
|
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
|
||||||
|
const [scanLimit, setScanLimit] = useState(16);
|
||||||
|
const [scanLimitTouched, setScanLimitTouched] = useState(false);
|
||||||
|
const [skipRows, setSkipRows] = useState(0);
|
||||||
|
const [automationLog, setAutomationLog] = useState<string[]>([]);
|
||||||
|
const [storedTotal, setStoredTotal] = useState<number | null>(null);
|
||||||
|
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
|
||||||
|
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
|
||||||
|
const [learningRulesLoaded, setLearningRulesLoaded] = useState(false);
|
||||||
|
const runtimeInfo = useScanRuntimeInfo(runtimeRepo);
|
||||||
|
|
||||||
|
const parsedArtifact = useMemo(
|
||||||
|
() => parseLearnedArtifactHelper(latestCapture, scannerLearningRules),
|
||||||
|
[latestCapture, scannerLearningRules],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSource = captureSources.find((source) => source.id === selectedSourceId);
|
||||||
|
const genshinSource = captureSources.find((source) => source.isGenshinCandidate);
|
||||||
|
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||||
|
const hasSourceSelected = selectedSourceId.length > 0;
|
||||||
|
const canCaptureSource = bridgeReady && Boolean(captureRepo?.captureSource);
|
||||||
|
const canReadReviewQueue = bridgeReady && Boolean(reviewSamplesRepo?.loadSamples);
|
||||||
|
const canSaveReviewSample = bridgeReady && Boolean(reviewSamplesRepo?.saveSample);
|
||||||
|
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
|
||||||
|
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
|
||||||
|
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
|
||||||
|
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||||
|
const activeTargetCount = autoScanRunning
|
||||||
|
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
|
||||||
|
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||||
|
const scanProgressBase = Math.max(autoScanStats.attempted, autoScanStats.parsed, autoScanStats.verified);
|
||||||
|
const scanProgressPercent = Math.min(100, Math.round((scanProgressBase / Math.max(1, activeTargetCount)) * 100));
|
||||||
|
const scannerModeLabel = autoScanRunning ? "Scan laeuft" : runtimeInfo?.isElevated ? "Admin bereit" : "Bereit";
|
||||||
|
const sourceLabel = selectedSource?.name ?? "Keine Quelle";
|
||||||
|
const gridLabel = latestCapture?.inventoryGrid ? `${latestCapture.inventoryGrid.cols} x ${latestCapture.inventoryGrid.rows}` : "warte auf Capture";
|
||||||
|
const inventoryLabel = latestCapture?.inventoryCount?.current ? `${latestCapture.inventoryCount.current}/${latestCapture.inventoryCount.total || "?"}` : "nicht erkannt";
|
||||||
|
|
||||||
|
const appendAutomationLog = useCallback((line: string) => {
|
||||||
|
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
|
||||||
|
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
|
||||||
|
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
|
||||||
|
const blocked = result.inputBlocked ? " input:blocked" : "";
|
||||||
|
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
|
||||||
|
}, [appendAutomationLog]);
|
||||||
|
|
||||||
|
const toggleDevMode = useCallback(() => {
|
||||||
|
setDevMode((previous) => {
|
||||||
|
const next = !previous;
|
||||||
|
localStorage.setItem("gaa-dev-mode", next ? "1" : "0");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseArtifact = useCallback(
|
||||||
|
(capture: CaptureResult | null) => parseLearnedArtifactHelper(capture, scannerLearningRules),
|
||||||
|
[scannerLearningRules],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
requestScanStop,
|
||||||
|
saveReviewSample: handleSaveReviewSample,
|
||||||
|
loadReviewQueue: loadReviewQueueAction,
|
||||||
|
openReviewQueue: openReviewQueueModal,
|
||||||
|
runAutoReviewScan,
|
||||||
|
runVisibleGridScan,
|
||||||
|
} = useScanViewActions({
|
||||||
|
autoScanRunning,
|
||||||
|
setAutoScanRunning,
|
||||||
|
isScanning,
|
||||||
|
stopVisibleScanRef,
|
||||||
|
selectedSourceId,
|
||||||
|
bridgeReady,
|
||||||
|
automationRepo,
|
||||||
|
runtimeInfo,
|
||||||
|
runtimeRepo,
|
||||||
|
scanLimit,
|
||||||
|
skipRows,
|
||||||
|
detectedInventoryCount,
|
||||||
|
setScanSummary,
|
||||||
|
setAutoScanStats,
|
||||||
|
setReviewStatus,
|
||||||
|
appendAutomationLog,
|
||||||
|
appendClickDiagnostics,
|
||||||
|
parseArtifact,
|
||||||
|
artifactRepo,
|
||||||
|
reviewSamplesRepo,
|
||||||
|
learningRepo,
|
||||||
|
onStoredArtifactsChanged,
|
||||||
|
setReviewSampleTotal,
|
||||||
|
setReviewSamples,
|
||||||
|
setLearningRulesLoaded,
|
||||||
|
setScannerLearningRules,
|
||||||
|
setStoredTotal,
|
||||||
|
scannerLearningRules,
|
||||||
|
captureSelectedSource,
|
||||||
|
latestCapture,
|
||||||
|
parsedArtifact,
|
||||||
|
canCaptureSource,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
useScanViewStateSync({
|
||||||
|
artifactRepo,
|
||||||
|
latestCapture,
|
||||||
|
scanLimitTouched,
|
||||||
|
setScanLimit,
|
||||||
|
setStoredTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
useScanSnapshotPublisher({
|
||||||
|
autoScanRunning,
|
||||||
|
reviewStatus,
|
||||||
|
captureStatus,
|
||||||
|
selectedSourceName: selectedSource?.name ?? null,
|
||||||
|
autoScanStats,
|
||||||
|
scanSummary,
|
||||||
|
snapshot,
|
||||||
|
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
|
||||||
|
automationLog,
|
||||||
|
runtimeInfo,
|
||||||
|
storedTotal,
|
||||||
|
learningRuleCount,
|
||||||
|
snapshotRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailsOpen,
|
||||||
|
diagnosticsOpen,
|
||||||
|
settingsOpen,
|
||||||
|
reviewQueueOpen,
|
||||||
|
reviewSamples,
|
||||||
|
reviewSampleTotal,
|
||||||
|
reviewStatus,
|
||||||
|
autoScanRunning,
|
||||||
|
autoScanStats,
|
||||||
|
scanSummary,
|
||||||
|
scanLimit,
|
||||||
|
scanLimitTouched,
|
||||||
|
skipRows,
|
||||||
|
automationLog,
|
||||||
|
storedTotal,
|
||||||
|
devMode,
|
||||||
|
scannerLearningRules,
|
||||||
|
learningRulesLoaded,
|
||||||
|
runtimeInfo,
|
||||||
|
parsedArtifact,
|
||||||
|
reviewAnalysis,
|
||||||
|
learningRuleCount,
|
||||||
|
detectedInventoryCount,
|
||||||
|
scanProgressPercent,
|
||||||
|
activeTargetCount,
|
||||||
|
requiresAdminForAutoScan,
|
||||||
|
hasSourceSelected,
|
||||||
|
canCaptureSource,
|
||||||
|
canReadReviewQueue,
|
||||||
|
canSaveReviewSample,
|
||||||
|
canAutoScan,
|
||||||
|
scannerModeLabel,
|
||||||
|
sourceLabel,
|
||||||
|
gridLabel,
|
||||||
|
inventoryLabel,
|
||||||
|
selectedSource,
|
||||||
|
genshinSource,
|
||||||
|
setDetailsOpen,
|
||||||
|
setDiagnosticsOpen,
|
||||||
|
setSettingsOpen,
|
||||||
|
setReviewQueueOpen,
|
||||||
|
setScanSummary,
|
||||||
|
setScanLimit,
|
||||||
|
setScanLimitTouched,
|
||||||
|
setSkipRows,
|
||||||
|
toggleDevMode,
|
||||||
|
requestScanStop,
|
||||||
|
saveReviewSample: handleSaveReviewSample,
|
||||||
|
loadReviewQueue: loadReviewQueueAction,
|
||||||
|
openReviewQueue: openReviewQueueModal,
|
||||||
|
runAutoReviewScan,
|
||||||
|
runVisibleGridScan,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { CaptureResult } from "../../../types/global";
|
||||||
|
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import { clampScanLimit } from "../../../lib/scannerSession";
|
||||||
|
|
||||||
|
interface ScanViewStateSyncInput {
|
||||||
|
artifactRepo?: ArtifactRepositoryPort;
|
||||||
|
latestCapture: CaptureResult | null;
|
||||||
|
scanLimitTouched: boolean;
|
||||||
|
setScanLimit: Dispatch<SetStateAction<number>>;
|
||||||
|
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanViewStateSync({
|
||||||
|
artifactRepo,
|
||||||
|
latestCapture,
|
||||||
|
scanLimitTouched,
|
||||||
|
setScanLimit,
|
||||||
|
setStoredTotal,
|
||||||
|
}: ScanViewStateSyncInput) {
|
||||||
|
useEffect(() => {
|
||||||
|
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||||
|
if (!scanLimitTouched && detectedCount > 0) {
|
||||||
|
setScanLimit(clampScanLimit(detectedCount));
|
||||||
|
}
|
||||||
|
}, [latestCapture?.inventoryCount?.current, scanLimitTouched, setScanLimit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
artifactRepo?.loadAll().then((result) => {
|
||||||
|
if (result?.ok) setStoredTotal(result.total);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, [artifactRepo, setStoredTotal]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { AppSnapshot } from "../../types/domain";
|
||||||
|
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global";
|
||||||
|
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
|
||||||
|
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
|
||||||
|
import type { ScannerLearningRules } from "../../lib/scannerLearning";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
|
||||||
|
|
||||||
|
export interface ScanViewProps {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
isScanning: boolean;
|
||||||
|
captureSources: CaptureSourceInfo[];
|
||||||
|
selectedSourceId: string;
|
||||||
|
setSelectedSourceId: (value: string) => void;
|
||||||
|
latestCapture: CaptureResult | null;
|
||||||
|
captureStatus: string;
|
||||||
|
refreshCaptureSources: () => Promise<void>;
|
||||||
|
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||||
|
bridgeReady: boolean;
|
||||||
|
onStoredArtifactsChanged?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanViewControllerResult {
|
||||||
|
detailsOpen: boolean;
|
||||||
|
diagnosticsOpen: boolean;
|
||||||
|
settingsOpen: boolean;
|
||||||
|
reviewQueueOpen: boolean;
|
||||||
|
reviewSamples: ReviewSampleRecord[];
|
||||||
|
reviewSampleTotal: number;
|
||||||
|
reviewStatus: string;
|
||||||
|
autoScanRunning: boolean;
|
||||||
|
autoScanStats: AutoScanStats;
|
||||||
|
scanSummary: ScanSummary | null;
|
||||||
|
scanLimit: number;
|
||||||
|
scanLimitTouched: boolean;
|
||||||
|
skipRows: number;
|
||||||
|
automationLog: string[];
|
||||||
|
storedTotal: number | null;
|
||||||
|
devMode: boolean;
|
||||||
|
scannerLearningRules: ScannerLearningRules;
|
||||||
|
learningRulesLoaded: boolean;
|
||||||
|
runtimeInfo: RuntimeInfo | null;
|
||||||
|
parsedArtifact: ParsedArtifactCandidate | null;
|
||||||
|
reviewAnalysis: ReturnType<typeof analyzeReviewSamples>;
|
||||||
|
learningRuleCount: number;
|
||||||
|
detectedInventoryCount: number;
|
||||||
|
scanProgressPercent: number;
|
||||||
|
activeTargetCount: number;
|
||||||
|
requiresAdminForAutoScan: boolean;
|
||||||
|
hasSourceSelected: boolean;
|
||||||
|
canCaptureSource: boolean;
|
||||||
|
canReadReviewQueue: boolean;
|
||||||
|
canSaveReviewSample: boolean;
|
||||||
|
canAutoScan: boolean;
|
||||||
|
scannerModeLabel: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
gridLabel: string;
|
||||||
|
inventoryLabel: string;
|
||||||
|
selectedSource: CaptureSourceInfo | undefined;
|
||||||
|
genshinSource: CaptureSourceInfo | undefined;
|
||||||
|
|
||||||
|
setDetailsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setDiagnosticsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setSettingsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setReviewQueueOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||||
|
setScanLimit: Dispatch<SetStateAction<number>>;
|
||||||
|
setScanLimitTouched: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setSkipRows: Dispatch<SetStateAction<number>>;
|
||||||
|
|
||||||
|
toggleDevMode: () => void;
|
||||||
|
requestScanStop: (reason?: string) => void;
|
||||||
|
saveReviewSample: (
|
||||||
|
capture?: CaptureResult | null,
|
||||||
|
parsed?: ParsedArtifactCandidate | null,
|
||||||
|
reason?: string,
|
||||||
|
) => Promise<BooleanResult | null>;
|
||||||
|
loadReviewQueue: () => Promise<void>;
|
||||||
|
openReviewQueue: () => Promise<void>;
|
||||||
|
runAutoReviewScan: () => Promise<void>;
|
||||||
|
runVisibleGridScan: () => Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ArtifactRowProps, TriageViewProps } from "./types";
|
||||||
|
import { useArtifactRowModel } from "./hooks/useArtifactRowModel";
|
||||||
|
import { useTriageViewModel } from "./hooks/useTriageViewModel";
|
||||||
|
|
||||||
|
function ArtifactRow({
|
||||||
|
artifact,
|
||||||
|
recommendation,
|
||||||
|
characters,
|
||||||
|
}: ArtifactRowProps) {
|
||||||
|
const { metaLabel, metaClassName, metaIcon, score, characterNames, artifactSummary, substatRows } = useArtifactRowModel({
|
||||||
|
recommendation,
|
||||||
|
artifact,
|
||||||
|
characters,
|
||||||
|
characterIds: recommendation?.bestCharacters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="artifact-row">
|
||||||
|
<div>
|
||||||
|
<strong>{artifact.setName}</strong>
|
||||||
|
<span>{artifactSummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="substats">
|
||||||
|
{substatRows.map((substat) => (
|
||||||
|
<span key={`${artifact.id}-${substat}`}>{substat}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={metaClassName}>{metaIcon}{metaLabel}</div>
|
||||||
|
<div className="reason">
|
||||||
|
<strong>{score}</strong>
|
||||||
|
<span>{characterNames}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TriageView({ snapshot }: TriageViewProps) {
|
||||||
|
const { panelEyebrow, panelTitle, artifactCountLabel, recommendationByArtifact } = useTriageViewModel({ snapshot });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{panelEyebrow}</p>
|
||||||
|
<h2>{panelTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="muted">{artifactCountLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="artifact-table">
|
||||||
|
{snapshot.artifacts.map((artifact) => (
|
||||||
|
<ArtifactRow
|
||||||
|
key={artifact.id}
|
||||||
|
artifact={artifact}
|
||||||
|
recommendation={recommendationByArtifact.get(artifact.id)}
|
||||||
|
characters={snapshot.characters}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { verdictMeta } from "../../common/verdictMeta";
|
||||||
|
import type { Artifact, Character, Recommendation } from "../../../types/domain";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface ArtifactRowModel {
|
||||||
|
metaLabel: string;
|
||||||
|
metaClassName: string;
|
||||||
|
metaIcon: ReactNode;
|
||||||
|
score: number;
|
||||||
|
characterNames: string;
|
||||||
|
artifactSummary: string;
|
||||||
|
substatRows: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseArtifactRowModelInput {
|
||||||
|
artifact: Artifact;
|
||||||
|
recommendation?: Recommendation;
|
||||||
|
characters: Character[];
|
||||||
|
characterIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtifactRowModel({
|
||||||
|
recommendation,
|
||||||
|
characters,
|
||||||
|
artifact,
|
||||||
|
characterIds,
|
||||||
|
}: UseArtifactRowModelInput): ArtifactRowModel {
|
||||||
|
const meta = verdictMeta[recommendation?.verdict ?? "needs_review"];
|
||||||
|
const characterNames = !characterIds || characterIds.length === 0
|
||||||
|
? "Review first"
|
||||||
|
: characterIds.map((id) => characters.find((character) => character.id === id)?.name ?? id).join(", ");
|
||||||
|
const score = recommendation ? Math.round(recommendation.score) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metaLabel: meta.label,
|
||||||
|
metaClassName: meta.className,
|
||||||
|
metaIcon: meta.icon,
|
||||||
|
score,
|
||||||
|
characterNames,
|
||||||
|
artifactSummary: `${artifact.slot} - +${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
|
||||||
|
substatRows: artifact.substats.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AppSnapshot, Recommendation } from "../../../types/domain";
|
||||||
|
import type { Artifact, Character } from "../../../types/domain";
|
||||||
|
|
||||||
|
export interface TriageViewModel {
|
||||||
|
artifactCount: number;
|
||||||
|
panelEyebrow: string;
|
||||||
|
panelTitle: string;
|
||||||
|
artifactCountLabel: string;
|
||||||
|
recommendationByArtifact: Map<string, Recommendation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTriageViewModelInput {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTriageViewModel({ snapshot }: UseTriageViewModelInput): TriageViewModel {
|
||||||
|
const recommendationByArtifact = new Map(snapshot.recommendations.map((entry) => [entry.artifactId, entry]));
|
||||||
|
const artifactCount = snapshot.artifacts.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artifactCount,
|
||||||
|
panelEyebrow: "Artifact triage",
|
||||||
|
panelTitle: "No-brainer decisions",
|
||||||
|
artifactCountLabel: `${artifactCount} scanned pieces`,
|
||||||
|
recommendationByArtifact,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { AppSnapshot, Artifact, Character, Recommendation } from "../../types/domain";
|
||||||
|
|
||||||
|
export interface TriageViewProps {
|
||||||
|
snapshot: AppSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactRowProps {
|
||||||
|
artifact: Artifact;
|
||||||
|
recommendation?: Recommendation;
|
||||||
|
characters: Character[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./rendererBridgeRepositoryTypes";
|
||||||
|
export { createRendererRepositories } from "./rendererBridgeRepositoryFactory";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user