22 Commits

Author SHA1 Message Date
AzuTear 639b0b7f59 feat(scanner): add native artifact pipeline
Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
2026-07-09 23:30:42 +02:00
AzuTear 28d60eb915 Document scanner product roadmap and Gitea workflow 2026-07-09 10:28:17 +02:00
AzuTear eb666febb6 Document Gitea authentication setup 2026-07-09 10:25:40 +02:00
AzuTear c10f17b4ef Improve scanner repeatability guardrails 2026-07-09 10:21:13 +02:00
AzuTear 13fd46c104 docs: update scanner roadmap after merge 2026-07-09 08:53:57 +02:00
AzuTear c025daa4f1 Merge scanner readiness work 2026-07-09 08:46:20 +02:00
AzuTear 8b73c01e46 Prepare scanner branch for merge 2026-07-09 08:44:50 +02:00
AzuTear f791d1464c Improve IK-style artifact scanner pipeline 2026-07-07 22:02:24 +02:00
AzuTear 8ebbe91c39 merge feat/ocr-eval-harness into main 2026-07-07 07:49:47 +02:00
AzuTear ef65c3e6a0 feat(scanner): validate elevated live automation 2026-07-07 07:49:22 +02:00
AzuTear 7930e369a7 feat(ocr): substat-roll validation + rarity inference for GOOD export
Adds the accuracy check yas / Genshin Optimizer use: a substat value is only
legitimate if it equals round(sum of 1..6 rolls) from that stat's roll table.
Values that fit no combination at either rarity are guaranteed OCR misreads.

- src/lib/substatRolls.ts: 5-star roll tables (+ 4-star %/crit tables to tell
  rarities apart), pure isPlausibleSubstat/implausibleSubstats, and inferRarity
  (level > 16 or roll-table fit; conservative, defaults to 5). Validates against
  the union of rarities so valid 4-star pieces are not false-flagged.
- Wired in: shouldFlagArtifactForReview routes implausible substats to review;
  the parser adds an explanatory note; goodInterop export replaces the hardcoded
  rarity:5 with inferRarity (fixes wrong 4-star exports to GO/IK).
- 10 new unit tests; 131 total green; eval still 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:21:28 +02:00
AzuTear b8309af377 fix(input): inject a no-op input event so force-foreground actually works
Follow-up to the AttachThreadInput change: verified against an isolated repro
(a foreground-stealing window + the helper spawned exactly like the app) that
AttachThreadInput + clearing the foreground-lock timeout was NOT sufficient on
this Windows build - SetForegroundWindow still returned false and Genshin stayed
in the background.

The missing condition is "the calling process received the last input event".
Injecting a benign no-op input (a 0,0 relative mouse move, no cursor movement, no
menu-mnemonic side effect) right before SetForegroundWindow satisfies it. With the
nudge the repro now returns focused:true / setForegroundResult:true from a
background process while another app holds the foreground - the exact auto-scan
start scenario. Applied to both the C# sidecar and the PowerShell fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:13:25 +02:00
AzuTear c8ae0dd7bf fix(input): force Genshin foreground via AttachThreadInput (auto-scan no longer aborts)
Auto-scan aborted immediately with "Genshin konnte nicht in den Vordergrund
geholt werden". Root cause: the focus call runs in the background input/capture
helper process, and Windows' foreground lock silently refuses SetForegroundWindow
from a process that is neither foreground nor the last input source. When the user
clicks "Auto-Scan starten" the Electron window is foreground, so the helper's plain
SetForegroundWindow is dropped and focus stays false.

Fix (both the C# sidecar and the PowerShell fallback): before SetForegroundWindow,
attach our thread's input queue to the target (and current-foreground) window
thread with AttachThreadInput and clear SPI_..FOREGROUNDLOCKTIMEOUT, then restore.
This is the same technique Inventory Kamera and other reliable automators use; it
is what our helper was missing after the old ALT-tap workaround was removed on the
wrong assumption that equal integrity level is sufficient (that only covers UIPI
input injection, not foreground changes).

Sidecar recompiled + republished; electron build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:07:30 +02:00
AzuTear 113601f031 fix(electron): point main + preload + index paths at the real build layout
npm run dev failed with ERR_MODULE_NOT_FOUND for dist-electron/services/inputHelper.

Root cause: the electron program includes src runtime files (repositories,
layoutProfile, ocrPreprocess), so tsc's inferred rootDir is the project root and
it emits the entry at dist-electron/electron/main.js (with src at
dist-electron/src). package.json "main" still pointed at a stale flat
dist-electron/main.js fossil from an older build layout, whose extensionless
imports don't resolve under NodeNext ESM.

- package.json main -> dist-electron/electron/main.js.
- predev/build copy preload.cjs into dist-electron/electron/ (next to main.js,
  where main.ts resolves it via __dirname).
- main.ts loads ../../dist/index.html (one level deeper now) for the packaged
  window + overlay.

Verified: clean electron build emits only the nested layout; electron . loads the
main process past module resolution (only ERR_CONNECTION_REFUSED for the dev
server, expected standalone); npm run build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:11:34 +02:00
AzuTear 39691fd40b feat(good): wire GOOD import/export UI into the Diagnose view
Completes the GOOD interop point end-to-end (the conversion engine landed earlier
in goodInterop.ts). No new IPC needed - reuses the existing artifacts:load /
artifacts:saveMany / good:export bridge.

- Scan controller gains exportGoodFromStore (loadArtifacts -> storedArtifactsToGood
  -> exportGood) and importGoodArtifacts (saveMany + snapshot refresh), plus a
  canGoodInterop flag.
- DiagnosticsView adds a GOOD Interop card: export the scan store as GOOD, or
  import a GOOD file. The file is read in the renderer via a file input +
  goodDatabaseToStoredArtifacts, so no file-dialog IPC is required.

Verified in the browser preview after a clean restart: the Diagnose view renders
all cards (Status, Last scan, GOOD Interop, Automation log, Crops/OCR), the
Scan<->Diagnose switch works with no console errors (the earlier hook-order
warnings were stale-HMR artifacts from deleting files mid-session). 120 tests +
build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:56:31 +02:00
AzuTear 8d4d24f0bb feat(ui): separate dev info into a Diagnose view, declutter the scan workspace
Reworks the UI so the Scan tab shows only core actions and all developer /
diagnostic surfaces live in one dedicated view.

- New "Diagnose" nav view. ScanView stays mounted for scan|diagnose (the scan
  controller state persists across the switch) and renders either the clean
  workspace or the new DiagnosticsView by mode.
- DiagnosticsView consolidates runtime/rights, grid detection, learning + data
  staleness, fingerprint, auto-scan counters, the automation log, the dev-output
  toggle, the Demo-Daten action, and the raw crops/OCR/confidence dump. Reuses
  the existing diagnostics/details model hooks.
- Scan workspace decluttered: removed the Scanner Diagnose button and the Details
  button, dropped the rules/grid/mode dev fields from the result brief and capture
  meta, shortened the topbar headline, and moved Demo-Daten out of the topbar.
- Removed the now-dead ScanDiagnosticsModal / ScanDetailsModal components (their
  content moved into the view); metrics grid hidden on the Diagnose view.
- Added dev:web script + .claude/launch.json for browser preview.

Verified in the browser preview (both views render, no console errors); 120
tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:46:31 +02:00
AzuTear b8a38ba547 docs: scanner rework status (done vs remaining live-calibration items)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:57:23 +02:00
AzuTear dcac155887 feat(store): GOOD interop, rescan-merge, data staleness, lock detection
Task #5 building blocks, each a pure + unit-tested module:

- goodInterop.ts: GOOD (Genshin Optimizer / Inventory Kamera / Akasha) import and
  export for scanned StoredArtifactRecords - slot/stat/set key maps both ways,
  substat string <-> { key, value }, main-value reconstruction on import
  (ADR-003). Export is lossless; import is best-effort (GOOD lacks piece names).
- artifactMerge.ts: rescan-merge (ADR-006 follow-up). Level-independent identity
  (set + slot + main + substat NAME set) collapses leveled re-scan duplicates,
  keeping the higher-level/stronger record and summing timesSeen. Conservative:
  differing substat lineups never merge.
- dataPackageStatus.ts: warns when the genshin-db package is older than ~45 days
  (a patch cycle) so new sets/characters aren't silently missed; surfaced in the
  Scanner Diagnose data-package line. Adds dataGeneratedAt to genshinData.
- lockDetection.ts: EXPERIMENTAL read-only lock-status heuristic (gold-pixel
  ratio in a top-right icon crop). Pure + tested but not wired into capture; crop
  position and threshold need calibration against a reference 16:9 screenshot.

120 tests + build green. Remaining wiring (needs UI / live calibration): GOOD
import/export buttons and live lock detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:55:20 +02:00
AzuTear 6ea3d9e714 feat(scan): card-ready gating replaces the fixed settle delay
Adds src/lib/cardReadyGate.ts: after a tile click, poll the detail fingerprint
until it has both changed from the previous artifact and stabilized across
consecutive samples, instead of waiting a hardcoded 280ms and hoping.

- Faster on quick machines (proceeds as soon as the card is stable), correct on
  slow ones (waits up to the budget).
- Robust to particle/hover-glow animation: requiring two consecutive equal
  samples ignores single-frame noise, and if the card never fully stabilizes it
  still proceeds once the content has changed rather than looping on an animated
  frame.
- ESC/stop abort is honored between polls via checkAbort.

autoScanLoop now uses waitForCardReady for both the initial read and the one
retry; CLICK_SETTLE_MS removed. 6 new unit tests; 94 total green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:47:03 +02:00
AzuTear 2c6c1a8b31 feat(ocr): resolution-anchored layout module + crop preprocessing
Implements ADR-009 (structure + preprocessing; exact IK fixed coordinates still
need calibration against a reference 16:9 screenshot).

- src/lib/layoutProfile.ts: pure, unit-tested geometry for the artifact screen -
  detail rect, the four detail crops, inventory rect/count crop, 5-col grid,
  16:9 detection, aspect label, and an off-16:9 support warning. Single source of
  truth; electron/main.ts now delegates all crop/grid geometry to it and keeps
  colour detection only as the detail-rect fallback.
- src/lib/ocrPreprocess.ts: pure, unit-tested Otsu binarization with inversion
  (artifact text is the bright foreground) over a BGRA bitmap.
- main.ts: OCR now reads an upscaled + binarized copy of each crop; the original
  crop is retained for the diagnostics UI. CaptureResult carries layout info
  { aspect, isSixteenNine, warning }.

NOTE: image preprocessing changes the OCR input and cannot be validated by the
text-level eval harness; it needs a live Genshin 16:9 capture to confirm/tune
(threshold, invert, upscale factor). 88 tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:42:16 +02:00
AzuTear c7138b541d feat(native): C# input/capture sidecar replacing the PowerShell helper
Implements ADR-008. native/input-helper is a self-contained .NET 9 console exe
speaking the identical JSON-over-stdin/stdout protocol as the old PowerShell
helper (ping/cursor/runtime/focus/click/scroll/bounds/capture), so the
InputHelperService interface is unchanged.

- Win32 interop compiled once (native exe), not per call.
- PerMonitorV2 DPI via manifest so click/capture coordinates stay correct on
  mixed-DPI multi-monitor setups.
- capture returns base64 PNG bytes inline (imageBase64) instead of writing a
  temp file per frame; the client handles both base64 and the PowerShell path.
- InputHelperClient prefers the exe and falls back to the embedded PowerShell
  helper when the exe is absent, so the app still runs without the .NET build.
- main.ts resolves the exe (INPUT_HELPER_EXE env -> packaged resources/input-helper
  -> native/input-helper/bin/publish). electron-builder ships it via extraResources.
- npm run helper:build; README documents the build + fallback.

Verified end-to-end through the compiled client: sidecar spawns, runtime info
and a base64 primary-screen capture return correctly. Build stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:33:26 +02:00
AzuTear 92345ef51a fix(scan): repair pre-existing tsc errors so the build is green
The scan feature had 16 tsc errors from refactor drift; npm run build failed.

- modals/hooks/* are five levels deep but imported ../../../../lib (four);
  corrected to ../../../../../lib.
- useScanDiagnosticsModalModel / useScanReviewQueueModalModel picked
  saveReviewSample / canSaveReviewSample / loadReviewQueue from the modal Props,
  but the components wire those through from controller.*; source them from the
  controller type instead (fixes the downstream unknown-type errors).
- useScanResultCardModel let its field-row tuple array widen to
  (string | ParsedField)[][]; annotate it Array<[string, ParsedField]> like the
  sibling hook.
- ScanTopControlsModel was missing autoScanRunning (destructured by the
  component); add it. useScanTopControlsModel does not use refreshCaptureSources,
  so its input is Omit<...,"refreshCaptureSources">.

tsc, vite build, and the electron build all pass; 74 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:25:34 +02:00
167 changed files with 29461 additions and 3722 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "vite",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:web"],
"port": 5173
}
]
}
+5
View File
@@ -5,6 +5,11 @@ node_modules/
dist/
dist-electron/
outputs/dist/
outputs/admin-start/
outputs/live-capture/
outputs/live-soak/
outputs/native-live-smoke/
outputs/review-eval-candidates/
# Logs
*.log
+24 -9
View File
@@ -1,23 +1,24 @@
# 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.
Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on external scanner or optimizer tools 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.
- Electron + React + TypeScript app shell with a C# sidecar for fast read-only scan input/capture.
- Artifact-first scanner scope. Weapons, materials, and character details are intentionally not active scanner features yet.
- Smart Capture for the currently visible artifact detail view with focused OCR crops, parser confidence, and review notes.
- Visible-inventory auto-scan baseline with read-only tile selection, detail verification, OCR, parse, store/review, scroll, and summary.
- Native IK-style artifact capture path that writes card crops and run artifacts for post-capture processing.
- Vendored Inventory Kamera `inventorylists` under `data/ik-inventorylists` as artifact reference data.
- Scan result rail plus Inventory view for native results, stored artifacts, crop previews, IK/GOOD match state, explicit promotion, single-result review/edit/approve, and Artifact-only pipeline state.
- GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and 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/CURRENT_STATUS.md](docs/CURRENT_STATUS.md)
- [docs/PROJECT.md](docs/PROJECT.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
@@ -34,6 +35,20 @@ npm run dev
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
### Input/Capture helper (C# sidecar)
Input automation and screen capture run through a compiled C# sidecar
(`native/input-helper`, see ADR-008). Build it once:
```powershell
npm run helper:build # requires the .NET SDK; produces a self-contained exe
```
The app auto-detects the exe (`INPUT_HELPER_EXE` env override → packaged
`resources/input-helper``native/input-helper/bin/publish`). If the exe is not
present it falls back to the embedded PowerShell helper, so the app still runs
without the .NET build - just slower and with the old per-frame temp-file capture.
### 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:
+20
View File
@@ -0,0 +1,20 @@
# IK inventorylists
Quelle: lokales Inventory-Kamera-1.4.4-Paket, Unterordner `inventorylists`
Kopiert am: 2026-07-09
Version laut `version.txt`: `6.7.0`
Geladene Kategorien laut Helper:
- artifacts: `61` Sets / `289` Pieces
- weapons: `247`
- characters: `119`
- materials: `715`
- totalEntries: `1370`
Die Dateien wurden 1:1 uebernommen, damit der native Scanner dieselben Listen
fuer Artefakte, Waffen, Charaktere und Materialien wie Inventory Kamera nutzen
kann. Die App soll diese Daten nur laden und abgleichen, nicht manuell
nachmodellieren.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+717
View File
@@ -0,0 +1,717 @@
{
"aberrantcoreofthedeepshadow": "AberrantCoreOfTheDeepShadow",
"abidingangelfish": "AbidingAngelfish",
"adhigamawood": "AdhigamaWood",
"adventurersexperience": "AdventurersExperience",
"afloweryettobloom": "AFlowerYetToBloom",
"afterglowoflongnightflint": "AfterglowOfLongNightFlint",
"agentssacrificialknife": "AgentsSacrificialKnife",
"agnidusagatechunk": "AgnidusAgateChunk",
"agnidusagatefragment": "AgnidusAgateFragment",
"agnidusagategemstone": "AgnidusAgateGemstone",
"agnidusagatesliver": "AgnidusAgateSliver",
"aizenmedaka": "AizenMedaka",
"ajilenakhnut": "AjilenakhNut",
"akaimaou": "AkaiMaou",
"akossakevessel": "AkosSakeVessel",
"alderwood": "AlderWood",
"alienlifecore": "AlienLifeCore",
"alkahest": "Alkahest",
"almond": "Almond",
"amakumofruit": "AmakumoFruit",
"amethystlump": "AmethystLump",
"araliawood": "AraliaWood",
"artfuldevicefragment": "ArtfulDeviceFragment",
"artfuldeviceinheritance": "ArtfulDeviceInheritance",
"artfuldevicereplica": "ArtfulDeviceReplica",
"artfuldevicewish": "ArtfulDeviceWish",
"artificeddynamicgear": "ArtificedDynamicGear",
"artificedspareclockworkcomponentcoppelia": "ArtificedSpareClockworkComponentCoppelia",
"artificedspareclockworkcomponentcoppelius": "ArtificedSpareClockworkComponentCoppelius",
"ascendedsampleknight": "AscendedSampleKnight",
"ascendedsamplequeen": "AscendedSampleQueen",
"ascendedsamplerook": "AscendedSampleRook",
"ashenaratikuwood": "AshenAratikuWood",
"ashenheart": "AshenHeart",
"ashwood": "AshWood",
"athelwood": "AthelWood",
"aureateradianceofthefarnorthscions": "AureateRadianceOfTheFarNorthScions",
"axisofthesecretsource": "AxisOfTheSecretSource",
"azuregazecrystaleye": "AzuregazeCrystalEye",
"bacon": "Bacon",
"bamboosegment": "BambooSegment",
"bambooshoot": "BambooShoot",
"basaltpillar": "BasaltPillar",
"berry": "Berry",
"berrybait": "BerryBait",
"berylconch": "BerylConch",
"betta": "Betta",
"bewilderingbroadleaf": "BewilderingBroadleaf",
"birchwood": "BirchWood",
"birdegg": "BirdEgg",
"bitofaerosiderite": "BitOfAerosiderite",
"bitterpufferfish": "BitterPufferfish",
"blackbronzehorn": "BlackBronzeHorn",
"blackcrystalhorn": "BlackCrystalHorn",
"blazeoflongnightflint": "BlazeOfLongNightFlint",
"blazingaxeheadfish": "BlazingAxeheadFish",
"blazingheartfeatherbass": "BlazingHeartfeatherBass",
"blazingprismshell": "BlazingPrismshell",
"blazingsacrificialheartshesitance": "BlazingSacrificialHeartsHesitance",
"blazingsacrificialheartsresolve": "BlazingSacrificialHeartsResolve",
"blazingsacrificialheartssplendor": "BlazingSacrificialHeartsSplendor",
"blazingsacrificialheartsterror": "BlazingSacrificialHeartsTerror",
"bloodjadebranch": "BloodjadeBranch",
"bluedye": "BlueDye",
"borderlandbowbillet": "BorderlandBowBillet",
"borderlandcatalystbillet": "BorderlandCatalystBillet",
"borderlandclaymorebillet": "BorderlandClaymoreBillet",
"borderlandpolearmbillet": "BorderlandPolearmBillet",
"borderlandswordbillet": "BorderlandSwordBillet",
"borealwolfsbrokenfang": "BorealWolfsBrokenFang",
"borealwolfscrackedtooth": "BorealWolfsCrackedTooth",
"borealwolfsmilktooth": "BorealWolfsMilkTooth",
"borealwolfsnostalgia": "BorealWolfsNostalgia",
"brightwood": "Brightwood",
"brilliantchrysanthemum": "BrilliantChrysanthemum",
"brilliantdiamondchunk": "BrilliantDiamondChunk",
"brilliantdiamondfragment": "BrilliantDiamondFragment",
"brilliantdiamondgemstone": "BrilliantDiamondGemstone",
"brilliantdiamondsliver": "BrilliantDiamondSliver",
"brokendriveshaft": "BrokenDriveShaft",
"brokengobletofthepristinesea": "BrokenGobletOfThePristineSea",
"brownshirakodai": "BrownShirakodai",
"butter": "Butter",
"butterflywings": "ButterflyWings",
"cabbage": "Cabbage",
"cacahuatl": "Cacahuatl",
"callalily": "CallaLily",
"camandcablesoflaw": "CamAndCablesOfLaw",
"carrot": "Carrot",
"cecilia": "Cecilia",
"ceruleandoppeldrake": "CeruleanDoppeldrake",
"chainsofthedandeliongladiator": "ChainsOfTheDandelionGladiator",
"chaosaxis": "ChaosAxis",
"chaosbolt": "ChaosBolt",
"chaoscircuit": "ChaosCircuit",
"chaoscore": "ChaosCore",
"chaosdevice": "ChaosDevice",
"chaosgear": "ChaosGear",
"chaosmodule": "ChaosModule",
"chaosoculus": "ChaosOculus",
"chaosstorage": "ChaosStorage",
"chapterofanancientchord": "ChapterOfAnAncientChord",
"chasmlightfin": "ChasmlightFin",
"cheese": "Cheese",
"chenyuadeptea": "ChenyuAdeptea",
"chilledmeat": "ChilledMeat",
"chunkofaerosiderite": "ChunkOfAerosiderite",
"cleansingheart": "CleansingHeart",
"clearwaterjade": "ClearwaterJade",
"cloudseamscale": "CloudseamScale",
"coffeebeans": "CoffeeBeans",
"coldcrackedshellshard": "ColdCrackedShellshard",
"commonaxeheadfish": "CommonAxeheadFish",
"compositebowtuningkit": "CompositeBowTuningKit",
"concealedclaw": "ConcealedClaw",
"concealedtalon": "ConcealedTalon",
"concealedunguis": "ConcealedUnguis",
"condessencecrystal": "CondessenceCrystal",
"congealedpupawax": "CongealedPupaWax",
"coppertalismanoftheforestdew": "CopperTalismanOfTheForestDew",
"coralbranchofadistantsea": "CoralBranchOfADistantSea",
"corlapis": "CorLapis",
"counterfeitresin": "CounterfeitResin",
"crab": "Crab",
"crabroe": "CrabRoe",
"crafteditems": "CraftedItems",
"cream": "Cream",
"crownofinsight": "CrownOfInsight",
"crystalchunk": "CrystalChunk",
"crystalcore": "CrystalCore",
"crystalfish": "Crystalfish",
"crystallinebloom": "CrystallineBloom",
"crystallinecystdust": "CrystallineCystDust",
"crystalmarrow": "CrystalMarrow",
"crystalprism": "CrystalPrism",
"cuihuawood": "CuihuaWood",
"cyclicmilitarykuuvahkicore": "CyclicMilitaryKuuvahkiCore",
"cypresswood": "CypressWood",
"dakasbell": "DakasBell",
"damagedmask": "DamagedMask",
"damagedprism": "DamagedPrism",
"dandelionbookmark": "DandelionBookmark",
"dandelionseed": "DandelionSeed",
"darkstatuette": "DarkStatuette",
"dawncatcher": "Dawncatcher",
"deadleylinebranch": "DeadLeyLineBranch",
"deadleylineleaves": "DeadLeyLineLeaves",
"deathlystatuette": "DeathlyStatuette",
"debrisofdecarabianscity": "DebrisOfDecarabiansCity",
"deliriousdecadenceofthesacredlord": "DeliriousDecadenceOfTheSacredLord",
"deliriousdemeanorofthesacredlord": "DeliriousDemeanorOfTheSacredLord",
"deliriousdesolationofthesacredlord": "DeliriousDesolationOfTheSacredLord",
"deliriousdivinityofthesacredlord": "DeliriousDivinityOfTheSacredLord",
"dendrobium": "Dendrobium",
"denialandjudgment": "DenialAndJudgment",
"depletedlunariron": "DepletedLunarIron",
"desiccatedshell": "DesiccatedShell",
"dewofrepudiation": "DewOfRepudiation",
"dismalprism": "DismalPrism",
"divdaray": "DivdaRay",
"divinebodyfromguyun": "DivineBodyFromGuyun",
"divingrapidfightingfish": "DivingRapidfightingFish",
"diviningscroll": "DiviningScroll",
"dormantfungalnucleus": "DormantFungalNucleus",
"dracolite": "Dracolite",
"dragonheirsfalsefin": "DragonheirsFalseFin",
"dragonlordscrown": "DragonLordsCrown",
"dreamofscorchingmight": "DreamOfScorchingMight",
"dreamofthedandeliongladiator": "DreamOfTheDandelionGladiator",
"driedfish": "DriedFish",
"drifterscrystalmarrow": "DriftersCrystalMarrow",
"dropoftaintedwater": "DropOfTaintedWater",
"drossofpuresacreddewdrop": "DrossOfPureSacredDewdrop",
"dusksunfish": "DuskSunfish",
"dvalinsclaw": "DvalinsClaw",
"dvalinsplume": "DvalinsPlume",
"dvalinssigh": "DvalinsSigh",
"echoofanancientchord": "EchoOfAnAncientChord",
"echoofscorchingmight": "EchoOfScorchingMight",
"eelmeat": "EelMeat",
"electrocrystal": "ElectroCrystal",
"elixiroftheheretic": "ElixirOfTheHeretic",
"embercoreflower": "EmbercoreFlower",
"emberglowbait": "EmberglowBait",
"emberoflongnightflint": "EmberOfLongNightFlint",
"emperorsbalsam": "EmperorsBalsam",
"emperorsresolution": "EmperorsResolution",
"empowereddragontooth": "EmpoweredDragontooth",
"energynectar": "EnergyNectar",
"enhancementore": "EnhancementOre",
"ensnaringgaze": "EnsnaringGaze",
"erodedhorn": "ErodedHorn",
"erodedscalefeather": "ErodedScaleFeather",
"erodedsunfire": "ErodedSunfire",
"essenceofpuresacreddewdrop": "EssenceOfPureSacredDewdrop",
"etherwingmoth": "EtherwingMoth",
"everamber": "Everamber",
"everflameseed": "EverflameSeed",
"evergloomring": "EvergloomRing",
"exaltedearth": "ExaltedEarth",
"fabric": "Fabric",
"fadedflaminghilt": "FadedFlamingHilt",
"fadedredsatin": "FadedRedSatin",
"fadingcandle": "FadingCandle",
"fakeflybait": "FakeFlyBait",
"falsewormbait": "FalseWormBait",
"famedhandguard": "FamedHandguard",
"featheryfin": "FeatheryFin",
"fermentedjuice": "FermentedJuice",
"festeringdragonmarrow": "FesteringDragonMarrow",
"fettersofthedandeliongladiator": "FettersOfTheDandelionGladiator",
"fineenhancementore": "FineEnhancementOre",
"firmarrowhead": "FirmArrowhead",
"firwood": "FirWood",
"fish": "Fish",
"flamingflowerstamen": "FlamingFlowerStamen",
"flammabombwood": "FlammabombWood",
"flareoflongnightflint": "FlareOfLongNightFlint",
"flashingmaintenancemekbait": "FlashingMaintenanceMekBait",
"floralrapidfightingfish": "FloralRapidfightingFish",
"flour": "Flour",
"fluorescentfungus": "FluorescentFungus",
"fontemerunihorn": "FontemerUnihorn",
"forbiddencursescroll": "ForbiddenCurseScroll",
"foreignsynapse": "ForeignSynapse",
"formaloray": "FormaloRay",
"fossilizedboneshard": "FossilizedBoneShard",
"fowl": "Fowl",
"fowls": "FowlS",
"fracturedeyeofthedeepshadow": "FracturedEyeOfTheDeepShadow",
"fracturedflaminghilt": "FracturedFlamingHilt",
"fracturedlunariron": "FracturedLunarIron",
"fragileboneshard": "FragileBoneShard",
"fragmentofagoldenmelody": "FragmentOfAGoldenMelody",
"fragmentofanancientchord": "FragmentOfAnAncientChord",
"fragmentofdecarabiansepic": "FragmentOfDecarabiansEpic",
"fragmentsofinnocence": "FragmentsOfInnocence",
"fragrantcedarwood": "FragrantCedarWood",
"frog": "Frog",
"frostedaxeheadfish": "FrostedAxeheadFish",
"frostetchedwarrant": "FrostEtchedWarrant",
"frostlampflower": "FrostlampFlower",
"frostnightsglimmer": "FrostnightsGlimmer",
"frostnightsglory": "FrostnightsGlory",
"frostnightsglow": "FrostnightsGlow",
"fruitpastebait": "FruitPasteBait",
"fungalspores": "FungalSpores",
"gildedscale": "GildedScale",
"glabrousbeans": "GlabrousBeans",
"glazelily": "GlazeLily",
"glazemedaka": "GlazeMedaka",
"gloomystatuette": "GloomyStatuette",
"glowgrassbait": "GlowgrassBait",
"glowinggem": "GlowingGem",
"glowinghornshroom": "GlowingHornshroom",
"glowingremains": "GlowingRemains",
"goldenbranchofadistantsea": "GoldenBranchOfADistantSea",
"goldengobletofthepristinesea": "GoldenGobletOfThePristineSea",
"goldenkoi": "GoldenKoi",
"goldenraveninsignia": "GoldenRavenInsignia",
"goldenstrings": "GoldenStrings",
"goldentalismanoftheforestdew": "GoldenTalismanOfTheForestDew",
"goldinscribedsecretsourcecore": "GoldInscribedSecretSourceCore",
"grainfruit": "Grainfruit",
"grainofaerosiderite": "GrainOfAerosiderite",
"greenwavesunfish": "GreenwaveSunfish",
"guidetoadmonition": "GuideToAdmonition",
"guidetoballad": "GuideToBallad",
"guidetoconflict": "GuideToConflict",
"guidetocontention": "GuideToContention",
"guidetodiligence": "GuideToDiligence",
"guidetoelegance": "GuideToElegance",
"guidetoelysium": "GuideToElysium",
"guidetoequity": "GuideToEquity",
"guidetofreedom": "GuideToFreedom",
"guidetogold": "GuideToGold",
"guidetoingenuity": "GuideToIngenuity",
"guidetojustice": "GuideToJustice",
"guidetokindling": "GuideToKindling",
"guidetolight": "GuideToLight",
"guidetomoonlight": "GuideToMoonlight",
"guidetoorder": "GuideToOrder",
"guidetopraxis": "GuideToPraxis",
"guidetoprosperity": "GuideToProsperity",
"guidetoresistance": "GuideToResistance",
"guidetotransience": "GuideToTransience",
"guidetovagrancy": "GuideToVagrancy",
"halcyonjadeaxemarlin": "HalcyonJadeAxeMarlin",
"ham": "Ham",
"harrafruit": "HarraFruit",
"hazelnutwood": "HazelnutWood",
"heartofthesecretsource": "HeartOfTheSecretSource",
"heavyhorn": "HeavyHorn",
"hellfirebutterfly": "HellfireButterfly",
"hennaberry": "HennaBerry",
"heroswit": "HerosWit",
"hoarfrostcore": "HoarfrostCore",
"hookedbeakofthedeepshadow": "HookedBeakOfTheDeepShadow",
"horsetail": "Horsetail",
"hunterssacrificialknife": "HuntersSacrificialKnife",
"hurricaneseed": "HurricaneSeed",
"icypebble": "IcyPebble",
"ignitedseedoflife": "IgnitedSeedOfLife",
"ignitedseeingeye": "IgnitedSeeingEye",
"ignitedstone": "IgnitedStone",
"illusoryleafcoil": "IllusoryLeafcoil",
"immaculatewarrant": "ImmaculateWarrant",
"inactivatedfungalnucleus": "InactivatedFungalNucleus",
"inspectorssacrificialknife": "InspectorsSacrificialKnife",
"ironchunk": "IronChunk",
"irontalismanoftheforestdew": "IronTalismanOfTheForestDew",
"jadebranchofadistantsea": "JadeBranchOfADistantSea",
"jadeheartfeatherbass": "JadeHeartfeatherBass",
"jam": "Jam",
"jeweledbranchofadistantsea": "JeweledBranchOfADistantSea",
"jeweledflaminghilt": "JeweledFlamingHilt",
"jueyunchili": "JueyunChili",
"juvenilefang": "JuvenileFang",
"juvenilejade": "JuvenileJade",
"kageuchihandguard": "KageuchiHandguard",
"kalpalatalotus": "KalpalataLotus",
"karmaphalawood": "KarmaphalaWood",
"lakelightlily": "LakelightLily",
"lakkaberry": "Lakkaberry",
"lanternfiber": "LanternFiber",
"lavendermelon": "LavenderMelon",
"lazuriteaxemarlin": "LazuriteAxeMarlin",
"leylinesprout": "LeyLineSprout",
"lieutenantsinsignia": "LieutenantsInsignia",
"lightbearingscalefeather": "LightbearingScaleFeather",
"lightguidingtetrahedron": "LightGuidingTetrahedron",
"lightlessbone": "LightlessBone",
"lightlesseyeofthemaelstrom": "LightlessEyeOfTheMaelstrom",
"lightlessmass": "LightlessMass",
"lightlesssilkstring": "LightlessSilkString",
"lightningprism": "LightningPrism",
"lindenwood": "LindenWood",
"lizardtail": "LizardTail",
"loachpearl": "LoachPearl",
"locusofaclearwill": "LocusOfAClearWill",
"lotushead": "LotusHead",
"lumidoucebell": "LumidouceBell",
"luminescentpollen": "LuminescentPollen",
"luminescentspine": "LuminescentSpine",
"luminoussandsfromguyun": "LuminousSandsFromGuyun",
"lumitoile": "Lumitoile",
"lunarfin": "LunarFin",
"lungedstickleback": "LungedStickleback",
"lustrousstonefromguyun": "LustrousStoneFromGuyun",
"madmansrestraint": "MadmansRestraint",
"magicalcrystalchunk": "MagicalCrystalChunk",
"magmarapidfightingfish": "MagmaRapidfightingFish",
"maintenancemekgoldleader": "MaintenanceMekGoldLeader",
"maintenancemekinitialconfiguration": "MaintenanceMekInitialConfiguration",
"maintenancemekplatinumcollection": "MaintenanceMekPlatinumCollection",
"maintenancemeksituationcontroller": "MaintenanceMekSituationController",
"maintenancemekwaterbodycleaner": "MaintenanceMekWaterBodyCleaner",
"majestichookedbeak": "MajesticHookedBeak",
"mallowwood": "MallowWood",
"maplewood": "MapleWood",
"marcotte": "Marcotte",
"marionettecore": "MarionetteCore",
"markedshell": "MarkedShell",
"markofthebindingblessing": "MarkOfTheBindingBlessing",
"martensomnifix": "MartensOmniFix",
"maskofthekijin": "MaskOfTheKijin",
"maskoftheonehorned": "MaskOfTheOneHorned",
"maskofthetigersbite": "MaskOfTheTigersBite",
"maskofthevirtuousdoctor": "MaskOfTheVirtuousDoctor",
"maskofthewickedlieutenant": "MaskOfTheWickedLieutenant",
"matsutake": "Matsutake",
"mechanicalspurgear": "MechanicalSpurGear",
"medaka": "Medaka",
"meshinggear": "MeshingGear",
"midlanderbowbillet": "MidlanderBowBillet",
"midlandercatalystbillet": "MidlanderCatalystBillet",
"midlanderclaymorebillet": "MidlanderClaymoreBillet",
"midlanderpolearmbillet": "MidlanderPolearmBillet",
"midlanderswordbillet": "MidlanderSwordBillet",
"midsommarberry": "MidsommarBerry",
"milk": "Milk",
"mint": "Mint",
"mirrorofmushin": "MirrorOfMushin",
"mistflowercorolla": "MistFlowerCorolla",
"mistgrass": "MistGrass",
"mistgrasspollen": "MistGrassPollen",
"mistgrasswick": "MistGrassWick",
"mistshroudhelmet": "MistshroudHelmet",
"mistshroudmanifestation": "MistshroudManifestation",
"mistshroudplate": "MistshroudPlate",
"mistveiledgoldelixir": "MistVeiledGoldElixir",
"mistveiledleadelixir": "MistVeiledLeadElixir",
"mistveiledmercuryelixir": "MistVeiledMercuryElixir",
"mistveiledprimoelixir": "MistVeiledPrimoElixir",
"moltenmoment": "MoltenMoment",
"moonfallsilver": "MoonfallSilver",
"mountaindatewood": "MountainDateWood",
"mourningflower": "MourningFlower",
"movementofanancientchord": "MovementOfAnAncientChord",
"mudraofthemaleficgeneral": "MudraOfTheMaleficGeneral",
"mushroom": "Mushroom",
"mysteriousmeat": "MysteriousMeat",
"mysticenhancementore": "MysticEnhancementOre",
"nagadusemeraldchunk": "NagadusEmeraldChunk",
"nagadusemeraldfragment": "NagadusEmeraldFragment",
"nagadusemeraldgemstone": "NagadusEmeraldGemstone",
"nagadusemeraldsliver": "NagadusEmeraldSliver",
"nakuweed": "NakuWeed",
"narukamisaffection": "NarukamisAffection",
"narukamisjoy": "NarukamisJoy",
"narukamisvalor": "NarukamisValor",
"narukamiswisdom": "NarukamisWisdom",
"neonmaulershark": "NeonMaulerShark",
"newborntaintedhydrophantasm": "NewbornTaintedHydroPhantasm",
"nightgazecrystaleye": "NightgazeCrystalEye",
"nightwindsmysticaugury": "NightWindsMysticAugury",
"nightwindsmysticconsideration": "NightWindsMysticConsideration",
"nightwindsmysticpremonition": "NightWindsMysticPremonition",
"nightwindsmysticrevelation": "NightWindsMysticRevelation",
"nilotpalalotus": "NilotpalaLotus",
"noctilucousjade": "NoctilucousJade",
"nocturnalblossom": "NocturnalBlossom",
"northlanderbowbillet": "NorthlanderBowBillet",
"northlandercatalystbillet": "NorthlanderCatalystBillet",
"northlanderclaymorebillet": "NorthlanderClaymoreBillet",
"northlanderpolearmbillet": "NorthlanderPolearmBillet",
"northlanderswordbillet": "NorthlanderSwordBillet",
"oasisgardenskindness": "OasisGardensKindness",
"oasisgardensmourning": "OasisGardensMourning",
"oasisgardensreminiscence": "OasisGardensReminiscence",
"oasisgardenstruth": "OasisGardensTruth",
"oblationofthefarnorthscions": "OblationOfTheFarNorthScions",
"ointmentofsight": "OintmentOfSight",
"oldendaysofscorchingmight": "OldenDaysOfScorchingMight",
"oldhandguard": "OldHandguard",
"oldoperativespocketwatch": "OldOperativesPocketWatch",
"ominousmask": "OminousMask",
"onikabuto": "Onikabuto",
"onion": "Onion",
"operativesconstancy": "OperativesConstancy",
"operativesstandardpocketwatch": "OperativesStandardPocketWatch",
"originalfishointment": "OriginalFishOintment",
"otogiwood": "OtogiWood",
"overripeflamegranate": "OverripeFlamegranate",
"padisarah": "Padisarah",
"parasoltalcum": "ParasolTalcum",
"peachofthedeepwaves": "PeachOfTheDeepWaves",
"peachpalmwood": "PeachPalmWood",
"pedunculateoakwood": "PedunculateOakWood",
"pepper": "Pepper",
"perpetualcaliber": "PerpetualCaliber",
"perpetualheart": "PerpetualHeart",
"philanemomushroom": "PhilanemoMushroom",
"philosophiesofadmonition": "PhilosophiesOfAdmonition",
"philosophiesofballad": "PhilosophiesOfBallad",
"philosophiesofconflict": "PhilosophiesOfConflict",
"philosophiesofcontention": "PhilosophiesOfContention",
"philosophiesofdiligence": "PhilosophiesOfDiligence",
"philosophiesofelegance": "PhilosophiesOfElegance",
"philosophiesofelysium": "PhilosophiesOfElysium",
"philosophiesofequity": "PhilosophiesOfEquity",
"philosophiesoffreedom": "PhilosophiesOfFreedom",
"philosophiesofgold": "PhilosophiesOfGold",
"philosophiesofingenuity": "PhilosophiesOfIngenuity",
"philosophiesofjustice": "PhilosophiesOfJustice",
"philosophiesofkindling": "PhilosophiesOfKindling",
"philosophiesoflight": "PhilosophiesOfLight",
"philosophiesofmoonlight": "PhilosophiesOfMoonlight",
"philosophiesoforder": "PhilosophiesOfOrder",
"philosophiesofpraxis": "PhilosophiesOfPraxis",
"philosophiesofprosperity": "PhilosophiesOfProsperity",
"philosophiesofresistance": "PhilosophiesOfResistance",
"philosophiesoftransience": "PhilosophiesOfTransience",
"philosophiesofvagrancy": "PhilosophiesOfVagrancy",
"phonyphlogistonunihornfish": "PhonyPhlogistonUnihornfish",
"pieceofaerosiderite": "PieceOfAerosiderite",
"pineamber": "PineAmber",
"pinecone": "Pinecone",
"pinewood": "PineWood",
"plaustriteshard": "PlaustriteShard",
"pluielotus": "PluieLotus",
"plumeofthechangingwinds": "PlumeOfTheChangingWinds",
"plumeofthefallenwatcher": "PlumeOfTheFallenWatcher",
"polarizingprism": "PolarizingPrism",
"portablebearing": "PortableBearing",
"potato": "Potato",
"precisiondriveshaft": "PrecisionDriveShaft",
"precisionkuuvahkistampingdie": "PrecisionKuuvahkiStampingDie",
"primordialessence": "PrimordialEssence",
"primordialgreenbloom": "PrimordialGreenbloom",
"prismaticseveredtail": "PrismaticSeveredTail",
"prithivatopazchunk": "PrithivaTopazChunk",
"prithivatopazfragment": "PrithivaTopazFragment",
"prithivatopazgemstone": "PrithivaTopazGemstone",
"prithivatopazsliver": "PrithivaTopazSliver",
"profanedsprout": "ProfanedSprout",
"pseudosharkunihornfish": "PseudosharkUnihornfish",
"pseudostamens": "PseudoStamens",
"pufferfish": "Pufferfish",
"puppetstrings": "PuppetStrings",
"purpleshirakodai": "PurpleShirakodai",
"qingxin": "Qingxin",
"quelledcreeper": "QuelledCreeper",
"quenepaberry": "QuenepaBerry",
"radiantantler": "RadiantAntler",
"radiantexoskeleton": "RadiantExoskeleton",
"radiantprism": "RadiantPrism",
"radish": "Radish",
"raimeiangelfish": "RaimeiAngelfish",
"rainbowdropcrystal": "RainbowdropCrystal",
"rainbowrose": "RainbowRose",
"rawmeat": "RawMeat",
"rawmeats": "RawMeatS",
"recruitsinsignia": "RecruitsInsignia",
"redberryshroom": "RedBerryshroom",
"reddye": "RedDye",
"redrotbait": "RedrotBait",
"refinedheart": "RefinedHeart",
"refractivebud": "RefractiveBud",
"refreshinglakkabait": "RefreshingLakkaBait",
"reinforceddriveshaft": "ReinforcedDriveShaft",
"relicfromguyun": "RelicFromGuyun",
"remnantglowofscorchingmight": "RemnantGlowOfScorchingMight",
"remnantofthedreadwing": "RemnantOfTheDreadwing",
"rice": "Rice",
"richredbrocade": "RichRedBrocade",
"riftbornregalia": "RiftbornRegalia",
"riftcore": "RiftCore",
"ringofboreas": "RingOfBoreas",
"ripplingheartfeatherbass": "RipplingHeartfeatherBass",
"robustfungalnucleus": "RobustFungalNucleus",
"romaritimeflower": "RomaritimeFlower",
"ruinedhilt": "RuinedHilt",
"rukkhashavamushrooms": "RukkhashavaMushrooms",
"runicfang": "RunicFang",
"rustykoi": "RustyKoi",
"rye": "Rye",
"ryeflour": "RyeFlour",
"sakurabloom": "SakuraBloom",
"salt": "Salt",
"sandbearerwood": "SandbearerWood",
"sandgreasepupa": "SandGreasePupa",
"sandstormangler": "SandstormAngler",
"sangopearl": "SangoPearl",
"saurianclawsucculent": "SaurianClawSucculent",
"sauriancrownedwarriorsgoldenwhistle": "SaurianCrownedWarriorsGoldenWhistle",
"sausage": "Sausage",
"scarab": "Scarab",
"scatteredpieceofdecarabiansdream": "ScatteredPieceOfDecarabiansDream",
"scoopoftaintedwater": "ScoopOfTaintedWater",
"seaganoderma": "SeaGanoderma",
"seagrass": "Seagrass",
"sealedscroll": "SealedScroll",
"seasonedfang": "SeasonedFang",
"secretsourceairflowaccumulator": "SecretSourceAirflowAccumulator",
"secretsourcescoutsweeper": "SecretSourceScoutSweeper",
"sentryswoodenwhistle": "SentrysWoodenWhistle",
"sergeantsinsignia": "SergeantsInsignia",
"shacklesofthedandeliongladiator": "ShacklesOfTheDandelionGladiator",
"shadowofthewarrior": "ShadowOfTheWarrior",
"shardofafoullegacy": "ShardOfAFoulLegacy",
"shardofashatteredwill": "ShardOfAShatteredWill",
"sharparrowhead": "SharpArrowhead",
"sheathofthesecretsource": "SheathOfTheSecretSource",
"shimmeringnectar": "ShimmeringNectar",
"shivadajadechunk": "ShivadaJadeChunk",
"shivadajadefragment": "ShivadaJadeFragment",
"shivadajadegemstone": "ShivadaJadeGemstone",
"shivadajadesliver": "ShivadaJadeSliver",
"shrimpmeat": "ShrimpMeat",
"shuttleofodara": "ShuttleOfOdara",
"sigilofastridingwill": "SigilOfAStridingWill",
"silkenfeather": "SilkenFeather",
"silkflower": "SilkFlower",
"silverfirwood": "SilverFirWood",
"silvergobletofthepristinesea": "SilverGobletOfThePristineSea",
"silverlotus": "SilverLotus",
"silverraveninsignia": "SilverRavenInsignia",
"silvertalismanoftheforestdew": "SilverTalismanOfTheForestDew",
"skysplitgembloom": "SkysplitGembloom",
"slimeconcentrate": "SlimeConcentrate",
"slimecondensate": "SlimeCondensate",
"slimesecretions": "SlimeSecretions",
"smalllampgrass": "SmallLampGrass",
"smetana": "Smetana",
"smokedfish": "SmokedFish",
"smokedfowl": "SmokedFowl",
"smolderingpearl": "SmolderingPearl",
"smolderingphosphorescentflame": "SmolderingPhosphorescentFlame",
"snapdragon": "Snapdragon",
"snowstrider": "Snowstrider",
"sourbait": "SourBait",
"sparklessstatuecore": "SparklessStatueCore",
"spectralheart": "SpectralHeart",
"spectralhusk": "SpectralHusk",
"spectralnucleus": "SpectralNucleus",
"spice": "Spice",
"spinelfruit": "SpinelFruit",
"spinelgrainbait": "SpinelgrainBait",
"spiritlocketofboreas": "SpiritLocketOfBoreas",
"splinteredhilt": "SplinteredHilt",
"sprayfeathergill": "SprayfeatherGill",
"springofpuresacreddewdrop": "SpringOfPureSacredDewdrop",
"springofthefirstdewdrop": "SpringOfTheFirstDewdrop",
"stainedmask": "StainedMask",
"starconch": "Starconch",
"starsilver": "Starsilver",
"stillsmolderinghilt": "StillSmolderingHilt",
"stormbeads": "StormBeads",
"strangetooth": "StrangeTooth",
"streamingaxemarlin": "StreamingAxeMarlin",
"sturdyboneshard": "SturdyBoneShard",
"sturdyshell": "SturdyShell",
"subdetectionunit": "SubdetectionUnit",
"sublimationofpuresacreddewdrop": "SublimationOfPureSacredDewdrop",
"sugar": "Sugar",
"sugardewbait": "SugardewBait",
"sumerurose": "SumeruRose",
"sunderedgloryofthefarnorthscions": "SunderedGloryOfTheFarNorthScions",
"sunsetcloudangler": "SunsetCloudAngler",
"superduperinvincibleshiningsparklymagiccrystal": "SuperDuperInvincibleShiningSparklyMagicCrystal",
"surgingsacredchalice": "SurgingSacredChalice",
"sweetflower": "SweetFlower",
"sweetflowermedaka": "SweetFlowerMedaka",
"tailofboreas": "TailOfBoreas",
"talismanoftheenigmaticland": "TalismanOfTheEnigmaticLand",
"tatteredwarrant": "TatteredWarrant",
"teachingsofadmonition": "TeachingsOfAdmonition",
"teachingsofballad": "TeachingsOfBallad",
"teachingsofconflict": "TeachingsOfConflict",
"teachingsofcontention": "TeachingsOfContention",
"teachingsofdiligence": "TeachingsOfDiligence",
"teachingsofelegance": "TeachingsOfElegance",
"teachingsofelysium": "TeachingsOfElysium",
"teachingsofequity": "TeachingsOfEquity",
"teachingsoffreedom": "TeachingsOfFreedom",
"teachingsofgold": "TeachingsOfGold",
"teachingsofingenuity": "TeachingsOfIngenuity",
"teachingsofjustice": "TeachingsOfJustice",
"teachingsofkindling": "TeachingsOfKindling",
"teachingsoflight": "TeachingsOfLight",
"teachingsofmoonlight": "TeachingsOfMoonlight",
"teachingsoforder": "TeachingsOfOrder",
"teachingsofpraxis": "TeachingsOfPraxis",
"teachingsofprosperity": "TeachingsOfProsperity",
"teachingsofresistance": "TeachingsOfResistance",
"teachingsoftransience": "TeachingsOfTransience",
"teachingsofvagrancy": "TeachingsOfVagrancy",
"teacoloredshirakodai": "TeaColoredShirakodai",
"teardropofthemoon": "TeardropOfTheMoon",
"tearsofthecalamitousgod": "TearsOfTheCalamitousGod",
"thecornerstoneofstarsandflames": "TheCornerstoneOfStarsAndFlames",
"themeaningofaeons": "TheMeaningOfAeons",
"theseassilentshade": "TheSeasSilentShade",
"thevisiblewinds": "TheVisibleWinds",
"thunderclapfruitcore": "ThunderclapFruitcore",
"tidalga": "Tidalga",
"tileofdecarabianstower": "TileOfDecarabiansTower",
"tofu": "Tofu",
"tomato": "Tomato",
"torchwood": "TorchWood",
"tourbillondevice": "TourbillonDevice",
"transoceanicchunk": "TransoceanicChunk",
"transoceanicpearl": "TransoceanicPearl",
"treasuredflower": "TreasuredFlower",
"treasurehoarderinsignia": "TreasureHoarderInsignia",
"trimmedredsilk": "TrimmedRedSilk",
"trishiraite": "Trishiraite",
"truefruitangler": "TrueFruitAngler",
"turbidprism": "TurbidPrism",
"tuskofmonoceroscaeli": "TuskOfMonocerosCaeli",
"twistedwitheredbranch": "TwistedWitheredBranch",
"tyrantsfang": "TyrantsFang",
"unblemishedlunariron": "UnblemishedLunarIron",
"unfadingsilkygrace": "UnfadingSilkyGrace",
"unyieldingdelusionofthefarnorthscions": "UnyieldingDelusionOfTheFarNorthScions",
"vajradaamethystchunk": "VajradaAmethystChunk",
"vajradaamethystfragment": "VajradaAmethystFragment",
"vajradaamethystgemstone": "VajradaAmethystGemstone",
"vajradaamethystsliver": "VajradaAmethystSliver",
"valberry": "Valberry",
"varunadalazuritechunk": "VarunadaLazuriteChunk",
"varunadalazuritefragment": "VarunadaLazuriteFragment",
"varunadalazuritegemstone": "VarunadaLazuriteGemstone",
"varunadalazuritesliver": "VarunadaLazuriteSliver",
"vayudaturquoisechunk": "VayudaTurquoiseChunk",
"vayudaturquoisefragment": "VayudaTurquoiseFragment",
"vayudaturquoisegemstone": "VayudaTurquoiseGemstone",
"vayudaturquoisesliver": "VayudaTurquoiseSliver",
"veggiemaulershark": "VeggieMaulerShark",
"venomspinefish": "VenomspineFish",
"violetgrass": "Violetgrass",
"viparyas": "Viparyas",
"wanderersadvice": "WanderersAdvice",
"wanderersbloomingflower": "WanderersBloomingFlower",
"warmbackshell": "WarmBackShell",
"warriorsmetalwhistle": "WarriorsMetalWhistle",
"waterthatfailedtotranscend": "WaterThatFailedToTranscend",
"weatheredarrowhead": "WeatheredArrowhead",
"wheat": "Wheat",
"whitechestnutoakwood": "WhiteChestnutOakWood",
"whiteironchunk": "WhiteIronChunk",
"whopperflowernectar": "WhopperflowerNectar",
"wickmaterial": "WickMaterial",
"windrestflower": "WindrestFlower",
"windwheelaster": "WindwheelAster",
"winegobletofthepristinesea": "WineGobletOfThePristineSea",
"wintericelea": "WinterIcelea",
"witheringpurpurbloom": "WitheringPurpurbloom",
"wolfhook": "Wolfhook",
"worldspanfern": "WorldspanFern",
"xenochromaticcrystal": "XenochromaticCrystal",
"yellowdye": "YellowDye",
"yumemiruwood": "YumemiruWood",
"zaytunpeach": "ZaytunPeach"
}
+1
View File
@@ -0,0 +1 @@
6.7.0
+249
View File
@@ -0,0 +1,249 @@
{
"absolution": "Absolution",
"akuoumaru": "Akuoumaru",
"alleyhunter": "AlleyHunter",
"amberbead": "AmberBead",
"amenomakageuchi": "AmenomaKageuchi",
"amosbow": "AmosBow",
"angelosheptades": "AngelosHeptades",
"apprenticesnotes": "ApprenticesNotes",
"aquasimulacra": "AquaSimulacra",
"aquilafavonia": "AquilaFavonia",
"ashgravendrinkinghorn": "AshGravenDrinkingHorn",
"astralvulturescrimsonplumage": "AstralVulturesCrimsonPlumage",
"ateaspoonoftranscendence": "ATeaspoonOfTranscendence",
"athameartis": "AthameArtis",
"athousandblazingsuns": "AThousandBlazingSuns",
"athousandfloatingdreams": "AThousandFloatingDreams",
"azurelight": "Azurelight",
"balladoftheboundlessblue": "BalladOfTheBoundlessBlue",
"balladofthefjords": "BalladOfTheFjords",
"beaconofthereedsea": "BeaconOfTheReedSea",
"beginnersprotector": "BeginnersProtector",
"blackcliffagate": "BlackcliffAgate",
"blackclifflongsword": "BlackcliffLongsword",
"blackcliffpole": "BlackcliffPole",
"blackcliffslasher": "BlackcliffSlasher",
"blackcliffwarbow": "BlackcliffWarbow",
"blackmarrowlantern": "BlackmarrowLantern",
"blacktassel": "BlackTassel",
"bloodsoakedruins": "BloodsoakedRuins",
"bloodtaintedgreatsword": "BloodtaintedGreatsword",
"calamityofeshu": "CalamityOfEshu",
"calamityqueller": "CalamityQueller",
"cashflowsupervision": "CashflowSupervision",
"chainbreaker": "ChainBreaker",
"cinnabarspindle": "CinnabarSpindle",
"cloudforged": "Cloudforged",
"compoundbow": "CompoundBow",
"coolsteel": "CoolSteel",
"cranesechoingcall": "CranesEchoingCall",
"crescentpike": "CrescentPike",
"crimsonmoonssemblance": "CrimsonMoonsSemblance",
"darkironsword": "DarkIronSword",
"dawningfrost": "DawningFrost",
"deathmatch": "Deathmatch",
"debateclub": "DebateClub",
"deicide": "Deicide",
"dialoguesofthedesertsages": "DialoguesOfTheDesertSages",
"disasterandremorse": "DisasterAndRemorse",
"dodocotales": "DodocoTales",
"dragonsbane": "DragonsBane",
"dragonspinespear": "DragonspineSpear",
"dullblade": "DullBlade",
"earthshaker": "EarthShaker",
"ebonybow": "EbonyBow",
"elegyfortheend": "ElegyForTheEnd",
"emeraldorb": "EmeraldOrb",
"endoftheline": "EndOfTheLine",
"engulfinglightning": "EngulfingLightning",
"etherlightspindlelute": "EtherlightSpindlelute",
"everlastingmoonglow": "EverlastingMoonglow",
"eyeofperception": "EyeOfPerception",
"fadingtwilight": "FadingTwilight",
"fangofthemountainking": "FangOfTheMountainKing",
"favoniuscodex": "FavoniusCodex",
"favoniusgreatsword": "FavoniusGreatsword",
"favoniuslance": "FavoniusLance",
"favoniussword": "FavoniusSword",
"favoniuswarbow": "FavoniusWarbow",
"ferrousshadow": "FerrousShadow",
"festeringdesire": "FesteringDesire",
"filletblade": "FilletBlade",
"finaleofthedeep": "FinaleOfTheDeep",
"flameforgedinsight": "FlameForgedInsight",
"fleuvecendreferryman": "FleuveCendreFerryman",
"flowerwreathedfeathers": "FlowerWreathedFeathers",
"flowingpurity": "FlowingPurity",
"fluteofezpitzal": "FluteOfEzpitzal",
"footprintoftherainbow": "FootprintOfTheRainbow",
"forestregalia": "ForestRegalia",
"fracturedhalo": "FracturedHalo",
"freedomsworn": "FreedomSworn",
"frostbearer": "Frostbearer",
"fruitfulhook": "FruitfulHook",
"fruitoffulfillment": "FruitOfFulfillment",
"gestofthemightywolf": "GestOfTheMightyWolf",
"goldenfrostboundoath": "GoldenFrostboundOath",
"hakushinring": "HakushinRing",
"halberd": "Halberd",
"hamayumi": "Hamayumi",
"harangeppakufutsu": "HaranGeppakuFutsu",
"harbingerofdawn": "HarbingerOfDawn",
"huntersbow": "HuntersBow",
"hunterspath": "HuntersPath",
"ibispiercer": "IbisPiercer",
"ironpoint": "IronPoint",
"ironsting": "IronSting",
"jadefallssplendor": "JadefallsSplendor",
"kagotsurubeisshin": "KagotsurubeIsshin",
"kagurasverity": "KagurasVerity",
"katsuragikirinagamasa": "KatsuragikiriNagamasa",
"keyofkhajnisut": "KeyOfKhajNisut",
"kingssquire": "KingsSquire",
"kitaincrossspear": "KitainCrossSpear",
"kunwuswyrmbane": "KunwusWyrmbane",
"lightbearingmoonshard": "LightbearingMoonshard",
"lightoffoliarincision": "LightOfFoliarIncision",
"lionsroar": "LionsRoar",
"lithicblade": "LithicBlade",
"lithicspear": "LithicSpear",
"lostballade": "LostBallade",
"lostprayertothesacredwinds": "LostPrayerToTheSacredWinds",
"lumidouceelegy": "LumidouceElegy",
"luxurioussealord": "LuxuriousSeaLord",
"magicguide": "MagicGuide",
"mailedflower": "MailedFlower",
"makhairaaquamarine": "MakhairaAquamarine",
"mappamare": "MappaMare",
"masterkey": "MasterKey",
"memoryofdust": "MemoryOfDust",
"messenger": "Messenger",
"mirrorbreaker": "MirrorBreaker",
"missivewindspear": "MissiveWindspear",
"mistsplitterreforged": "MistsplitterReforged",
"mitternachtswaltz": "MitternachtsWaltz",
"moonpiercer": "Moonpiercer",
"moonweaversdawn": "MoonweaversDawn",
"mountainbracingbolt": "MountainBracingBolt",
"mouunsmoon": "MouunsMoon",
"nightweaverslookingglass": "NightweaversLookingGlass",
"nocturnescurtaincall": "NocturnesCurtainCall",
"oathsworneye": "OathswornEye",
"oldmercspal": "OldMercsPal",
"oneside": "OneSide",
"otherworldlystory": "OtherworldlyStory",
"peakpatrolsong": "PeakPatrolSong",
"pocketgrimoire": "PocketGrimoire",
"polarstar": "PolarStar",
"portablepowersaw": "PortablePowerSaw",
"predator": "Predator",
"primordialjadecutter": "PrimordialJadeCutter",
"primordialjadegreatsword": "PrimordialJadeGreatsword",
"primordialjadevista": "PrimordialJadeVista",
"primordialjadewingedspear": "PrimordialJadeWingedSpear",
"prizedisshinblade": "PrizedIsshinBlade",
"prospectorsdrill": "ProspectorsDrill",
"prospectorsshovel": "ProspectorsShovel",
"prototypeamber": "PrototypeAmber",
"prototypearchaic": "PrototypeArchaic",
"prototypecrescent": "PrototypeCrescent",
"prototyperancour": "PrototypeRancour",
"prototypestarglitter": "PrototypeStarglitter",
"quartz": "Quartz",
"rainbowserpentsrainbow": "RainbowSerpentsRainBow",
"rainslasher": "Rainslasher",
"rangegauge": "RangeGauge",
"ravenbow": "RavenBow",
"recurvebow": "RecurveBow",
"redhornstonethresher": "RedhornStonethresher",
"reliquaryoftruth": "ReliquaryOfTruth",
"rightfulreward": "RightfulReward",
"ringofyaxche": "RingOfYaxche",
"royalbow": "RoyalBow",
"royalgreatsword": "RoyalGreatsword",
"royalgrimoire": "RoyalGrimoire",
"royallongsword": "RoyalLongsword",
"royalspear": "RoyalSpear",
"rust": "Rust",
"sacrificersstaff": "SacrificersStaff",
"sacrificialbow": "SacrificialBow",
"sacrificialfragments": "SacrificialFragments",
"sacrificialgreatsword": "SacrificialGreatsword",
"sacrificialjade": "SacrificialJade",
"sacrificialsword": "SacrificialSword",
"sapwoodblade": "SapwoodBlade",
"scionoftheblazingsun": "ScionOfTheBlazingSun",
"seasonedhuntersbow": "SeasonedHuntersBow",
"sequenceofsolitude": "SequenceOfSolitude",
"serenityscall": "SerenitysCall",
"serpentspine": "SerpentSpine",
"sharpshootersoath": "SharpshootersOath",
"silvershowerheartstrings": "SilvershowerHeartstrings",
"silversword": "SilverSword",
"skyridergreatsword": "SkyriderGreatsword",
"skyridersword": "SkyriderSword",
"skywardatlas": "SkywardAtlas",
"skywardblade": "SkywardBlade",
"skywardharp": "SkywardHarp",
"skywardpride": "SkywardPride",
"skywardspine": "SkywardSpine",
"slingshot": "Slingshot",
"snarehook": "SnareHook",
"snowtombedstarsilver": "SnowTombedStarsilver",
"solarpearl": "SolarPearl",
"songofbrokenpines": "SongOfBrokenPines",
"songofstillness": "SongOfStillness",
"splendoroftranquilwaters": "SplendorOfTranquilWaters",
"staffofhoma": "StaffOfHoma",
"staffofthescarletsands": "StaffOfTheScarletSands",
"starcallerswatch": "StarcallersWatch",
"sturdybone": "SturdyBone",
"summitshaper": "SummitShaper",
"sunnymorningsleepin": "SunnyMorningSleepIn",
"surfsup": "SurfsUp",
"swordofdescension": "SwordOfDescension",
"swordofnarzissenkreuz": "SwordOfNarzissenkreuz",
"symphonistofscents": "SymphonistOfScents",
"talkingstick": "TalkingStick",
"tamayurateinoohanashi": "TamayurateiNoOhanashi",
"thealleyflash": "TheAlleyFlash",
"thebell": "TheBell",
"theblacksword": "TheBlackSword",
"thecatch": "TheCatch",
"thedaybreakchronicles": "TheDaybreakChronicles",
"thedockhandsassistant": "TheDockhandsAssistant",
"thefirstgreatmagic": "TheFirstGreatMagic",
"theflagstaff": "TheFlagstaff",
"theflute": "TheFlute",
"theotherside": "TheOtherSide",
"thestringless": "TheStringless",
"theunforged": "TheUnforged",
"theviridescenthunt": "TheViridescentHunt",
"thewidsith": "TheWidsith",
"thrillingtalesofdragonslayers": "ThrillingTalesOfDragonSlayers",
"thunderingpulse": "ThunderingPulse",
"tidalshadow": "TidalShadow",
"tomeoftheeternalflow": "TomeOfTheEternalFlow",
"toukaboushigure": "ToukabouShigure",
"travelershandysword": "TravelersHandySword",
"tulaytullahsremembrance": "TulaytullahsRemembrance",
"twinnephrite": "TwinNephrite",
"ultimateoverlordsmegamagicsword": "UltimateOverlordsMegaMagicSword",
"urakumisugiri": "UrakuMisugiri",
"verdict": "Verdict",
"vividnotions": "VividNotions",
"vortexvanquisher": "VortexVanquisher",
"wanderingevenstar": "WanderingEvenstar",
"wastergreatsword": "WasterGreatsword",
"wavebreakersfin": "WavebreakersFin",
"waveridingwhirl": "WaveridingWhirl",
"whiteblind": "Whiteblind",
"whiteirongreatsword": "WhiteIronGreatsword",
"whitetassel": "WhiteTassel",
"windblumeode": "WindblumeOde",
"wineandsong": "WineAndSong",
"wolffang": "WolfFang",
"wolfsgravestone": "WolfsGravestone",
"xiphosmoonlight": "XiphosMoonlight"
}
-23
View File
@@ -1,23 +0,0 @@
@echo off
rem Startet den Dev-Modus mit Administratorrechten (ein UAC-Prompt erscheint).
rem Noetig, weil Genshin erhoeht laeuft: Windows (UIPI) verwirft sonst alle
rem simulierten Maus-Eingaben an das Spiel - SendInput meldet trotzdem Erfolg.
rem Der Punkt hinter %~dp0 verhindert, dass der abschliessende Backslash das
rem schliessende Anfuehrungszeichen escaped.
rem -NoExit haelt das erhoehte (innere) Fenster offen, selbst wenn das Skript
rem einen Fehler wirft oder npm run dev sofort wieder beendet.
rem
rem Dieses AEUSSERE Fenster (das du beim Doppelklick oder ueber
rem "npm run dev:admin" siehst) schloss sich frueher sofort, sobald
rem Start-Process zurueckkehrte - auch wenn UAC abgelehnt wurde oder die
rem Elevation ganz fehlschlug, ohne dass davon irgendetwas sichtbar war.
rem try/catch + timeout zeigen jetzt den Fehler und halten das Fenster kurz offen.
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$script='%~dp0scripts\dev-admin-start.ps1'; $project='%~dp0.'; try { Start-Process powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File',$script,'-ProjectRoot',$project) -Verb RunAs -ErrorAction Stop; Write-Host ''; Write-Host 'UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev.' -ForegroundColor Green } catch { Write-Host ''; Write-Host 'Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:' -ForegroundColor Red; Write-Host $_.Exception.Message -ForegroundColor Red }"
echo.
echo Dieses Fenster kannst du jetzt schliessen (Taste druecken oder 15s warten). Das eigentliche Programm laeuft im neuen Administrator-Fenster.
rem timeout statt pause/choice: pause und choice warten unter umgeleiteter
rem Standardeingabe (z.B. beim Testen ueber ein Skript) fuer immer, weil sie
rem auf ein echtes Konsolen-Handle angewiesen sind. timeout erkennt eine
rem umgeleitete Eingabe explizit und bricht sofort ab statt zu haengen, waehrend
rem es bei einem echten Doppelklick normal 15s wartet oder bei Tastendruck endet.
timeout /t 15 >nul 2>&1
+196 -7
View File
@@ -6,18 +6,27 @@ This document describes the structure, boundaries, flows, and technical rules of
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`.
The scanner performance direction is now native-first: the C# input helper owns
the fast capture/click/scroll loop and uses vendored Inventory Kamera
`inventorylists` as the scanner dictionary source. Electron is the process,
IPC, packaging, hotkey, and dev-control shell. React is only the visual control
and status surface for this path.
```mermaid
flowchart LR
User["User"]
Genshin["Genshin Impact Window"]
Electron["Electron Main Process"]
Native["C# Input Helper / Native Scanner"]
React["React Renderer"]
Parser["OCR Parser and Scoring"]
LocalData["Local Snapshot / Future SQLite"]
User --> React
React --> Electron
Electron --> Genshin
Electron --> Native
Native --> Genshin
Native --> Electron
Electron --> React
React --> Parser
Parser --> React
@@ -49,17 +58,44 @@ flowchart LR
| Module | Responsibility |
| --- | --- |
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, persistent PowerShell input/capture helper, JSON artifact store |
| `electron/main.ts` | Electron app composition, dependency wiring, app lifecycle, hotkeys, and IPC/dev-control registration |
| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery |
| `electron/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup |
| `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable |
| `electron/services/nativeScannerProcessingService.ts` | Post-capture processor for native IK runs; reads crop jobs, OCRs/parses card crops, matches IK metadata, writes processing outputs, and safely loads native crop previews |
| `electron/services/nativeScannerResultWorkflowService.ts` | Authoritative promotion and review workflow for native results; reloads run state, validates edits, updates durable results/logs, and only writes the artifact store after explicit confirmation |
| `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling |
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
| `native/input-helper/IkInventoryLists.cs` | Loads and validates the vendored IK `inventorylists` feature catalog |
| `native/input-helper/NativeScannerFiles.cs` | Writes native scanner manifest, status, and JSONL crop-job files |
| `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/ikArtifactMatcher.ts` | Pure IK inventorylist matcher for native artifact set/piece/slot validation and GOOD key metadata |
| `src/lib/ikCatalogMatcher.ts` | Pure IK inventorylist matcher for simple weapon, character, and material names plus compact GOOD-key samples |
| `src/lib/ikScanCapabilities.ts` | Pure capability summary that separates IK catalog coverage from implemented native capture support |
| `src/lib/scanResultEntry.ts` | Pure durable scan-result entry/status helpers that keep extraction confidence separate from deferred artifact value evaluation |
| `src/App.tsx` | Thin React entry that renders the app page |
| `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing |
| `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly |
| `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration |
| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and ESC/B fallback paths |
| `src/features/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports |
| `src/features/inventory/*` | Scanned-artifact inventory browser for native result entries, stored artifacts, filters, sorting, compact detail state, native crop preview display, and IK catalog status |
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
| `src/lib/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
| `src/lib/autoScanEntry.ts` | Pure entry-mode planning and auto-scan preflight validation |
| `src/lib/cardReadyGate.ts` | Detail/page fingerprint readiness gate for scan timing |
| `src/lib/artifactEvaluation.ts` | Planned deterministic artifact value evaluation with score reasons and review-safe output |
| `src/lib/upgradeProjection.ts` | Planned best/middle/worst upgrade projection for under-leveled artifacts |
| `src/lib/scoring.ts` | Recommendation and build scoring logic |
| `src/lib/demoData.ts` | Temporary local demo snapshot |
| `data/ik-inventorylists/*` | 1:1 vendored Inventory Kamera inventory lists used by the native scanner data preflight and future matching |
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
| `src/types/*` | Shared app, capture, and domain contracts |
| `src/styles/global.css` | Stylesheet entrypoint importing split style modules |
| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay styles |
| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles |
## Dependency Rules
@@ -99,8 +135,16 @@ sequenceDiagram
| 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 |
| Native card crops | C# input helper | Native scanner run directory under app userData | Electron status, inventory preview, later OCR/parse queue |
| IK inventorylists | `data/ik-inventorylists` copied from Inventory Kamera 1.4.4 | Source data package | Native scanner data preflight and future matcher |
| IK simple catalog match | IK weapon/character/material inventorylist catalog | `IkCatalogItemMatch` | Future category scanners, inventory catalog evidence |
| 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 |
| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, post-capture report, future local DB |
| IK artifact match | IK artifact inventorylist catalog | `ScanResultIkMatch` | Native post-capture review gate, scan result entry, inventory detail |
| Parser field confidence | Native post-capture parser | `ScanResultFieldConfidence` | Scan result entry, inventory detail review signal |
| Scan result entry | Scan loop or native post-capture processor | `StoredScanResultEntry` / native run `scan-results.json` | Live scan rail, artifact inventory, summary |
| Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only |
| 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 |
@@ -111,15 +155,156 @@ sequenceDiagram
**Automatic grid scan** is user-triggered input automation limited to clicking detected inventory tiles and wheel-scrolling the inventory. Safety and reliability rules:
- All input goes through one persistent PowerShell helper process (`input-helper.ps1` in userData) that compiles the Win32 interop once and speaks JSON over stdin/stdout (ops: ping, focus, cursor, click, scroll, capture). Mouse movement is sent as iterated relative SendInput deltas (what a real mouse produces): Genshin tracks the cursor via raw input and snaps the OS cursor back to its own position every frame, so SetCursorPos/absolute moves silently stop working once the game owns the cursor. The helper verifies the cursor reached the target and refuses to click otherwise.
- All input goes through the helper service boundary (currently a C# sidecar with
fallback support behind the same JSON protocol). The helper owns focus, cursor
movement, click, scroll, guard-state polling, elevation detection, and GDI
capture. Mouse movement is sent as iterated relative input deltas instead of
relying on a single absolute cursor jump. The helper verifies the cursor
reached the target and refuses to click otherwise.
- `npm run dev:admin` is the validated dev path for automation when elevated
input is required. The elevated PowerShell startup is handled by
`scripts/dev-admin.ps1` and logged to `outputs/admin-start/admin-dev.log`.
The user must approve UAC manually; the app cannot approve the Secure Desktop
prompt itself.
- Failsafe: before every click and scroll the renderer polls cursor position and ESC state. Holding ESC or moving the mouse away from the last automated position aborts the scan immediately; the Stop button also aborts. Only the `GetAsyncKeyState` held-down bit (0x8000) is used - the "pressed since last call" bit fires for stale ESC presses from normal Genshin menu navigation and caused false aborts.
- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing.
- Dev-only probes under `http://127.0.0.1:17317` are used for live validation:
`/automation/probe-click?index=N` tests one read-only tile selection, and
`/scanner/start?limit=N` starts the native capture scan with a temporary
limit payload from an already visible artifact detail view.
The live known-good result on 2026-07-07 is documented in
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
- Artifact grid automation uses 32 safe click targets per full page
(`8 x 4`). The apparent lower fifth row sits in the bottom control band on
16:9 captures and is not clicked automatically.
- Guided auto-entry is state gated. The normal scan button first performs a
lightweight no-OCR preflight; OCR/store/review work starts only after the
artifact inventory grid and artifact detail card are visually confirmed.
- The normal scan button does not navigate into inventory when that preflight
fails; it blocks and asks the operator to open the Artifact inventory with a
visible detail card. Explicit Dev-Control entry modes can still test direct
inventory or Paimon-menu choreography, but they are not the merge-ready
default path.
- Item verification uses the artifact OCR capture's own detail fingerprint, so
the loop no longer performs a separate card-ready capture before OCR. Page
waits remain fingerprint based and can proceed as soon as the inventory pane
changes and stabilizes.
- 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).
**Native IK capture scan** is the new high-speed path. It runs inside the C#
sidecar, computes a fixed 16:9 8x4 visible-inventory grid, clicks and scrolls
natively, captures the artifact detail card as PNG crops, and reports progress
through IPC/dev-control. The renderer only starts, stops, and displays status.
OCR, parsing, GOOD persistence, and artifact value evaluation are deliberately
separate follow-up stages so capture speed is not blocked by UI work.
Each native run writes a self-contained run directory under app userData:
- `manifest.json`: run schema, IK data version/categories, Genshin bounds,
grid, detail crop rectangle, explicit scan category, and downstream queue
contract.
- `capture-jobs.jsonl`: one job per captured card crop with page/row/column,
client/screen coordinates, click event count, image path, and downstream
`ocr-parse-store` marker. Jobs include the scan category; today only
`artifacts` produces jobs. Native preflight and post-capture processing also
guard this category boundary so catalog-only weapon, character, and material
entries cannot be accidentally parsed as artifacts.
- `status.json`: latest scanner status snapshot for recovery and dev tooling.
Its `supportedCategories` block separates IK catalog availability from native
capture support: artifacts are the only native-capture category today, while
weapons, characters, and materials are catalog-only until their own capture
flows have evidence.
- `scan-results.json`: durable per-artifact result entries with capture
metadata, parsed artifact identity when available, extraction status, and
value status. Native capture currently writes `deferred` value status for
clean extraction and `review` for uncertain extraction.
- `processing-report.json`: optional post-capture OCR/parse report generated
from `capture-jobs.jsonl`. It records `queueConcurrency` for the bounded
post-capture OCR/parse worker and is non-persisting by default until native
crop OCR has live validation evidence.
Parsed artifacts from manual and renderer auto-scan 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). Native IK capture currently writes card crops first; a
downstream OCR/parse processor can report parsed artifacts from those crops
without slowing capture. Store promotion remains opt-in until native crop OCR is
validated.
The Inventory surface derives a dry-run promotion summary from native
`scan-results.json` entries and the local artifact store. It can show which
native artifacts are ready for explicit promotion, already stored, review-only,
or blocked, but it does not write store records by itself.
During auto-scan, artifact store writes can be batched and flushed after the
click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path.
Auto-scan artifact captures also bypass Electron source-list enumeration and use
the GDI capture helper directly once the selected source/Genshin state has been
preflighted. Manual captures and source refresh still use `desktopCapturer`.
## Scan Results And Inventory UX
The next product surface is documented in
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
Architecture rules:
- The active scan view is an operator surface, not the full evaluator. It should
show the screenshot/preview, the right-side live result rail, Stop/status
controls, and review access.
- The live result rail receives completed artifact results only. Intermediate
OCR/debug stats stay in diagnostics or detail.
- A scan result row preserves extraction status and artifact value status as
separate data even when the UI shows one compact pill.
- The artifact inventory view owns browsing, filtering, sorting, opening detail,
and showing the current artifact-only native pipeline state.
- Weapons, materials, and character details stay out of the active Inventory UI
until their values are actually scanned; loaded IK catalog data alone is not a
user-facing scanner capability.
- The artifact detail view owns screenshot/crop inspection, OCR confidence,
parser notes, value score reasons, and upgrade projection.
- Native crop previews are served through the Electron bridge only for PNG paths
inside the selected native scanner run directory.
- Native artifact post-processing uses IK artifact set/piece/slot matching as a
review gate. A conflict is extraction uncertainty, not a weak artifact value.
- Native artifact post-processing stores per-field parser confidence so detail
review can explain which OCR/parser fields are trustworthy.
- Upgrade projection is a local deterministic/probabilistic helper, never a
claim that an artifact will roll a specific way.
Planned result flow:
```mermaid
flowchart LR
ScanLoop["Scan loop"]
Parsed["Parsed artifact"]
ReviewGate["Extraction confidence / review gate"]
Value["Artifact value evaluator"]
Store["Artifact store"]
Rail["Live result rail"]
Inventory["Artifact inventory"]
Detail["Artifact detail"]
ScanLoop --> Parsed
Parsed --> ReviewGate
ReviewGate --> Value
Value --> Store
Value --> Rail
Store --> Inventory
Rail --> Detail
Inventory --> Detail
```
Future queue refactor:
- One capture/game-control worker may click, scroll, focus, and poll failsafes.
- OCR/parse/evaluation may process bounded queued screenshot/crop jobs.
- Queueing must preserve stop behavior, duplicate handling, review decisions,
and the existing read-only safety boundary.
- The native post-capture processor already consumes crop jobs with bounded
parallelism while preserving report order. Further queue work is secondary to
live throughput evidence and result quality.
## Security And Safety
@@ -141,4 +326,8 @@ Parsed artifacts from both modes are persisted into `artifact-store.json` keyed
## Performance
Current OCR is prototype-grade and may be slower than the target scanner. Two batch-scan bottlenecks were removed: input/capture no longer spawn a PowerShell process (and recompile Win32 interop) per action, and the Tesseract worker is created once and reused across captures. The eventual batch scanner should still move expensive capture/OCR/build work into workers or a native sidecar.
Current OCR is measured through the eval harness and live scan assessments
rather than assumed good. The app keeps a Tesseract.js worker pool and reports
capture, OCR, card-ready, scroll-ready, active-scan, and projected-100 timings.
For the next product phase, performance work should not displace extraction
quality, result clarity, or review safety unless evidence shows a regression.
+273
View File
@@ -0,0 +1,273 @@
# Automation Live Scan Runbook
This document is the durable reference for automatic artifact scanning, mouse
movement, click input, elevation, and live validation status.
## Current Known-Good State
Validated live with Genshin open in the artifact inventory at 1920x1080,
English UI:
- `npm run dev:admin` starts the app elevated after the user confirms UAC.
- Runtime status reports `isElevated: true`, `genshinFound: true`, and
`targetProcess: "GenshinImpact"`.
- `/automation/probe-click?index=1` performs one read-only inventory tile
selection click.
- The visible-inventory auto-scan path is the production baseline. It requires
the Artifact inventory to already be open with a visible artifact detail card.
- On 2026-07-09, `/scanner/start?entry=visible-inventory&limit=100` completed
with `100/100` attempted, verified, and parsed, `98` stored, `2` duplicates,
`0` review samples, `0` misses, `4` pages, and `393 ms/artifact`.
- On 2026-07-09, `npm run scan:native:smoke` completed against runtime
signature `2026-07-09-native-ik-visual-probe`: native visual preflight passed
at 1920x1080 with `32` grid targets, guarded probe changed the detail panel,
native capture wrote `2/2` artifact card crops in `510 ms`, and post-capture
processing parsed `2/2` with `0` review, `0` errors, `queueConcurrency: 2`,
and `persisted: false`.
- The same native path later completed dry 20/50/100-item runs. The final run
captured `100/100` crops across 4 pages in `24,673 ms`, parsed `100/100`,
produced `0` errors, routed 3 implausible OCR values into Review, and wrote
nothing to the artifact store. Full evidence and the parser/timestamp fixes
are recorded in
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
The old alternate OCR-engine and comparison paths have been retired. The app now
runs the current OCR/capture path only. Performance claims should be stated as
current-run repeatability evidence, not as external-tool parity.
## Elevation And UAC
Use:
```powershell
npm run dev:admin
```
The command runs `scripts/dev-admin.ps1`, which launches a new elevated
PowerShell window running `scripts/dev-admin-start.ps1`. The elevated start is
logged to:
```text
outputs/admin-start/admin-dev.log
```
The user must confirm the Windows UAC prompt. The app cannot and must not click
the Secure Desktop UAC prompt for itself.
Useful checks:
```powershell
Invoke-RestMethod http://127.0.0.1:17317/health
Invoke-RestMethod http://127.0.0.1:17317/scanner/status
```
Expected runtime facts before automatic scan:
- `isElevated: true`
- `genshinFound: true`
- `targetProcess: "GenshinImpact"`
- `/health.appBuild.signature` matches `APP_RUNTIME_SIGNATURE` in
`electron/main.ts`
## Mouse And Click Validation
Use a probe before broad auto-scan work:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" |
ConvertTo-Json -Depth 12
```
The probe performs one read-only inventory selection click. It does not delete,
feed, enhance, lock, unlock, spend, or modify game resources.
Interpretation:
- `click.ok: true`, `clicked: true`, and `inputBlocked: false` means Windows did
not block SendInput/UIPI in the current configuration.
- `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was
sent while Genshin was foreground.
- `changed: true` means the detail panel changed after the click.
- `changed: false` can be benign if the target tile was already selected or two
neighboring artifacts render identically; retry with another `index`, `row`,
or `col`.
## Bounded Live Auto-Scan
Start with a tiny bounded run:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2"
```
Then poll:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
ConvertTo-Json -Depth 12
```
The scan starts only after a valid lookup package, supported 16:9 layout,
detected artifact grid, Genshin-client capture, and visual artifact-detail
markers are all present. If any preflight check fails, keep using the
visible-inventory path and fix the blocking evidence before trying broader
runs.
The loop verifies detail fingerprints after clicks, retries one unchanged detail
read, stores parsed artifacts in a batch, and flushes pending writes before the
final summary. Stats separate clicked, attempted, verified, parsed, stored,
review, duplicates, and misses so progress is not confused with successful
reads.
For the native IK-style path, prefer the dedicated smoke runner:
```powershell
npm run scan:native:smoke
npm run scan:native:smoke:5
```
It checks `/health`, `/scanner/native/data`,
`/scanner/native/preflight?category=artifacts` including the native visual
blank-capture guard, runs the guarded
`/automation/probe-click?index=1`, starts `/scanner/start?limit=N&category=artifacts`,
waits for the native helper to finish, runs `/scanner/native/process` with
`persist=0`, and loads `/scanner/native/results`. Evidence is written to:
```text
outputs/native-live-smoke/<timestamp>/
```
Use this native smoke path before enabling any artifact-store promotion from
native `scan-results.json`.
The native preflight and start endpoints both accept an explicit category:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/native/preflight?category=artifacts"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2&category=artifacts"
```
Only `artifacts` is implemented as native capture today. `weapons`,
`characters`, and `materials` are expected to block with a catalog-only message
at preflight/start time until their category-specific capture flows are
implemented and validated. They are not part of the active UI scope while those
values are not scanned.
Artifact preflight also blocks when the Genshin client capture is blank,
almost entirely white/black, or too visually uniform to be trusted.
The probe-click endpoint additionally requires a detected Genshin-client
artifact inventory grid and visible artifact detail card before sending input.
## Evidence Commands
After the elevated app is running and Genshin is open on the artifact inventory,
the non-elevated terminal can drive the local dev-control endpoints and save a
full evidence bundle:
```powershell
npm run scan:soak
```
The helper writes timestamped JSON snapshots and a transcript to:
```text
outputs/live-soak/<timestamp>/
```
For short iteration:
```powershell
npm run scan:iterate:validated
npm run scan:iterate:validated:wait
```
For a full 2, 5, 20, 45, 100 current-run evidence chain:
```powershell
npm run scan:goal:validated
npm run scan:goal:validated:wait
```
For later-session repeatability without changing OCR engines:
```powershell
npm run scan:repeatability:wait
```
Validate a saved assessment before using it as final evidence:
```powershell
npm run scan:assessment:validate -- --latest --summary
npm run scan:assessment:validate -- --input=<run-dir>\scan-performance-assessment.json --summary
```
Optional budget flags:
```powershell
npm run scan:assessment:validate -- --latest --summary --limit=20 --max-active-average-ms=333 --max-capture-roundtrip-overhead-ms=120
```
`--max-active-average-ms=333` is the strict 3 artifacts/second check. A passing
100-artifact current run is valid scanner evidence; it is not evidence that the
3 artifacts/second budget was met unless this budget check also passes.
The assessment self-test does not need Genshin:
```powershell
npm run scan:assessment:test
```
## Review-To-Eval Quality Loop
After any live scan that creates review samples, export candidates before adding
anything to the permanent eval corpus:
```powershell
npm run eval:review-candidates -- --limit=80
```
Read `outputs/review-eval-candidates/review-eval-candidates.md`. It is a review
worklist, not ground truth. Only after expected fields are confirmed or
corrected against the real artifact should a case move into
`src/eval/corpus/confirmedReviewCorpus.ts`.
For a manually checked candidate, generate a paste-ready confirmed-case snippet:
```powershell
npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json
```
## Anti-Cheat And Safety Boundary
Do not describe the implementation as bypassing anti-cheat. The app does not
read memory, hook the process, inject code, modify game files, inspect packets,
or interact with kernel drivers. It uses normal Windows screen capture, focus,
cursor movement, wheel, and click input.
Never add automation that deletes, feeds, enhances, locks/unlocks, spends
resources, reads memory, hooks, injects, or modifies Genshin.
## Live Layout Facts
The current 16:9 layout profile is calibrated from a 1920x1080 English
artifact-inventory capture:
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
- inventory grid: `8 x 4` safe automated targets
- first tile center: `x=179`, `y=254`, `row=0`, `col=0`
- second tile center: `x=325`, `y=254`, `row=0`, `col=1`
The profile is resolution-scaled for 16:9. Off-profile setups should be treated
as higher risk and validated with Smart Capture plus the probe.
## Validation Checklist
Before marking an automation change done:
1. Run `npm run lint`.
2. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
3. Run `npm test`.
4. Run `npm run build`.
5. If Genshin is available, run `/automation/probe-click?index=1`.
6. For scan-loop changes, run
`/scanner/start?entry=visible-inventory&limit=2` before any broader scan.
7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
+66
View File
@@ -4,6 +4,7 @@
- [ ] The expected crop or capture behavior is clear.
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
- [ ] Auto-scan starts only from confirmed artifact inventory plus visible detail card, or blocks with a reason.
- [ ] Capture failures are shown to the user.
- [ ] OCR uncertainty remains inspectable in Details.
- [ ] Parser output does not silently trust low-confidence text.
@@ -12,6 +13,25 @@
- [ ] `npm run build` passes.
- [ ] Manual Smart Capture is tested when possible.
## Live Scan Timing Claim
- [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`.
- [ ] `npm run scan:live:preflight` passes, or use the validated command that runs it first.
- [ ] During manual UAC startup, `npm run scan:live:preflight:wait` may be used before the scan chain.
- [ ] `npm run scan:assessment:test` passes.
- [ ] Use `npm run scan:iterate:validated` or `npm run scan:iterate:validated:wait` for short 20-artifact tuning loops.
- [ ] Prefer `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` for the final live run because it runs preflight, scan, and assessment validation in sequence.
- [ ] The run includes `scan-performance-assessment.json`.
- [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=<path>\scan-performance-assessment.json` passes.
- [ ] If claiming the current scanner path, the validator is run with `--expect-winner=current`.
- [ ] Final report cites the validator JSON or `--summary` output, including the assessment path.
- [ ] For iteration claims, the 20-artifact run finishes cleanly and is validated with `--limit=20`.
- [ ] For final claims, the 100-artifact run finishes cleanly.
- [ ] Parsed count is at least the requested count.
- [ ] Miss rate is at or below 2%.
- [ ] Review rate is at or below 15%.
- [ ] Speed reporting uses active scan timing plus quality, not click count alone.
## UI Change
- [ ] The main workflow remains visible without unnecessary scrolling.
@@ -21,6 +41,34 @@
- [ ] Desktop viewport is checked manually.
- [ ] `npm run build` passes.
## Scan Results And Inventory UX
- [ ] The scan page keeps preview, Stop/status, and the live result rail visible without page-level scrolling.
- [ ] The live rail shows finished artifact rows only, not intermediate OCR/debug state.
- [ ] Each live row has scan number, artifact name or compact fallback, value score, and a short status pill.
- [ ] Extraction confidence and artifact value remain separate in the data model.
- [ ] Native post-capture runs write `scan-results.json` with extraction status and deferred/review value status.
- [ ] Native post-capture reports include bounded queue evidence such as `queueConcurrency` when OCR/parse workers run.
- [ ] Native artifact post-processing records IK/GOOD match metadata and sends IK set/piece/slot conflicts to review.
- [ ] Native scan result details preserve parser field confidence for review instead of hiding uncertainty.
- [ ] Native inventory promotion is previewed as a dry-run decision before any artifact-store write path is enabled.
- [ ] Weapon, character, and material IK matching claims are backed by category-specific capture evidence, not just catalog availability.
- [ ] Native `supportedCategories` distinguishes `catalogAvailable` from `nativeCaptureSupported`.
- [ ] Native scan start, run manifest, status, and capture jobs carry an explicit scan category.
- [ ] Native preflight and post-capture processing block catalog-only categories before OCR/parsing.
- [ ] Native preflight blocks blank or too-uniform Genshin captures before any click is sent.
- [ ] Native crop previews are loaded only through the Electron bridge and stay constrained to PNG files inside the selected run directory.
- [ ] Native live smoke uses `npm run scan:native:smoke` before broad native timing or store-promotion claims.
- [ ] Artifact-only scope is visible in the UI; weapons, materials, and character details are not presented as active scanner features while they are not scanned.
- [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision.
- [ ] Duplicate state is represented separately from artifact quality.
- [ ] The artifact inventory view can browse stored scan results without opening diagnostics.
- [ ] Inventory filters/sorting cover review, score, set, slot, equipped, locked, and newest scan where data exists.
- [ ] Artifact detail shows screenshot/crop, parsed fields, OCR confidence, parser notes, value reasons, and review state.
- [ ] Upgrade projection, if shown, is labeled as a projection and includes worst/middle/best cases.
- [ ] Unit tests cover score/status derivation and projection edge cases when those modules change.
- [ ] Scanner safety rules and existing quality gates are unchanged.
## Parser Or Scoring Change
- [ ] Known-good OCR samples still parse correctly.
@@ -35,3 +83,21 @@
- [ ] App starts outside browser preview.
- [ ] Smart Capture bridge is available.
- [ ] No generated debug artifacts are included accidentally.
## Git Merge And Gitea Push
- [ ] `git status --short --branch` is checked before switching branches,
committing, merging, or pushing.
- [ ] Work is committed on the intended feature branch before merging into
`main`.
- [ ] Merge into `main` uses fast-forward when possible, or a deliberate merge
commit when the history requires it.
- [ ] `main` is clean before push except for explicitly ignored or deliberately
untracked local files.
- [ ] `git remote -v` points to the intended Gitea remote before pushing.
- [ ] Gitea HTTPS credentials are stored through Git Credential Manager, not in
repository files or remote URLs.
- [ ] If authentication fails, refresh the credential using
[GITEA_AUTH.md](GITEA_AUTH.md), then rerun the same `git push`.
- [ ] After push, `git status --short --branch` shows `main...origin/main`
without ahead/behind drift.
+12
View File
@@ -11,16 +11,22 @@ This document defines project engineering standards.
## File Organization
- Keep Electron OS integration in `electron/`.
- Keep Electron `main.ts` as composition/wiring. Move durable window, file, helper, capture, OCR, or dev-control responsibilities into named modules under `electron/`.
- Keep React components in `src/`, with extraction when `App.tsx` becomes hard to review.
- Keep feature controller hooks small enough to review. If a hook owns persistence, import/export, entry choreography, scan-loop orchestration, and UI state at once, split those concerns into feature-local hooks or services.
- Keep pure domain logic in `src/lib/`.
- Keep shared contracts in `src/types/`.
- Keep generated outputs in `dist/`, `dist-electron/`, and `outputs/`.
- Keep `src/styles/global.css` as the stylesheet entrypoint. Put broad app styles in `base.css` and dev/diagnostic-only styling in `diagnostics.css` unless a more specific style module is introduced.
## UI Rules
- The scan page should prioritize the capture workspace over secondary status content.
- The active scan view should read as preview plus compact result rail, not as a dashboard of live diagnostics.
- Details and debug information belong in modals or secondary panels.
- Avoid long, overfilled cards on scanner pages.
- Do not collapse OCR/extraction confidence and artifact value into one ambiguous UI state; uncertain reads should be visibly `Review`.
- Artifact value pills should be short, stable labels backed by deterministic data, with detailed reasons behind click-through detail.
- The design direction is dark purple fintech glassmorphism with premium, focused controls.
- Disable buttons when their required data does not exist.
@@ -30,6 +36,7 @@ This document defines project engineering standards.
- Confidence and raw OCR details must remain inspectable.
- Heuristics should fail safely into unknown fields or review notes.
- Do not add irreversible game actions.
- Keep auto-entry choreography separate from scan-loop execution. Entry code may navigate to a readable artifact detail state; loop code should process verified grid targets.
## TypeScript Rules
@@ -48,3 +55,8 @@ This document defines project engineering standards.
- 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.
- Keep workflow/runbook docs linked from `docs/PROJECT.md` so future sessions
do not depend on chat history.
- Do not store tokens, passwords, cookies, or generated credentials in project
files. Use Git Credential Manager for Gitea HTTPS credentials; document only
the setup path in `docs/GITEA_AUTH.md`.
+139
View File
@@ -0,0 +1,139 @@
# Current App Status
Updated: 2026-07-09
This document is the short current-state entry point. For deeper architecture
details see [ARCHITECTURE.md](ARCHITECTURE.md). For the scanner and inventory
roadmap see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
For live scanner runbooks see [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
## Summary
The app is now an Artifact-first local scanner with a native high-speed capture
path validated at 20/50/100-item scale in the current live session. The current product scope is intentionally limited to
artifacts. Weapons, materials, and character details may exist as vendored IK
catalog data, but they are not active scanner features while their values are
not scanned.
Electron/React should act as the visual control and status surface. The C#
helper owns fast Genshin focus, click, scroll, and card-crop capture for the
native path. OCR, parsing, review, promotion, and value evaluation remain
separate downstream stages so capture speed is not blocked by UI work.
## What Works Now
- Smart Capture can read the currently opened artifact detail view through
focused crops, OCR preprocessing, deterministic parsing, confidence, and
notes.
- The visible-inventory auto-scan path is the current stable production
baseline: preflight, grid detection, read-only tile selection, detail
verification, OCR, parse, store/review, scroll, and summary.
- The native IK-style artifact capture path is wired through the C# helper.
It captures visible 16:9 Artifact inventory detail card crops and writes a
self-contained run directory.
- Native runs write `manifest.json`, `capture-jobs.jsonl`, and `status.json`.
Post-capture processing can consume the job file and write
`scan-results.json` plus `processing-report.json`.
- Vendored Inventory Kamera `inventorylists` are copied under
`data/ik-inventorylists` and packaged as scanner reference data. Current
version is `6.7.0`.
- Native artifact post-processing matches parsed artifacts against IK artifact
set/piece/slot data and sends conflicts to review.
- The scan page has a compact latest-results rail. Native parsed results are
labeled as `Geparst`, persisted results as stored, uncertain results as
review, and value evaluation as `Wert offen`.
- The Inventory view can browse native scan results, stored artifacts, and
snapshot fallback rows. It shows native crop previews, field confidence,
IK/GOOD metadata, dry-run promotion status, and a per-result
`Naechster Schritt`.
- The Inventory view now shows an Artifact-only pipeline strip for scope,
native capture, OCR queue, review gate, promotion, and evidence.
- GOOD import/export, review samples, local text replacements, scanner
diagnostics, OCR eval, and local artifact storage exist.
## What Was Just Done
- The active UI scope was narrowed to Artifact scanning only.
- Weapon, material, and character-detail IK catalog coverage was removed from
the primary Inventory feature surface so it cannot look like implemented scan
support.
- Native result labels were made more honest:
`Geparst` means extracted, `Stored` means persisted, `Review` means unsafe,
and `Wert offen` means artifact value evaluation has not run yet.
- The Inventory detail panel gained a `Naechster Schritt` card:
ready for promotion, review first, already in store, blocked, or value later.
- New filters were added for native rows and promotable rows.
- Native 20/50/100-item dry runs were completed. A live OCR decimal-loss bug
and missing native result timestamp were fixed and regression-tested.
- Native Inventory results now support explicit single-result promotion with a
second confirmation, authoritative main-process revalidation, duplicate
detection, store write, scan-result update, and `promotion-log.jsonl`.
- Review-required native results now have an inline editor for identity,
main stat/value, level, substats, equipped state, lock state, and notes.
Approve requires IK identity validation, canonical main-value validation,
and legal substat rolls; Reject keeps the result blocked. Approved cases
write `review-log.jsonl` and reuse `review-samples.jsonl` for OCR eval export.
- The three real Review rows from the 100-item native run were manually
corrected and approved through that workflow. The run now has zero remaining
Review rows, all three corrections reached the eval candidate pipeline, and
the confirmed cases are permanent OCR regressions.
- The parser can now repair the confirmed dropped/extra-digit substat cases at
+20 only when one legal 5-star roll-count combination exists. Ambiguous OCR
stays reviewable.
- Project docs and roadmap were updated to state Artifact-only scope and the
next scanner/product priorities.
## Current Evidence
- `npm run lint` passed.
- `npm test` passed with `252` tests.
- `npm run build` passed.
- `git diff --check` passed; only existing CRLF warnings were reported for
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
- Native IK dry runs on 2026-07-09 completed at 20, 50, and 100 items with
complete crop/job/result counts, `0` processing errors, and `persisted: false`.
The 100-item run captured `100/100` across 4 pages in `24,673 ms`, parsed
`100/100`, and safely routed 3 OCR value losses into Review. See
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
- The older visible-inventory path has broader live evidence, including a
100-artifact run with `100/100` verified and parsed, `98` stored,
`2` duplicates, `0` review samples, `0` misses, and `393 ms/artifact`.
## Known Limits
- Native IK-style capture has current-session scale evidence, but later-session
repeatability and packaged-app evidence are still open.
- The native path is limited to visible 16:9 Artifact inventory with a visible
detail card.
- Native post-capture results do not persist by default. Clean selected results
can now be promoted explicitly after a second UI confirmation.
- Artifact value scoring and upgrade projection are not implemented for native
results yet.
- Review/edit/approve is implemented for one selected native result at a time.
Batch review is intentionally not available.
- The `333 ms/artifact` target for 3 artifacts/second is not proven.
- The native capture worker exceeded 4 artifacts/second, but the smoke runner
still captures and processes sequentially; 3 artifacts/second end to end is
therefore not proven.
- Weapons, materials, and character details are intentionally ignored until
their values are actually scanned.
## What Is Next
1. Add Artifact value evaluation after extraction is trustworthy. Keep value
reasons separate from OCR confidence.
2. Add detail-level value reasons and optional upgrade projection. Projection
must stay labeled as probabilistic.
3. Confirm packaged-app behavior for the C# helper, IK lists, preload bridge,
native crop previews, and smoke commands.
4. Repeat the native 20/50/100 evidence in a later session to prove repeatability.
5. Return to recommendations only after native artifact ingestion, promotion,
review, value scoring, and repeatability are strong enough.
## Not Next
- Do not prioritize weapons, materials, or character details yet.
- Do not auto-persist native scan results before review/promotion is safe.
- Do not claim Inventory Kamera parity until broad native Artifact runs prove
repeatable speed and quality.
- Do not merge extraction confidence and artifact value into one UI score.
+208 -8
View File
@@ -15,6 +15,10 @@ This document contains Architecture Decision Records.
| ADR-007 | Measure OCR accuracy with a labeled eval harness before reworking the scanner | Accepted | 2026-07-05 |
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
| ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
| ADR-011 | Retire alternate OCR comparison paths after current scanner baseline | Superseded | 2026-07-09 |
| ADR-012 | Separate live scan results, artifact inventory, and value evaluation | Accepted | 2026-07-09 |
| ADR-013 | Move high-speed scanning into the native helper and vendor IK inventorylists | Accepted | 2026-07-09 |
## ADR-001: Build A Local Electron App First
@@ -44,7 +48,8 @@ Accepted
### Context
The user wants an app that works without Inventory Kamera, Genshin Optimizer, Enka, or HoYoLAB as core dependencies.
The user wants an app that works without optimizer imports, external scanner
tools, Enka, or HoYoLAB as core dependencies.
### Decision
@@ -181,9 +186,8 @@ Accepted
The persistent PowerShell helper (ADR-006) still carries Windows PowerShell 5.1
quirks (the `Marshal::SizeOf` interop bug), compiles Win32 interop at startup,
and captures each frame by writing a PNG to the temp directory and reading it
back. Inventory Kamera - the proven reference for automated Genshin scanning -
uses a C#/.NET stack with InputSimulator (SendInput) and direct GDI/BitBlt
capture.
back. A C#/.NET sidecar with SendInput and direct GDI/BitBlt capture is a
cleaner fit for the validated Windows automation path.
### Decision
@@ -212,10 +216,9 @@ Accepted
The current pipeline finds the artifact detail panel with hardcoded orange/green
color thresholds (`inferDetailRect`) and then crops fixed percentages of that
guessed rectangle. This is brittle against HDR, color profiles, UI scale, aspect
ratio, and game UI updates. Inventory Kamera instead requires borderless 16:9 and
scales fixed crop coordinates from a reference resolution, then feeds Tesseract
preprocessed (grayscale, upscaled, thresholded) crops - which is why general
Tesseract is accurate enough for them.
ratio, and game UI updates. A stricter borderless 16:9 profile with fixed crop
coordinates from a reference resolution is easier to validate and reproduce than
per-frame color hunting.
### Decision
@@ -235,3 +238,200 @@ validated against the ADR-007 eval harness.
insufficient.
- Non-16:9 or non-borderless setups are explicitly unsupported for the auto
scanner; the app should detect and warn rather than silently misread.
## ADR-010: Elevated Dev Runner And Bounded Live Automation Probes
### Status
Accepted
### Context
Automatic grid scanning needs read-only mouse movement, click, and wheel input
to reach the focused Genshin window. A lower-integrity app can fail to deliver
input to an elevated or protected target because of Windows UIPI/integrity
boundaries. During live testing, `npm run dev:admin` originally printed that a
new Administrator window was started, but the elevated PowerShell received no
arguments, so the intended dev process did not reliably start.
The project also needed a smaller live validation path than a full inventory
scan. A full scan is too risky as the first proof of input delivery because it
can click many tiles before a bad coordinate, focus issue, or blocked input is
understood.
### Decision
Keep automatic scan input automation read-only and require an elevated runtime
when Windows reports that automation would otherwise be blocked. Replace the
old `dev-admin.cmd` entry with `scripts/dev-admin.ps1`, quote the elevated
PowerShell arguments explicitly, and log elevated startup to
`outputs/admin-start/admin-dev.log`.
Add dev-only HTTP checks:
- `/automation/probe-click?index=N` or `?row=R&col=C` performs one safe
inventory selection click and verifies whether the detail panel changed.
- `/scanner/start?limit=N` sends a temporary scan-limit payload to the renderer,
so live auto-scan validation can start with two items instead of the UI
default.
Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
### Consequences
- The user still has to approve Windows UAC manually; the app must not try to
click the Secure Desktop prompt.
- We can distinguish input delivery from OCR/parser quality with a one-click
probe before running any broader scan.
- Live validation now has a low-risk path: check elevation and Genshin
detection, run a single probe click, then run a bounded `limit=2` scan.
- The implementation remains inside the allowed safety boundary: no memory
reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
locking/unlocking, or spending resources.
## ADR-011: Retire Alternate OCR Comparison Paths After Current Scanner Baseline
### Status
Superseded
### Context
The project previously carried alternate OCR-engine and comparison paths to
decide whether the current scanner should change OCR defaults. The current
visible-inventory path has now produced clean 100-artifact evidence, and the
next product phase is result clarity, artifact inventory, detail evaluation, and
corpus growth. Keeping an alternate engine path increases app size and
maintenance surface without serving the current user workflow.
### Decision
Retire the alternate OCR-engine and current-vs-reference comparison paths. Keep a
single current OCR/capture path and preserve the quality-gated live soak
assessment. A qualified scan result must:
- finish cleanly,
- parse at least the requested count,
- keep miss rate at or below 2%,
- keep review rate at or below 15%,
- report active scan timing and bottlenecks,
- come from a runtime whose `/health.appBuild.signature` matches the current
source.
`scripts/live-soak.ps1` writes the evidence bundle and
`scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that
the ranking logic rejects fast but low-quality synthetic runs without needing
Genshin. `npm run scan:assessment:validate -- --summary` prints the assessment
path and `createdAt` timestamp so reports can cite the exact evidence file.
### Consequences
- Speed claims cannot be based on click count or elapsed time alone.
- A new OCR engine is out of scope until scanner UX, inventory, detail
evaluation, and corpus growth justify reopening that work.
- Stale elevated Electron instances are treated as invalid evidence, not as a
harmless warning.
- The validated `:wait` scan scripts are acceptable for manual post-UAC startup;
non-waiting scripts remain useful when automation should fail fast.
## ADR-012: Separate Live Scan Results, Artifact Inventory, And Value Evaluation
### Status
Accepted
### Context
The visible-inventory scanner baseline is fast enough for the current product
phase. The next user value comes from better artifact content extraction and a
clearer surface for scanned artifacts, not from another broad speed pass.
The scan page currently has more diagnostic/result detail than the operator
needs during a live scan. Showing every stat, confidence value, and evaluation
while the scanner is running makes the main workspace noisy and increases the
risk that a user treats uncertain OCR as a final artifact judgment.
The desired product flow is:
- left side: screenshot/preview,
- right side: compact live list of finished artifact results,
- later menu item: browsable scanned artifact inventory,
- click-through detail: screenshot, parsed stats, OCR confidence, evaluation
reasons, and upgrade projection.
### Decision
Adopt a split scan/result/inventory model:
- The active scan page shows the latest screenshot/preview and a compact live
result rail.
- The live rail shows only scan number, artifact name or compact fallback,
artifact value score `0-100`, and a result pill after evaluation has finished.
- Extraction confidence and artifact value are separate data concepts. A low
confidence read becomes `Review`; it must not be silently displayed as a
normal weak artifact.
- Debug stats, raw OCR, confidence breakdowns, and detailed evaluation belong in
diagnostics, scan summary, or artifact detail.
- Add a scanned artifact inventory view for browsing, filtering, sorting, and
opening details.
- Add artifact detail evaluation before promoting broad recommendations.
- Add upgrade projection only as a detail-level feature, with worst/middle/best
projected value scores and clear uncertainty labeling.
- Keep the producer/consumer screenshot queue as a later implementation phase:
one game-control worker may click/scroll/capture, while OCR/parse/evaluation
workers can process bounded queued jobs once the UI/data contracts are stable.
### Consequences
- The scan page becomes calmer and more task-focused.
- The app can surface useful artifact outcomes without hiding OCR uncertainty.
- Inventory and detail views become the natural place for richer analysis.
- Recommendation work has a cleaner dependency chain: trusted scans, compact
results, artifact inventory, detail evaluation, then build recommendations.
- Renderer-loop speed work remains secondary unless measured timings show a real
regression; native capture speed work is handled by ADR-013.
## ADR-013: Move High-Speed Scanning Into The Native Helper And Vendor IK Inventorylists
### Status
Accepted
### Context
Inventory Kamera is fast because the game-control loop is native and the UI does
not perform per-artifact work. The Electron renderer path paid for focus,
capture, OCR, parse, persistence, status updates, and UI state inside one loop.
The user also has Inventory Kamera 1.4.4 locally, including `inventorylists`
that can be used 1:1 as scanner reference data.
### Decision
Use the C# input helper as the high-speed scanner service. Electron starts,
stops, packages, and reports status. React remains a visual control surface. The
IK `inventorylists` are copied into `data/ik-inventorylists` and packaged as
extra resources. The first native milestone captures artifact detail card crops
quickly; OCR, parsing, GOOD persistence, and evaluation are separate downstream
steps. Each native run writes `manifest.json`, `capture-jobs.jsonl`, and
`status.json`; downstream workers must consume those files instead of asking the
renderer to do per-artifact scanner work.
The first downstream worker is a post-capture processor that reads
`capture-jobs.jsonl` and writes both `scan-results.json` and
`processing-report.json`. `scan-results.json` is the durable result contract:
one entry per captured artifact card with extraction status, artifact identity
when parsed, review state, and deferred value status. `processing-report.json`
remains the diagnostic OCR/parse report. The worker OCRs/parses after capture
and does not persist by default; DB writes require explicit opt-in after native
crop OCR is validated with live evidence.
### Consequences
- Capture throughput can be optimized without renderer roundtrips per artifact.
- The scanner data source now matches IK's artifact names and set/piece keys.
Other vendored IK lists are data only until those values are actually scanned.
- Evaluation can remain deferred without blocking capture speed.
- Live safety stays read-only: no memory reads, hooks, injection, game-file
modification, deleting, feeding, enhancing, or spending resources.
- Non-16:9 layouts currently block in native preflight until layout support is
expanded.
+52
View File
@@ -0,0 +1,52 @@
# Gitea Authentication
Remote:
```text
https://git.noveria.net/bao/genshin-assistant.git
```
Personal access tokens can be managed here:
```text
https://git.noveria.net/user/settings/applications
```
Use the token as the HTTPS password when Git asks for credentials. Do not save
tokens, passwords, or generated credentials in this repository, in `.env`
files, or in the remote URL.
On Windows, this repository uses Git Credential Manager from the system Git
config:
```powershell
git config --show-origin --get-all credential.helper
```
If authentication breaks again, refresh the saved credential in Windows
Credential Manager or let Git Credential Manager prompt again during:
```powershell
git push origin main
```
Recommended push check:
```powershell
git status --short --branch
git remote -v
git push origin main
git status --short --branch
```
Expected clean result after a successful push:
```text
## main...origin/main
```
If Git reports `Failed to authenticate user`, do not change the remote to embed
a token. Open the token page above, create or refresh a token with repository
write access, clear the stale Windows credential for `git.noveria.net` if
needed, and rerun the same push command so Git Credential Manager can store the
new credential safely.
+61
View File
@@ -0,0 +1,61 @@
# Scanner Merge Evidence
Merged branch: scanner baseline branch
Target branch: `main`
Merge commit: `c025daa`
Merged on: 2026-07-09
This document records the evidence used to merge the scanner branch into
`main`. It separates the merge-relevant proof from optional follow-up work.
## Merge-Relevant Evidence
| Area | Status | Evidence |
| --- | --- | --- |
| TypeScript/lint gate | Passed | `npm run lint` |
| Unit/regression suite | Passed | `npm test` with 209 tests, including equipped footer, lock detection, and PNG lock-crop regression coverage |
| Production build | Passed | `npm run build` |
| Whitespace check | Passed | `git diff --check` |
| OCR eval gate | Passed | `npm run eval` with `23/23` exact-match cases and `100%` critical fields |
| Scan assessment self-test | Passed | `npm run scan:assessment:test`; the fixture rejects fast but low-quality synthetic runs |
| Live runtime preflight | Passed | `npm run scan:live:preflight`; signature `2026-07-08-direct-gdi-reviewfix`, elevated yes, Genshin found |
| Safe visible-inventory scan path | Passed | 2026-07-09 live run: `/scanner/start?entry=visible-inventory&limit=20` completed `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, `0` misses, `8047 ms` elapsed |
| Equipped character OCR/persist path | Passed for live smoke | 2026-07-09 live captures read equipped footers for `Citlali` and `Linnea`; parser regression covers `Equipped: Linnea l` -> `Linnea` |
| Unlocked lock state | Passed for live smoke | 2026-07-09 Smart Capture reported `locked: false` on an unlocked artifact; after restart, `lockSignal.ratio: 0` with threshold `0.06` |
| Positive locked lock state | Passed | 2026-07-09 Smart Capture on a visibly locked artifact reported `locked: true`, `lockSignal.ratio: 0.14797913950456323`, threshold `0.06` |
| Lock-state persistence | Passed | 2026-07-09 `/scanner/start?entry=visible-inventory&limit=1` stored `A Note in Spring's Leich` with `equipped: "Citlali"` and `locked: true` |
| Lock-state diagnostics | Passed | Capture results include `lockSignal.ratio`, `lockSignal.threshold`, and the lock crop rect. Detection uses decoded PNG crop pixels to avoid native bitmap channel-order ambiguity |
| Unsafe auto-entry default | Mitigated | Normal guided Auto-Scan now blocks when no artifact detail card is visible instead of falling back to `auto-entry` |
| Review-to-eval export | Passed by tests | `npm run eval:review-candidates` script is covered by `src/eval/reviewEvalCandidatesScript.test.ts` and output is Git-ignored |
## Final Gate
```powershell
npm run lint
npm test
npm run build
git diff --check
```
Latest run after the cleanup: passed with `209` tests.
`npm run eval` and `npm run scan:assessment:test` also passed after the
lock-state fix.
## Explicitly Not Merge-Blocking
- `auto-entry`, `direct-inventory`, and `paimon-menu` are still experimental
Dev-Control entry modes. They can be tested later with low limits, but they
are no longer the normal merge path.
- The optional `3 artifacts/second` target is not proven. The stable current
path is closer to `2.5` to `2.75 artifacts/second` on clean 20-artifact live
runs.
- Alternate OCR-engine comparison paths have been retired. The current scanner
path is the maintained baseline; future engine work should be reopened only
with a new explicit product reason.
## Merge Result
Merged into `main` and pushed to `origin/main` on 2026-07-09. The previously
open `locked: true` proof passed for both Smart Capture and auto-scan
persistence before merge. The feature branch was deleted locally and remotely
after the merge.
@@ -0,0 +1,84 @@
# Native Artifact Scanner Validation - 2026-07-09
This report records the first bounded 20/50/100-item validation of the native
Artifact capture and post-capture processing path. All runs used the visible
1920x1080 16:9 Artifact inventory, category `artifacts`, and `persist=0`.
No Artifact result was written to the local store.
## Result Summary
| Limit | Capture | Pages | Capture time | Capture rate | Parsed | Review | Errors | Processing time | Processing rate |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 20 | 20/20 | 1 | 4,544 ms | 4.40/s | 20/20 | 0 | 0 | 7,349 ms | 2.72/s |
| 50 | 50/50 | 2 | 11,925 ms | 4.19/s | 50/50 | 1 | 0 | 15,398 ms after fix | 3.25/s |
| 100 | 100/100 | 4 | 24,673 ms | 4.05/s | 100/100 | 3 | 0 | 38,343 ms | 2.61/s |
The 100-item run produced exactly 100 PNG crops, 100 JSONL capture jobs, and
100 durable scan results. All 100 results preserved a real native capture
timestamp; none fell back to the Unix epoch.
The native capture worker stayed above 4 artifacts/second. The current smoke
runner performs capture and post-capture OCR sequentially, so these numbers do
not prove a 3 artifacts/second end-to-end pipeline. That target remains open.
## Quality Findings
The 50-item run exposed two correctness gaps in the downstream result path:
- OCR read `31.1%` as `311%`, while the parser still assigned high confidence.
- Native jobs carried the correct `capturedAt`, but durable scan results used
the Unix epoch because the processor did not forward the timestamp.
The parser now reconciles percent main values against the canonical slot,
stat, and level reference. It repairs an unambiguous missing decimal and lowers
confidence for unresolved <= +16 conflicts instead of silently replacing a
possible 4-star value. The processor now forwards native timestamps. The
existing 50-item run was reprocessed and confirmed `31.1%`, the original
capture time, one review, zero errors, and no persistence.
The final 100-item run placed three real OCR losses into Review:
- `DEF%+11.7%` was read as `DEF%+1.7%`.
- `ATK%+15.7%` was read as `ATK%+156.7%`.
- `HP+1,165` was read as `HP+1`.
All three values failed the legal substat-roll check and were prevented from
becoming clean, promotable results. They were then manually verified and
approved through the real single-result review workflow. The corrected results
are `DEF%+11.7%`, `ATK%+15.7%`, and `HP+1,165`; the run now has zero remaining
Review results. Approval wrote three durable review-log entries and three eval
samples. The confirmed cases were also added to the permanent OCR regression
corpus.
The parser now applies a conservative repair for this exact class of error: on
a +20 Artifact with four substats, dropped or extra percentage digits are only
repaired when exactly one combination satisfies the legal 5-star total roll
count. Thousands separators in flat substats are preserved. Ambiguous cases
continue to Review instead of being silently changed.
## Evidence Paths
- 20-item bundle: `outputs/native-live-smoke/20260709-223003/`
- 20-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223006/`
- 50-item bundle: `outputs/native-live-smoke/20260709-223038/`
- 50-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223041/`
- 100-item bundle: `outputs/native-live-smoke/20260709-223438/`
- 100-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223441/`
## Validation
- `npm run lint`: passed
- `npm test`: passed, 246 tests at the time of the live validation
- Manual review follow-up: 3/3 corrected and approved, 0 remaining Review rows
- OCR regression eval after follow-up: 26/26 exact cases, 77/77 fields
- `npm run build`: passed
- Native 20/50/100 dry runs: passed capture and processing completeness
- Artifact-store writes: zero
## Decision
The native Artifact path has enough same-session scale evidence to move the
next implementation focus from capture expansion, promotion, and single-result
review to deterministic Artifact value evaluation. The visible-inventory path
remains the production baseline until packaged behavior and later-session
native repeatability are also proven.
+198 -31
View File
@@ -3,13 +3,18 @@
This document is the source of truth for project intent, scope, runtime facts, and operational expectations.
For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md).
For the current app status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
For the next scan-result and artifact-inventory product phase, see
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
For the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md).
For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md).
## Project Identity
| Field | Value |
| --- | --- |
| Project name | Genshin Artifact Assistant |
| Status | Scanner rebuild in progress |
| Status | Artifact-first scanner baseline with native IK-style 20/50/100 scale evidence, explicit selected promotion, review/edit/approve, scan result rail, and Artifact-only Inventory pipeline; value scoring and later-session repeatability are next |
| Platform | Windows desktop |
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
| Runtime | Electron app with React UI and TypeScript |
@@ -26,7 +31,11 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
- 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.
- Present finished scan results as a compact artifact list instead of a debug-heavy live stats surface.
- Keep extraction confidence separate from artifact value so uncertain OCR becomes review, not a misleading low score.
- Provide a browsable local artifact inventory with detail views before promoting broader recommendations.
- Keep the app offline-first and usable without optimizer imports, Enka, HoYoLAB,
or any external scanner as a core dependency.
- Re-introduce recommendations only after the scanner base is trustworthy.
## Non-Goals
@@ -40,17 +49,20 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
| 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-001 | List capture sources and automatically prefer the detected Genshin window when available. | Must | Implemented |
| FR-002 | Read one currently opened artifact reliably from the local screen and show its parsed result. | Must | Implemented |
| FR-003 | Generate and maintain a local canonical Genshin data package for sets, pieces, slots, stats, characters, aliases, and UI profiles. | Must | Implemented baseline |
| FR-004 | Parse artifact fields only through deterministic matching, validation, and derivation against the canonical package. | Must | Implemented baseline |
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Implemented for visible-inventory baseline |
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Implemented baseline |
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Implemented baseline |
| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | Implemented baseline |
| 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 |
| FR-012 | Show active scan results as a minimal right-side rail with artifact number, name or compact fallback, value score, and status pill. | Should | Partial foundation |
| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | Should | Partial foundation |
| FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Planned |
## Non-Functional Requirements
@@ -58,8 +70,10 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
| --- | --- | --- |
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
| Performance | A 100-artifact visible-inventory run should finish cleanly with low review/miss rates and report timing evidence. | `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` quality-gated report |
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
| Reliability | Uncertain OCR must be visible to the user. | Confidence and details view |
| Score integrity | Extraction confidence and artifact value are separate concepts. | Review state can block or qualify a value score |
| 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 |
@@ -72,7 +86,7 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path |
| Input automation | C# sidecar with elevated dev runner when needed | Live-validated for read-only inventory selection clicks; see `docs/AUTOMATION_LIVE_SCAN.md` |
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
| Packaging | electron-builder | Configured in `package.json` |
@@ -94,28 +108,99 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
- 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 scan surface has an initial recent-results rail backed by the newest
stored artifacts.
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
- The scanner now has a validated visible-inventory path: 32 safe artifact
targets per page, lookup-derived fields, fast OCR crop profile, and a
quality-gated live soak runner.
- Elevated live automation is validated in the current dev environment:
`/automation/probe-click?index=1` changed the selected artifact and
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
- A 2026-07-08 visible-inventory 50-artifact run completed cleanly with
50/50 parsed and stored, 0 review, 0 duplicates, and 0 misses. It is stable
but still too slow for the 2-3 artifacts/second target.
- Later 2026-07-08 direct-GDI hot-path runs completed 20/20 parsed with
0 misses and 0 review. The best clean 20-artifact iteration reached
7285 ms, or roughly 2.75 artifacts/second; the final stable
`2026-07-08-direct-gdi-reviewfix` run completed in 7973 ms. The
3 artifacts/second target remains unproven.
- The same direct-GDI path completed a 100-artifact run with 100/100 parsed,
0 review, 0 misses, and 42064 ms elapsed across 4 pages.
- Review samples can now be exported with `npm run eval:review-candidates` into
a Git-ignored human-labeling worklist. This is the next quality phase before
adding more OCR corpus cases or trusting review queue data as labels.
- Native IK-style Artifact capture is wired through the C# helper. It writes
card crops and run artifacts for downstream OCR/parse processing instead of
making React do per-artifact work in the hot capture loop.
- The native post-capture processor can write `scan-results.json` and
`processing-report.json`, preserve parser field confidence, match Artifact
results against IK artifact set/piece/slot data, and keep store persistence
opt-in.
- The Inventory view now has an Artifact-only pipeline surface for scope,
native capture, OCR queue, review gate, promotion, and evidence. Native rows
expose crop previews, IK/GOOD metadata, dry-run promotion state, and a
`Naechster Schritt` card.
- The active UI intentionally hides weapon, material, and character-detail IK
catalog coverage until those values are actually scanned.
### What is still structurally weak
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
- The current PowerShell input sidecar is serviceable for experimentation but not a strong production base for long-running, low-jitter auto-scan.
- Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs
with 0 misses on the current engine. The current 2026-07-09 100-artifact run
completed `100/100` verified and parsed with `0` review and `0` misses.
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
- Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus.
- The scan page now has a minimalist recent-results rail, and the Inventory view
can inspect native Artifact results, crop previews, IK match state, promotion
dry-runs, and pipeline risk/status. Value scoring remains incomplete;
single-result review/edit/approve is implemented and batch review is
intentionally unavailable.
- The new native IK-style path has same-session 20/50/100 live scale evidence
with complete capture/result counts and safe Review gating. Later-session
repeatability and packaged behavior remain open.
- Store promotion from native `scan-results.json` now supports one selected,
confirmed result at a time with main-process revalidation and a durable log.
Batch promotion intentionally remains unavailable.
- Native Review results can be corrected and approved or rejected one at a
time. Approval revalidates IK identity, canonical main values, and legal
substat rolls, then feeds the existing review-to-eval candidate pipeline.
- Recommendations and build logic exist, but artifact inventory, detail review,
and value scoring should land first so recommendations have trustworthy inputs.
- Repeatability and 3 artifacts/second are still open; speed work should not
outrank result clarity, inventory UX, or corpus growth while the current path
is stable.
### 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.
The app has crossed from OCR-demo/prototype into an Artifact-first scanner app.
The broadest proven live path is still the visible-inventory scan flow: the
operator opens Artifact inventory with a visible detail card, the app verifies
the state, scans read-only, persists parsed artifacts, and keeps uncertain data
reviewable. The newer native IK-style path is the intended high-speed direction:
the helper captures Artifact card crops quickly, while OCR, parsing, review,
promotion, and value evaluation run downstream.
The next product phase is not broad category expansion. Weapons, materials, and
character details stay out of active scope. The priority is later-session
native repeatability, value
evaluation, and detail explanations before recommendations become the core
product surface.
## 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.
- External scanners are not runtime dependencies. The app owns its scan
choreography, OCR, and quality gates.
- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion.
- The scan workspace should be an operator surface: preview, live result rail,
Stop, status, and review access. Detailed stats and debug evidence belong in
diagnostics, summaries, or artifact detail.
- Artifact value scoring must not hide OCR uncertainty. `Review` is a distinct
outcome, not just a weak artifact score.
## Execution Plan
@@ -127,7 +212,9 @@ Outcome:
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
Status:
- In progress
- Mostly done for the scanner baseline. The Diagnose/dev surface is separated
and scrollable, scan summaries are compact, and the normal Auto-Scan path is
guarded. Further UI polish remains useful but no longer blocks scanner merge.
### Phase 1 - Canonical game data package
@@ -145,7 +232,9 @@ Outcome:
- Parser stops "free guessing" outside the canonical package.
Status:
- In progress
- Implemented as a generated lookup package in `src/data/genshinGameData.json`
with validation and parser integration. Continue regenerating and expanding
aliases deliberately when Genshin data or OCR samples require it.
### Phase 2 - Deterministic parser hardening
@@ -158,9 +247,15 @@ Outcome:
5. safe derivation from piece/slot/value references
- Main stat/value inference is tightened with slot constraints and reference tables.
- Bad parses automatically generate structured review reasons.
- Equipped-character parsing is canonical-data constrained: noisy known names can
match through aliases/fuzzy lookup, but unknown footer fragments stay
`Not detected` instead of being stored as invented character names.
Status:
- In progress
- Implemented for the merge baseline. Parser tests cover canonical set/slot/stat
matching, equipped-character footer noise, known aliases, and unsafe one-letter
fragments. Continue growing the confirmed review corpus before tightening
thresholds further.
### Phase 3 - Scanner core rebuild
@@ -178,9 +273,15 @@ Outcome:
- resume or stop
- Progress counts only when a new verified artifact or duplicate signature is confirmed.
- Repeated pages, unchanged detail cards, blocked cursor movement, and scroll failures stop the scan with diagnosis instead of producing fake progress.
- Fast artifact-read captures include the equipped footer when an equipped
marker is visible; preflight and polling captures still skip expensive OCR.
Status:
- In progress
- Implemented and merged for the visible-inventory path. Live validation on
2026-07-09 covered `20/20` verified/parsed with `0` review and `0` misses,
equipped-character persistence, unlocked lock state, positive locked state,
and locked persistence. Explicit entry-mode experiments remain separate from
the normal merge-ready path.
### Phase 4 - Input automation replacement
@@ -196,7 +297,10 @@ Outcome:
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
Status:
- Planned
- Read-only C# helper path and elevated dev startup are validated for the
visible-inventory scanner baseline. Direct inventory, Paimon-menu, and
auto-entry modes remain Dev-Control experiments and should be tested with low
limits before being promoted.
### Phase 5 - Learning loop that actually compounds
@@ -212,31 +316,94 @@ Outcome:
- Review samples become both parser regression fixtures and learning inputs.
Status:
- Planned
- Prepared in code for text replacements, field aliases, constrained fixes,
crop adjustment proposals, and UI-profile adjustment proposals. Crop/profile
changes still require live review before being auto-applied.
### Phase 6 - Recommendations come back on top of a trusted scanner
### Phase 6 - Minimal scan result rail and artifact inventory
Outcome:
- The scan page shows a large screenshot/preview and a compact right-side rail
of finished artifact evaluations.
- Each row shows scan number, artifact name or compact fallback, value score,
and a colored status pill.
- Debug stats, confidence breakdowns, and OCR internals move out of the main
scan surface.
- A new artifact inventory menu provides compact browsing, filters, sorting,
and click-through details.
Status:
- Started with a scan result rail in the scan surface and an `Inventory` view.
The rail can show native `scan-results.json` entries after post-processing
and falls back to recent stored artifacts. The inventory browser can filter,
sort, inspect native/store/snapshot rows, and preview native card crops from
the selected run directory. It also exposes the current IK inventorylist
version, active Artifact-only scope, and pipeline status for capture,
post-processing, review, promotion, and evidence. Native Artifact rows carry
IK/GOOD match status from the post-capture processor and a dry-run promotion
decision that shows whether a native result is speicherbar, already stored,
review-only, or blocked without writing to the store. Detail review exists;
deterministic value score and value reasons are still pending. See
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
for the implementation phases and acceptance criteria.
### Phase 7 - Artifact detail evaluation and upgrade projection
Outcome:
- Artifact detail explains parsed fields, OCR confidence, scoring reasons, and
review needs.
- Upgrade projection is available only when enough data is known and is labeled
as probabilistic, with worst/middle/best projected value scores.
- Low-confidence OCR disables or qualifies value conclusions instead of showing
false certainty.
Status:
- Planned after the scan result rail and inventory data contracts.
### Phase 8 - 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
- Next major product area after inventory, detail evaluation, repeatability, and
corpus work. Do not promote recommendation UX until stored artifact quality is
backed by more confirmed review samples and repeat live scan runs.
## 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.
1. Add deterministic Artifact value evaluation with explainable reasons.
2. Keep the visible-inventory scanner path as the production baseline and avoid
promoting `auto-entry`, `direct-inventory`, or `paimon-menu` until they pass
their own low-limit live validations.
3. Keep the active native scope artifact-only until weapons, materials, and
character details are actually scanned.
4. Finish the scan result and inventory detail contracts from
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md),
preserving separate extraction confidence and artifact value.
5. Finish value reasons in the existing Artifact inventory detail view before
expanding broad build recommendations.
6. Keep the existing scan preview/result rail and Inventory workflow compact;
move new diagnostics behind the dedicated diagnostics surface.
7. Continue growing the confirmed OCR corpus from review samples exported by
`npm run eval:review-candidates` and prepared through
`npm run eval:prepare-confirmed`.
8. Repeat live scanner runs in later sessions to prove repeatability across
pages, locked/unlocked artifacts, equipped footers, and duplicate handling.
9. Continue the optional `3 artifacts/second` work only if the next change can
reduce OCR/capture transport time without weakening quality gates.
10. Start recommendation/product UX work only after inventory/detail evaluation,
repeat scan quality, and confirmed corpus coverage are strong enough to trust
stored artifacts.
## Open Questions
| Question | Status |
| --- | --- |
| Should the production input sidecar be Rust/C++ first, or a transitional Node native addon, for the next iteration? | Open |
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
| What exact first-pass value formula should drive the `0-100` artifact score before build-aware recommendations exist? | Open |
| Which upgrade projection model is honest enough for early UX: deterministic roll buckets, probability-weighted outcomes, or a deliberately simple best/middle/worst estimate? | Open |
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
+54 -3
View File
@@ -2,18 +2,31 @@
Field-level accuracy measurement for the artifact OCR parser. This is the gate
every OCR, crop, layout, or parser change runs against (see ADR-007).
It is necessary but not sufficient for live scanner proof: scan speed and
review/miss rates are measured by `npm run scan:iterate:validated` for short
iteration and `npm run scan:goal:validated` for the final 100-artifact proof.
Use the `:wait` variants directly after UAC startup.
## Run it
```powershell
npm run eval # full accuracy report for the seed corpus
npm run eval:review-candidates # export unconfirmed review samples for human labeling
npm test # runs the eval gate alongside the rest of the suite
npm run scan:assessment:test # verifies quality-first scan ranking logic
npm run scan:iterate:validated:wait # 20-artifact live scan
npm run scan:repeatability:wait # 20/45/100 current-engine repeatability
npm run scan:goal:validated:wait # final 100-artifact live scan
```
The report prints exact-match rate, overall field accuracy, a per-field
breakdown (critical fields marked with `*`), and every failing case with an
`expected "..." got "..."` diff.
Repeatability runs prove whether the visible-inventory scanner stays stable
across later sessions. They are current-path evidence and should be reported
with review/miss rates plus timing, not just click count.
## How it works
- `src/eval/ocrEvalHarness.ts` - pure metric functions. `runOcrEval(cases)`
@@ -28,6 +41,35 @@ breakdown (critical fields marked with `*`), and every failing case with an
The review queue is the corpus source. A saved review sample carries the OCR
text plus the parser's *guess* - `reviewSampleToEvalCase` extracts both.
For the local Electron queue, run:
```powershell
npm run eval:review-candidates -- --limit=80
```
This writes:
- `outputs/review-eval-candidates/review-eval-candidates.json`
- `outputs/review-eval-candidates/review-eval-candidates.md`
The exporter deduplicates samples, puts complete modern OCR captures first,
marks missing fast-profile fields so stale/partial captures do not crowd out
useful cases, and surfaces ownership/lock evidence (`artifact-footer`,
`equipped`, and `locked=true/false`) for the next validation pass.
After manually checking one candidate against the real artifact, create a
confirmed corpus snippet with explicit expected labels:
```powershell
npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json
```
The script reads the latest
`outputs/review-eval-candidates/review-eval-candidates.json` by default and
writes a `.confirmed.ts` snippet under `outputs/review-eval-candidates/`.
It refuses to run without explicit labels, so parser guesses are not silently
promoted to ground truth. Review that snippet, then paste the object into
`src/eval/corpus/confirmedReviewCorpus.ts`.
The parser's guess is a label **candidate, not ground truth** (using it directly
would be the parser grading itself). To add a real case:
@@ -35,11 +77,20 @@ would be the parser grading itself). To add a real case:
1. Convert review samples with `reviewSamplesToEvalCases(records)`.
2. Open each produced case and confirm or correct the `expect` values against
what the artifact actually is in-game. Set `confirmed: true`.
3. Move the corrected case into a file under `src/eval/corpus/` and add it to the
corpus array.
3. Move the corrected case into
`src/eval/corpus/confirmedReviewCorpus.ts`. The main eval gate imports
`src/eval/corpus/index.ts`, which combines the seed corpus with confirmed
review cases.
`src/eval/corpus/confirmedReviewCorpus.test.ts` rejects common corpus mistakes:
duplicate case ids, missing OCR text, empty labels, missing source notes, or a
case that was copied in without `confirmed: true`.
Prefer cases that cover new failure modes: unseen resolutions, new sets or
characters, and OCR noise the current corpus does not exercise.
characters, equipped footer noise, and OCR noise the current corpus does not
exercise. `locked` is a capture-side visual flag rather than a text parser field;
validate it from review-export metadata and live screenshots instead of adding
it to the OCR eval labels.
## When a change moves a number
+353
View File
@@ -0,0 +1,353 @@
# Scanner Results And Artifact Inventory Roadmap
This document defines the next product phase after the validated visible-inventory
scanner baseline and the new native IK-style capture direction. The next capture
milestone optimizes speed by moving game-control and card-crop capture into the
C# helper. Artifact extraction, review safety, and inventory UX stay separate
downstream work so the Electron app remains a visual/status surface instead of
the worker.
For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
## Product Stance
- Keep the scan workspace focused on operation, not analysis.
- Keep the live preview as the dominant surface.
- Keep Electron/React out of per-artifact scanner work in the fast path.
- Use IK `inventorylists` 1:1 as the scanner dictionary source for artifacts.
Other IK lists may stay vendored, but weapons, materials, and character
details are not active product scope until their values are actually scanned.
- Move debug metrics, OCR internals, and detailed evaluation behind details,
diagnostics, or the inventory view.
- Do not merge scan confidence and artifact value into one ambiguous score.
- Treat uncertain OCR as review, not as a low-quality artifact.
- Prioritize extracting correct artifact content over another speed pass unless
live timings regress materially.
## Target User Flow
1. The user opens Artifact inventory in Genshin with a visible detail card.
2. The scanner runs the existing read-only visible-inventory flow.
3. The scan view shows the latest screenshot/preview on the left.
4. A compact live result rail on the right receives one row per finished
artifact evaluation.
5. Each row shows only:
- scan number,
- artifact name or compact slot/set fallback,
- artifact value score from `0` to `100`,
- a colored result pill.
6. After the scan, the user opens the inventory menu to browse all scanned
artifacts.
7. Clicking an artifact opens a detail view with screenshot, parsed fields,
OCR confidence, scoring reasons, and optional upgrade projection.
## Score Contract
The UI must keep two concepts separate:
| Concept | Meaning | UI behavior |
| --- | --- | --- |
| Extraction confidence | How reliable the scan/OCR/parser result is. | Drives `Review`, warnings, and detail confidence rows. |
| Artifact value score | How useful the artifact appears for builds. | Drives the `0-100` value and good/mid/weak pill. |
Rules:
- If extraction confidence is too low, show `Review` instead of a normal value
decision, even when a tentative value score exists.
- If the artifact is a duplicate, show duplicate state separately from value.
- The live rail may show one compact pill, but the data model should preserve
separate `extractionStatus` and `valueStatus` fields.
- Score labels should be stable and simple:
| Value score | Label |
| --- | --- |
| `90-100` | Strong |
| `70-89` | Good |
| `45-69` | Mid |
| `0-44` | Weak |
| unknown or unsafe | Review |
The exact formula can start simple and deterministic. It should explain its
reasons in the detail view before it becomes a recommendation source.
## Planned Pipeline Shape
The current TypeScript scan loop can keep shipping as fallback while the native
pipeline takes over high-speed capture. The target producer/consumer pipeline is:
```mermaid
flowchart LR
Native["C# native click, scroll, capture worker"]
Queue["Bounded card-crop queue"]
OCR["OCR and parse workers"]
Match["IK inventorylists matching"]
Eval["Artifact evaluation (deferred)"]
Aggregate["Aggregator and store"]
UI["Live rail and inventory"]
Native --> Queue
Queue --> OCR
OCR --> Match
Match --> Eval
Eval --> Aggregate
Aggregate --> UI
```
Constraints:
- Only the native helper may control Genshin input, focus, click, scroll, card
capture, or failsafe polling in the fast path.
- OCR/parse/evaluation workers may run concurrently on already captured
screenshot/crop jobs.
- The queue must be bounded, initially around `4-8` jobs, so the scanner does
not outrun retries, review decisions, or stop requests.
- The pipeline must preserve current safety rules: no memory reads, hooks,
injection, game-file changes, deleting, feeding, enhancing, locking/unlocking,
or spending resources.
- Evaluation may be omitted until after capture speed and crop quality are
validated.
## Implementation Phases
### Phase 0 - Documentation and contracts
Status: prepared by this document and ADR-013.
Outcome:
- Product direction is documented.
- Main docs point to this roadmap.
- Acceptance criteria and checklists exist before code changes.
- Native run artifacts are part of the scanner contract:
`manifest.json`, `capture-jobs.jsonl`, `status.json`,
`scan-results.json`, and `processing-report.json`.
### Phase 1 - Result data model
Status: foundation implemented for native post-capture processing. The native
scanner reports run status and writes crop jobs; the post-capture processor now
writes durable per-artifact `scan-results.json` entries next to the diagnostic
`processing-report.json`. Native artifact results can now carry IK inventorylist
match metadata, and IK set/piece/slot conflicts force review instead of clean
extraction. The post-capture OCR/parse worker runs as a bounded queue while
preserving result order. `scan-results.json` also stores parser field confidence
metadata for native artifact details. Evaluation remains explicitly deferred.
Outcome:
- Add a durable scan result entry model with sequence number, capture metadata,
parsed artifact identity, extraction status, artifact value score, value
status, duplicate/review flags, and timestamps. Initial native entries use
`valueStatus: "deferred"` for clean parses and `valueStatus: "review"` for
uncertain extraction.
- Keep existing stored artifact records compatible.
- Add tests for status derivation so low-confidence OCR cannot become a normal
`Good` or `Strong` result.
Likely files:
- `src/types/domain.ts`
- `src/types/storage.ts`
- `src/lib/scannerSession.ts`
- `src/lib/storedArtifactAdapter.ts`
- `src/lib/scanReviewUtils.ts`
### Phase 2 - Minimal live result rail
Status: foundation implemented for native post-capture results. The scan main
section shows newest stored artifacts as fallback and can display the latest
native `scan-results.json` entries after post-processing. Result rail rows can
open the Inventory surface for crop/IK/detail inspection. Value scores are still
pending.
Outcome:
- Rework the scan main section into preview plus right-side result rail.
- Remove live evaluation cards and noisy stats from the primary scan area.
- Append rows only after an artifact has finished parse/evaluation.
- Keep Stop, scan status, and review access available.
- Keep debug stats in diagnostics or summary modals.
Likely files:
- `src/features/scan/components/ScanMainSection.tsx`
- `src/features/scan/components/ScanResultCards.tsx`
- `src/features/scan/components/hooks/useScanMainSectionModel.ts`
- `src/features/scan/components/hooks/useScanResultCardsModel.ts`
- `src/styles/base.css`
### Phase 3 - Artifact inventory view
Status: foundation implemented and now scoped to active Artifact scanning only.
The app has an `Inventory` navigation item with
a compact browser for native `scan-results.json` entries, stored artifacts, and
snapshot fallback rows. Filtering, sorting, a detail panel, and secure native
crop preview loading are present. The view now surfaces the vendored IK
Artifact version/counts, active Artifact-only scope, compact pipeline state for
native capture, OCR queue, review, promotion, and evidence, plus per-result
IK/GOOD match status for native artifacts. The inventory view also computes a
dry-run promotion summary from `scan-results.json` plus the local artifact
store, separating `speicherbar`, already stored, review, and blocked native
results. One selected clean result can now be promoted after a second UI
confirmation; the main process revalidates the run result, writes the store,
updates `scan-results.json`, and appends `promotion-log.jsonl`. Weapons,
materials, and character details
remain hidden from the active feature UI while they are not scanned. The native
helper still reports the category distinction in
`supportedCategories` via `catalogAvailable`, `nativeCaptureSupported`, and
`scanStatus`, so dev-control evidence cannot accidentally claim that every IK
catalog has an implemented scanner. Native scan start, status, manifest, and
capture jobs now carry an explicit scan category; only `artifacts` can currently
produce native capture jobs. Value scoring is still pending; single-result
review/edit/approve is implemented in the detail phase below.
Support code for simple IK weapon, character, and material name/GOOD-key
matching exists, but those categories still need their own capture flows before
they can be claimed as scanned inventory.
Outcome:
- Add a menu item for scanned artifact inventory.
- Show a compact, minimal list or dense grid of stored artifacts.
- Each entry shows the same score/pill language as the live rail.
- Provide filters and sorting for review, score, set, slot, equipped, locked,
and newest scan.
- Avoid a marketing/landing layout; the first screen is the actual inventory.
Likely files:
- `src/features/inventory/*`
- `src/features/layout/navigation.ts`
- `src/pages/app/*`
- `src/lib/artifactStore.ts`
- repository bridge/storage files as needed
### Phase 4 - Artifact detail view
Status: started for native scan results. Inventory detail can show the native
card crop from the run directory through the Electron bridge, constrained to
PNG files inside the active native run folder. It also shows stored parser
field-confidence rows from native `scan-results.json`, IK/GOOD metadata, dry-run
promotion state, and a `Naechster Schritt` card. Native Review results now have
an inline field editor with approve/reject, authoritative validation, run logs,
and review-to-eval export. Value reasons are still pending.
Outcome:
- Clicking a live row or inventory item opens detail.
- Detail shows screenshot or detail crop when available.
- Detail lists parsed fields, OCR confidence, parser notes, extraction status,
value score, and scoring reasons.
- Review-required items make the uncertainty explicit and do not present their
score as final.
Likely files:
- `src/features/inventory/components/*`
- `src/features/scan/components/modals/*`
- `src/lib/artifactOcrParser.ts`
- `src/lib/scoring.ts`
### Phase 5 - Artifact value evaluation
Status: next product feature after the now-completed same-session native scale,
selected promotion, and review/edit/approve workflow.
Outcome:
- Add a deterministic artifact value evaluator before promoting build
recommendations.
- Explain the score through factors such as set, slot, main stat, substat
quality, level, locked/equipped state, and available character/build context.
- The evaluator must accept incomplete data and return review/unknown instead
of confident nonsense.
Likely files:
- `src/lib/artifactEvaluation.ts`
- `src/lib/scoring.ts`
- `src/lib/substatRolls.ts`
- `src/lib/genshinLookup.ts`
- targeted unit tests under `src/lib/*.test.ts`
### Phase 6 - Upgrade projection
Status: later detail-level feature after artifact value evaluation.
Outcome:
- For artifacts below max level, show optional projection only in detail.
- Provide `worst`, `middle`, and `best` projected value scores.
- Label projection as probabilistic and not a guaranteed result.
- Use known Genshin upgrade constraints and current substats; unknown or
partially read data must disable or soften the projection.
Likely files:
- `src/lib/upgradeProjection.ts`
- `src/lib/substatRolls.ts`
- `src/features/inventory/components/*`
- parser/scoring tests
### Phase 7 - Queue-based analysis pipeline
Outcome:
- Introduce a bounded screenshot/crop job queue only after the UI/data contract
is stable.
- Keep one game-control worker.
- Allow OCR/parse/evaluation workers to process queued jobs.
- Preserve stop/failsafe behavior and review decisions.
- Compare throughput against the current baseline without weakening accuracy.
Status: started for post-capture processing. Native capture already writes
crop jobs without waiting for OCR. The downstream processor now consumes those
jobs with bounded parallelism and writes stable ordered reports. Live throughput
comparison remains a final validation item.
Likely files:
- `src/lib/autoScanLoop.ts`
- `src/lib/scannerSession.ts`
- Electron capture/OCR boundary in `electron/main.ts` or extracted services
- scan-loop tests and live soak scripts
### Phase 8 - Recommendation promotion
Status: intentionally delayed until native artifact ingestion, review,
promotion, value scoring, and repeatability are trustworthy.
Outcome:
- Promote account-level recommendations only after scan result quality,
inventory browsing, detail review, and value scoring are trustworthy.
- Recommendations must reference stored artifact quality and uncertainty.
## Acceptance Criteria
- The scan page still fits the primary workflow without page-level scrolling.
- The preview remains visible during active scan.
- The right rail shows finished artifact evaluations, not noisy intermediate
parser/debug state.
- Review items are visibly different from weak artifacts.
- Artifact value score and extraction confidence remain separate in data.
- Inventory view can browse stored scan results without opening diagnostics.
- Detail view explains why an artifact received its score.
- Upgrade projection never implies a guaranteed future roll.
- Existing safety constraints and scan quality gates remain intact.
## Validation Plan
- `npm run lint`
- `npm test`
- `npm run build`
- `git diff --check`
- `npm run scan:native:smoke` before claiming native capture plus
post-capture processing on live Genshin data.
- Broader native Artifact runs with 20/50/100 items before claiming IK-style
speed or stability.
- Add unit tests for score/status derivation and upgrade projection.
- For scanner-facing changes, run a low-limit visible-inventory live scan before
wider validation.
- Keep `npm run scan:repeatability:wait` for later regression checks, not for
every UI-only pass.
+173
View File
@@ -0,0 +1,173 @@
# Scanner Rework Status
Updated: 2026-07-09
Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
## Was Es Kann
- Native IK-Erfassung ist als neuer schneller Pfad verdrahtet: Electron/React
starten und zeigen Status, der C# Helper prueft IK-Listen, fokussiert Genshin,
klickt das 16:9 8x4 Grid, scrollt und schreibt Detailkarten-Crops.
- IK `inventorylists` aus Inventory Kamera 1.4.4 sind 1:1 unter
`data/ik-inventorylists` uebernommen und werden in Builds als Resource
mitgeliefert. Der Helper meldet Version `6.7.0` mit `61` Artifact-Sets,
`289` Artifact-Pieces, `247` Waffen, `119` Charakteren und `715` Materialien.
- Jeder native Run legt `manifest.json`, `capture-jobs.jsonl` und `status.json`
im Run-Ordner an. Damit kann OCR/Parsing als nachgelagerte Queue laufen,
ohne den Capture-Loop wieder in React/Electron zu ziehen.
- Nach einem nativen Run kann die App `capture-jobs.jsonl` nachgelagert
verarbeiten und `scan-results.json` plus `processing-report.json` schreiben.
`scan-results.json` enthaelt dauerhafte Ergebnis-Eintraege mit
Extraction-Status, Review-Zustand und bewusst deferierter Value-Auswertung.
Diese Stufe OCRt/parst nach der Erfassung, matched native Artifact-Ergebnisse
gegen IK `inventorylists`, zwingt IK-Konflikte in Review, verarbeitet Crops
ueber eine bounded Queue, schreibt Parser-Feldconfidence in die Resultate und
persistiert standardmaessig noch nicht in die DB.
- Smart Capture reads the current artifact detail view through focused 16:9
crops, OCR preprocessing, deterministic parser matching, and local lookup data.
- Visible-inventory auto-scan is the production baseline: preflight, grid
detection, focus, click, detail verification, OCR, parse, store/review, scroll,
and summary.
- The current live path has completed a 100-artifact run with `100/100` verified
and parsed, `98` stored, `2` duplicates, `0` review samples, `0` misses, and
`393 ms/artifact`.
- Native IK live smoke passed on 2026-07-09 with runtime signature
`2026-07-09-native-ik-visual-probe`: visual preflight ready, guarded probe
changed the detail panel, `2/2` native artifact card crops captured,
post-capture processing parsed `2/2`, `0` review, `0` errors, and
`queueConcurrency: 2` without persisting to the artifact store.
- Native 20/50/100 dry validation also passed in the same live session. The
100-item run captured and parsed `100/100` across 4 pages with `0` errors,
`3` correctly gated Review results, and `0` store writes. See
`docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md`.
- Stored artifacts, review samples, local text replacements, GOOD-compatible
import/export, scanner diagnostics, and OCR eval are implemented.
- The scan page now has a compact `Letzte Ergebnisse` rail. It uses the latest
native `scan-results.json` entries after post-processing and falls back to
newest stored artifacts when no native run result is loaded. Rail rows open
the Inventory surface for detail inspection.
- The app now has an `Inventory` navigation view for browsing native scan
result entries, stored artifacts, and snapshot fallback rows with filters,
sorting, a detail panel, and native crop previews loaded from the scan run
directory. The same view is now scoped to Artifact scanning: it shows IK
Artifact set/piece coverage, a Native Artifact pipeline strip for capture,
post-processing, review, promotion, and evidence, plus a per-result
`Naechster Schritt` panel. Weapons, materials, and character details remain
loaded data only and are intentionally hidden from the active feature UI while
they are not scanned.
- The live-soak runner writes JSON/CSV evidence bundles and validates quality via
`scan-performance-assessment.json`.
## Was Zuletzt Gemacht Wurde
- Der aktive UI-Scope wurde auf Artifact scanning begrenzt.
- Waffen, Materialien und Charakterdetails bleiben als vendored IK-Daten
vorhanden, werden aber nicht mehr als aktive Scanner-Features in der
Inventory UI dargestellt.
- Die Inventory UI zeigt jetzt eine Native Artifact Pipeline:
Scope, Native Capture, OCR Queue, Review Gate, Promotion und Evidenz.
- Native Ergebnislabels wurden geschaerft:
`Geparst` bedeutet extrahiert, `Stored` bedeutet persistiert, `Review`
bedeutet unsicher und `Wert offen` bedeutet bewusst noch nicht bewertet.
- Native Artifact Details zeigen jetzt einen `Naechster Schritt`:
Promotion bereit, Review zuerst, bereits im Store, blockiert oder Wert spaeter.
- Inventory Filter wurden um native Ergebnisse und speicherbare Ergebnisse
erweitert.
- Ein ausgewaehltes, sauberes natives Ergebnis kann jetzt nach einer zweiten
UI-Bestaetigung promotet werden. Der Main-Prozess validiert die autoritative
Run-Datei erneut, prueft Store-Duplikate, schreibt den Store, aktualisiert
`scan-results.json` und protokolliert in `promotion-log.jsonl`.
- Native Review-Ergebnisse koennen direkt im Inventory anhand des Crops editiert,
gegen IK, kanonische Main-Werte und legale Substat-Rolls validiert sowie
freigegeben oder abgelehnt werden. Freigaben aktualisieren `scan-results.json`,
schreiben `review-log.jsonl` und erzeugen einen normalen Eval-Review-Sample.
- Projekt-, Roadmap-, Architektur-, Runbook- und Checklist-Doku wurden auf
Artifact-only Scope nachgezogen.
## Was Noch Nicht
- Native IK-Erfassung captured aktuell Karten-Crops; OCR/Parser laufen erst
nachgelagert ueber den Processor. GOOD-Speichern und Evaluierung laufen noch
nicht automatisch im nativen Pipeline-Nachgang.
- Native IK-Erfassung ist aktuell auf das sichtbare 16:9 Artifact-Inventar
begrenzt.
- The result rail is still a foundation: it can show native `scan-results.json`
entries after post-processing, but does not yet calculate or show real value
scores.
- The inventory browser is still a foundation: value scoring and richer
review/edit flows are not complete.
- Upgrade projection and build recommendations should wait until inventory and
detail evaluation have trustworthy stored artifacts.
- Repeatability across later sessions, more accounts, more locked/equipped
combinations, and more confirmed OCR corpus cases still needs growth.
- The strict `333 ms/artifact` budget for 3 artifacts/second is not proven.
- Weapons, materials, and character details are intentionally out of active
scope until their values are actually scanned.
## Wo Es Noch Probleme Macht
- Die native Pipeline hat jetzt 20/50/100-Skalenevidenz aus einer Live-Session.
Spaetere Session-Repeatability und Packaged-App-Evidenz stehen noch aus.
- Capture roundtrip and OCR remain the main timing costs in the 100-artifact
path.
- Auto-scan still requires a visible artifact inventory detail card for the
production path; broader auto-entry modes remain dev experiments.
- OCR/parser quality is good on the confirmed corpus, but review samples must be
manually labeled before they can become permanent eval ground truth.
- Non-16:9 or unusual game layouts are intentionally higher risk and should
block or fall into review instead of silently scanning.
- The UI now labels these risks in the Artifact Inventory pipeline. Same-session
scale is proven, but external-tool parity and later-session repeatability are
intentionally not claimed.
## Was Noch Verbesserungsfaehig Ist
- Repeat the bounded 20/50/100 native runs in a later session and in a packaged
app to verify that the current post-capture queue remains repeatable.
- Keep native persistence explicit: clean selected results can be promoted
after confirmation, while uncertain results must pass single-item review.
- Finish value scoring and value-reason contracts in the existing detail view from
`docs/scanner-results-inventory-roadmap.md`.
- Keep diagnostics available but out of the primary scan surface.
- Continue expanding confirmed OCR eval cases from real review samples. The
first three native Review cases are corrected, approved, and permanent
regression cases.
- Add artifact value/detail evaluation without collapsing extraction confidence
and artifact value into one ambiguous status.
- Continue performance work only when it reduces capture/OCR overhead without
weakening review, miss, duplicate, or safety gates.
## Was Als Naechstes Ansteht
1. Artifact Value Evaluation ergaenzen, aber erst nach sauberer Extraktion und
weiterhin getrennt von OCR/Parser-Confidence.
2. Detail-Ansicht um Value-Gruende und optionale Upgrade-Projektion erweitern.
3. Packaged-App-Verhalten fuer Helper, IK-Listen, Preload-Bridge,
Crop-Preview und Smoke-Kommandos pruefen.
4. Empfehlungen und Build-UX erst danach wieder nach vorne ziehen.
## Current Validation Commands
```powershell
npm run scan:live:preflight
npm run scan:iterate:validated
npm run scan:goal:validated
npm run scan:repeatability:wait
npm run scan:assessment:validate -- --latest --summary
npm run eval
npm test
npm run build
```
Latest static validation after the Artifact-only Inventory UI update:
```powershell
npm run lint # passed
npm test # passed, 252 tests
npm run build # passed
git diff --check
```
`git diff --check` passed with existing CRLF warnings for
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
+145
View File
@@ -0,0 +1,145 @@
import { BrowserWindow, Menu, screen } from "electron";
import path from "node:path";
export interface AppWindowManagerOptions {
preloadPath: string;
rendererUrl?: string;
rendererFilePath: string;
onMainReadyToShow?: () => void | Promise<void>;
}
export interface AppWindowManager {
createMainWindow: () => void;
focusMainWindow: () => { ok: boolean };
hasMainWindow: () => boolean;
getMainWindow: () => BrowserWindow | null;
sendScannerCommand: (command: unknown) => void;
createOverlayWindow: () => void;
hideOverlayWindow: () => { ok: boolean };
}
export function createAppWindowManager({
preloadPath,
rendererUrl,
rendererFilePath,
onMainReadyToShow,
}: AppWindowManagerOptions): AppWindowManager {
let mainWindow: BrowserWindow | null = null;
let overlayWindow: BrowserWindow | null = null;
function createMainWindow() {
Menu.setApplicationMenu(null);
mainWindow = new BrowserWindow({
width: 1320,
height: 860,
minWidth: 1120,
minHeight: 720,
backgroundColor: "#090711",
title: "Genshin Artifact Assistant",
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setMenuBarVisibility(false);
mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.once("ready-to-show", () => {
void onMainReadyToShow?.();
focusMainWindow();
});
mainWindow.webContents.once("did-finish-load", () => {
setTimeout(() => focusMainWindow(), 350);
});
if (rendererUrl) {
mainWindow.loadURL(rendererUrl);
} else {
mainWindow.loadFile(rendererFilePath);
}
}
function focusMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return { ok: false };
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
// Genshin often keeps foreground focus after a scan click. Toggling
// always-on-top for one tick nudges Windows to surface the dashboard again
// without leaving it pinned above other apps.
mainWindow.setAlwaysOnTop(true, "screen-saver");
mainWindow.focus();
setTimeout(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.setAlwaysOnTop(false);
mainWindow.focus();
}, 250);
return { ok: true };
}
function sendScannerCommand(command: unknown) {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("scanner:command", command);
}
function createOverlayWindow() {
if (overlayWindow) {
overlayWindow.show();
return;
}
const display = screen.getPrimaryDisplay();
overlayWindow = new BrowserWindow({
x: display.workArea.x,
y: display.workArea.y,
width: display.workArea.width,
height: display.workArea.height,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
focusable: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
if (rendererUrl) {
overlayWindow.loadURL(`${rendererUrl}?overlay=1`);
} else {
overlayWindow.loadFile(rendererFilePath, {
query: { overlay: "1" },
});
}
overlayWindow.on("closed", () => {
overlayWindow = null;
});
}
function hideOverlayWindow() {
overlayWindow?.close();
return { ok: true };
}
return {
createMainWindow,
focusMainWindow,
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
getMainWindow: () => mainWindow,
sendScannerCommand,
createOverlayWindow,
hideOverlayWindow,
};
}
+39
View File
@@ -14,9 +14,22 @@ import type {
ClickResult,
AutomationGuard,
ScrollResult,
KeyPressResult,
SaveResultWithPath,
SaveSnapshotResult,
GoodDatabase,
GoodImportFileResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
ScannerStatusPayload,
} from "../../src/types/global.js";
import type {
@@ -31,6 +44,17 @@ interface AppHandlersDependencies {
moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
readRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
@@ -51,6 +75,7 @@ interface PersistenceHandlersDependencies {
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: () => Promise<GoodImportFileResult>;
}
interface CaptureHandlersDependencies {
@@ -58,6 +83,7 @@ interface CaptureHandlersDependencies {
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>;
keyPress: (key: string) => Promise<KeyPressResult>;
getAutomationGuard: () => Promise<AutomationGuard>;
}
@@ -69,6 +95,17 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
publishScannerStatus: dependencies.publishScannerStatus,
nativeScannerDataStatus: dependencies.nativeScannerDataStatus,
nativeScannerCatalog: dependencies.nativeScannerCatalog,
nativeScannerPreflight: dependencies.nativeScannerPreflight,
nativeScannerStart: dependencies.nativeScannerStart,
nativeScannerStop: dependencies.nativeScannerStop,
nativeScannerStatus: dependencies.nativeScannerStatus,
nativeScannerProcessRun: dependencies.nativeScannerProcessRun,
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
getRuntimeInfo: dependencies.readRuntimeInfo,
loadSnapshot: dependencies.loadSnapshotFromDisk,
saveSnapshot: dependencies.saveSnapshotToDisk,
@@ -86,6 +123,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
loadScannerLearningRules: dependencies.loadScannerLearningRules,
writeScannerLearningRules: dependencies.writeScannerLearningRules,
exportGood: dependencies.exportGood,
importGoodFile: dependencies.importGoodFile,
});
registerCaptureHandlers({
@@ -93,6 +131,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
captureSource: dependencies.captureSource,
clickScreen: dependencies.clickScreen,
scrollScreen: dependencies.scrollScreen,
keyPress: dependencies.keyPress,
getAutomationGuard: dependencies.getAutomationGuard,
});
}
+506
View File
@@ -0,0 +1,506 @@
import fs from "node:fs/promises";
import http, { type Server } from "node:http";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
CaptureOptions,
CaptureResult,
CaptureSourceInfo,
ClickResult,
ReviewSampleListResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
ScannerCommand,
ScannerStatusPayload,
AppRuntimeInfo,
} from "../src/types/global.js";
import { validateLookupPackage } from "../src/lib/genshinLookup.js";
const execFileAsync = promisify(execFile);
interface DevControlServerDependencies {
registeredHotkeys: Record<string, boolean>;
appBuild: AppRuntimeInfo;
hasMainWindow: () => boolean;
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
clickScreen: (x: number, y: number) => Promise<ClickResult>;
scannerStatus: () => ScannerStatusPayload;
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
nativeScannerPreflight: (options?: { category?: string }) => Promise<NativeScannerPreflightStatus>;
nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise<NativeScannerRunStatus>;
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
warmOcr: (engine: "current") => Promise<unknown>;
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
captureSource: (
id: string,
delayMs?: number,
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult>;
requestShutdown?: (reason: string) => void;
}
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify(payload));
}
function dataUrlBase64(dataUrl: string) {
return dataUrl.replace(/^data:image\/png;base64,/, "");
}
function devCaptureOutputDir() {
return path.join(process.cwd(), "outputs", "live-capture");
}
function safeDebugFilePart(value: string) {
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
}
function dataUrlFingerprint(dataUrl: string | undefined) {
if (!dataUrl) return "";
let hash = 2166136261;
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
for (let index = 0; index < dataUrl.length; index += stride) {
hash ^= dataUrl.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
}
async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) {
if (!dataUrl) return null;
await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64"));
return filePath;
}
async function writeDevCaptureSnapshot(capture: CaptureResult) {
const outputDir = devCaptureOutputDir();
await fs.mkdir(outputDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "");
const prefix = safeDebugFilePart(`${stamp}-${capture.name}`);
const files = {
full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl),
detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl),
inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl),
crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>,
};
for (const crop of capture.crops ?? []) {
const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`);
const written = await writeDevCaptureImage(cropPath, crop.dataUrl);
if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect });
}
const summary = {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
capturedAt: capture.capturedAt,
captureTarget: capture.captureTarget,
ocrSkipped: capture.ocrSkipped,
ocrTimedOut: capture.ocrTimedOut,
layout: capture.layout,
inventoryGrid: capture.inventoryGrid
? {
rows: capture.inventoryGrid.rows,
cols: capture.inventoryGrid.cols,
confidence: capture.inventoryGrid.confidence,
source: capture.inventoryGrid.source,
firstCenter: capture.inventoryGrid.centers[0] ?? null,
lastCenter: capture.inventoryGrid.centers.at(-1) ?? null,
}
: null,
inventoryCount: capture.inventoryCount ?? null,
locked: capture.locked,
lockSignal: capture.lockSignal,
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
ocr: capture.ocr ?? [],
files,
};
const summaryPath = path.join(outputDir, `${prefix}-summary.json`);
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8");
return { ...summary, summaryPath };
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) {
return sourceId
? sources.find((entry) => entry.id === sourceId)
: sources.find((entry) => entry.isGenshinCandidate);
}
function sourceListForError(sources: CaptureSourceInfo[]) {
return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate }));
}
export function createDevControlServer(deps: DevControlServerDependencies): Server {
const server = http.createServer((req, res) => {
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
writeDevJson(res, 403, { ok: false, error: "local only" });
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/health") {
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild });
return;
}
if (url.pathname === "/dev/shutdown") {
if (!deps.requestShutdown) {
writeDevJson(res, 501, { ok: false, error: "shutdown not supported" });
return;
}
const reason = url.searchParams.get("reason") || "dev-control shutdown requested";
writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason });
setTimeout(() => deps.requestShutdown?.(reason), 50);
return;
}
if (url.pathname === "/scanner/start") {
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
const hasLimit = Number.isFinite(limit) && limit > 0;
const category = url.searchParams.get("category") ?? undefined;
deps.nativeScannerStart({ ...(hasLimit ? { limit } : {}), ...(category ? { category } : {}) })
.then((scanner) => writeDevJson(res, 200, { ok: true, scanner }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/stop") {
deps.nativeScannerStop()
.then((scanner) => writeDevJson(res, 200, { ok: true, scanner }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/probe") {
deps.sendScannerCommand("probe-click");
writeDevJson(res, 200, { ok: true, command: "probe-click" });
return;
}
if (url.pathname === "/automation/click") {
const x = Number(url.searchParams.get("x"));
const y = Number(url.searchParams.get("y"));
if (!Number.isFinite(x) || !Number.isFinite(y)) {
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
return;
}
deps.clickScreen(Math.round(x), Math.round(y))
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/status") {
deps.nativeScannerStatus()
.then((nativeScanner) => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus(), nativeScanner }))
.catch(() => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }));
return;
}
if (url.pathname === "/scanner/native/data") {
deps.nativeScannerDataStatus()
.then((status) => writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/native/catalog") {
deps.nativeScannerCatalog()
.then((catalog) => writeDevJson(res, catalog.data.valid ? 200 : 409, { ok: catalog.data.valid, catalog }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/native/preflight") {
const category = url.searchParams.get("category") ?? undefined;
deps.nativeScannerPreflight(category ? { category } : undefined)
.then((status) => writeDevJson(res, status.ready ? 200 : 409, { ok: status.ready, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/native/process") {
const runDir = url.searchParams.get("runDir") ?? undefined;
const persist = url.searchParams.get("persist") === "1";
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
deps.nativeScannerProcessRun({
runDir,
persist,
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
})
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/native/results") {
const runDir = url.searchParams.get("runDir") ?? undefined;
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
deps.nativeScannerLoadResults({
runDir,
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
})
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/native/image") {
const runDir = url.searchParams.get("runDir") ?? undefined;
const imagePath = url.searchParams.get("imagePath");
if (!imagePath) {
writeDevJson(res, 400, { ok: false, error: "imagePath query param is required" });
return;
}
deps.nativeScannerLoadImage({ runDir, imagePath })
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/ocr/warmup") {
deps.warmOcr("current")
.then((status) => writeDevJson(res, 200, { ok: true, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/lookup/status") {
const status = validateLookupPackage();
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status });
return;
}
if (url.pathname === "/scanner/lookup/regenerate") {
execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 })
.then(({ stdout, stderr }) => {
const status = validateLookupPackage();
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr });
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/scanner/benchmark-ocr") {
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
const sourceId = url.searchParams.get("sourceId");
const profileParam = url.searchParams.get("profile");
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
deps.listCaptureSources()
.then(async (sources) => {
const source = findGenshinSource(sources, sourceId);
if (!source) {
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
return;
}
const benchmarkSource = source;
async function runCurrentBenchmark() {
const startedAt = Date.now();
const captures: Array<{
index: number;
elapsedMs: number;
ocrFields: number;
timedOut: boolean;
ocrSkipped: boolean;
artifactDetailConfidence: number;
sanctified: boolean;
prepareMs: number;
ocrMs: number;
totalMs: number;
ocrProfile?: "full" | "fast";
ocrWorkerPoolSize?: number;
ocrFieldMs?: Record<string, number>;
}> = [];
for (let index = 0; index < limit; index += 1) {
const captureStartedAt = Date.now();
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
ocrMode: "artifact",
ocrProfile,
ocrEngine: "current",
omitFullFrame: true,
omitInventoryPreview: true,
skipOcrUnlessArtifactDetail: true,
});
captures.push({
index,
elapsedMs: Date.now() - captureStartedAt,
ocrFields: capture.ocr?.length ?? 0,
timedOut: Boolean(capture.ocrTimedOut),
ocrSkipped: Boolean(capture.ocrSkipped),
artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0,
sanctified: Boolean(capture.sanctified),
prepareMs: capture.timings?.prepareMs ?? 0,
ocrMs: capture.timings?.ocrMs ?? 0,
totalMs: capture.timings?.totalMs ?? 0,
ocrProfile: capture.timings?.ocrProfile,
ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize,
ocrFieldMs: capture.timings?.ocrFieldMs,
});
}
const elapsedMs = Date.now() - startedAt;
const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right);
const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right);
const averageMs = Math.round(elapsedMs / limit);
const averageOcrMs = ocrTimings.length > 0
? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length)
: 0;
const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0;
const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0;
const ocrFieldTotals = captures.reduce<Record<string, { totalMs: number; count: number; maxMs: number }>>((fields, capture) => {
for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) {
const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 };
current.totalMs += elapsed;
current.count += 1;
current.maxMs = Math.max(current.maxMs, elapsed);
fields[field] = current;
}
return fields;
}, {});
const ocrFieldAverages = Object.fromEntries(
Object.entries(ocrFieldTotals).map(([field, timing]) => [
field,
{
averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)),
maxMs: timing.maxMs,
count: timing.count,
},
]),
);
return {
engine: "current",
nativeTesseract: "not-enabled",
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
ocrProfile,
limit,
elapsedMs,
averageMs,
averageOcrMs,
minMs: timings[0] ?? 0,
p50Ms: percentile(0.5),
p90Ms: percentile(0.9),
maxMs: timings[timings.length - 1] ?? 0,
ocrP50Ms: ocrPercentile(0.5),
ocrP90Ms: ocrPercentile(0.9),
ocrFieldAverages,
projectedMs: {
artifacts20: averageMs * 20,
artifacts45: averageMs * 45,
artifacts100: averageMs * 100,
},
skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length,
captures,
};
}
const summary = await runCurrentBenchmark();
writeDevJson(res, 200, {
ok: true,
summary,
});
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/review/samples") {
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
.then((payload: unknown) => writeDevJson(res, 200, payload))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/capture/smart") {
const sourceId = url.searchParams.get("sourceId");
const focus = url.searchParams.get("focus") !== "0";
const skipOcr = url.searchParams.get("skipOcr") === "1";
deps.listCaptureSources()
.then(async (sources) => {
const source = findGenshinSource(sources, sourceId);
if (!source) {
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
return;
}
const capture = await deps.captureSource(source.id, 250, focus, { skipOcr });
const summary = await writeDevCaptureSnapshot(capture);
writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary });
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
if (url.pathname === "/automation/probe-click") {
const sourceId = url.searchParams.get("sourceId");
const requestedIndex = Number(url.searchParams.get("index") ?? "1");
const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN);
const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN);
deps.listCaptureSources()
.then(async (sources) => {
const source = findGenshinSource(sources, sourceId);
if (!source) {
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
return;
}
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true });
const grid = before.inventoryGrid;
const detail = before.artifactDetail;
const gridReady = before.captureTarget === "genshin-client"
&& grid?.source === "detected"
&& (grid.confidence ?? 0) >= 60;
const detailReady = Boolean(detail?.present && detail.confidence >= 45);
if (!gridReady || !detailReady) {
writeDevJson(res, 409, {
ok: false,
error: "Artifact inventory detail view is not ready for probe-click.",
captureTarget: before.captureTarget,
grid: grid ? { rows: grid.rows, cols: grid.cols, source: grid.source, confidence: grid.confidence } : null,
artifactDetail: detail ?? null,
});
return;
}
const centers = before.inventoryGrid?.centers ?? [];
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
: centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))];
if (!target) {
writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null });
return;
}
const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl);
const click = await deps.clickScreen(target.x, target.y);
await wait(650);
const after = await deps.captureSource(source.id, 0, true, { skipOcr: true });
const afterFingerprint = dataUrlFingerprint(after.detailDataUrl);
const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint);
writeDevJson(res, 200, {
ok: Boolean(click.ok && click.clicked && changed),
changed,
target,
click,
before: {
captureTarget: before.captureTarget,
grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null,
detailFingerprint: beforeFingerprint,
},
after: {
captureTarget: after.captureTarget,
grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null,
detailFingerprint: afterFingerprint,
},
});
})
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return;
}
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
});
server.listen(17317, "127.0.0.1");
return server;
}
+44
View File
@@ -2,6 +2,17 @@ import { ipcMain } from "electron";
import type {
BooleanResult,
FocusGenshinResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
RuntimeInfo,
SaveSnapshotResult,
ScannerStatusPayload,
@@ -13,6 +24,17 @@ interface AppCommandDependencies {
moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
getRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshot: () => Promise<AppSnapshot | null>;
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
@@ -26,6 +48,17 @@ export function registerAppHandlers({
moveMainWindowOffGenshin,
focusGenshinForScanStart,
publishScannerStatus,
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
nativeScannerProcessRun,
nativeScannerLoadResults,
nativeScannerPromoteResults,
nativeScannerReviewResult,
nativeScannerLoadImage,
getRuntimeInfo,
loadSnapshot,
saveSnapshot,
@@ -42,6 +75,17 @@ export function registerAppHandlers({
await publishScannerStatus(status);
return { ok: true };
});
ipcMain.handle("scanner:nativeDataStatus", async () => nativeScannerDataStatus());
ipcMain.handle("scanner:nativeCatalog", async () => nativeScannerCatalog());
ipcMain.handle("scanner:nativePreflight", async (_event, options?: { category?: NativeScannerStartCategory | string }) => nativeScannerPreflight(options));
ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options));
ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop());
ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus());
ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options));
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options));
ipcMain.handle("scanner:nativePromoteResults", async (_event, options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options));
ipcMain.handle("scanner:nativeReviewResult", async (_event, options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => nativeScannerReviewResult(options));
ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options));
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
ipcMain.handle("snapshot:load", async () => loadSnapshot());
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
+4 -1
View File
@@ -1,11 +1,12 @@
import { ipcMain } from "electron";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } from "../../src/types/global.js";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard, KeyPressResult } from "../../src/types/global.js";
interface CaptureCommandDependencies {
listSources: () => Promise<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>;
keyPress: (key: string) => Promise<KeyPressResult>;
getAutomationGuard: () => Promise<AutomationGuard>;
}
@@ -14,6 +15,7 @@ export function registerCaptureHandlers({
captureSource,
clickScreen,
scrollScreen,
keyPress,
getAutomationGuard,
}: CaptureCommandDependencies) {
ipcMain.handle("capture:listSources", async () => listSources());
@@ -22,5 +24,6 @@ export function registerCaptureHandlers({
});
ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y));
ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY));
ipcMain.handle("automation:keyPress", async (_event, key: string) => keyPress(key));
ipcMain.handle("automation:getGuard", async () => getAutomationGuard());
}
+7
View File
@@ -13,6 +13,7 @@ import type {
SaveScannerLearningRulesResult,
ScannerLearningRulePayload,
SaveResultWithPath,
GoodImportFileResult,
} from "../../src/types/global.js";
import type { StoredArtifactRecord } from "../../src/types/storage.js";
@@ -28,6 +29,7 @@ interface PersistenceDependencies {
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: () => Promise<GoodImportFileResult>;
}
export function registerPersistenceHandlers({
@@ -39,6 +41,7 @@ export function registerPersistenceHandlers({
loadScannerLearningRules,
writeScannerLearningRules,
exportGood,
importGoodFile,
}: PersistenceDependencies) {
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
try {
@@ -81,4 +84,8 @@ export function registerPersistenceHandlers({
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
return exportGood(payload);
});
ipcMain.handle("good:importFile", async () => {
return importGoodFile();
});
}
+1108 -377
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -8,9 +8,11 @@ contextBridge.exposeInMainWorld("assistantApi", {
captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y),
scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
keyPress: (key) => ipcRenderer.invoke("automation:keyPress", key),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
focusGenshinForScanStart: () => ipcRenderer.invoke("automation:focusGenshin"),
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
saveReviewSample: (sample) => ipcRenderer.invoke("review:saveSample", sample),
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
@@ -19,7 +21,19 @@ contextBridge.exposeInMainWorld("assistantApi", {
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
nativeScannerPreflight: (options) => ipcRenderer.invoke("scanner:nativePreflight", options),
nativeScannerStart: (options) => ipcRenderer.invoke("scanner:nativeStart", options),
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback) => {
+17 -3
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
import type { CaptureOptions, GoodDatabase, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
import type { StoredArtifactRecord } from "../src/types/storage.js";
import type { AppSnapshot } from "../src/types/domain.js";
@@ -11,9 +11,11 @@ contextBridge.exposeInMainWorld("assistantApi", {
captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y),
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
keyPress: (key: string) => ipcRenderer.invoke("automation:keyPress", key),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
focusGenshinForScanStart: () => ipcRenderer.invoke("automation:focusGenshin"),
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
saveReviewSample: (sample: ReviewSamplePayload) => ipcRenderer.invoke("review:saveSample", sample),
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
@@ -22,11 +24,23 @@ contextBridge.exposeInMainWorld("assistantApi", {
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativePreflight", options),
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options),
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => {
const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command);
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
const listener = (_event: Electron.IpcRendererEvent, command: ScannerCommand) => callback(command);
ipcRenderer.on("scanner:command", listener);
return () => ipcRenderer.removeListener("scanner:command", listener);
},
@@ -54,6 +54,7 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
lastSeenAt: now,
timesSeen: (existing.timesSeen ?? 1) + 1,
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
locked: typeof record.locked === "boolean" ? record.locked : existing.locked,
// A later confident scan clears the review flag; an uncertain rescan
// must not downgrade an already confirmed artifact.
needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview),
@@ -136,6 +137,7 @@ function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
...record,
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
locked: typeof record.locked === "boolean" ? record.locked : undefined,
};
}
@@ -183,6 +185,7 @@ function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredAr
substats: [...(preferredSubstats ?? [])],
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
locked: typeof incoming.locked === "boolean" ? incoming.locked : existing.locked,
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
source: resolveStoredArtifactSource(existing.source, incoming.source),
firstSeenAt: existing.firstSeenAt ?? now,
@@ -3,6 +3,9 @@ import path from "node:path";
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
const SMALL_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
const TAIL_READ_LIMIT_BYTES = 32 * 1024 * 1024;
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
private readonly filePath: string;
@@ -12,9 +15,12 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
async list(limit = 50): Promise<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 stats = await fs.stat(this.filePath);
const raw = stats.size <= SMALL_FILE_LIMIT_BYTES
? await fs.readFile(this.filePath, "utf8")
: await readTailText(this.filePath, stats.size, TAIL_READ_LIMIT_BYTES);
const lines = raw.split(/\r?\n/).filter(Boolean);
const samples = lines
.slice(-safeLimit)
.map((line) => {
@@ -25,7 +31,8 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
}
})
.filter(Boolean) as ReviewSampleRecord[];
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath };
const total = stats.size <= SMALL_FILE_LIMIT_BYTES ? lines.length : Math.max(samples.length, lines.length);
return { ok: true, samples: samples.reverse(), total, path: this.filePath };
} catch {
return { ok: true, samples: [], total: 0, path: this.filePath };
}
@@ -37,3 +44,17 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
return { ok: true, path: this.filePath };
}
}
async function readTailText(filePath: string, fileSize: number, maxBytes: number) {
const bytesToRead = Math.min(fileSize, maxBytes);
const handle = await fs.open(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, fileSize - bytesToRead);
const text = buffer.toString("utf8");
const firstNewline = text.indexOf("\n");
return fileSize > bytesToRead && firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
} finally {
await handle.close();
}
}
@@ -16,22 +16,71 @@ export class ScannerLearningRepository implements ScannerLearningRepositoryPort
return {
ok: true,
path: this.filePath,
rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} },
rules: parsed && typeof parsed === "object" ? parsed : emptyRules(),
};
} catch {
return { ok: true, path: this.filePath, rules: { textReplacements: {} } };
return { ok: true, path: this.filePath, rules: emptyRules() };
}
}
async save(rules: ScannerLearningRules): Promise<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 };
const payload: ScannerLearningRules = mergeRules(current.rules, rules);
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length };
return { ok: true, path: this.filePath, rules: payload, total: countRules(payload) };
}
}
function emptyRules(): ScannerLearningRules {
return {
textReplacements: {},
fieldAliases: {},
constrainedFixes: {},
cropAdjustments: {},
uiProfileAdjustments: {},
};
}
function mergeRules(current: ScannerLearningRules, incoming: ScannerLearningRules): ScannerLearningRules {
return {
textReplacements: {
...(current.textReplacements ?? {}),
...(incoming.textReplacements ?? {}),
},
fieldAliases: mergeNested(current.fieldAliases, incoming.fieldAliases),
constrainedFixes: {
...(current.constrainedFixes ?? {}),
...(incoming.constrainedFixes ?? {}),
},
cropAdjustments: {
...(current.cropAdjustments ?? {}),
...(incoming.cropAdjustments ?? {}),
},
uiProfileAdjustments: {
...(current.uiProfileAdjustments ?? {}),
...(incoming.uiProfileAdjustments ?? {}),
},
};
}
function mergeNested(
current: Record<string, Record<string, string>> | undefined,
incoming: Record<string, Record<string, string>> | undefined,
) {
const merged: Record<string, Record<string, string>> = {};
for (const [field, values] of Object.entries(current ?? {})) merged[field] = { ...(values ?? {}) };
for (const [field, values] of Object.entries(incoming ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(values ?? {}) };
return merged;
}
function countRules(rules: ScannerLearningRules) {
const fieldAliases = Object.values(rules.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0);
return (
Object.keys(rules.textReplacements ?? {}).length
+ fieldAliases
+ Object.keys(rules.constrainedFixes ?? {}).length
+ Object.keys(rules.cropAdjustments ?? {}).length
+ Object.keys(rules.uiProfileAdjustments ?? {}).length
);
}
+57
View File
@@ -0,0 +1,57 @@
import { dialog, type BrowserWindow } from "electron";
import fs from "node:fs/promises";
import path from "node:path";
import type { GoodDatabase, GoodImportFileResult, SaveResultWithPath } from "../../src/types/global.js";
export interface GoodFileService {
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: (parentWindow?: BrowserWindow | null) => Promise<GoodImportFileResult>;
}
export function createGoodFileService(exportDirectory: string): GoodFileService {
function exportPath(fileName: string) {
return path.join(exportDirectory, fileName);
}
async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`;
const filePath = exportPath(fileNameSafe);
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
return { ok: true, path: filePath };
} catch {
return { ok: false, path: filePath };
}
}
async function importGoodFile(parentWindow?: BrowserWindow | null): Promise<GoodImportFileResult> {
const dialogOptions = {
title: "GOOD-Datei importieren",
properties: ["openFile"],
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
} satisfies Electron.OpenDialogOptions;
const dialogResult = parentWindow && !parentWindow.isDestroyed()
? await dialog.showOpenDialog(parentWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
return { ok: false, canceled: true, path: "" };
}
const filePath = dialogResult.filePaths[0];
try {
const text = await fs.readFile(filePath, "utf8");
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
} catch (error) {
return {
ok: false,
canceled: false,
path: filePath,
error: error instanceof Error ? error.message : String(error),
};
}
}
return { exportGood, importGoodFile };
}
+117 -372
View File
@@ -7,372 +7,17 @@ import type {
FocusGenshinResult,
GdiCaptureResult,
HelperOperationResponse,
KeyPressResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerPreflightStatus,
NativeScannerRunStatus,
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)
}
`;
import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js";
class InputHelperClient {
private child: ChildProcessWithoutNullStreams | null = null;
@@ -382,7 +27,7 @@ class InputHelperClient {
private starting: Promise<void> | null = null;
private disposed = false;
constructor(private readonly scriptUserDataPath: string) {}
constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
private async ensureStarted() {
if (this.child) return;
@@ -396,14 +41,31 @@ class InputHelperClient {
}
private async start() {
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
// Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
// start, fall back to the embedded PowerShell helper so the app keeps working
// on machines where the native exe was never built.
if (this.options.exePath) {
try {
await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
return;
} catch {
this.teardownChild();
}
}
await this.startWith(await this.spawnPowershell());
}
private async spawnPowershell() {
const scriptPath = path.join(this.options.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], {
return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
}
private async startWith(child: ChildProcessWithoutNullStreams) {
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
child.stderr.setEncoding("utf8");
@@ -419,10 +81,22 @@ class InputHelperClient {
});
this.child = child;
// First request compiles the Win32 interop; give it extra time.
// The C# sidecar answers ping immediately; the PowerShell fallback compiles
// Win32 interop on the first request, so give it extra time.
await this.send("ping", {}, 20000);
}
private teardownChild() {
const child = this.child;
this.child = null;
this.buffer = "";
try {
child?.kill();
} catch {
// Child was never spawned or already gone.
}
}
private handleStdout(chunk: string) {
this.buffer += chunk;
let newlineIndex = this.buffer.indexOf("\n");
@@ -480,13 +154,20 @@ export interface InputHelperService {
getGenshinWindowBounds(): Promise<WindowBounds | null>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
keyPress(key: string): Promise<KeyPressResult>;
getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
nativeScannerDataStatus(dataDir: string): Promise<NativeScannerDataStatus>;
nativeScannerCatalog(dataDir: string): Promise<NativeScannerCatalogStatus>;
nativeScannerPreflight(dataDir: string, category?: string): Promise<NativeScannerPreflightStatus>;
nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }): Promise<NativeScannerRunStatus>;
nativeScannerStop(): Promise<NativeScannerRunStatus>;
nativeScannerStatus(): Promise<NativeScannerRunStatus>;
dispose(): void;
}
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
const inputHelper = new InputHelperClient(options.userDataPath);
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return inputHelper.request(op, params, timeoutMs);
@@ -576,6 +257,20 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
};
}
async function keyPress(key: string) {
const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
return {
ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
key,
focused: Boolean(result.focused),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
inputBlocked: Boolean(result.inputBlocked),
eventsSent: Number(result.eventsSent ?? 0),
};
}
async function getAutomationGuard() {
const result = await request("cursor", {}, 4000);
return {
@@ -590,16 +285,26 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
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);
// The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
// fallback writes a temp PNG and returns its path.
let base64: string;
if (typeof result.imageBase64 === "string" && result.imageBase64) {
base64 = result.imageBase64;
} else {
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
base64 = buffer.toString("base64");
}
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")}`,
...(typeof result.imageBase64 === "string" && result.imageBase64
? { imageBase64: base64 }
: { dataUrl: `data:image/png;base64,${base64}` }),
width: Number(result.width),
height: Number(result.height),
originX: Number(result.originX),
@@ -608,6 +313,39 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
};
}
function scannerPayload<T>(result: HelperOperationResponse): T {
return result.scanner as T;
}
async function nativeScannerDataStatus(dataDir: string) {
return scannerPayload<NativeScannerDataStatus>(await request("scanner-data-status", { dataDir }, 5000));
}
async function nativeScannerCatalog(dataDir: string) {
return scannerPayload<NativeScannerCatalogStatus>(await request("scanner-catalog", { dataDir }, 8000));
}
async function nativeScannerPreflight(dataDir: string, category = "artifacts") {
return scannerPayload<NativeScannerPreflightStatus>(await request("scanner-preflight", { dataDir, category }, 8000));
}
async function nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }) {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-start", {
dataDir: options.dataDir,
outputRoot: options.outputRoot,
limit: options.limit ?? 100,
category: options.category ?? "artifacts",
}, 8000));
}
async function nativeScannerStop() {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-stop", {}, 4000));
}
async function nativeScannerStatus() {
return scannerPayload<NativeScannerRunStatus>(await request("scanner-status", {}, 4000));
}
return {
getRuntimeInfo,
focusGenshinWindow,
@@ -615,8 +353,15 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
getGenshinWindowBounds,
clickScreen,
scrollScreen,
keyPress,
getAutomationGuard,
capturePrimaryScreenViaGdi,
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
dispose: () => inputHelper.dispose(),
};
}
@@ -0,0 +1,435 @@
// PowerShell fallback for environments where the compiled C# sidecar is unavailable. Keep the JSON protocol aligned with native/input-helper/Program.cs.
export const INPUT_HELPER_SCRIPT = String.raw`
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
$signature = @"
[DllImport("user32.dll")]
public static extern bool SetProcessDPIAware();
[DllImport("shcore.dll")]
public static extern int SetProcessDpiAwareness(int value);
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll", SetLastError=true)]
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError=true)]
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", SetLastError=true)]
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll", SetLastError=true)]
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("kernel32.dll")]
public static extern uint GetCurrentThreadId();
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[StructLayout(LayoutKind.Sequential)]
public struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)]
public struct INPUT { public int type; public MOUSEINPUT mi; }
"@
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
# same symptom): the older SetProcessDPIAware() only applies a single,
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
# on one display, this app's window on a differently-scaled second display),
# that single scale factor is wrong for whichever monitor didn't set it,
# silently shifting every SetCursorPos/click coordinate off-target even
# though cursor readback still matches what we asked for (both go through the
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
try {
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
} catch {
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# SizeOf must receive a struct instance: passing the type object throws in
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
$genshinHwnd = [IntPtr]::Zero
function Send-MouseInput {
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
$mouseInput = New-Object Native.InputHelper+INPUT
$mouseInput.type = 0
$mouseInput.mi.dx = $dx
$mouseInput.mi.dy = $dy
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
$mouseInput.mi.dwFlags = $flags
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
}
# Uses bare SetCursorPos, then sends button-down and button-up as ONE SendInput
# call (two INPUT structs in the same array) - back-to-back with no artificial
# delay between them, unlike two separate SendInput calls with a
# Start-Sleep in between. Returns the number of injected events (2 = ok).
function Send-MouseClickBatch {
$down = New-Object Native.InputHelper+INPUT
$down.type = 0
$down.mi.dwFlags = 0x0002
$up = New-Object Native.InputHelper+INPUT
$up.type = 0
$up.mi.dwFlags = 0x0004
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
}
function Send-KeyPressBatch {
param([int]$virtualKey)
$down = New-Object Native.InputHelper+INPUT
$down.type = 1
$down.mi.dx = $virtualKey
$up = New-Object Native.InputHelper+INPUT
$up.type = 1
$up.mi.dx = $virtualKey
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
$up.mi.dy = 0x0002
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
}
function Resolve-VirtualKey {
param([string]$key)
switch ($key.ToUpperInvariant()) {
"ESC" { return 27 }
"ESCAPE" { return 27 }
"ENTER" { return 13 }
"B" { return 66 }
"C" { return 67 }
"1" { return 49 }
default { throw "unsupported key: $key" }
}
}
function Get-CursorPoint {
$pt = New-Object Native.InputHelper+POINT
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
return $pt
}
function Get-ProcessNameFromHwnd {
param([IntPtr]$hwnd)
if ($hwnd -eq [IntPtr]::Zero) { return "" }
$pidValue = [uint32]0
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
if ($pidValue -eq 0) { return "" }
try {
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
} catch {
return ""
}
}
function Get-CurrentProcessElevation {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-ForegroundInfo {
$hwnd = [Native.InputHelper]::GetForegroundWindow()
return @{
foregroundHwnd = $hwnd.ToInt64()
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
}
}
function Get-CursorState {
$pt = New-Object Native.InputHelper+POINT
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
# call" bit is unreliable and fires for ESC presses that happened long
# before the scan (ESC is used constantly to navigate Genshin menus).
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
}
function Get-GenshinClientBounds {
$hwnd = Find-GenshinWindow
if ($hwnd -eq [IntPtr]::Zero) { return $null }
$rect = New-Object Native.InputHelper+RECT
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
$topLeft = New-Object Native.InputHelper+POINT
$topLeft.X = 0
$topLeft.Y = 0
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 0 -or $height -le 0) { return $null }
return @{
Left = $topLeft.X
Top = $topLeft.Y
Width = $width
Height = $height
}
}
function Find-GenshinWindow {
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
return $script:genshinHwnd
}
# Plain SetForegroundWindow from this background helper process is silently
# refused by Windows' foreground lock. Attach our thread's input queue to the
# target (and current foreground) window thread and clear the lock timeout, so
# the foreground change is honored.
function Force-Foreground {
param([IntPtr]$hwnd)
$current = [Native.InputHelper]::GetCurrentThreadId()
$targetPid = [uint32]0
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
$foreground = [uint32]0
if ($fgWindow -ne [IntPtr]::Zero) {
$fgPid = [uint32]0
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
}
$attachedTarget = $false
$attachedForeground = $false
$oldTimeout = [uint32]0
$timeoutRead = $false
try {
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
# Inject a no-op input (0,0 mouse move) so this process is the last input
# source, which Windows requires before it will honor a foreground change.
Send-MouseInput -flags 0x0001 | Out-Null
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
return [Native.InputHelper]::SetForegroundWindow($hwnd)
} finally {
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
}
}
function Focus-GenshinWindow {
$hwnd = Find-GenshinWindow
$info = @{
hwnd = $hwnd.ToInt64()
focused = $false
alreadyForeground = $false
foregroundProcess = ""
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
}
if ($hwnd -eq [IntPtr]::Zero) { return $info }
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
if (-not $info.alreadyForeground) {
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
Start-Sleep -Milliseconds 140
}
$foreground = [Native.InputHelper]::GetForegroundWindow()
$info.focused = ($foreground -eq $hwnd)
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
return $info
}
while ($true) {
$line = [Console]::In.ReadLine()
if ($null -eq $line) { break }
if ($line.Trim().Length -eq 0) { continue }
$response = @{ id = ""; ok = $true }
try {
$cmd = $line | ConvertFrom-Json
$response.id = "$($cmd.id)"
switch ("$($cmd.op)") {
"ping" {
$response.pong = $true
}
"cursor" {
$state = Get-CursorState
$response.cursorX = $state.cursorX
$response.cursorY = $state.cursorY
$response.escapePressed = $state.escapePressed
$response.enterPressed = $state.enterPressed
$response.f9Pressed = $state.f9Pressed
}
"runtime" {
$response.isElevated = Get-CurrentProcessElevation
$hwnd = Find-GenshinWindow
$foregroundInfo = Get-ForegroundInfo
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
$response.genshinHwnd = $hwnd.ToInt64()
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
$response.foregroundProcess = $foregroundInfo.foregroundProcess
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
$response.helperPid = $PID
}
"focus" {
$focusInfo = Focus-GenshinWindow
$response.focused = $focusInfo.focused
$response.alreadyForeground = $focusInfo.alreadyForeground
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.targetProcess = $focusInfo.targetProcess
$response.genshinFound = ($focusInfo.hwnd -ne 0)
$response.setForegroundResult = $focusInfo.setForegroundResult
}
"click" {
$focusInfo = Focus-GenshinWindow
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
Start-Sleep -Milliseconds 120
}
$targetX = [int]$cmd.x
$targetY = [int]$cmd.y
# Bare SetCursorPos immediately followed by a click, with NO extra move
# event and NO artificial delay between moving and clicking. Settling
# delays only happen after the click, in the scan loop. Down+up are sent
# as one SendInput call (see Send-MouseClickBatch).
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
$point = Get-CursorPoint
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
$clickEventsSent = 0
if ($onTarget) {
$clickEventsSent = Send-MouseClickBatch
}
$state = Get-CursorState
$response.cursorX = $state.cursorX
$response.cursorY = $state.cursorY
$response.escapePressed = $state.escapePressed
$response.enterPressed = $state.enterPressed
$response.f9Pressed = $state.f9Pressed
$response.moved = $onTarget
$response.focused = $focusInfo.focused
$response.alreadyForeground = $focusInfo.alreadyForeground
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.targetProcess = $focusInfo.targetProcess
$response.isElevated = Get-CurrentProcessElevation
# Never report a click unless the cursor is verifiably on the target.
# Real acceptance is proven later by the detail-panel fingerprint.
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
}
"scroll" {
$focusInfo = Focus-GenshinWindow
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
Start-Sleep -Milliseconds 120
}
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
Start-Sleep -Milliseconds 30
}
$point = Get-CursorPoint
$response.cursorX = $point.X
$response.cursorY = $point.Y
$response.focused = $focusInfo.focused
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.isElevated = Get-CurrentProcessElevation
$notches = [int]$cmd.notches
$stepDelta = 120
if ($notches -lt 0) { $stepDelta = -120 }
$count = [Math]::Abs($notches)
if ($count -gt 60) { $count = 60 }
$sentTotal = 0
for ($i = 0; $i -lt $count; $i++) {
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
Start-Sleep -Milliseconds 45
}
$response.notchesSent = $sentTotal
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
}
"key" {
$focusInfo = Focus-GenshinWindow
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
Start-Sleep -Milliseconds 120
}
$vk = Resolve-VirtualKey -key "$($cmd.key)"
$sent = Send-KeyPressBatch -virtualKey $vk
$response.key = "$($cmd.key)"
$response.focused = $focusInfo.focused
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.targetProcess = $focusInfo.targetProcess
$response.isElevated = Get-CurrentProcessElevation
$response.eventsSent = $sent
$response.inputBlocked = ($sent -lt 2)
}
"bounds" {
$clientBounds = Get-GenshinClientBounds
if ($null -eq $clientBounds) {
$response.found = $false
} else {
$response.found = $true
$response.left = $clientBounds.Left
$response.top = $clientBounds.Top
$response.width = $clientBounds.Width
$response.height = $clientBounds.Height
}
}
"capture" {
$clientBounds = Get-GenshinClientBounds
if ($null -eq $clientBounds) {
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$clientBounds = @{
Left = $screenBounds.Left
Top = $screenBounds.Top
Width = $screenBounds.Width
Height = $screenBounds.Height
}
$response.captureTarget = "primary-screen"
} else {
$response.captureTarget = "genshin-client"
}
$bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size)
$capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png")
$bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
$response.path = $capturePath
$response.width = $clientBounds.Width
$response.height = $clientBounds.Height
$response.originX = $clientBounds.Left
$response.originY = $clientBounds.Top
}
default {
$response.ok = $false
$response.error = "unknown op"
}
}
} catch {
$response.ok = $false
$response.error = $_.Exception.Message
}
Write-Output (ConvertTo-Json $response -Compress)
}
`;
@@ -0,0 +1,496 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pngBufferToBitmap } from "./pngBitmap.js";
import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js";
import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
import { toStoredArtifact } from "../../src/lib/artifactStore.js";
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js";
import type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
CaptureResult,
NativeScannerImageLoadStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
ReviewSamplePayload,
} from "../../src/types/global.js";
import type { ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
export type NativeCaptureJobPayload = {
sequence: number;
category?: string;
page?: number;
row?: number;
col?: number;
capturedAt?: string;
relativePath?: string;
absolutePath?: string;
};
export interface NativeScannerProcessingService {
processRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise<NativeScannerProcessStatus>;
loadResults(options?: { runDir?: string; limit?: number }): Promise<NativeScannerResultsLoadStatus>;
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
reviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus>;
loadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
}
interface NativeScannerProcessingServiceDependencies {
resolveRunDir(runDir?: string): string;
buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise<CaptureResult>;
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
}
type ProcessedNativeJob = {
result: NativeScannerProcessStatus["results"][number];
scanResult: StoredScanResultEntry;
storedRecord: StoredArtifactRecord | null;
};
const POST_CAPTURE_QUEUE_CONCURRENCY = 4;
export function createNativeScannerProcessingService(
deps: NativeScannerProcessingServiceDependencies,
): NativeScannerProcessingService {
const resultWorkflows = createNativeScannerResultWorkflowService(deps);
return {
async processRun(options = {}) {
const started = Date.now();
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyProcessStatus("No native scanner runDir available.");
}
const { jobsPath, jobs } = await readNativeCaptureJobs(runDir);
const limit = Math.max(1, Math.min(jobs.length, Math.round(options.limit ?? jobs.length)));
const selectedJobs = jobs.slice(0, limit);
const runId = path.basename(runDir);
const ikCatalog = deps.loadIkArtifactCatalog
? await deps.loadIkArtifactCatalog().catch(() => null)
: null;
const processedJobs = await mapWithConcurrency(
selectedJobs,
POST_CAPTURE_QUEUE_CONCURRENCY,
(job) => processNativeCaptureJob({
deps,
ikCatalog,
job,
persist: Boolean(options.persist),
runDir,
runId,
}),
);
const results = processedJobs.map((entry) => entry.result);
const scanResults = processedJobs.map((entry) => entry.scanResult);
const recordsToPersist = processedJobs
.map((entry) => entry.storedRecord)
.filter((record): record is StoredArtifactRecord => Boolean(record));
let stored = 0;
if (options.persist && recordsToPersist.length > 0) {
const saved = await deps.saveArtifacts(recordsToPersist);
stored = saved.added + saved.updated;
}
const status: NativeScannerProcessStatus = {
ok: true,
runDir,
jobsPath,
reportPath: path.join(runDir, "processing-report.json"),
scanResultsPath: path.join(runDir, "scan-results.json"),
processed: results.length,
parsed: results.filter((result) => result.parsed).length,
review: results.filter((result) => result.needsReview).length,
stored,
errors: results.filter((result) => result.error).length,
elapsedMs: Date.now() - started,
queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, selectedJobs.length),
persisted: Boolean(options.persist),
results,
};
await fs.writeFile(status.scanResultsPath, JSON.stringify(scanResults, null, 2), "utf8");
await fs.writeFile(status.reportPath, JSON.stringify(status, null, 2), "utf8");
return status;
},
async loadResults(options = {}) {
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyResultsStatus("No native scanner runDir available.");
}
const resultsPath = path.join(runDir, "scan-results.json");
try {
const raw = await fs.readFile(resultsPath, "utf8");
const parsed = JSON.parse(raw);
const results = Array.isArray(parsed)
? parsed.filter(isStoredScanResultEntry)
: [];
const limit = Number.isFinite(options.limit)
? Math.max(1, Math.min(results.length, Math.round(options.limit ?? results.length)))
: results.length;
return {
ok: true,
runDir,
path: resultsPath,
total: results.length,
results: results.slice(-limit),
};
} catch (error) {
return {
...emptyResultsStatus(error instanceof Error ? error.message : String(error)),
runDir,
path: resultsPath,
};
}
},
promoteResults: resultWorkflows.promoteResults,
reviewResult: resultWorkflows.reviewResult,
async loadImage(options) {
const runDir = deps.resolveRunDir(options.runDir);
if (!runDir) {
return emptyImageStatus("No native scanner runDir available.");
}
const resolvedRunDir = path.resolve(runDir);
const imagePath = path.isAbsolute(options.imagePath)
? path.resolve(options.imagePath)
: path.resolve(resolvedRunDir, options.imagePath);
if (!isPathInside(resolvedRunDir, imagePath)) {
return { ...emptyImageStatus("Image path is outside the native scanner run directory."), runDir: resolvedRunDir, path: imagePath };
}
if (!/\.png$/i.test(imagePath)) {
return { ...emptyImageStatus("Native scanner previews must be PNG files."), runDir: resolvedRunDir, path: imagePath };
}
try {
const stat = await fs.stat(imagePath);
if (stat.size > 20 * 1024 * 1024) {
return { ...emptyImageStatus("Native scanner preview is too large."), runDir: resolvedRunDir, path: imagePath };
}
const buffer = await fs.readFile(imagePath);
const bitmap = pngBufferToBitmap(buffer);
return {
ok: true,
runDir: resolvedRunDir,
path: imagePath,
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
width: bitmap.width,
height: bitmap.height,
};
} catch (error) {
return {
...emptyImageStatus(error instanceof Error ? error.message : String(error)),
runDir: resolvedRunDir,
path: imagePath,
};
}
},
};
}
export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record<string, number> {
return {
processed: status.processed,
parsed: status.parsed,
review: status.review,
stored: status.stored,
errors: status.errors,
elapsedMs: status.elapsedMs,
queueConcurrency: status.queueConcurrency,
};
}
async function processNativeCaptureJob({
deps,
ikCatalog,
job,
persist,
runDir,
runId,
}: {
deps: NativeScannerProcessingServiceDependencies;
ikCatalog: IkArtifactCatalog | null;
job: NativeCaptureJobPayload;
persist: boolean;
runDir: string;
runId: string;
}): Promise<ProcessedNativeJob> {
const category = nativeJobCategory(job.category);
const imagePath = job.absolutePath || (job.relativePath ? path.join(runDir, job.relativePath) : "");
if (!category.artifactProcessingSupported) {
return reviewJobResult({
category: category.scanResultCategory,
error: `Native post-capture processing for category '${category.nativeCategory}' is not implemented yet; IK catalog is available only.`,
imagePath,
job,
runId,
});
}
if (!imagePath || !(await fileExists(imagePath))) {
const error = "Card crop image missing.";
return reviewJobResult({ category: category.scanResultCategory, error, imagePath, job, runId });
}
try {
const capture = await deps.buildCaptureResult(imagePath, job);
const parsed = parseArtifactCandidate(capture);
const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null;
const notes = [...new Set([...(parsed?.notes ?? []), ...(ikMatch?.notes ?? [])])];
const needsReview = parsedArtifactNeedsReview(parsed) || Boolean(ikMatch && !ikMatch.matched);
const canPersist = parsedArtifactCanPersist(parsed, needsReview);
const shouldPersist = Boolean(persist && parsed && canPersist && !needsReview);
const storedRecord = parsed && shouldPersist
? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked)
: null;
return {
result: {
sequence: job.sequence,
category: category.scanResultCategory,
page: job.page,
row: job.row,
col: job.col,
imagePath,
parsed: Boolean(parsed),
artifactName: parsed?.name,
setName: parsed?.setName,
slot: parsed?.slot,
confidence: parsed?.confidence ?? 0,
needsReview,
persisted: shouldPersist,
ikMatch: ikMatch ?? undefined,
notes,
ocr: capture.ocr ?? [],
},
scanResult: createStoredScanResultEntry({
runId,
sequence: job.sequence,
page: job.page,
row: job.row,
col: job.col,
category: category.scanResultCategory,
source: "native-ik-scan",
imagePath,
parsed,
needsReview,
confidence: parsed?.confidence ?? 0,
capturedAt: job.capturedAt,
artifactRecordId: storedRecord?.id,
persistedArtifact: shouldPersist,
notes,
locked: capture.locked,
ikMatch: ikMatch ?? undefined,
}),
storedRecord,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return reviewJobResult({ category: category.scanResultCategory, error: message, imagePath, job, runId });
}
}
function reviewJobResult({
category,
error,
imagePath,
job,
runId,
}: {
category?: ScanResultCategory;
error: string;
imagePath: string;
job: NativeCaptureJobPayload;
runId: string;
}): ProcessedNativeJob {
const scanResultCategory = category ?? nativeJobCategory(job.category).scanResultCategory;
return {
result: {
sequence: job.sequence,
category: scanResultCategory,
page: job.page,
row: job.row,
col: job.col,
imagePath,
parsed: false,
needsReview: true,
confidence: 0,
ocr: [],
error,
},
scanResult: createStoredScanResultEntry({
runId,
sequence: job.sequence,
page: job.page,
row: job.row,
col: job.col,
category: scanResultCategory,
source: "native-ik-scan",
imagePath,
parsed: null,
needsReview: true,
confidence: 0,
capturedAt: job.capturedAt,
error,
notes: [error],
}),
storedRecord: null,
};
}
function nativeJobCategory(category: string | undefined): {
nativeCategory: string;
scanResultCategory: ScanResultCategory;
artifactProcessingSupported: boolean;
} {
const nativeCategory = (category ?? "artifacts").trim().toLowerCase() || "artifacts";
if (nativeCategory === "artifact" || nativeCategory === "artifacts") {
return { nativeCategory, scanResultCategory: "artifact", artifactProcessingSupported: true };
}
if (nativeCategory === "weapon" || nativeCategory === "weapons") {
return { nativeCategory, scanResultCategory: "weapon", artifactProcessingSupported: false };
}
if (nativeCategory === "character" || nativeCategory === "characters") {
return { nativeCategory, scanResultCategory: "character", artifactProcessingSupported: false };
}
if (nativeCategory === "material" || nativeCategory === "materials") {
return { nativeCategory, scanResultCategory: "material", artifactProcessingSupported: false };
}
return { nativeCategory, scanResultCategory: "unknown", artifactProcessingSupported: false };
}
async function mapWithConcurrency<TInput, TOutput>(
items: readonly TInput[],
concurrency: number,
worker: (item: TInput, index: number) => Promise<TOutput>,
) {
const output = Array<TOutput>(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(items.length, Math.floor(concurrency)));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
output[index] = await worker(items[index], index);
}
}));
return output;
}
async function readNativeCaptureJobs(runDir: string): Promise<{ jobsPath: string; jobs: NativeCaptureJobPayload[] }> {
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
const raw = await fs.readFile(jobsPath, "utf8");
const jobs = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as NativeCaptureJobPayload)
.filter((job) => Number.isFinite(job.sequence));
return { jobsPath, jobs };
}
async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function emptyProcessStatus(error: string): NativeScannerProcessStatus {
return {
ok: false,
runDir: "",
jobsPath: "",
reportPath: "",
scanResultsPath: "",
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 1,
elapsedMs: 0,
queueConcurrency: 0,
persisted: false,
results: [],
error,
};
}
function emptyResultsStatus(error: string): NativeScannerResultsLoadStatus {
return {
ok: false,
runDir: "",
path: "",
total: 0,
results: [],
error,
};
}
function emptyImageStatus(error: string): NativeScannerImageLoadStatus {
return {
ok: false,
runDir: "",
path: "",
dataUrl: "",
width: 0,
height: 0,
error,
};
}
function isPathInside(root: string, candidate: string) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<StoredScanResultEntry>;
return typeof entry.id === "string"
&& typeof entry.runId === "string"
&& Number.isFinite(entry.sequence)
&& typeof entry.source === "string"
&& typeof entry.imagePath === "string"
&& typeof entry.extractionStatus === "string"
&& typeof entry.valueStatus === "string"
&& Array.isArray(entry.notes);
}
function parsedArtifactNeedsReview(parsed: ReturnType<typeof parseArtifactCandidate>) {
if (!parsed) return true;
if (parsed.confidence < 78) return true;
const criticalFields: Array<"name" | "slot" | "mainStat" | "mainValue" | "setName"> = ["name", "slot", "mainStat", "mainValue", "setName"];
if (criticalFields.some((field) => (parsed.fields[field]?.confidence ?? 0) < 70)) return true;
if (parsed.substats.length === 0) return true;
return parsed.notes.some((note) => /not confidently parsed|substats look incomplete|likely OCR misread/i.test(note));
}
function parsedArtifactCanPersist(parsed: ReturnType<typeof parseArtifactCandidate>, needsReview: boolean) {
if (!parsed) return false;
if (parsed.name === "Unknown artifact") return false;
if (parsed.slot === "Unknown slot") return false;
if (parsed.setName === "Unknown set") return false;
if (parsed.mainStat === "Unknown main stat") return false;
if (parsed.mainValue === "?") return false;
if (parsed.substats.length === 0) return false;
if (!needsReview && parsed.confidence < 68) return false;
if (needsReview && parsed.confidence < 60) return false;
return true;
}
@@ -0,0 +1,357 @@
import fs from "node:fs/promises";
import path from "node:path";
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
import { buildScanResultPromotionSummary, scanResultToStoredArtifact } from "../../src/lib/scanResultPromotion.js";
import { implausibleSubstats } from "../../src/lib/substatRolls.js";
import type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
ReviewSamplePayload,
} from "../../src/types/global.js";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
export interface NativeScannerResultWorkflowDependencies {
resolveRunDir(runDir?: string): string;
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
}
export interface NativeScannerResultWorkflowService {
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
reviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus>;
}
export function createNativeScannerResultWorkflowService(
deps: NativeScannerResultWorkflowDependencies,
): NativeScannerResultWorkflowService {
return {
async promoteResults(options) {
const runDir = deps.resolveRunDir(options.runDir);
const logPath = runDir ? path.join(runDir, "promotion-log.jsonl") : "";
const requestedIds = [...new Set((options.resultIds ?? []).filter((id) => typeof id === "string" && id.trim()))];
if (!runDir || requestedIds.length === 0 || !deps.loadArtifacts) {
return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Promotion requires a run directory, selected result IDs, and artifact-store access.");
}
try {
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
const selectedIds = new Set(requestedIds);
const selectedResults = scanResults.filter((entry) => selectedIds.has(entry.id));
const store = await deps.loadArtifacts();
if (!store.ok) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store could not be loaded.");
const summary = buildScanResultPromotionSummary(selectedResults, store.artifacts);
const readyIds = new Set(summary.decisions.filter((decision) => decision.canPersist).map((decision) => decision.resultId));
const records = selectedResults
.filter((entry) => readyIds.has(entry.id))
.map(scanResultToStoredArtifact)
.filter((record): record is StoredArtifactRecord => Boolean(record));
const saved = records.length > 0
? await deps.saveArtifacts(records)
: { ok: true, added: 0, updated: 0, total: store.total, path: store.path };
if (saved.ok === false) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store write failed.");
const recordIdByResultId = new Map(selectedResults.map((entry) => [entry.id, scanResultToStoredArtifact(entry)?.id]));
const promotedResultIds = selectedResults.filter((entry) => readyIds.has(entry.id)).map((entry) => entry.id);
const promotedSet = new Set(promotedResultIds);
const updatedResults = scanResults.map((entry) => promotedSet.has(entry.id)
? { ...entry, artifactRecordId: recordIdByResultId.get(entry.id), persistedArtifact: true }
: entry);
await writeScanResults(resultsPath, updatedResults);
const status: NativeScannerPromotionStatus = {
ok: true,
runDir,
logPath,
requested: requestedIds.length,
selected: selectedResults.length,
promoted: promotedResultIds.length,
alreadyStored: summary.alreadyStored + summary.persisted,
review: summary.review,
blocked: summary.blocked + Math.max(0, requestedIds.length - selectedResults.length),
added: saved.added,
updated: saved.updated,
total: saved.total ?? store.total,
promotedResultIds,
};
await appendWorkflowLog(logPath, { at: new Date().toISOString(), ...status });
return status;
} catch (error) {
return emptyPromotionStatus(runDir, logPath, requestedIds.length, errorMessage(error));
}
},
async reviewResult(options) {
const runDir = deps.resolveRunDir(options.runDir);
const logPath = runDir ? path.join(runDir, "review-log.jsonl") : "";
const resultId = String(options.resultId ?? "").trim();
if (!runDir || !resultId || !["approve", "reject"].includes(options.action)) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, "Review requires a run directory, result ID, and valid action.");
}
try {
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
const index = scanResults.findIndex((entry) => entry.id === resultId);
if (index < 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Selected scan result was not found.");
const current = scanResults[index];
if (current.persistedArtifact) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Persisted results cannot be edited through review.");
const reviewedAt = new Date().toISOString();
const note = String(options.note ?? "").trim().slice(0, 500);
let correctedFields: string[] = [];
let evalSampleSaved = false;
let updated: StoredScanResultEntry;
if (options.action === "reject") {
updated = rejectResult(current, reviewedAt, note);
} else {
const artifact = normalizeReviewedArtifact(options.artifact);
const errors = reviewedArtifactErrors(artifact);
if (errors.length > 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, errors.join(" "));
const parsed = reviewedArtifactToParsed(artifact);
const catalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog() : null;
const ikMatch = matchParsedArtifactToIk(parsed, catalog);
if (!ikMatch?.matched) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, `IK validation failed: ${ikMatch?.notes.join(" ") || "catalog unavailable"}`);
}
correctedFields = artifactChangedFields(current.artifact, artifact);
updated = approveResult(current, artifact, ikMatch, reviewedAt, note, correctedFields);
evalSampleSaved = await saveApprovedEvalSample(deps, runDir, current, artifact, parsed);
}
scanResults[index] = updated;
await writeScanResults(resultsPath, scanResults);
const status: NativeScannerReviewStatus = {
ok: true,
runDir,
logPath,
resultId,
action: options.action,
evalSampleSaved,
correctedFields,
};
await appendWorkflowLog(logPath, { at: reviewedAt, ...status, note });
return status;
} catch (error) {
return emptyReviewStatus(runDir, logPath, resultId, options.action, errorMessage(error));
}
},
};
}
async function loadScanResults(runDir: string) {
const resultsPath = path.join(runDir, "scan-results.json");
const raw = JSON.parse(await fs.readFile(resultsPath, "utf8"));
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry) : [];
return { path: resultsPath, results };
}
async function writeScanResults(resultsPath: string, results: StoredScanResultEntry[]) {
await fs.writeFile(resultsPath, JSON.stringify(results, null, 2), "utf8");
}
async function appendWorkflowLog(logPath: string, payload: object) {
await fs.appendFile(logPath, `${JSON.stringify(payload)}\n`, "utf8");
}
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
return {
...current,
extractionStatus: "review",
needsReview: true,
valueStatus: "review",
notes: [...new Set([...current.notes, note || "Manual review rejected this result."])],
review: { status: "rejected", reviewedAt, note: note || undefined, correctedFields: [] },
};
}
function approveResult(
current: StoredScanResultEntry,
artifact: NativeScannerReviewArtifactInput,
ikMatch: NonNullable<StoredScanResultEntry["ikMatch"]>,
reviewedAt: string,
note: string,
correctedFields: string[],
): StoredScanResultEntry {
return {
...current,
extractionStatus: "parsed",
extractionConfidence: 100,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
artifact,
ikMatch,
fieldConfidences: reviewedFieldConfidences(artifact),
artifactRecordId: undefined,
persistedArtifact: false,
notes: note ? [`Manual review approved: ${note}`] : ["Manual review approved."],
error: undefined,
review: { status: "approved", reviewedAt, note: note || undefined, correctedFields },
};
}
async function saveApprovedEvalSample(
deps: NativeScannerResultWorkflowDependencies,
runDir: string,
current: StoredScanResultEntry,
artifact: NativeScannerReviewArtifactInput,
parsed: ParsedArtifactCandidate,
) {
if (!deps.saveReviewSample) return false;
const ocr = await loadReviewOcr(runDir, current.sequence);
if (ocr.length === 0) return false;
const saved = await deps.saveReviewSample({
reason: "native-review-approved",
parsed,
capture: {
id: `${current.runId}:${current.sequence}`,
name: current.imagePath,
width: 492,
height: 838,
capturedAt: current.capturedAt,
locked: artifact.locked,
ocr,
},
});
return Boolean(saved.ok);
}
function emptyPromotionStatus(runDir: string, logPath: string, requested: number, error: string): NativeScannerPromotionStatus {
return {
ok: false,
runDir,
logPath,
requested,
selected: 0,
promoted: 0,
alreadyStored: 0,
review: 0,
blocked: requested,
added: 0,
updated: 0,
total: 0,
promotedResultIds: [],
error,
};
}
function emptyReviewStatus(
runDir: string,
logPath: string,
resultId: string,
action: "approve" | "reject",
error: string,
): NativeScannerReviewStatus {
return { ok: false, runDir, logPath, resultId, action, evalSampleSaved: false, correctedFields: [], error };
}
function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): NativeScannerReviewArtifactInput {
return {
name: String(input?.name ?? "").trim(),
slot: String(input?.slot ?? "").trim(),
level: Math.round(Number(input?.level ?? -1)),
setName: String(input?.setName ?? "").trim(),
mainStat: String(input?.mainStat ?? "").trim(),
mainValue: String(input?.mainValue ?? "").trim(),
substats: [...new Set((input?.substats ?? []).map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 4),
equipped: String(input?.equipped ?? "Not detected").trim() || "Not detected",
locked: typeof input?.locked === "boolean" ? input.locked : undefined,
};
}
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
const errors: string[] = [];
if (!artifact.name || artifact.name === "Unknown artifact") errors.push("Artifact name is required.");
if (!artifact.slot || artifact.slot === "Unknown slot") errors.push("Artifact slot is required.");
if (!artifact.setName || artifact.setName === "Unknown set") errors.push("Artifact set is required.");
if (!artifact.mainStat || artifact.mainStat === "Unknown main stat") errors.push("Main stat is required.");
if (!artifact.mainValue || artifact.mainValue === "?") errors.push("Main value is required.");
if (!Number.isInteger(artifact.level) || artifact.level < 0 || artifact.level > 20) errors.push("Level must be between 0 and 20.");
if (artifact.substats.length === 0) errors.push("At least one substat is required.");
const mainValueError = reviewedMainValueError(artifact.slot, artifact.mainStat, artifact.level, artifact.mainValue);
if (mainValueError) errors.push(mainValueError);
const implausible = implausibleSubstats(artifact.substats, artifact.level > 16 ? 5 : undefined);
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
return errors;
}
function reviewedArtifactToParsed(artifact: NativeScannerReviewArtifactInput): ParsedArtifactCandidate {
const manual = (value: string) => ({ value, confidence: 100, source: "database" as const });
return {
...artifact,
confidence: 100,
notes: ["Manually reviewed and approved."],
fields: {
name: manual(artifact.name),
slot: manual(artifact.slot),
level: manual(String(artifact.level)),
mainStat: manual(artifact.mainStat),
mainValue: manual(artifact.mainValue),
setName: manual(artifact.setName),
equipped: manual(artifact.equipped),
substats: manual(artifact.substats.join(", ")),
},
};
}
function artifactChangedFields(
before: StoredScanResultEntry["artifact"],
after: NativeScannerReviewArtifactInput,
) {
if (!before) return ["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"];
return (["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"] as const)
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
}
function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
return [
{ key: "name", label: "Name", value: artifact.name },
{ key: "slot", label: "Slot", value: artifact.slot },
{ key: "level", label: "Level", value: String(artifact.level) },
{ key: "mainStat", label: "Main stat", value: artifact.mainStat },
{ key: "mainValue", label: "Main value", value: artifact.mainValue },
{ key: "setName", label: "Set", value: artifact.setName },
{ key: "equipped", label: "Equipped", value: artifact.equipped },
{ key: "substats", label: "Substats", value: artifact.substats.join(", ") },
].map((field) => ({ ...field, confidence: 100, source: "database" as const }));
}
async function loadReviewOcr(runDir: string, sequence: number) {
try {
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
const result = Array.isArray(report?.results) ? report.results.find((entry: { sequence?: number }) => entry.sequence === sequence) : null;
return Array.isArray(result?.ocr) ? result.ocr : [];
} catch {
return [];
}
}
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<StoredScanResultEntry>;
return typeof entry.id === "string"
&& typeof entry.runId === "string"
&& Number.isFinite(entry.sequence)
&& typeof entry.source === "string"
&& typeof entry.imagePath === "string"
&& typeof entry.extractionStatus === "string"
&& typeof entry.valueStatus === "string"
&& Array.isArray(entry.notes);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+92
View File
@@ -0,0 +1,92 @@
import { inflateSync } from "node:zlib";
import type { Bitmap } from "../../src/lib/ocrPreprocess.js";
interface PngChunk {
type: string;
data: Buffer;
}
function readChunks(buffer: Buffer): PngChunk[] {
const signature = buffer.subarray(0, 8);
if (!signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
throw new Error("Invalid PNG signature.");
}
const chunks: PngChunk[] = [];
let offset = 8;
while (offset + 12 <= buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
if (dataEnd + 4 > buffer.length) throw new Error("Invalid PNG chunk length.");
chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) });
offset = dataEnd + 4;
if (type === "IEND") break;
}
return chunks;
}
function paethPredictor(left: number, up: number, upperLeft: number) {
const estimate = left + up - upperLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upperLeftDistance = Math.abs(estimate - upperLeft);
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
if (upDistance <= upperLeftDistance) return up;
return upperLeft;
}
function unfilterScanlines(raw: Buffer, width: number, height: number, bytesPerPixel: number) {
const stride = width * bytesPerPixel;
const output = Buffer.alloc(stride * height);
let rawOffset = 0;
for (let row = 0; row < height; row++) {
const filter = raw[rawOffset++];
const rowOffset = row * stride;
const previousRowOffset = rowOffset - stride;
for (let col = 0; col < stride; col++) {
const value = raw[rawOffset++];
const left = col >= bytesPerPixel ? output[rowOffset + col - bytesPerPixel] : 0;
const up = row > 0 ? output[previousRowOffset + col] : 0;
const upperLeft = row > 0 && col >= bytesPerPixel ? output[previousRowOffset + col - bytesPerPixel] : 0;
let restored = value;
if (filter === 1) restored = value + left;
else if (filter === 2) restored = value + up;
else if (filter === 3) restored = value + Math.floor((left + up) / 2);
else if (filter === 4) restored = value + paethPredictor(left, up, upperLeft);
else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`);
output[rowOffset + col] = restored & 0xff;
}
}
return output;
}
export function pngBufferToBitmap(buffer: Buffer): Bitmap {
const chunks = readChunks(buffer);
const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data;
if (!ihdr) throw new Error("PNG missing IHDR.");
const width = ihdr.readUInt32BE(0);
const height = ihdr.readUInt32BE(4);
const bitDepth = ihdr[8];
const colorType = ihdr[9];
const compression = ihdr[10];
const filter = ihdr[11];
const interlace = ihdr[12];
if (bitDepth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0) {
throw new Error("Unsupported PNG format.");
}
const sourceBytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
if (!sourceBytesPerPixel) throw new Error(`Unsupported PNG color type: ${colorType}`);
const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data));
const unfiltered = unfilterScanlines(inflateSync(idat), width, height, sourceBytesPerPixel);
if (colorType === 6) return { data: unfiltered, width, height };
const rgba = Buffer.alloc(width * height * 4);
for (let pixel = 0; pixel < width * height; pixel++) {
rgba[pixel * 4] = unfiltered[pixel * 3];
rgba[pixel * 4 + 1] = unfiltered[pixel * 3 + 1];
rgba[pixel * 4 + 2] = unfiltered[pixel * 3 + 2];
rgba[pixel * 4 + 3] = 255;
}
return { data: rgba, width, height };
}
+4
View File
@@ -0,0 +1,4 @@
# .NET build outputs — the self-contained exe is built via `npm run helper:build`,
# not committed (it is ~100 MB).
bin/
obj/
+274
View File
@@ -0,0 +1,274 @@
using System.Text.Json;
namespace GenshinAssistant.InputHelper;
internal static class IkInventoryLists
{
public static IkInventoryListStatus Load(string dataDir)
{
var dir = ResolveDirectory(dataDir);
var required = new[] { "artifacts.json", "weapons.json", "characters.json", "materials.json", "version.txt" };
var missing = required.Where(file => !File.Exists(Path.Combine(dir, file))).ToArray();
var status = new IkInventoryListStatus { Directory = dir, Missing = missing };
if (missing.Length > 0) return status;
status.Version = File.ReadAllText(Path.Combine(dir, "version.txt")).Trim();
status.ArtifactSets = CountJsonObjectProperties(Path.Combine(dir, "artifacts.json"));
status.ArtifactPieces = CountArtifactPieces(Path.Combine(dir, "artifacts.json"));
status.Weapons = CountJsonObjectProperties(Path.Combine(dir, "weapons.json"));
status.Characters = CountJsonObjectProperties(Path.Combine(dir, "characters.json"));
status.Materials = CountJsonObjectProperties(Path.Combine(dir, "materials.json"));
return status;
}
private static string ResolveDirectory(string dataDir)
{
if (!string.IsNullOrWhiteSpace(dataDir)) return dataDir;
var candidates = new List<string>();
var envDir = Environment.GetEnvironmentVariable("IK_INVENTORYLISTS_DIR");
if (!string.IsNullOrWhiteSpace(envDir)) candidates.Add(envDir);
AddDirectoryCandidates(candidates, AppContext.BaseDirectory);
AddDirectoryCandidates(candidates, Environment.CurrentDirectory);
var baseParent = Directory.GetParent(AppContext.BaseDirectory);
for (var depth = 0; depth < 6 && baseParent != null; depth++)
{
AddDirectoryCandidates(candidates, baseParent.FullName);
baseParent = baseParent.Parent;
}
return candidates.FirstOrDefault(IsCompleteInventoryListDirectory)
?? Path.Combine(AppContext.BaseDirectory, "inventorylists");
}
private static void AddDirectoryCandidates(List<string> candidates, string root)
{
if (string.IsNullOrWhiteSpace(root)) return;
candidates.Add(Path.Combine(root, "inventorylists"));
candidates.Add(Path.Combine(root, "ik-inventorylists"));
candidates.Add(Path.Combine(root, "data", "ik-inventorylists"));
}
private static bool IsCompleteInventoryListDirectory(string dir)
{
return File.Exists(Path.Combine(dir, "artifacts.json"))
&& File.Exists(Path.Combine(dir, "weapons.json"))
&& File.Exists(Path.Combine(dir, "characters.json"))
&& File.Exists(Path.Combine(dir, "materials.json"))
&& File.Exists(Path.Combine(dir, "version.txt"));
}
public static object CatalogPayload(string dataDir)
{
var status = Load(dataDir);
if (!status.Valid)
{
return new
{
data = status.ToPayload(),
artifacts = Array.Empty<object>(),
weapons = Array.Empty<object>(),
characters = Array.Empty<object>(),
materials = Array.Empty<object>(),
};
}
return new
{
data = status.ToPayload(),
artifacts = LoadArtifactCatalog(Path.Combine(status.Directory, "artifacts.json")),
weapons = LoadStringMapCatalog(Path.Combine(status.Directory, "weapons.json")),
characters = LoadCharacterCatalog(Path.Combine(status.Directory, "characters.json")),
materials = LoadStringMapCatalog(Path.Combine(status.Directory, "materials.json")),
};
}
private static int CountJsonObjectProperties(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.ValueKind == JsonValueKind.Object
? doc.RootElement.EnumerateObject().Count()
: 0;
}
private static int CountArtifactPieces(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return 0;
var count = 0;
foreach (var set in doc.RootElement.EnumerateObject())
{
if (!set.Value.TryGetProperty("artifacts", out var artifacts)) continue;
if (artifacts.ValueKind == JsonValueKind.Object) count += artifacts.EnumerateObject().Count();
}
return count;
}
private static List<object> LoadStringMapCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var entry in doc.RootElement.EnumerateObject())
{
entries.Add(new
{
normalizedName = entry.Name,
good = entry.Value.ValueKind == JsonValueKind.String ? entry.Value.GetString() ?? "" : "",
});
}
return entries;
}
private static List<object> LoadCharacterCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var entry in doc.RootElement.EnumerateObject())
{
var value = entry.Value;
entries.Add(new
{
normalizedName = entry.Name,
good = JsonString(value, "GOOD"),
element = JsonFirstString(value, "Element"),
weaponType = JsonFirstInt(value, "WeaponType"),
constellationName = JsonFirstString(value, "ConstellationName"),
});
}
return entries;
}
private static List<object> LoadArtifactCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var set in doc.RootElement.EnumerateObject())
{
var pieces = new List<object>();
if (set.Value.TryGetProperty("artifacts", out var artifacts) && artifacts.ValueKind == JsonValueKind.Object)
{
foreach (var piece in artifacts.EnumerateObject())
{
pieces.Add(new
{
slot = piece.Name,
artifactName = JsonString(piece.Value, "artifactName"),
good = JsonString(piece.Value, "GOOD"),
normalizedName = JsonString(piece.Value, "normalizedName"),
});
}
}
entries.Add(new
{
normalizedName = set.Name,
setName = JsonString(set.Value, "setName"),
good = JsonString(set.Value, "GOOD"),
pieces,
});
}
return entries;
}
private static string JsonString(JsonElement value, string propertyName)
=> value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString() ?? ""
: "";
private static string JsonFirstString(JsonElement value, string propertyName)
{
if (!value.TryGetProperty(propertyName, out var property)) return "";
if (property.ValueKind == JsonValueKind.String) return property.GetString() ?? "";
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
{
var first = property[0];
return first.ValueKind == JsonValueKind.String ? first.GetString() ?? "" : "";
}
return "";
}
private static int JsonFirstInt(JsonElement value, string propertyName)
{
if (!value.TryGetProperty(propertyName, out var property)) return -1;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number)) return number;
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
{
var first = property[0];
return first.ValueKind == JsonValueKind.Number && first.TryGetInt32(out number) ? number : -1;
}
return -1;
}
}
internal sealed class IkInventoryListStatus
{
public string Directory { get; init; } = "";
public string Version { get; set; } = "";
public int ArtifactSets { get; set; }
public int ArtifactPieces { get; set; }
public int Weapons { get; set; }
public int Characters { get; set; }
public int Materials { get; set; }
public string[] Missing { get; init; } = Array.Empty<string>();
public bool Valid => Missing.Length == 0;
public object SupportedCategoriesPayload() => new
{
artifacts = ArtifactCategoryPayload("artifacts.json", ArtifactSets, ArtifactPieces),
weapons = SimpleCategoryPayload("weapons.json", Weapons),
characters = SimpleCategoryPayload("characters.json", Characters),
materials = SimpleCategoryPayload("materials.json", Materials),
};
private object ArtifactCategoryPayload(string file, int setCount, int pieceCount)
{
var catalogAvailable = Valid;
var nativeCaptureSupported = Valid;
return new
{
file,
setCount,
pieceCount,
supported = nativeCaptureSupported,
catalogAvailable,
nativeCaptureSupported,
scanStatus = nativeCaptureSupported ? "native_capture" : "missing_data",
};
}
private object SimpleCategoryPayload(string file, int count)
{
var catalogAvailable = Valid;
const bool nativeCaptureSupported = false;
return new
{
file,
count,
supported = nativeCaptureSupported,
catalogAvailable,
nativeCaptureSupported,
scanStatus = catalogAvailable ? "catalog_only" : "missing_data",
};
}
public object ToPayload() => new
{
directory = Directory,
version = Version,
artifactSets = ArtifactSets,
artifactPieces = ArtifactPieces,
weapons = Weapons,
characters = Characters,
materials = Materials,
totalEntries = ArtifactPieces + Weapons + Characters + Materials,
categories = SupportedCategoriesPayload(),
missing = Missing,
valid = Valid,
source = "InventoryKamera inventorylists",
};
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InputHelper</AssemblyName>
<RootNamespace>GenshinAssistant.InputHelper</RootNamespace>
<!-- Screen.PrimaryScreen (Forms) + Bitmap/Graphics.CopyFromScreen (Drawing). -->
<UseWindowsForms>true</UseWindowsForms>
<!-- Single self-contained exe: no .NET install needed on the user's machine. -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Per-monitor DPI v2 so SendInput/capture coordinates match a mixed-DPI
multi-monitor setup (same reason as the old PowerShell helper). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
namespace GenshinAssistant.InputHelper;
internal static class NativeScannerFiles
{
private static readonly JsonSerializerOptions PrettyJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
private static readonly JsonSerializerOptions LineJson = new(JsonSerializerDefaults.Web);
public static void WriteJson(string path, object payload)
{
File.WriteAllText(path, JsonSerializer.Serialize(payload, PrettyJson));
}
public static void AppendJsonLine(StreamWriter writer, object payload)
{
writer.WriteLine(JsonSerializer.Serialize(payload, LineJson));
writer.Flush();
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="GenshinAssistant.InputHelper" type="win32" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- PerMonitorV2: coordinates stay correct across mixed-DPI monitors. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
</assembly>
+39 -4
View File
@@ -3,18 +3,37 @@
"version": "0.1.0",
"private": true,
"description": "Local Windows assistant for scanning Genshin artifacts and suggesting no-brainer builds.",
"main": "dist-electron/main.js",
"main": "dist-electron/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",
"predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-electron\\electron\\preload.cjs",
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
"dev:admin": ".\\dev-admin.cmd",
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\preload.cjs",
"dev:web": "vite --host 127.0.0.1",
"dev:admin": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\dev-admin.ps1 -ProjectRoot .",
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
"preview": "vite preview --host 127.0.0.1",
"start": "electron .",
"lint": "tsc --noEmit",
"test": "vitest run",
"eval": "vitest run src/eval/ocrEval.test.ts",
"eval:review-candidates": "node scripts/export-review-eval-candidates.cjs",
"eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs",
"scan:soak": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1",
"scan:goal": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun",
"scan:goal:validated": "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
"scan:goal:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
"scan:iterate": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
"scan:iterate:validated": "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
"scan:iterate:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
"scan:repeatability": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
"scan:repeatability:wait": "npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
"scan:live:preflight": "node scripts/live-preflight.cjs",
"scan:live:preflight:wait": "node scripts/live-preflight.cjs --wait=120",
"scan:assessment:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment",
"scan:assessment:validate": "node scripts/validate-scan-assessment.cjs",
"scan:native:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1",
"scan:native:smoke:5": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1 -Limit 5",
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
"data:genshin": "node scripts/generate-genshin-data.cjs"
},
"dependencies": {
@@ -48,6 +67,22 @@
"dist-electron/**/*",
"package.json"
],
"extraResources": [
{
"from": "native/input-helper/bin/publish",
"to": "input-helper",
"filter": [
"**/*"
]
},
{
"from": "data/ik-inventorylists",
"to": "ik-inventorylists",
"filter": [
"**/*"
]
}
],
"win": {
"target": "nsis",
"requestedExecutionLevel": "requireAdministrator"
+31 -1
View File
@@ -1,5 +1,6 @@
param(
[string]$ProjectRoot
[string]$ProjectRoot,
[string]$OcrWorkers = ""
)
# Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd),
@@ -9,8 +10,21 @@ $ErrorActionPreference = "Stop"
try {
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
$logDir = Join-Path $project "outputs\admin-start"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$logPath = Join-Path $logDir "admin-dev.log"
try {
Start-Transcript -Path $logPath -Append | Out-Null
} catch {
Write-Host "WARNUNG: Admin-Start-Log konnte nicht geschrieben werden: $($_.Exception.Message)" -ForegroundColor Yellow
}
Write-Host "Projekt: $project"
Write-Host "Admin-Log: $logPath"
if ($OcrWorkers) {
$env:GAA_OCR_WORKERS = $OcrWorkers
Write-Host "GAA_OCR_WORKERS: $env:GAA_OCR_WORKERS"
}
# A UAC-elevated process gets its environment rebuilt fresh from the
# registry; it does NOT inherit PATH edits that only exist in the calling
@@ -47,6 +61,22 @@ try {
& (Join-Path $PSScriptRoot "kill-stale-instances.ps1")
$devPort = 5173
$devControlPort = 17317
$devControlInUse = $null
try {
$devControlInUse = Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop | Select-Object -First 1
} catch {
# Get-NetTCPConnection kann auf manchen Systemen fehlen; das ist kein Fehler.
$devControlInUse = $null
}
if ($devControlInUse) {
$portOwner = Get-Process -Id $devControlInUse.OwningProcess -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "FEHLER: Dev-Control-Port $devControlPort ist noch belegt (Prozess: $($portOwner.ProcessName), PID $($devControlInUse.OwningProcess))." -ForegroundColor Red
Write-Host "Das wuerde Live-Tests gegen eine alte App-Instanz laufen lassen. Schliesse die alte App oder beende diesen Prozess und starte npm run dev:admin erneut." -ForegroundColor Red
throw "Dev-Control-Port $devControlPort ist durch PID $($devControlInUse.OwningProcess) belegt."
}
$portInUse = $null
try {
$portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1
+37
View File
@@ -0,0 +1,37 @@
param(
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path,
[string]$OcrWorkers = $env:GAA_OCR_WORKERS
)
$ErrorActionPreference = "Stop"
function Quote-ProcessArgument([string]$Value) {
return '"' + $Value.Replace('"', '\"') + '"'
}
try {
$project = (Resolve-Path -LiteralPath $ProjectRoot).Path
$script = Join-Path $PSScriptRoot "dev-admin-start.ps1"
$powershellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$arguments = @(
"-NoProfile",
"-ExecutionPolicy Bypass",
"-NoExit",
"-File $(Quote-ProcessArgument $script)",
"-ProjectRoot $(Quote-ProcessArgument $project)"
) -join " "
if ($OcrWorkers) {
$arguments += " -OcrWorkers $(Quote-ProcessArgument $OcrWorkers)"
}
Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop
Write-Host ""
Write-Host "UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev." -ForegroundColor Green
Write-Host "Dieses Fenster kann geschlossen werden; das eigentliche Programm laeuft im neuen Administrator-Fenster."
} catch {
Write-Host ""
Write-Host "Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit 1
}
+288
View File
@@ -0,0 +1,288 @@
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const os = require("node:os");
const DEFAULT_LIMIT = 80;
function argValue(name, fallback = "") {
const prefix = `--${name}=`;
const match = process.argv.find((entry) => entry.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function defaultReviewSamplesPath() {
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
return path.join(appData, "genshin-artifact-assistant", "review-samples.jsonl");
}
function simplifyId(value) {
return String(value || "")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "unknown";
}
function parsedSummary(parsed) {
if (!parsed || typeof parsed !== "object") return {};
return {
name: parsed.name,
slot: parsed.slot,
level: parsed.level,
mainStat: parsed.mainStat,
mainValue: parsed.mainValue,
setName: parsed.setName,
equipped: parsed.equipped,
substats: Array.isArray(parsed.substats) ? parsed.substats : [],
confidence: parsed.confidence,
notes: Array.isArray(parsed.notes) ? parsed.notes : [],
};
}
const REQUIRED_FAST_OCR_FIELDS = [
"artifact-name",
"artifact-slot",
"artifact-main-stat-label",
"artifact-level",
"artifact-substats",
];
function ocrMap(record) {
const entries = record?.sample?.capture?.ocr;
if (!Array.isArray(entries) || entries.length === 0) return null;
const result = {};
for (const entry of entries) {
if (typeof entry?.id === "string" && typeof entry?.text === "string") {
result[entry.id] = entry.text;
}
}
return Object.keys(result).length ? result : null;
}
function candidateFromRecord(record, index) {
const ocr = ocrMap(record);
if (!ocr) return null;
const parsed = parsedSummary(record?.sample?.parsed);
const reason = record?.sample?.reason || "missing-reason";
const savedAt = record?.savedAt || "unknown";
const capture = record?.sample?.capture || {};
const ocrFieldIds = Object.keys(ocr).sort();
const missingFastFields = REQUIRED_FAST_OCR_FIELDS.filter((field) => !ocr[field]);
const hasEquippedFooterOcr = Boolean(ocr["artifact-footer"]);
const locked = typeof capture.locked === "boolean" ? capture.locked : null;
return {
id: `review-${simplifyId(savedAt)}-${index}`,
savedAt,
reason,
confirmed: false,
resolution: capture.width && capture.height ? `${capture.width}x${capture.height}` : "",
ocrFieldIds,
missingFastFields,
hasEquippedFooterOcr,
locked,
likelyStaleCapture: missingFastFields.includes("artifact-slot"),
parsed,
ocr,
reviewPrompt: {
action: "Confirm or correct parsed fields before moving this case into src/eval/corpus/confirmedReviewCorpus.ts.",
expectedFields: {
name: parsed.name || "",
slot: parsed.slot || "",
level: parsed.level ?? "",
mainStat: parsed.mainStat || "",
mainValue: parsed.mainValue || "",
setName: parsed.setName || "",
equipped: parsed.equipped || "",
substats: parsed.substats || [],
},
validationChecks: {
equipped: hasEquippedFooterOcr ? "Confirm character name or mark Not detected." : "No footer OCR in this sample.",
locked: locked === null ? "No lock-state payload in this sample." : `Confirm locked=${locked}.`,
},
},
};
}
function candidateKey(candidate) {
return [
candidate.reason,
candidate.parsed.name,
candidate.parsed.slot,
candidate.parsed.mainStat,
candidate.parsed.setName,
JSON.stringify(candidate.parsed.substats || []),
JSON.stringify(candidate.ocr),
].join("\u001f");
}
function candidatePriority(candidate) {
const reason = candidate.reason || "";
const parsed = candidate.parsed || {};
const missingCount = candidate.missingFastFields?.length || 0;
const hasUnknownCritical = [parsed.name, parsed.slot, parsed.mainStat, parsed.setName].some((value) => String(value || "").startsWith("Unknown"));
if (candidate.likelyStaleCapture) return 4;
if (missingCount === 0 && /low-field|low-total|capture-rejected|initial-selection/i.test(reason)) return 0;
if (missingCount === 0 && (parsed.notes || []).some((note) => /fuzzy|incomplete|not confidently|low/i.test(note))) return 1;
if (missingCount === 0) return 2;
if (missingCount <= 1 && !hasUnknownCritical) return 3;
return 5;
}
async function readCandidates(inputPath, limit) {
const candidates = [];
const seen = new Set();
let total = 0;
let invalid = 0;
const rl = readline.createInterface({ input: fs.createReadStream(inputPath, { encoding: "utf8" }) });
for await (const line of rl) {
if (!line.trim()) continue;
total++;
let record;
try {
record = JSON.parse(line);
} catch {
invalid++;
continue;
}
const candidate = candidateFromRecord(record, total);
if (!candidate) continue;
const key = candidateKey(candidate);
if (seen.has(key)) continue;
seen.add(key);
candidates.push(candidate);
}
candidates.sort((left, right) => {
const priority = candidatePriority(left) - candidatePriority(right);
if (priority) return priority;
const missingDiff = (left.missingFastFields?.length || 0) - (right.missingFastFields?.length || 0);
if (missingDiff) return missingDiff;
return String(right.savedAt).localeCompare(String(left.savedAt));
});
return { total, invalid, candidates: candidates.slice(0, limit), uniqueCandidates: candidates.length };
}
function increment(map, key) {
const normalizedKey = key || "unknown";
map[normalizedKey] = (map[normalizedKey] || 0) + 1;
}
function buildExportStats(candidates) {
const reasonCounts = {};
const missingFastFieldCounts = {};
let completeFastFields = 0;
let likelyStaleCaptures = 0;
let equippedFooterCandidates = 0;
let lockedTrueCandidates = 0;
let lockedFalseCandidates = 0;
for (const candidate of candidates) {
increment(reasonCounts, candidate.reason);
if (candidate.likelyStaleCapture) likelyStaleCaptures++;
if (candidate.hasEquippedFooterOcr) equippedFooterCandidates++;
if (candidate.locked === true) lockedTrueCandidates++;
if (candidate.locked === false) lockedFalseCandidates++;
const missingFields = candidate.missingFastFields || [];
if (missingFields.length === 0) completeFastFields++;
for (const field of missingFields) increment(missingFastFieldCounts, field);
}
return {
completeFastFields,
likelyStaleCaptures,
equippedFooterCandidates,
lockedTrueCandidates,
lockedFalseCandidates,
reasonCounts,
missingFastFieldCounts,
};
}
function markdownFor(summary, candidates) {
const lines = [
"# Review Eval Candidates",
"",
`Source: ${summary.inputPath}`,
`Generated: ${summary.generatedAt}`,
`Records read: ${summary.recordsRead}`,
`Unique candidates: ${summary.uniqueCandidates}`,
`Exported candidates: ${summary.exportedCandidates}`,
`Complete fast-field candidates: ${summary.exportStats.completeFastFields}`,
`Likely stale captures: ${summary.exportStats.likelyStaleCaptures}`,
`Equipped footer candidates: ${summary.exportStats.equippedFooterCandidates}`,
`Locked=true candidates: ${summary.exportStats.lockedTrueCandidates}`,
`Locked=false candidates: ${summary.exportStats.lockedFalseCandidates}`,
"",
"These cases are not ground truth yet. Confirm or correct the expected fields before committing any case into `src/eval/corpus/`.",
"",
"## Export stats",
"",
"Reason counts:",
"",
...Object.entries(summary.exportStats.reasonCounts).map(([reason, count]) => `- ${reason}: ${count}`),
"",
"Missing fast-field counts:",
"",
...Object.entries(summary.exportStats.missingFastFieldCounts).map(([field, count]) => `- ${field}: ${count}`),
"",
];
for (const candidate of candidates) {
lines.push(`## ${candidate.id}`);
lines.push("");
lines.push(`- savedAt: ${candidate.savedAt}`);
lines.push(`- reason: ${candidate.reason}`);
lines.push(`- missingFastFields: ${candidate.missingFastFields.join(", ") || "none"}`);
lines.push(`- likelyStaleCapture: ${candidate.likelyStaleCapture ? "yes" : "no"}`);
lines.push(`- hasEquippedFooterOcr: ${candidate.hasEquippedFooterOcr ? "yes" : "no"}`);
lines.push(`- locked: ${candidate.locked === null ? "unknown" : candidate.locked}`);
lines.push(`- parsed: ${candidate.parsed.name || "?"} | ${candidate.parsed.slot || "?"} | ${candidate.parsed.mainStat || "?"} | ${candidate.parsed.setName || "?"} | equipped=${candidate.parsed.equipped || "?"}`);
lines.push(`- substats: ${(candidate.parsed.substats || []).join(", ") || "?"}`);
lines.push("");
lines.push("OCR:");
for (const [id, text] of Object.entries(candidate.ocr)) {
lines.push(`- ${id}: ${JSON.stringify(text)}`);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
async function main() {
const inputPath = path.resolve(argValue("input", defaultReviewSamplesPath()));
const outputDir = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates")));
const limit = Math.max(1, Math.min(500, Number(argValue("limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT));
if (!fs.existsSync(inputPath)) {
throw new Error(`Review sample file not found: ${inputPath}`);
}
fs.mkdirSync(outputDir, { recursive: true });
const result = await readCandidates(inputPath, limit);
const summary = {
inputPath,
outputDir,
generatedAt: new Date().toISOString(),
recordsRead: result.total,
invalidRecords: result.invalid,
uniqueCandidates: result.uniqueCandidates,
exportedCandidates: result.candidates.length,
exportStats: buildExportStats(result.candidates),
};
const payload = { summary, candidates: result.candidates };
const jsonPath = path.join(outputDir, "review-eval-candidates.json");
const mdPath = path.join(outputDir, "review-eval-candidates.md");
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
fs.writeFileSync(mdPath, markdownFor(summary, result.candidates), "utf8");
console.log(JSON.stringify({ ok: true, ...summary, jsonPath, mdPath }, null, 2));
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
module.exports = {
buildExportStats,
candidateFromRecord,
candidatePriority,
defaultReviewSamplesPath,
markdownFor,
readCandidates,
};
+106
View File
@@ -44,6 +44,9 @@ const artifactPieces = artifacts
const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot]));
const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName]));
const setToPieces = Object.fromEntries(
artifacts.map((set) => [set.name, artifactPieces.filter((piece) => piece.setName === set.name).map((piece) => piece.name)]),
);
const mainStats = [
'Elemental Mastery',
@@ -198,6 +201,23 @@ const data = {
'Qiqi ': 'Qiqi',
},
},
lookup: {
normalizedKeys: {
sets: normalizedMap(artifacts.map((set) => set.name)),
pieces: normalizedMap(artifactPieces.map((piece) => piece.name)),
slots: normalizedMap(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']),
stats: normalizedMap([...mainStats, ...substats]),
characters: normalizedMap(characters.map((character) => character.name)),
},
goodKeys: {
sets: Object.fromEntries(artifacts.map((set) => [set.name, goodKey(set.name)])),
pieces: Object.fromEntries(artifactPieces.map((piece) => [piece.name, goodKey(piece.name)])),
stats: Object.fromEntries([...mainStats, ...substats].map((stat) => [stat, goodStatKey(stat)])),
characters: Object.fromEntries(characters.map((character) => [character.name, goodKey(character.name)])),
},
setToPieces,
validation: validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }),
},
uiProfiles: {
artifactDetailEn: {
language: 'English',
@@ -231,3 +251,89 @@ function slotFromRelicType(relicType) {
return '';
}
}
function normalizeLookupKey(value) {
return String(value)
.toLowerCase()
.normalize('NFKD')
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function normalizedMap(values) {
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
}
function goodKey(value) {
return String(value)
.replace(/[']/g, '')
.replace(/[^A-Za-z0-9]+(.)/g, (_match, next) => String(next).toUpperCase())
.replace(/^[a-z]/, (first) => first.toUpperCase())
.replace(/[^A-Za-z0-9]/g, '');
}
function goodStatKey(stat) {
switch (stat) {
case 'HP':
return 'hp';
case 'HP%':
return 'hp_';
case 'ATK':
return 'atk';
case 'ATK%':
return 'atk_';
case 'DEF':
return 'def';
case 'DEF%':
return 'def_';
case 'Elemental Mastery':
return 'eleMas';
case 'Energy Recharge':
return 'enerRech_';
case 'CRIT Rate':
return 'critRate_';
case 'CRIT DMG':
return 'critDMG_';
case 'Healing Bonus':
return 'heal_';
case 'Physical DMG Bonus':
return 'physical_dmg_';
default:
return stat.toLowerCase().replace(' dmg bonus', '_dmg_').replace(/\s+/g, '');
}
}
function validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }) {
const errors = [];
const warnings = [];
const setNames = new Set(artifacts.map((set) => set.name));
const slotNames = new Set(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']);
const goodSetKeys = new Set();
for (const set of artifacts) {
const key = goodKey(set.name);
if (goodSetKeys.has(key)) errors.push(`Duplicate GOOD set key: ${key}`);
goodSetKeys.add(key);
if ((setToPieces[set.name] ?? []).length === 0) warnings.push(`Set has no pieces: ${set.name}`);
}
for (const piece of artifactPieces) {
if (!setNames.has(piece.setName)) errors.push(`Piece ${piece.name} references missing set ${piece.setName}`);
if (!slotNames.has(piece.slot)) errors.push(`Piece ${piece.name} references missing slot ${piece.slot}`);
}
if (!characters.length) warnings.push('No characters generated.');
if (!mainStats.length || !substats.length) errors.push('Stats were not generated.');
return {
valid: errors.length === 0,
errors,
warnings,
summary: {
artifactSets: artifacts.length,
artifactPieces: artifactPieces.length,
characters: characters.length,
stats: mainStats.length + substats.length,
},
};
}
+54 -5
View File
@@ -16,8 +16,25 @@
$ErrorActionPreference = "Stop"
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
$devControlPort = 17317
$killed = 0
$candidatePids = @{}
try {
$health = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/health" -TimeoutSec 2 -ErrorAction Stop
if ($health.appBuild -and $health.appBuild.cwd -eq $project) {
Write-Host "Dev-Control-Port $devControlPort gehoert zu diesem Projekt (PID $($health.appBuild.pid), Signatur $($health.appBuild.signature)). Versuche Self-Shutdown..."
try {
Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/dev/shutdown?reason=restart" -TimeoutSec 2 -ErrorAction Stop | Out-Null
Start-Sleep -Milliseconds 900
} catch {
Write-Host "Self-Shutdown nicht verfuegbar oder fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} catch {
# No dev-control server or an older/stuck process; continue with process scan.
}
Get-CimInstance Win32_Process |
Where-Object {
@@ -26,15 +43,47 @@ Get-CimInstance Win32_Process |
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
} |
ForEach-Object {
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
$killed++
$candidatePids[[int]$_.ProcessId] = $_.Name
}
try {
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
ForEach-Object {
if ($_.OwningProcess -gt 0) {
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
}
}
} catch {
# Get-NetTCPConnection can be unavailable on some machines; process matching
# above still handles the normal non-elevated path.
}
$failed = 0
foreach ($entry in $candidatePids.GetEnumerator()) {
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
try {
Stop-Process -Id $entry.Key -Force -ErrorAction Stop
$killed++
} catch {
Write-Host "WARNUNG: Konnte PID $($entry.Key) nicht beenden: $($_.Exception.Message)" -ForegroundColor Yellow
$failed++
}
}
if ($killed -gt 0) {
# Windows braucht einen Moment, um Ports/Handles wirklich freizugeben.
Start-Sleep -Milliseconds 500
Write-Host "$killed alte Prozess(e) beendet."
} else {
Write-Host "Keine alten Instanzen gefunden."
}
if ($failed -gt 0) {
Write-Host "$failed alte Prozess(e) konnten nicht beendet werden. Wenn das ein Admin-Prozess ist, starte npm run dev:admin und bestaetige UAC oder schliesse die alte App manuell." -ForegroundColor Yellow
throw "$failed alte Prozess(e) konnten nicht beendet werden."
}
if ($killed -eq 0 -and $failed -eq 0) {
Write-Host "Keine alten Instanzen gefunden."
} else {
Start-Sleep -Milliseconds 300
}
+148
View File
@@ -0,0 +1,148 @@
const fs = require("node:fs");
const path = require("node:path");
function argValue(name, fallback = "") {
const prefix = `--${name}=`;
const match = process.argv.find((entry) => entry.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function hasFlag(name) {
return process.argv.includes(`--${name}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseWaitSeconds(value) {
if (value === "") return 0;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error("--wait must be a non-negative integer number of seconds.");
}
return parsed;
}
function expectedSignature() {
const mainPath = path.join(process.cwd(), "electron", "main.ts");
const source = fs.readFileSync(mainPath, "utf8");
const match = source.match(/APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"/);
return match?.[1] || "";
}
async function fetchJson(baseUrl, endpoint) {
let response;
try {
response = await fetch(`${baseUrl}${endpoint}`);
} catch (error) {
throw new Error(`Could not reach ${baseUrl}${endpoint}. Start the elevated app with npm run dev:admin and confirm UAC before running live scans. (${error instanceof Error ? error.message : String(error)})`);
}
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${endpoint} returned non-JSON response (${response.status}).`);
}
if (!response.ok) throw new Error(`${endpoint} returned ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
function validatePreflight({ health, status, expected, requireElevated = true, requireGenshin = true }) {
const errors = [];
const appBuild = health?.appBuild;
const runtime = status?.status?.runtimeInfo;
if (!appBuild?.signature) {
errors.push("/health is missing appBuild.signature.");
} else if (expected && appBuild.signature !== expected) {
errors.push(`Runtime signature '${appBuild.signature}' does not match source '${expected}'.`);
}
if (!status?.status) errors.push("/scanner/status is missing status payload.");
if (!runtime) {
errors.push("/scanner/status is missing runtimeInfo. Open the scanner view and restart the elevated app if needed.");
} else {
if (requireElevated && runtime.isElevated !== true) errors.push("Runtime is not elevated.");
if (requireGenshin && runtime.genshinFound !== true) errors.push("Genshin process/window was not found.");
}
return {
ok: errors.length === 0,
errors,
signature: appBuild?.signature || "",
expectedSignature: expected || "",
isElevated: runtime?.isElevated,
genshinFound: runtime?.genshinFound,
targetProcess: runtime?.targetProcess || "",
foregroundProcess: runtime?.foregroundProcess || "",
};
}
function formatSummary(result) {
const lines = [
`live preflight: ${result.ok ? "PASS" : "FAIL"}`,
`signature: ${result.signature || "missing"}`,
`expected: ${result.expectedSignature || "unknown"}`,
`elevated: ${result.isElevated === true ? "yes" : result.isElevated === false ? "no" : "unknown"}`,
`genshin: ${result.genshinFound === true ? "yes" : result.genshinFound === false ? "no" : "unknown"}`,
`target: ${result.targetProcess || "unknown"}`,
`foreground: ${result.foregroundProcess || "unknown"}`,
];
if (!result.ok) {
lines.push("errors:");
for (const error of result.errors) lines.push(`- ${error}`);
}
return lines.join("\n");
}
async function runPreflight({ baseUrl, expected, requireElevated, requireGenshin }) {
const health = await fetchJson(baseUrl, "/health");
const status = await fetchJson(baseUrl, "/scanner/status");
return validatePreflight({ health, status, expected, requireElevated, requireGenshin });
}
async function waitForPreflight(options, waitSeconds) {
const deadline = Date.now() + waitSeconds * 1000;
let lastError = null;
let lastResult = null;
while (true) {
try {
const result = await runPreflight(options);
lastResult = result;
if (result.ok || Date.now() >= deadline) return result;
} catch (error) {
lastError = error;
if (Date.now() >= deadline) throw lastError;
}
await sleep(1000);
}
}
async function main() {
const baseUrl = argValue("base-url", "http://127.0.0.1:17317").replace(/\/$/, "");
const expected = argValue("expected-signature", expectedSignature());
const requireElevated = !hasFlag("allow-standard");
const requireGenshin = !hasFlag("allow-missing-genshin");
const waitSeconds = parseWaitSeconds(argValue("wait", ""));
const options = { baseUrl, expected, requireElevated, requireGenshin };
const result = waitSeconds > 0 ? await waitForPreflight(options, waitSeconds) : await runPreflight(options);
console.log(hasFlag("json") ? JSON.stringify(result, null, 2) : formatSummary(result));
if (!result.ok) process.exit(1);
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
module.exports = {
formatSummary,
parseWaitSeconds,
runPreflight,
validatePreflight,
};
+785
View File
@@ -0,0 +1,785 @@
param(
[string]$BaseUrl = "http://127.0.0.1:17317",
[int[]]$ProbeIndices = @(1, 3),
[int[]]$Limits = @(2, 5, 10, 20),
[int]$PollIntervalSeconds = 2,
[int]$TimeoutSeconds = 600,
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"),
[switch]$GoalRun,
[switch]$RepeatabilityRun,
[ValidateSet("current")]
[string]$ScanEngine = "current",
[switch]$BenchmarkOcr,
[int]$BenchmarkLimit = 5,
[ValidateSet("current")]
[string]$BenchmarkEngine = "current",
[ValidateSet("fast", "full")]
[string]$BenchmarkProfile = "fast",
[switch]$SkipSmartCapture,
[switch]$SaveFullReviewSamples,
[switch]$AllowStaleBuild,
[string]$ExpectedAppSignature,
[switch]$ContinueAfterBlocked,
[switch]$SelfTestAssessment
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($ExpectedAppSignature)) {
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
if (Test-Path -LiteralPath $mainPath) {
$mainSource = Get-Content -LiteralPath $mainPath -Raw
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
if ($match.Success) {
$ExpectedAppSignature = $match.Groups[1].Value
}
}
}
function ConvertTo-SafeFilePart([string]$Value) {
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
$safe = $safe.Trim("-")
if ($safe.Length -eq 0) { return "item" }
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
return $safe
}
function Invoke-DevJson([string]$Path) {
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 60
}
function Save-Json([string]$Name, [object]$Payload) {
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
$Payload | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Assert-CurrentAppBuild([object]$HealthPayload) {
if ($AllowStaleBuild) { return }
if ($null -eq $HealthPayload.appBuild) {
throw "Dev endpoint is stale: /health has no appBuild signature. Close the old elevated Genshin Artifact Assistant/Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
if ([string]::IsNullOrWhiteSpace([string]$HealthPayload.appBuild.signature)) {
throw "Dev endpoint is stale: appBuild.signature is empty. Restart the elevated app before live scanner timing."
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedAppSignature) -and [string]$HealthPayload.appBuild.signature -ne $ExpectedAppSignature) {
throw "Dev endpoint is stale: appBuild.signature='$($HealthPayload.appBuild.signature)' but current source expects '$ExpectedAppSignature'. Close the old Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
}
function Get-ScannerStatus {
Invoke-DevJson "/scanner/status"
}
function Test-ProbeSucceeded([object]$ProbePayload) {
if ($ProbePayload.ok) { return $true }
if ($ProbePayload.changed) { return $true }
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
return $false
}
function Get-CompletedScanSummary([object]$StatusPayload, [int]$Limit) {
if ($null -ne $StatusPayload.status.summary) {
return $StatusPayload.status.summary
}
$reviewStatus = [string]$StatusPayload.status.reviewStatus
$status = "done"
if ($reviewStatus -match "blockiert") {
$status = "blocked"
} elseif ($reviewStatus -match "gestoppt") {
$status = "stopped"
}
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
mode = "Automatischer Scan"
status = $status
clicked = [int]$stats.clicked
attempted = [int]$stats.attempted
verified = [int]$stats.verified
parsed = [int]$stats.parsed
stored = [int]$stats.stored
review = [int]$stats.review
duplicates = [int]$stats.duplicates
misses = [int]$stats.misses
pages = [int]$stats.pages
targetCount = $Limit
gridLabel = $reviewStatus
}
}
function Convert-ScanTimingSummary([object]$StatusPayload, [int]$Limit, [string]$Engine) {
$summary = Get-CompletedScanSummary $StatusPayload $Limit
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
engine = $Engine
limit = $Limit
status = $summary.status
clicked = $summary.clicked
attempted = $summary.attempted
verified = $summary.verified
parsed = $summary.parsed
stored = $summary.stored
review = $summary.review
duplicates = $summary.duplicates
misses = $summary.misses
pages = $summary.pages
elapsedMs = [int]$stats.elapsedMs
activeScanMs = [int]$stats.activeScanMs
writeFlushMs = [int]$stats.writeFlushMs
averageMsPerParsed = [int]$stats.averageMsPerParsed
activeAverageMsPerParsed = [int]$stats.activeAverageMsPerParsed
artifactsPerMinute = [double]$stats.artifactsPerMinute
activeArtifactsPerMinute = [double]$stats.activeArtifactsPerMinute
projectedMsFor100 = [int]$stats.projectedMsFor100
activeProjectedMsFor100 = [int]$stats.activeProjectedMsFor100
averageCaptureMs = [int]$stats.averageCaptureMs
averageCaptureRoundTripMs = [int]$stats.averageCaptureRoundTripMs
averageCaptureRoundTripOverheadMs = [int]$stats.averageCaptureRoundTripOverheadMs
averageOcrMs = [int]$stats.averageOcrMs
captureP50Ms = [int]$stats.captureP50Ms
captureP90Ms = [int]$stats.captureP90Ms
ocrP50Ms = [int]$stats.ocrP50Ms
ocrP90Ms = [int]$stats.ocrP90Ms
cardReadyMs = [int]$stats.cardReadyMs
cardReadyCount = [int]$stats.cardReadyCount
averageCardReadyMs = [int]$stats.averageCardReadyMs
scrollReadyMs = [int]$stats.scrollReadyMs
scrollReadyCount = [int]$stats.scrollReadyCount
averageScrollReadyMs = [int]$stats.averageScrollReadyMs
captureMs = [int]$stats.captureMs
captureRoundTripMs = [int]$stats.captureRoundTripMs
captureRoundTripOverheadMs = [int]$stats.captureRoundTripOverheadMs
ocrMs = [int]$stats.ocrMs
reviewStatus = [string]$StatusPayload.status.reviewStatus
}
}
function Write-ScanTimingLine([object]$Timing) {
Write-Host ("engine={0} limit={1} status={2} parsed={3} review={4} miss={5} elapsed={6}ms active={7}ms flush={8}ms avg={9}ms activeAvg={10}ms captureAvg={11}ms roundtripAvg={12}ms roundtripOverheadAvg={13}ms captureP50={14}ms captureP90={15}ms ocrAvg={16}ms ocrP50={17}ms ocrP90={18}ms cardReadyAvg={19}ms scrollReadyAvg={20}ms ppm={21} activePpm={22} projected100={23}ms activeProjected100={24}ms" -f `
$Timing.engine,
$Timing.limit,
$Timing.status,
$Timing.parsed,
$Timing.review,
$Timing.misses,
$Timing.elapsedMs,
$Timing.activeScanMs,
$Timing.writeFlushMs,
$Timing.averageMsPerParsed,
$Timing.activeAverageMsPerParsed,
$Timing.averageCaptureMs,
$Timing.averageCaptureRoundTripMs,
$Timing.averageCaptureRoundTripOverheadMs,
$Timing.captureP50Ms,
$Timing.captureP90Ms,
$Timing.averageOcrMs,
$Timing.ocrP50Ms,
$Timing.ocrP90Ms,
$Timing.averageCardReadyMs,
$Timing.averageScrollReadyMs,
$Timing.artifactsPerMinute,
$Timing.activeArtifactsPerMinute,
$Timing.projectedMsFor100,
$Timing.activeProjectedMsFor100)
}
function Get-TimingBottleneck([object]$Timing) {
$parts = @(
[pscustomobject]@{ name = "ocr"; value = [int]$Timing.averageOcrMs },
[pscustomobject]@{ name = "capture-roundtrip-overhead"; value = [int]$Timing.averageCaptureRoundTripOverheadMs },
[pscustomobject]@{ name = "capture"; value = [int]$Timing.averageCaptureMs },
[pscustomobject]@{ name = "card-ready"; value = [int]$Timing.averageCardReadyMs },
[pscustomobject]@{ name = "scroll-ready"; value = [int]$Timing.averageScrollReadyMs }
) | Sort-Object -Property value -Descending
if ($parts.Count -eq 0 -or $parts[0].value -le 0) { return "unknown" }
return $parts[0].name
}
function Get-TimingRecommendation([object]$Timing) {
$bottleneck = Get-TimingBottleneck $Timing
switch ($bottleneck) {
"ocr" { return "OCR dominates; inspect crop count, worker pool, and parser-derived fields first." }
"capture-roundtrip-overhead" { return "Capture transport overhead dominates; inspect native encode, IPC payload size, and Base64/DataURL conversion before OCR changes." }
"capture" { return "Capture dominates; reduce payloads/crops and avoid full-frame or Base64 work in the hot loop." }
"card-ready" { return "Card-ready dominates; tune detail fingerprint polling before OCR changes." }
"scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling before OCR changes." }
default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." }
}
}
function Get-ScanQuality([object]$Timing) {
$parsed = [int]$Timing.parsed
$misses = [int]$Timing.misses
$review = [int]$Timing.review
$target = [int]$Timing.limit
$processed = [Math]::Max(1, $parsed + $misses)
$parseCoverage = if ($target -gt 0) { [Math]::Round($parsed / $target, 4) } else { 0 }
$missRate = [Math]::Round($misses / $processed, 4)
$reviewRate = if ($parsed -gt 0) { [Math]::Round($review / $parsed, 4) } else { 1 }
$statusOk = [string]$Timing.status -eq "done"
$coverageOk = $parsed -ge $target
$missOk = $missRate -le 0.02
$reviewOk = $reviewRate -le 0.15
$qualityPenalty = [Math]::Round(($missRate * 1000000) + ($reviewRate * 250000) + ((1 - $parseCoverage) * 1000000), 0)
$qualityDecision = if (-not $statusOk) {
"not-qualified: scan did not finish cleanly"
} elseif (-not $coverageOk) {
"not-qualified: parsed fewer artifacts than requested"
} elseif (-not $missOk) {
"not-qualified: miss rate above 2%"
} elseif (-not $reviewOk) {
"not-qualified: review rate above 15%"
} else {
"qualified"
}
return [pscustomobject]@{
parseCoverage = $parseCoverage
missRate = $missRate
reviewRate = $reviewRate
qualityDecision = $qualityDecision
qualityPenalty = $qualityPenalty
qualified = ($statusOk -and $coverageOk -and $missOk -and $reviewOk)
}
}
function New-PerformanceAssessment([object[]]$Summaries) {
$limitReports = @()
foreach ($group in ($Summaries | Group-Object -Property limit | Sort-Object { [int]$_.Name })) {
$entries = @($group.Group | ForEach-Object {
$quality = Get-ScanQuality $_
$_ | Add-Member -NotePropertyName parseCoverage -NotePropertyValue $quality.parseCoverage -Force
$_ | Add-Member -NotePropertyName missRate -NotePropertyValue $quality.missRate -Force
$_ | Add-Member -NotePropertyName reviewRate -NotePropertyValue $quality.reviewRate -Force
$_ | Add-Member -NotePropertyName qualityDecision -NotePropertyValue $quality.qualityDecision -Force
$_ | Add-Member -NotePropertyName qualityPenalty -NotePropertyValue $quality.qualityPenalty -Force
$_ | Add-Member -NotePropertyName qualified -NotePropertyValue $quality.qualified -Force
$_
} | Sort-Object -Property @{ Expression = "qualified"; Descending = $true }, qualityPenalty, activeAverageMsPerParsed, averageMsPerParsed)
if ($entries.Count -eq 0) { continue }
$winner = $entries[0]
$engineReports = @()
foreach ($entry in $entries) {
$engineReports += [pscustomobject]@{
engine = $entry.engine
status = $entry.status
parsed = $entry.parsed
review = $entry.review
misses = $entry.misses
parseCoverage = $entry.parseCoverage
missRate = $entry.missRate
reviewRate = $entry.reviewRate
qualified = $entry.qualified
qualityDecision = $entry.qualityDecision
qualityPenalty = $entry.qualityPenalty
activeAverageMsPerParsed = $entry.activeAverageMsPerParsed
activeProjectedMsFor100 = $entry.activeProjectedMsFor100
averageOcrMs = $entry.averageOcrMs
averageCaptureMs = $entry.averageCaptureMs
averageCaptureRoundTripMs = $entry.averageCaptureRoundTripMs
averageCaptureRoundTripOverheadMs = $entry.averageCaptureRoundTripOverheadMs
averageCardReadyMs = $entry.averageCardReadyMs
averageScrollReadyMs = $entry.averageScrollReadyMs
bottleneck = Get-TimingBottleneck $entry
recommendation = Get-TimingRecommendation $entry
}
}
$limitReports += [pscustomobject]@{
limit = [int]$group.Name
engineCount = $entries.Count
enginesCompared = @($entries | ForEach-Object { $_.engine })
singleEngine = $true
winnerEngine = $winner.engine
winnerQualified = $winner.qualified
winnerMissRate = $winner.missRate
winnerReviewRate = $winner.reviewRate
winnerActiveAverageMsPerParsed = $winner.activeAverageMsPerParsed
winnerActiveProjectedMsFor100 = $winner.activeProjectedMsFor100
winnerAverageCaptureRoundTripMs = $winner.averageCaptureRoundTripMs
winnerAverageCaptureRoundTripOverheadMs = $winner.averageCaptureRoundTripOverheadMs
engines = $engineReports
}
}
$goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
$goal100Decision = "not-run: missing 100-artifact assessment"
if ($goal100.Count -gt 0) {
if (-not $goal100[0].winnerQualified) {
$goal100Decision = "not-qualified: 100-artifact winner failed quality gates"
} else {
$goal100Decision = "qualified: winner=$($goal100[0].winnerEngine)"
}
}
return [pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goalLimit = 100
goalEngines = @("current")
goal100Decision = $goal100Decision
goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
limits = $limitReports
}
}
function Write-PerformanceAssessment([object]$Assessment) {
if ($Assessment.goal100Decision) {
Write-Host "assessment goal100: $($Assessment.goal100Decision)"
}
foreach ($limit in @($Assessment.limits)) {
Write-Host ("assessment limit={0}: winner={1} qualified={2} engines={3} missRate={4:P1} reviewRate={5:P1} activeAvg={6}ms projected100={7}ms roundtripOverheadAvg={8}ms" -f `
$limit.limit,
$limit.winnerEngine,
$limit.winnerQualified,
($limit.enginesCompared -join ","),
$limit.winnerMissRate,
$limit.winnerReviewRate,
$limit.winnerActiveAverageMsPerParsed,
$limit.winnerActiveProjectedMsFor100,
$limit.winnerAverageCaptureRoundTripOverheadMs)
foreach ($engine in @($limit.engines)) {
Write-Host (" {0}: qualified={1}, missRate={2:P1}, reviewRate={3:P1}, bottleneck={4}, activeAvg={5}ms, ocr={6}ms, capture={7}ms, roundtrip={8}ms, roundtripOverhead={9}ms, cardReady={10}ms, scrollReady={11}ms, decision={12}" -f `
$engine.engine,
$engine.qualified,
$engine.missRate,
$engine.reviewRate,
$engine.bottleneck,
$engine.activeAverageMsPerParsed,
$engine.averageOcrMs,
$engine.averageCaptureMs,
$engine.averageCaptureRoundTripMs,
$engine.averageCaptureRoundTripOverheadMs,
$engine.averageCardReadyMs,
$engine.averageScrollReadyMs,
$engine.qualityDecision)
}
}
}
function Invoke-AssessmentSelfTest {
$synthetic = @(
[pscustomobject]@{
engine = "current"
limit = 100
status = "done"
parsed = 100
review = 0
misses = 0
activeAverageMsPerParsed = 500
averageMsPerParsed = 520
activeProjectedMsFor100 = 50000
averageOcrMs = 180
averageCaptureMs = 120
averageCaptureRoundTripMs = 260
averageCaptureRoundTripOverheadMs = 140
averageCardReadyMs = 100
averageScrollReadyMs = 50
},
[pscustomobject]@{
engine = "high-review"
limit = 100
status = "done"
parsed = 100
review = 22
misses = 0
activeAverageMsPerParsed = 300
averageMsPerParsed = 330
activeProjectedMsFor100 = 30000
averageOcrMs = 100
averageCaptureMs = 90
averageCaptureRoundTripMs = 190
averageCaptureRoundTripOverheadMs = 100
averageCardReadyMs = 40
averageScrollReadyMs = 20
},
[pscustomobject]@{
engine = "current"
limit = 20
status = "done"
parsed = 20
review = 1
misses = 0
activeAverageMsPerParsed = 390
averageMsPerParsed = 405
activeProjectedMsFor100 = 39000
averageOcrMs = 150
averageCaptureMs = 130
averageCaptureRoundTripMs = 280
averageCaptureRoundTripOverheadMs = 150
averageCardReadyMs = 80
averageScrollReadyMs = 0
},
[pscustomobject]@{
engine = "broken-fast"
limit = 45
status = "done"
parsed = 45
review = 0
misses = 3
activeAverageMsPerParsed = 300
averageMsPerParsed = 330
activeProjectedMsFor100 = 30000
averageOcrMs = 100
averageCaptureMs = 90
averageCaptureRoundTripMs = 190
averageCaptureRoundTripOverheadMs = 100
averageCardReadyMs = 40
averageScrollReadyMs = 20
},
[pscustomobject]@{
engine = "current"
limit = 45
status = "done"
parsed = 45
review = 1
misses = 0
activeAverageMsPerParsed = 600
averageMsPerParsed = 650
activeProjectedMsFor100 = 60000
averageOcrMs = 230
averageCaptureMs = 130
averageCaptureRoundTripMs = 300
averageCaptureRoundTripOverheadMs = 170
averageCardReadyMs = 160
averageScrollReadyMs = 50
}
)
$assessment = New-PerformanceAssessment -Summaries $synthetic
$goal100 = $assessment.goal100
$limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0]
$limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]
if ($goal100.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=100, got '$($goal100.winnerEngine)'."
}
if (-not $goal100.winnerQualified) {
throw "Assessment self-test failed: expected limit=100 winner to be qualified."
}
if ($assessment.goal100Decision -ne "qualified: winner=current") {
throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'."
}
if ($limit20.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'."
}
if (-not $limit20.winnerQualified) {
throw "Assessment self-test failed: expected limit=20 winner to be qualified."
}
if ($limit45.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
}
if (@($goal100.engines | Where-Object { $_.engine -eq "high-review" })[0].qualityDecision -ne "not-qualified: review rate above 15%") {
throw "Assessment self-test failed: expected high-review run to be rejected."
}
if (@($limit45.engines | Where-Object { $_.engine -eq "broken-fast" })[0].qualityDecision -ne "not-qualified: miss rate above 2%") {
throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
}
$missingGoalAssessment = New-PerformanceAssessment -Summaries @(
[pscustomobject]@{
engine = "current"
limit = 20
status = "done"
parsed = 20
review = 0
misses = 0
activeAverageMsPerParsed = 500
averageMsPerParsed = 520
activeProjectedMsFor100 = 50000
averageOcrMs = 180
averageCaptureMs = 120
averageCaptureRoundTripMs = 260
averageCaptureRoundTripOverheadMs = 140
averageCardReadyMs = 100
averageScrollReadyMs = 50
}
)
if ($missingGoalAssessment.goal100Decision -ne "not-run: missing 100-artifact assessment") {
throw "Assessment self-test failed: expected missing 100 run to be not-run, got '$($missingGoalAssessment.goal100Decision)'."
}
Write-PerformanceAssessment $assessment
Write-Host "Assessment self-test passed." -ForegroundColor Green
return $assessment
}
function Invoke-OcrBenchmark {
param(
[int]$Limit,
[string]$Engine,
[string]$Profile
)
Write-Host "Warming current OCR workers..."
$warmCurrent = Invoke-DevJson "/scanner/ocr/warmup"
Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null
Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit"
$benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&profile=$Profile"
Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null
$summary = $benchmark.summary
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$summary.engine,
$summary.averageMs,
$summary.averageOcrMs,
$summary.p50Ms,
$summary.p90Ms,
$summary.projectedMs.artifacts100,
$summary.skippedOcrCaptures,
$summary.workerPoolSize)
return $benchmark
}
function Convert-ReviewSamplesSummary([object]$ReviewPayload) {
$samples = @()
foreach ($record in @($ReviewPayload.samples)) {
$parsed = $record.sample.parsed
$capture = $record.sample.capture
$ocr = @()
foreach ($entry in @($capture.ocr)) {
$ocr += [pscustomobject]@{
id = $entry.id
label = $entry.label
text = $entry.text
confidence = $entry.confidence
}
}
$samples += [pscustomobject]@{
savedAt = $record.savedAt
reason = $record.sample.reason
parsed = [pscustomobject]@{
name = $parsed.name
slot = $parsed.slot
level = $parsed.level
mainStat = $parsed.mainStat
mainValue = $parsed.mainValue
setName = $parsed.setName
equipped = $parsed.equipped
confidence = $parsed.confidence
notes = $parsed.notes
fields = $parsed.fields
}
capture = [pscustomobject]@{
name = $capture.name
width = $capture.width
height = $capture.height
captureTarget = $capture.captureTarget
capturedAt = $capture.capturedAt
inventoryGrid = if ($capture.inventoryGrid) {
[pscustomobject]@{
rows = $capture.inventoryGrid.rows
cols = $capture.inventoryGrid.cols
confidence = $capture.inventoryGrid.confidence
source = $capture.inventoryGrid.source
}
} else { $null }
inventoryCount = $capture.inventoryCount
locked = $capture.locked
ocr = $ocr
}
}
}
return [pscustomobject]@{
ok = $ReviewPayload.ok
total = $ReviewPayload.total
path = $ReviewPayload.path
samples = $samples
}
}
function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
$startedAt = Get-Date
$pollIndex = 0
$lastStatus = $null
$observedMatchingRun = $false
while ($true) {
Start-Sleep -Seconds $PollIntervalSeconds
$pollIndex += 1
$statusPayload = Get-ScannerStatus
$lastStatus = $statusPayload
Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null
$running = [bool]$statusPayload.status.running
$summaryTarget = if ($statusPayload.status.summary) { [int]$statusPayload.status.summary.targetCount } else { 0 }
$scanStart = @($statusPayload.status.diagnosticEvents | Where-Object { $_.phase -eq "scan-start" } | Select-Object -Last 1)
$scanStartDetails = if ($scanStart.Count -gt 0) { $scanStart[0].details } else { $null }
$scanStartLimit = if ($scanStartDetails -and $scanStartDetails.scanLimit) { [int]$scanStartDetails.scanLimit } else { 0 }
$scanStartEngine = if ($scanStartDetails -and $scanStartDetails.ocrEngine) { [string]$scanStartDetails.ocrEngine } else { "" }
$matchesScanStart = $scanStartLimit -eq $Limit -and ($scanStartEngine -eq "" -or $scanStartEngine -eq $Engine)
$matchesFinalSummary = $summaryTarget -eq $Limit
if ($running -and ($matchesScanStart -or $matchesFinalSummary)) {
$observedMatchingRun = $true
}
if (-not $running) {
if ($observedMatchingRun -or $matchesScanStart -or $matchesFinalSummary) {
return $statusPayload
}
Write-Host "Waiting for scanner run limit=$Limit engine=$Engine to appear; ignoring unrelated idle status." -ForegroundColor DarkGray
}
$elapsed = ((Get-Date) - $startedAt).TotalSeconds
if ($elapsed -gt $TimeoutSeconds) {
$stop = Invoke-DevJson "/scanner/stop"
Save-Json "scan-$Engine-limit-$Limit-timeout-stop" $stop | Out-Null
throw "Scanner timed out after $TimeoutSeconds seconds for limit=$Limit engine=$Engine. Stop command was sent."
}
}
}
if ($SelfTestAssessment) {
Invoke-AssessmentSelfTest | Out-Null
exit 0
}
$stamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$RunDir = Join-Path $OutputRoot $stamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
if ($GoalRun) {
$Limits = @(2, 5, 20, 45, 100)
$BenchmarkOcr = $true
}
if ($RepeatabilityRun) {
$Limits = @(20, 45, 100)
}
$Limits = @($Limits | ForEach-Object {
$limit = [int]$_
if ($limit -lt 1 -or $limit -gt 1800) {
throw "Refusing unsafe scan limit '$limit'. Expected a bounded value from 1 to 1800."
}
$limit
})
$ScanEngines = @($ScanEngine)
$RunSummaries = @()
$transcriptPath = Join-Path $RunDir "transcript.log"
try {
Start-Transcript -Path $transcriptPath -Append | Out-Null
} catch {
Write-Warning "Could not start transcript: $($_.Exception.Message)"
}
try {
Write-Host "Live soak output: $RunDir"
Write-Host "Checking dev control server at $BaseUrl"
$health = Invoke-DevJson "/health"
Save-Json "00-health" $health | Out-Null
Assert-CurrentAppBuild $health
if ($health.appBuild) {
Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)"
if ($health.appBuild.expectedOcrWorkerPoolSize -lt 4) {
Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for scanner timing." -ForegroundColor Yellow
}
}
$initialStatus = Get-ScannerStatus
Save-Json "01-status-before" $initialStatus | Out-Null
if ($initialStatus.status.runtimeInfo) {
$runtime = $initialStatus.status.runtimeInfo
Write-Host "Runtime: elevated=$($runtime.isElevated), genshinFound=$($runtime.genshinFound), target=$($runtime.targetProcess)"
} else {
Write-Host "Runtime info is not published yet. Open the app scanner view before running broad scans." -ForegroundColor Yellow
}
if ($BenchmarkOcr) {
Invoke-OcrBenchmark -Limit $BenchmarkLimit -Engine $BenchmarkEngine -Profile $BenchmarkProfile | Out-Null
}
if (-not $SkipSmartCapture) {
Write-Host "Capturing smart preflight snapshot without OCR..."
$capture = Invoke-DevJson "/capture/smart?skipOcr=1"
Save-Json "02-smart-capture-skip-ocr" $capture | Out-Null
}
foreach ($index in $ProbeIndices) {
Write-Host "Running probe click index=$index"
$probe = Invoke-DevJson "/automation/probe-click?index=$index"
Save-Json "probe-index-$index" $probe | Out-Null
if (-not (Test-ProbeSucceeded $probe)) {
Write-Host "Probe index=$index did not fully pass. Review the saved JSON before broader scans." -ForegroundColor Yellow
if (-not $ContinueAfterBlocked) {
throw "Stopping after failed probe index=$index. Re-run with -ContinueAfterBlocked only if you deliberately want to continue."
}
} elseif (-not $probe.ok -and $probe.changed) {
Write-Host "Probe index=$index changed the detail panel even though helper cursor/click readback was not clean; continuing." -ForegroundColor Yellow
} elseif (-not $probe.ok -and $probe.click -and $probe.click.clicked -and $probe.click.moved -and -not $probe.click.inputBlocked) {
Write-Host "Probe index=$index delivered input but detail did not change; continuing because the target may already be selected." -ForegroundColor Yellow
}
}
foreach ($engine in $ScanEngines) {
foreach ($limit in $Limits) {
if ($limit -lt 1) { continue }
Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
$start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit"
Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null
$finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
Save-Json "scan-$engine-limit-$limit-final" $finalStatus | Out-Null
$summary = Get-CompletedScanSummary $finalStatus $limit
Write-Host "engine=$engine limit=$limit summary: status=$($summary.status), attempted=$($summary.attempted), verified=$($summary.verified), parsed=$($summary.parsed), stored=$($summary.stored), review=$($summary.review), misses=$($summary.misses), pages=$($summary.pages)"
$timing = Convert-ScanTimingSummary $finalStatus $limit $engine
$RunSummaries += $timing
Write-ScanTimingLine $timing
if (($summary.status -eq "blocked" -or $summary.status -eq "stopped") -and -not $ContinueAfterBlocked) {
throw "Stopping after scan status '$($summary.status)' for limit=$limit engine=$engine."
}
}
}
$review = Invoke-DevJson "/review/samples?limit=30"
Save-Json "review-samples-tail-summary" (Convert-ReviewSamplesSummary $review) | Out-Null
if ($SaveFullReviewSamples) {
Save-Json "review-samples-tail-full" $review | Out-Null
}
$afterStatus = Get-ScannerStatus
Save-Json "99-status-after" $afterStatus | Out-Null
Save-Json "scan-run-summary" ([pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goalRun = [bool]$GoalRun
scanEngine = $ScanEngine
scanEngines = $ScanEngines
benchmarkOcr = [bool]$BenchmarkOcr
limits = $Limits
summaries = $RunSummaries
}) | Out-Null
if ($RunSummaries.Count -gt 0) {
$assessment = New-PerformanceAssessment -Summaries $RunSummaries
Save-Json "scan-performance-assessment" $assessment | Out-Null
Write-PerformanceAssessment $assessment
$csvPath = Join-Path $RunDir "scan-run-summary.csv"
$RunSummaries | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
Write-Host "Timing summary CSV: $csvPath"
}
Write-Host "Live soak complete. Output: $RunDir" -ForegroundColor Green
} catch {
Write-Host "Live soak failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Output so far: $RunDir" -ForegroundColor Yellow
exit 1
} finally {
try {
Stop-Transcript | Out-Null
} catch {
}
}
+265
View File
@@ -0,0 +1,265 @@
param(
[string]$BaseUrl = "http://127.0.0.1:17317",
[int]$Limit = 2,
[ValidateSet("artifacts", "weapons", "characters", "materials")]
[string]$Category = "artifacts",
[int]$PollIntervalSeconds = 1,
[int]$TimeoutSeconds = 180,
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\native-live-smoke"),
[switch]$SkipProbe,
[switch]$Persist
)
$ErrorActionPreference = "Stop"
function ConvertTo-SafeFilePart([string]$Value) {
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
$safe = $safe.Trim("-")
if ($safe.Length -eq 0) { return "item" }
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
return $safe
}
function Invoke-DevJson([string]$Path) {
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
try {
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
} catch {
$response = $_.Exception.Response
if ($response) {
$stream = $response.GetResponseStream()
if ($stream) {
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if (-not [string]::IsNullOrWhiteSpace($body)) {
try {
return $body | ConvertFrom-Json
} catch {
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
}
}
}
}
throw
}
}
function Save-Json([string]$Name, [object]$Payload) {
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
$Payload | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Test-ProbeSucceeded([object]$ProbePayload) {
if ($ProbePayload.ok) { return $true }
if ($ProbePayload.changed) { return $true }
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
return $false
}
function Get-NativeScanner([object]$StatusPayload) {
if ($StatusPayload.nativeScanner) { return $StatusPayload.nativeScanner }
if ($StatusPayload.scanner) { return $StatusPayload.scanner }
return $null
}
function Get-ExpectedAppSignature() {
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
$mainSource = Get-Content -LiteralPath $mainPath -Raw
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
if (-not $match.Success) {
throw "APP_RUNTIME_SIGNATURE not found in electron/main.ts."
}
return $match.Groups[1].Value
}
function UriEscape([string]$Value) {
return [System.Uri]::EscapeDataString($Value)
}
$safeLimit = [Math]::Max(1, [Math]::Min(100, $Limit))
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$RunDir = Join-Path $OutputRoot $timestamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
$summary = [ordered]@{
ok = $false
createdAt = (Get-Date).ToString("o")
limit = $safeLimit
category = $Category
expectedSignature = ""
outputDir = $RunDir
persistRequested = [bool]$Persist
health = $null
data = $null
preflight = $null
probe = $null
finalStatus = $null
process = $null
results = $null
errors = @()
}
try {
$expectedSignature = Get-ExpectedAppSignature
$summary.expectedSignature = $expectedSignature
$health = Invoke-DevJson "/health"
$summary.health = @{
ok = [bool]$health.ok
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
}
Save-Json "01-health" $health | Out-Null
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
}
$data = Invoke-DevJson "/scanner/native/data"
$summary.data = @{
ok = [bool]$data.ok
version = [string]$data.status.version
totalEntries = [int]$data.status.totalEntries
valid = [bool]$data.status.valid
}
Save-Json "02-native-data" $data | Out-Null
if (-not $data.ok) {
throw "Native IK data check failed."
}
$preflight = Invoke-DevJson "/scanner/native/preflight?category=$Category"
$summary.preflight = @{
ok = [bool]$preflight.ok
ready = [bool]$preflight.status.ready
category = [string]$preflight.status.category
categoryReady = [bool]$preflight.status.categoryReady
genshinFound = [bool]$preflight.status.genshinFound
isSixteenNine = [bool]$preflight.status.isSixteenNine
gridCount = [int]$preflight.status.grid.count
visualReady = if ($preflight.status.visual) { [bool]$preflight.status.visual.ready } else { $null }
visualWhitePct = if ($preflight.status.visual) { [double]$preflight.status.visual.whitePct } else { $null }
visualDarkPct = if ($preflight.status.visual) { [double]$preflight.status.visual.darkPct } else { $null }
visualColorPct = if ($preflight.status.visual) { [double]$preflight.status.visual.colorPct } else { $null }
visualLumaStdDev = if ($preflight.status.visual) { [double]$preflight.status.visual.lumaStdDev } else { $null }
blockReason = [string]$preflight.status.blockReason
}
Save-Json "03-native-preflight" $preflight | Out-Null
if (-not $preflight.ok) {
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
}
if (-not $SkipProbe) {
$probe = Invoke-DevJson "/automation/probe-click?index=1"
$summary.probe = @{
ok = [bool](Test-ProbeSucceeded $probe)
endpointOk = [bool]$probe.ok
changed = [bool]$probe.changed
}
Save-Json "04-probe-click" $probe | Out-Null
if (-not (Test-ProbeSucceeded $probe)) {
throw "Probe click did not prove safe input delivery."
}
}
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
Save-Json "05-native-start" $start | Out-Null
$startedScanner = Get-NativeScanner $start
if ($null -eq $startedScanner) {
throw "Native scanner start returned no scanner payload."
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$polls = @()
do {
Start-Sleep -Seconds $PollIntervalSeconds
$status = Invoke-DevJson "/scanner/status"
$scanner = Get-NativeScanner $status
$polls += [pscustomobject]@{
at = (Get-Date).ToString("o")
status = if ($scanner) { [string]$scanner.status } else { "missing" }
running = if ($scanner) { [bool]$scanner.running } else { $false }
captured = if ($scanner) { [int]$scanner.captured } else { 0 }
activeMs = if ($scanner) { [int]$scanner.activeMs } else { 0 }
message = if ($scanner) { [string]$scanner.message } else { "missing native scanner payload" }
}
if ($null -ne $scanner -and -not $scanner.running) {
break
}
} while ((Get-Date) -lt $deadline)
Save-Json "06-native-polls" $polls | Out-Null
$final = Invoke-DevJson "/scanner/status"
Save-Json "07-native-final-status" $final | Out-Null
$finalScanner = Get-NativeScanner $final
if ($null -eq $finalScanner) {
throw "Native scanner final status returned no scanner payload."
}
$summary.finalStatus = @{
status = [string]$finalScanner.status
running = [bool]$finalScanner.running
runDir = [string]$finalScanner.runDir
captured = [int]$finalScanner.captured
clicked = [int]$finalScanner.clicked
pages = [int]$finalScanner.pages
activeMs = [int]$finalScanner.activeMs
message = [string]$finalScanner.message
}
if ($finalScanner.running) {
throw "Native scanner did not finish before timeout."
}
if ([string]$finalScanner.status -ne "done") {
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
}
if ([int]$finalScanner.captured -lt $safeLimit) {
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
}
$runDirParam = UriEscape([string]$finalScanner.runDir)
$persistParam = if ($Persist) { "1" } else { "0" }
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
Save-Json "08-native-process" $process | Out-Null
$summary.process = @{
ok = [bool]$process.ok
processed = [int]$process.status.processed
parsed = [int]$process.status.parsed
review = [int]$process.status.review
errors = [int]$process.status.errors
stored = [int]$process.status.stored
persisted = [bool]$process.status.persisted
queueConcurrency = [int]$process.status.queueConcurrency
elapsedMs = [int]$process.status.elapsedMs
}
if (-not $process.ok) {
throw "Native post-capture processing failed."
}
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
Save-Json "09-native-results" $results | Out-Null
$summary.results = @{
ok = [bool]$results.ok
total = [int]$results.status.total
loaded = [int]$results.status.results.Count
}
if (-not $results.ok) {
throw "Native scan results could not be loaded."
}
$summary.ok = $true
} catch {
$summary.errors += [string]$_.Exception.Message
throw
} finally {
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
Write-Host "Native live smoke summary: $summaryPath"
if ($summary.ok) {
Write-Host ("ok limit={0} captured={1} parsed={2} review={3} errors={4} stored={5} persisted={6}" -f `
$summary.limit,
$summary.finalStatus.captured,
$summary.process.parsed,
$summary.process.review,
$summary.process.errors,
$summary.process.stored,
$summary.process.persisted)
}
}
+109
View File
@@ -0,0 +1,109 @@
const fs = require("node:fs");
const path = require("node:path");
const EVAL_FIELDS = new Set(["name", "slot", "level", "mainStat", "mainValue", "setName", "equipped", "substats"]);
function argValue(name, fallback = "") {
const prefix = `--${name}=`;
const match = process.argv.find((entry) => entry.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function parseExpectedFields() {
const inlineJson = argValue("expect-json");
const expectFile = argValue("expect-file");
if (!inlineJson && !expectFile) {
throw new Error("Missing expected labels. Pass --expect-json=... or --expect-file=...");
}
const raw = (inlineJson || fs.readFileSync(path.resolve(expectFile), "utf8")).replace(/^\uFEFF/, "");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Expected labels must be a JSON object.");
}
const expect = {};
for (const [field, value] of Object.entries(parsed)) {
if (!EVAL_FIELDS.has(field)) throw new Error(`Unknown expected field: ${field}`);
if (field === "level") {
if (!Number.isInteger(value) || value < 0) throw new Error("Expected level must be a non-negative integer.");
expect[field] = value;
continue;
}
if (field === "substats") {
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim())) {
throw new Error("Expected substats must be a non-empty string array.");
}
expect[field] = value;
continue;
}
if (typeof value !== "string" || !value.trim()) throw new Error(`Expected ${field} must be a non-empty string.`);
expect[field] = value;
}
if (Object.keys(expect).length === 0) throw new Error("Expected labels must include at least one field.");
return expect;
}
function loadCandidate(inputPath, candidateId) {
const payload = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8"));
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
const candidate = candidates.find((entry) => entry.id === candidateId);
if (!candidate) throw new Error(`Candidate not found: ${candidateId}`);
if (!candidate.ocr || typeof candidate.ocr !== "object" || Object.keys(candidate.ocr).length === 0) {
throw new Error(`Candidate has no OCR payload: ${candidateId}`);
}
return candidate;
}
function stableId(value) {
return String(value || "")
.replace(/[^0-9A-Za-z]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "review-case";
}
function objectLiteral(value, indent = 2) {
return JSON.stringify(value, null, indent).replace(/"([A-Za-z_$][0-9A-Za-z_$]*)":/g, "$1:");
}
function snippetFor(candidate, expect) {
const id = stableId(argValue("id", `confirmed-${candidate.id}`));
const entry = {
id,
confirmed: true,
ocr: candidate.ocr,
expect,
meta: {
source: "review-sample",
resolution: candidate.resolution || undefined,
note: `${candidate.reason || "review-sample"} | savedAt=${candidate.savedAt || "unknown"} | sourceCandidate=${candidate.id}`,
},
};
return `${objectLiteral(entry, 2)},\n`;
}
function defaultInputPath() {
return path.join(process.cwd(), "outputs", "review-eval-candidates", "review-eval-candidates.json");
}
function main() {
const inputPath = argValue("input", defaultInputPath());
const candidateId = argValue("candidate");
if (!candidateId) throw new Error("Missing candidate id. Pass --candidate=<id>.");
const outputPath = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates", `${stableId(candidateId)}.confirmed.ts`)));
const candidate = loadCandidate(inputPath, candidateId);
const expect = parseExpectedFields();
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const snippet = snippetFor(candidate, expect);
fs.writeFileSync(outputPath, snippet, "utf8");
console.log(JSON.stringify({ ok: true, candidateId, outputPath, labeledFields: Object.keys(expect) }, null, 2));
}
if (require.main === module) {
main();
}
module.exports = {
loadCandidate,
parseExpectedFields,
snippetFor,
stableId,
};
+220
View File
@@ -0,0 +1,220 @@
const fs = require("node:fs");
const path = require("node:path");
function argValue(name, fallback = "") {
const prefix = `--${name}=`;
const match = process.argv.find((entry) => entry.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function hasFlag(name) {
return process.argv.includes(`--${name}`);
}
function defaultAssessmentRoot() {
return path.join(process.cwd(), "outputs", "live-soak");
}
function findLatestAssessment(rootDir = defaultAssessmentRoot()) {
const resolvedRoot = path.resolve(rootDir);
if (!fs.existsSync(resolvedRoot)) throw new Error(`Assessment root not found: ${resolvedRoot}`);
const candidates = fs.readdirSync(resolvedRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const assessmentPath = path.join(resolvedRoot, entry.name, "scan-performance-assessment.json");
if (!fs.existsSync(assessmentPath)) return null;
const stat = fs.statSync(assessmentPath);
return { assessmentPath, runName: entry.name, mtimeMs: stat.mtimeMs };
})
.filter(Boolean)
.sort((left, right) => {
const mtimeDiff = right.mtimeMs - left.mtimeMs;
if (Math.abs(mtimeDiff) > 1) return mtimeDiff;
return right.runName.localeCompare(left.runName);
});
if (candidates.length === 0) throw new Error(`No scan-performance-assessment.json found under: ${resolvedRoot}`);
return candidates[0].assessmentPath;
}
function loadAssessment(inputPath) {
if (!inputPath) throw new Error("Missing assessment file. Pass --input=<scan-performance-assessment.json>.");
return JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8").replace(/^\uFEFF/, ""));
}
function findLimitAssessment(assessment, limit) {
if (limit === 100 && assessment?.goal100) return assessment.goal100;
const limits = Array.isArray(assessment?.limits) ? assessment.limits : [];
return limits.find((entry) => Number(entry?.limit) === limit) || null;
}
function validateAssessment(assessment, options = {}) {
const errors = [];
const expectedWinner = options.expectedWinner || "any";
const expectedLimit = options.limit === undefined ? 100 : Number(options.limit);
const maxActiveAverageMsPerParsed = options.maxActiveAverageMsPerParsed === undefined
? null
: Number(options.maxActiveAverageMsPerParsed);
const maxCaptureRoundTripOverheadMs = options.maxCaptureRoundTripOverheadMs === undefined
? null
: Number(options.maxCaptureRoundTripOverheadMs);
if (!assessment || typeof assessment !== "object") {
return { ok: false, errors: ["Assessment must be a JSON object."] };
}
if (!Number.isInteger(expectedLimit) || expectedLimit < 1) {
errors.push(`--limit must be a positive integer, got ${options.limit}.`);
}
if (maxActiveAverageMsPerParsed !== null && (!Number.isFinite(maxActiveAverageMsPerParsed) || maxActiveAverageMsPerParsed <= 0)) {
errors.push(`--max-active-average-ms must be a positive finite number, got ${options.maxActiveAverageMsPerParsed}.`);
}
if (maxCaptureRoundTripOverheadMs !== null && (!Number.isFinite(maxCaptureRoundTripOverheadMs) || maxCaptureRoundTripOverheadMs < 0)) {
errors.push(`--max-capture-roundtrip-overhead-ms must be a non-negative finite number, got ${options.maxCaptureRoundTripOverheadMs}.`);
}
const limitAssessment = findLimitAssessment(assessment, expectedLimit);
if (!limitAssessment || typeof limitAssessment !== "object") {
errors.push(`Missing limit=${expectedLimit} assessment.`);
}
if (expectedLimit === 100 && assessment.goal100Decision !== `qualified: winner=${limitAssessment?.winnerEngine}`) {
errors.push(`goal100Decision is not a qualified 100-artifact run: ${assessment.goal100Decision || "<missing>"}`);
}
if (limitAssessment?.limit !== expectedLimit) {
errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? "<missing>"}.`);
}
if (limitAssessment?.winnerQualified !== true) errors.push(`limit=${expectedLimit}.winnerQualified must be true.`);
if (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) {
errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? "<missing>"}'.`);
}
const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : [];
if (engines.length < 1) {
errors.push(`limit=${expectedLimit} must include at least one engine result.`);
}
const winner = engines.find((entry) => entry?.engine === limitAssessment?.winnerEngine);
if (!winner) {
errors.push(`Winner engine is missing from limit=${expectedLimit}.engines: ${limitAssessment?.winnerEngine ?? "<missing>"}.`);
} else {
const winnerMissRate = Number(winner.missRate);
const winnerReviewRate = Number(winner.reviewRate);
const summaryWinnerMissRate = Number(limitAssessment?.winnerMissRate);
const summaryWinnerReviewRate = Number(limitAssessment?.winnerReviewRate);
const winnerActiveAverageMsPerParsed = Number(limitAssessment?.winnerActiveAverageMsPerParsed);
const winnerActiveProjectedMsFor100 = Number(limitAssessment?.winnerActiveProjectedMsFor100);
const winnerAverageCaptureRoundTripOverheadMs = Number(limitAssessment?.winnerAverageCaptureRoundTripOverheadMs);
if (winner.qualified !== true) errors.push("Winner engine result must be qualified.");
if (!Number.isFinite(winnerMissRate)) {
errors.push(`Winner missRate must be a finite number, got ${winner.missRate ?? "<missing>"}.`);
} else if (winnerMissRate > 0.02) {
errors.push(`Winner missRate exceeds 2%: ${winner.missRate}.`);
}
if (!Number.isFinite(winnerReviewRate)) {
errors.push(`Winner reviewRate must be a finite number, got ${winner.reviewRate ?? "<missing>"}.`);
} else if (winnerReviewRate > 0.15) {
errors.push(`Winner reviewRate exceeds 15%: ${winner.reviewRate}.`);
}
if (!Number.isFinite(summaryWinnerMissRate)) {
errors.push(`Winner summary missRate must be a finite number, got ${limitAssessment?.winnerMissRate ?? "<missing>"}.`);
} else if (summaryWinnerMissRate > 0.02) {
errors.push(`Winner summary missRate exceeds 2%: ${limitAssessment.winnerMissRate}.`);
}
if (!Number.isFinite(summaryWinnerReviewRate)) {
errors.push(`Winner summary reviewRate must be a finite number, got ${limitAssessment?.winnerReviewRate ?? "<missing>"}.`);
} else if (summaryWinnerReviewRate > 0.15) {
errors.push(`Winner summary reviewRate exceeds 15%: ${limitAssessment.winnerReviewRate}.`);
}
if (!Number.isFinite(winnerActiveAverageMsPerParsed) || winnerActiveAverageMsPerParsed <= 0) {
errors.push(`Winner active average timing must be a positive finite number, got ${limitAssessment?.winnerActiveAverageMsPerParsed ?? "<missing>"}.`);
} else if (maxActiveAverageMsPerParsed !== null && Number.isFinite(maxActiveAverageMsPerParsed) && winnerActiveAverageMsPerParsed > maxActiveAverageMsPerParsed) {
errors.push(`Winner active average timing exceeds ${maxActiveAverageMsPerParsed}ms: ${winnerActiveAverageMsPerParsed}.`);
}
if (!Number.isFinite(winnerActiveProjectedMsFor100) || winnerActiveProjectedMsFor100 <= 0) {
errors.push(`Winner projected100 timing must be a positive finite number, got ${limitAssessment?.winnerActiveProjectedMsFor100 ?? "<missing>"}.`);
}
if (maxCaptureRoundTripOverheadMs !== null && Number.isFinite(maxCaptureRoundTripOverheadMs)) {
if (!Number.isFinite(winnerAverageCaptureRoundTripOverheadMs)) {
errors.push(`Winner capture roundtrip overhead must be a finite number, got ${limitAssessment?.winnerAverageCaptureRoundTripOverheadMs ?? "<missing>"}.`);
} else if (winnerAverageCaptureRoundTripOverheadMs > maxCaptureRoundTripOverheadMs) {
errors.push(`Winner capture roundtrip overhead exceeds ${maxCaptureRoundTripOverheadMs}ms: ${winnerAverageCaptureRoundTripOverheadMs}.`);
}
}
}
return {
ok: errors.length === 0,
errors,
createdAt: assessment.createdAt || "",
limit: expectedLimit,
winnerEngine: limitAssessment?.winnerEngine,
winnerActiveAverageMsPerParsed: limitAssessment?.winnerActiveAverageMsPerParsed,
winnerActiveProjectedMsFor100: limitAssessment?.winnerActiveProjectedMsFor100,
winnerAverageCaptureRoundTripMs: limitAssessment?.winnerAverageCaptureRoundTripMs,
winnerAverageCaptureRoundTripOverheadMs: limitAssessment?.winnerAverageCaptureRoundTripOverheadMs,
winnerMissRate: limitAssessment?.winnerMissRate,
winnerReviewRate: limitAssessment?.winnerReviewRate,
};
}
function formatSummary(result) {
const status = result.ok ? "PASS" : "FAIL";
const lines = [
`scan assessment: ${status}`,
`input: ${result.inputPath || "unknown"}`,
`createdAt: ${result.createdAt || "unknown"}`,
`limit: ${result.limit ?? "unknown"}`,
`winner: ${result.winnerEngine || "unknown"}`,
`activeAvg: ${result.winnerActiveAverageMsPerParsed ?? "unknown"}ms/artifact`,
`projected100: ${result.winnerActiveProjectedMsFor100 ?? "unknown"}ms`,
`captureRoundTrip: ${result.winnerAverageCaptureRoundTripMs ?? "unknown"}ms`,
`captureRoundTripOverhead: ${result.winnerAverageCaptureRoundTripOverheadMs ?? "unknown"}ms`,
`missRate: ${result.winnerMissRate ?? "unknown"}`,
`reviewRate: ${result.winnerReviewRate ?? "unknown"}`,
];
if (!result.ok) {
lines.push("errors:");
for (const error of result.errors) lines.push(`- ${error}`);
}
return lines.join("\n");
}
function main() {
const inputPath = hasFlag("latest")
? findLatestAssessment(argValue("root", defaultAssessmentRoot()))
: argValue("input", process.argv[2] || "");
const expectedWinner = argValue("expect-winner", "any");
if (!["any", "current"].includes(expectedWinner)) {
throw new Error("--expect-winner must be one of: any, current.");
}
const limit = Number(argValue("limit", "100"));
const assessment = loadAssessment(inputPath);
const maxActiveAverageMsPerParsed = argValue("max-active-average-ms", "");
const maxCaptureRoundTripOverheadMs = argValue("max-capture-roundtrip-overhead-ms", "");
const result = validateAssessment(assessment, {
expectedWinner,
limit,
maxActiveAverageMsPerParsed: maxActiveAverageMsPerParsed === "" ? undefined : maxActiveAverageMsPerParsed,
maxCaptureRoundTripOverheadMs: maxCaptureRoundTripOverheadMs === "" ? undefined : maxCaptureRoundTripOverheadMs,
});
const payload = { inputPath: path.resolve(inputPath), ...result };
console.log(hasFlag("summary") ? formatSummary(payload) : JSON.stringify(payload, null, 2));
if (!result.ok) process.exit(1);
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
module.exports = {
findLatestAssessment,
findLimitAssessment,
formatSummary,
loadAssessment,
validateAssessment,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { confirmedReviewCorpus, ocrEvalCorpus, seedCorpus, validateConfirmedReviewCorpus } from ".";
describe("confirmed review corpus", () => {
it("contains only human-confirmed review labels", () => {
expect(validateConfirmedReviewCorpus()).toEqual([]);
});
it("does not duplicate eval case ids", () => {
const ids = ocrEvalCorpus.map((entry) => entry.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("is included in the full OCR eval corpus", () => {
expect(ocrEvalCorpus).toHaveLength(seedCorpus.length + confirmedReviewCorpus.length);
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { OcrEvalCase } from "../ocrEvalHarness";
export interface ConfirmedReviewEvalCase extends OcrEvalCase {
confirmed: true;
meta: NonNullable<OcrEvalCase["meta"]> & {
source: "review-sample";
note: string;
};
}
// Human-confirmed review samples belong here after their `expect` values were
// checked against the real artifact. Do not paste unconfirmed exporter output
// directly from outputs/review-eval-candidates/.
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [
{
id: "native-review-crown-befallen-decimal-loss",
confirmed: true,
ocr: {
"artifact-name": "Crown of the Befallen",
"artifact-slot": "Circlet of Logos",
"artifact-main-stat-label": "CRIT Rate",
"artifact-main-stat-value": "311%",
"artifact-level": "+20",
"artifact-substats": "+ HP+508\n- DEF+44\n- DEF+1.7%\n+ CRIT DMG+13.2%",
"artifact-set-effects": ":2-Piece Set: Increases Elementa\nMastery by 80.\nZ4-Piece Set: When nearby party\nmembers trigger Lunar",
"artifact-footer": "Equipped: Zibai",
},
expect: {
name: "Crown of the Befallen",
slot: "Circlet of Logos",
level: 20,
mainStat: "CRIT Rate",
mainValue: "31.1%",
setName: "Night of the Sky's Unveiling",
equipped: "Zibai",
substats: ["HP+508", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=6",
},
},
{
id: "native-review-viridescent-extra-digit",
confirmed: true,
ocr: {
"artifact-name": "Viridescent Arrow Feather",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-main-stat-value": "",
"artifact-level": "+20",
"artifact-substats": "- DEF+39\n+ HP+687\n+ HP+5.3%\n+ ATK+156.7%",
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
"artifact-footer": "Equipped: Sucrose",
},
expect: {
name: "Viridescent Arrow Feather",
slot: "Plume of Death",
level: 20,
mainStat: "ATK",
mainValue: "311",
setName: "Viridescent Venerer",
equipped: "Sucrose",
substats: ["DEF+39", "HP+687", "HP%+5.3%", "ATK%+15.7%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=60",
},
},
{
id: "native-review-gladiator-flat-hp-comma",
confirmed: true,
ocr: {
"artifact-name": "Gladiator's Intoxication",
"artifact-slot": "Goblet of Eonothem",
"artifact-main-stat-label": "Dendro DMG Bonus",
"artifact-main-stat-value": "46.6%",
"artifact-level": "+20",
"artifact-substats": "+ HP+1,165\n+ HP+5.8%\n+ CRIT Rate+3.9%\n- Energy Recharge+11.7%",
"artifact-set-effects": "2-Piece Set: ATK +18%.\n4-Piece Set: If the wielder of this artifact set uses a Sword",
"artifact-footer": "Equipped: Tighnari",
},
expect: {
name: "Gladiator's Intoxication",
slot: "Goblet of Eonothem",
level: 20,
mainStat: "Dendro DMG Bonus",
mainValue: "46.6%",
setName: "Gladiator's Finale",
equipped: "Tighnari",
substats: ["HP+1,165", "HP%+5.8%", "CRIT Rate+3.9%", "Energy Recharge+11.7%"],
},
meta: {
source: "review-sample",
resolution: "492x838",
note: "native-review-approved | run=20260709-223441 | sequence=76",
},
},
];
+23
View File
@@ -0,0 +1,23 @@
import { confirmedReviewCorpus, type ConfirmedReviewEvalCase } from "./confirmedReviewCorpus";
import { seedCorpus } from "./seedCorpus";
import type { OcrEvalCase } from "../ocrEvalHarness";
export { confirmedReviewCorpus, seedCorpus };
export type { ConfirmedReviewEvalCase };
export const ocrEvalCorpus: OcrEvalCase[] = [...seedCorpus, ...confirmedReviewCorpus];
export function validateConfirmedReviewCorpus(corpus: readonly ConfirmedReviewEvalCase[] = confirmedReviewCorpus) {
const errors: string[] = [];
const seen = new Set<string>();
for (const entry of corpus) {
if (seen.has(entry.id)) errors.push(`${entry.id}: duplicate id`);
seen.add(entry.id);
if (entry.confirmed !== true) errors.push(`${entry.id}: confirmed must be true`);
if (entry.meta.source !== "review-sample") errors.push(`${entry.id}: meta.source must be review-sample`);
if (!entry.meta.note?.trim()) errors.push(`${entry.id}: meta.note must describe the review source/reason`);
if (Object.keys(entry.expect).length === 0) errors.push(`${entry.id}: expect must label at least one field`);
if (Object.keys(entry.ocr).length === 0) errors.push(`${entry.id}: ocr must contain at least one field`);
}
return errors;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
const { formatSummary, parseWaitSeconds, validatePreflight } = require("../../scripts/live-preflight.cjs") as {
formatSummary: (result: unknown) => string;
parseWaitSeconds: (value: string) => number;
validatePreflight: (input: unknown) => { ok: boolean; errors: string[]; signature: string; isElevated?: boolean; genshinFound?: boolean };
};
function health(signature = "sig-current") {
return { ok: true, appBuild: { signature } };
}
function status(runtime = { isElevated: true, genshinFound: true, targetProcess: "GenshinImpact.exe", foregroundProcess: "GenshinImpact.exe" }) {
return { ok: true, status: { runtimeInfo: runtime } };
}
describe("live preflight script", () => {
it("accepts a matching elevated Genshin runtime", () => {
const result = validatePreflight({ health: health(), status: status(), expected: "sig-current" });
expect(result.ok).toBe(true);
expect(result.errors).toEqual([]);
expect(result.signature).toBe("sig-current");
});
it("rejects stale runtime signatures", () => {
const result = validatePreflight({ health: health("old"), status: status(), expected: "sig-current" });
expect(result.ok).toBe(false);
expect(result.errors.join("\n")).toContain("does not match");
});
it("rejects non-elevated or missing Genshin runtime by default", () => {
const result = validatePreflight({
health: health(),
status: status({ isElevated: false, genshinFound: false, targetProcess: "", foregroundProcess: "explorer.exe" }),
expected: "sig-current",
});
expect(result.ok).toBe(false);
expect(result.errors).toContain("Runtime is not elevated.");
expect(result.errors).toContain("Genshin process/window was not found.");
});
it("formats a concise human summary", () => {
const summary = formatSummary(validatePreflight({ health: health(), status: status(), expected: "sig-current" }));
expect(summary).toContain("live preflight: PASS");
expect(summary).toContain("elevated: yes");
expect(summary).toContain("genshin: yes");
});
it("parses optional wait seconds strictly", () => {
expect(parseWaitSeconds("")).toBe(0);
expect(parseWaitSeconds("120")).toBe(120);
expect(() => parseWaitSeconds("-1")).toThrow("--wait");
expect(() => parseWaitSeconds("1.5")).toThrow("--wait");
expect(() => parseWaitSeconds("soon")).toThrow("--wait");
});
});
@@ -0,0 +1,613 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createNativeScannerProcessingService } from "../../electron/services/nativeScannerProcessingService";
import type { NativeCaptureJobPayload } from "../../electron/services/nativeScannerProcessingService";
import type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
import { captureFromOcr } from "./ocrEvalHarness";
const tempDirs: string[] = [];
async function makeRunDir() {
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-process-"));
tempDirs.push(runDir);
return runDir;
}
async function writeJobs(runDir: string, jobs: NativeCaptureJobPayload[]) {
const lines = jobs.map((job) => JSON.stringify(job)).join("\n");
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
}
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
const absolutePath = path.join(runDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "test image placeholder", "utf8");
return { relativePath, absolutePath };
}
async function writePreviewPng(runDir: string, relativePath = "cards/preview.png") {
const absolutePath = path.join(runDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(
absolutePath,
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "base64"),
);
return { relativePath, absolutePath };
}
function safePlumeCapture() {
return captureFromOcr({
"artifact-name": "Pristine Plume of the Blessed",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-level": "+20",
"artifact-substats": "+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\n+ ATK+5.8%",
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
"artifact-footer": "Equipped: Aino",
});
}
function safePlumeIkCatalog(overrides: Partial<IkArtifactCatalog["artifacts"][number]["pieces"][number]> = {}): IkArtifactCatalog {
return {
artifacts: [
{
normalizedName: "silkenmoonsserenade",
setName: "Silken Moon's Serenade",
good: "SilkenMoonsSerenade",
pieces: [
{
slot: "plume",
artifactName: "Pristine Plume of the Blessed",
good: "PristinePlumeOfTheBlessed",
normalizedName: "pristineplumeoftheblessed",
...overrides,
},
],
},
],
};
}
function reviewOnlyCapture() {
return captureFromOcr({
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
"artifact-main-stat": "46.6%\n1S 2.5.8 J\nSe",
"artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%",
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
"artifact-footer": "",
});
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("native scanner processing service", () => {
it("reads native capture jobs and writes a processing report without persisting by default", async () => {
const runDir = await makeRunDir();
const { relativePath, absolutePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async (imagePath, job) => {
expect(imagePath).toBe(absolutePath);
expect(job.sequence).toBe(7);
return safePlumeCapture();
},
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun();
const loadedResults = await service.loadResults();
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
expect(status.processed).toBe(1);
expect(status.parsed).toBe(1);
expect(status.stored).toBe(0);
expect(status.persisted).toBe(false);
expect(status.results[0]).toMatchObject({
sequence: 7,
page: 2,
row: 1,
col: 3,
imagePath: absolutePath,
parsed: true,
artifactName: "Pristine Plume of the Blessed",
setName: "Silken Moon's Serenade",
slot: "Plume of Death",
persisted: false,
});
expect(report.processed).toBe(1);
expect(loadedResults).toMatchObject({
ok: true,
runDir,
path: path.join(runDir, "scan-results.json"),
total: 1,
});
expect(loadedResults.results[0]).toMatchObject({
sequence: 7,
extractionStatus: "parsed",
valueStatus: "deferred",
});
expect(scanResults).toHaveLength(1);
expect(scanResults[0]).toMatchObject({
runId: path.basename(runDir),
sequence: 7,
category: "artifact",
source: "native-ik-scan",
extractionStatus: "parsed",
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
setName: "Silken Moon's Serenade",
},
});
expect(savedRecords).toHaveLength(0);
});
it("returns a safe empty status when native scan results are not available", async () => {
const runDir = await makeRunDir();
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const loadedResults = await service.loadResults();
expect(loadedResults.ok).toBe(false);
expect(loadedResults.runDir).toBe(runDir);
expect(loadedResults.path).toBe(path.join(runDir, "scan-results.json"));
expect(loadedResults.results).toEqual([]);
});
it("loads native crop previews only from inside the run directory", async () => {
const runDir = await makeRunDir();
const { absolutePath } = await writePreviewPng(runDir);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const preview = await service.loadImage({ imagePath: absolutePath });
const blocked = await service.loadImage({ imagePath: path.join(os.tmpdir(), "outside.png") });
expect(preview).toMatchObject({
ok: true,
runDir,
path: absolutePath,
width: 1,
height: 1,
});
expect(preview.dataUrl).toMatch(/^data:image\/png;base64,/);
expect(blocked.ok).toBe(false);
expect(blocked.error).toMatch(/outside/);
});
it("marks missing crop images as review errors and skips OCR processing", async () => {
const runDir = await makeRunDir();
await writeJobs(runDir, [{ sequence: 1, relativePath: "cards/missing.png" }]);
let buildCalls = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
buildCalls += 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.processed).toBe(1);
expect(status.parsed).toBe(0);
expect(status.review).toBe(1);
expect(status.errors).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
sequence: 1,
parsed: false,
needsReview: true,
error: "Card crop image missing.",
});
expect(scanResults[0]).toMatchObject({
extractionStatus: "missing_crop",
valueStatus: "review",
needsReview: true,
error: "Card crop image missing.",
});
expect(buildCalls).toBe(0);
});
it("blocks non-artifact native jobs before OCR processing", async () => {
const runDir = await makeRunDir();
const { relativePath, absolutePath } = await writeCrop(runDir, "cards/weapon-0001.png");
await writeJobs(runDir, [{ sequence: 2, category: "weapons", page: 1, row: 0, col: 1, relativePath }]);
let buildCalls = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
buildCalls += 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status.ok).toBe(true);
expect(status.processed).toBe(1);
expect(status.parsed).toBe(0);
expect(status.review).toBe(1);
expect(status.errors).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
sequence: 2,
category: "weapon",
imagePath: absolutePath,
parsed: false,
needsReview: true,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
});
expect(scanResults[0]).toMatchObject({
category: "weapon",
extractionStatus: "error",
valueStatus: "review",
needsReview: true,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
});
expect(buildCalls).toBe(0);
});
it("persists safe parsed artifacts only when persistence is explicitly enabled", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const dryRun = await service.processRun({ persist: false });
const persistedRun = await service.processRun({ persist: true });
expect(dryRun.stored).toBe(0);
expect(dryRun.results[0].persisted).toBe(false);
expect(persistedRun.stored).toBe(1);
expect(persistedRun.results[0].persisted).toBe(true);
expect(persistedRun.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
expect(savedRecords).toHaveLength(1);
expect(savedRecords[0]).toMatchObject({
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
source: "native-ik-scan",
needsReview: false,
});
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
expect(scanResults[0]).toMatchObject({
extractionStatus: "parsed",
valueStatus: "deferred",
artifactRecordId: savedRecords[0].id,
persistedArtifact: true,
});
});
it("preserves the native capture timestamp in durable scan results", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
const capturedAt = "2026-07-09T22:30:50.5886316+02:00";
await writeJobs(runDir, [{ sequence: 1, relativePath, capturedAt }]);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun({ persist: false });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(scanResults[0].capturedAt).toBe(capturedAt);
});
it("promotes only explicitly selected safe results and writes a durable log", async () => {
const runDir = await makeRunDir();
const safeResult: StoredScanResultEntry = {
id: "safe-result",
runId: "run-1",
sequence: 1,
category: "artifact",
source: "native-ik-scan",
imagePath: "artifact-0001.png",
capturedAt: "2026-07-09T22:30:50.000Z",
extractionStatus: "parsed",
extractionConfidence: 96,
needsReview: false,
valueStatus: "deferred",
valueScore: null,
persistedArtifact: false,
notes: [],
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "DEF+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
equipped: "Aino",
},
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
fieldConfidences: [],
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([safeResult], null, 2), "utf8");
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
loadArtifacts: async () => ({ ok: true, artifacts: [], total: 0, path: path.join(runDir, "artifact-store.json") }),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { ok: true, added: records.length, updated: 0, total: records.length, path: path.join(runDir, "artifact-store.json") };
},
});
const status = await service.promoteResults({ resultIds: [safeResult.id] });
const updatedResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
const logLines = (await fs.readFile(path.join(runDir, "promotion-log.jsonl"), "utf8")).trim().split(/\r?\n/);
expect(status).toMatchObject({ ok: true, requested: 1, selected: 1, promoted: 1, added: 1, updated: 0 });
expect(savedRecords).toHaveLength(1);
expect(savedRecords[0]).toMatchObject({ source: "native-ik-scan", needsReview: false });
expect(updatedResults[0]).toMatchObject({ persistedArtifact: true, artifactRecordId: savedRecords[0].id });
expect(logLines).toHaveLength(1);
});
it("approves a corrected review result only after structural and IK validation", async () => {
const runDir = await makeRunDir();
const reviewResult: StoredScanResultEntry = {
id: "review-result",
runId: "run-review",
sequence: 6,
category: "artifact",
source: "native-ik-scan",
imagePath: path.join(runDir, "artifact-0006.png"),
capturedAt: "2026-07-09T22:34:43.000Z",
extractionStatus: "review",
extractionConfidence: 96,
needsReview: true,
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
notes: ["Substat value has no valid roll combination: DEF+1."],
artifact: {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+1"],
equipped: "Aino",
},
};
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([reviewResult], null, 2), "utf8");
await fs.writeFile(path.join(runDir, "processing-report.json"), JSON.stringify({
results: [{ sequence: 6, ocr: [{ id: "artifact-substats", label: "Substats", text: "DEF+1", confidence: 80 }] }],
}), "utf8");
const evalSamples: unknown[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
loadIkArtifactCatalog: async () => safePlumeIkCatalog(),
saveReviewSample: async (sample) => {
evalSamples.push(sample);
return { ok: true };
},
});
const invalidRarity = await service.reviewResult({
resultId: reviewResult.id,
action: "approve",
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT Rate+2.3%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
},
});
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
expect(invalidRarity.error).toContain("Implausible substats: CRIT Rate+2.3%");
const status = await service.reviewResult({
resultId: reviewResult.id,
action: "approve",
note: "Crop confirms DEF+23.",
artifact: {
...reviewResult.artifact!,
level: 20,
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
},
});
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
const log = await fs.readFile(path.join(runDir, "review-log.jsonl"), "utf8");
expect(status).toMatchObject({ ok: true, action: "approve", evalSampleSaved: true, correctedFields: ["substats"] });
expect(updated[0]).toMatchObject({ extractionStatus: "parsed", needsReview: false, valueStatus: "deferred" });
expect(updated[0].artifact.substats).toContain("DEF+23");
expect(updated[0].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
expect(evalSamples).toHaveLength(1);
expect(log).toContain("review-result");
});
it("keeps rejected review results blocked from promotion", async () => {
const runDir = await makeRunDir();
const result = {
id: "reject-result",
runId: "run-review",
sequence: 7,
category: "artifact",
source: "native-ik-scan",
imagePath: "artifact-0007.png",
capturedAt: "2026-07-09T22:34:44.000Z",
extractionStatus: "review",
extractionConfidence: 70,
needsReview: true,
valueStatus: "review",
valueScore: null,
persistedArtifact: false,
notes: [],
} satisfies StoredScanResultEntry;
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.reviewResult({ resultId: result.id, action: "reject", note: "Crop unreadable." });
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
expect(status).toMatchObject({ ok: true, action: "reject", evalSampleSaved: false });
expect(updated[0]).toMatchObject({ extractionStatus: "review", needsReview: true, valueStatus: "review" });
expect(updated[0].review).toMatchObject({ status: "rejected" });
});
it("uses IK inventorylists matching to force review on catalog conflicts", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => safePlumeCapture(),
saveArtifacts: async () => ({ added: 0, updated: 0 }),
loadIkArtifactCatalog: async () => safePlumeIkCatalog({ slot: "flower" }),
});
const status = await service.processRun({ persist: true });
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(status.review).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0].ikMatch).toMatchObject({
matched: false,
source: "ik-inventorylists",
artifactGood: "PristinePlumeOfTheBlessed",
slotKey: "flower",
});
expect(status.results[0].notes?.join(" ")).toContain("IK slot mismatch");
expect(scanResults[0]).toMatchObject({
extractionStatus: "review",
valueStatus: "review",
persistedArtifact: false,
ikMatch: {
matched: false,
artifactGood: "PristinePlumeOfTheBlessed",
},
});
});
it("processes native crops through a bounded post-capture queue while preserving result order", async () => {
const runDir = await makeRunDir();
const jobs: NativeCaptureJobPayload[] = [];
for (let sequence = 1; sequence <= 5; sequence++) {
const { relativePath } = await writeCrop(runDir, `cards/artifact-${sequence}.png`);
jobs.push({ sequence, relativePath });
}
await writeJobs(runDir, jobs);
let active = 0;
let maxActive = 0;
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 10));
active -= 1;
return safePlumeCapture();
},
saveArtifacts: async () => ({ added: 0, updated: 0 }),
});
const status = await service.processRun();
expect(status.queueConcurrency).toBe(4);
expect(maxActive).toBeGreaterThan(1);
expect(status.results.map((result) => result.sequence)).toEqual([1, 2, 3, 4, 5]);
});
it("keeps review-only parsed artifacts out of the persistent store", async () => {
const runDir = await makeRunDir();
const { relativePath } = await writeCrop(runDir);
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
const savedRecords: StoredArtifactRecord[] = [];
const service = createNativeScannerProcessingService({
resolveRunDir: () => runDir,
buildCaptureResult: async () => reviewOnlyCapture(),
saveArtifacts: async (records) => {
savedRecords.push(...records);
return { added: records.length, updated: 0 };
},
});
const status = await service.processRun({ persist: true });
expect(status.processed).toBe(1);
expect(status.parsed).toBe(1);
expect(status.review).toBe(1);
expect(status.stored).toBe(0);
expect(status.results[0]).toMatchObject({
parsed: true,
needsReview: true,
persisted: false,
slot: "Sands of Eon",
});
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
expect(scanResults[0]).toMatchObject({
extractionStatus: "review",
valueStatus: "review",
persistedArtifact: false,
artifact: {
slot: "Sands of Eon",
},
});
expect(savedRecords).toHaveLength(0);
});
});
+9 -9
View File
@@ -1,24 +1,24 @@
import { describe, expect, it } from "vitest";
import { formatReport, runOcrEval } from "./ocrEvalHarness";
import { seedCorpus } from "./corpus/seedCorpus";
import { ocrEvalCorpus } from "./corpus";
// Regression gate: the seed corpus is verified ground truth, so the parser must
// read every labeled field correctly. A drop here means an OCR/parser change
// regressed a previously-correct read - look at the printed failures. If a
// change intentionally alters a correct output, update the corpus label in the
// same commit (the label is the source of truth, not the code).
// Regression gate: every case in the combined corpus is verified ground truth,
// so the parser must read every labeled field correctly. A drop here means an
// OCR/parser change regressed a previously-correct read - look at the printed
// failures. If a change intentionally alters a correct output, update the corpus
// label in the same commit (the label is the source of truth, not the code).
describe("OCR eval harness", () => {
const report = runOcrEval(seedCorpus);
const report = runOcrEval(ocrEvalCorpus);
it("prints the accuracy report", () => {
// Surfaced in test output for humans; not an assertion.
// eslint-disable-next-line no-console
console.log("\n" + formatReport(report) + "\n");
expect(report.totalCases).toBe(seedCorpus.length);
expect(report.totalCases).toBe(ocrEvalCorpus.length);
});
it("reads every labeled field on the seed corpus correctly", () => {
it("reads every labeled field on the eval corpus correctly", () => {
const failureSummary = report.failures
.map((failure) => {
const wrong = failure.fields
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { deflateSync } from "node:zlib";
import { pngBufferToBitmap } from "../../electron/services/pngBitmap";
import { lockSignalRatio } from "../lib/lockDetection";
function chunk(type: string, data: Buffer) {
const result = Buffer.alloc(12 + data.length);
result.writeUInt32BE(data.length, 0);
result.write(type, 4, 4, "ascii");
data.copy(result, 8);
return result;
}
function rgbPng1x1(r: number, g: number, b: number) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(1, 0);
ihdr.writeUInt32BE(1, 4);
ihdr[8] = 8;
ihdr[9] = 2;
const raw = Buffer.from([0, r, g, b]);
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
describe("pngBufferToBitmap", () => {
it("decodes RGB PNG pixels for lock detection", () => {
const bitmap = pngBufferToBitmap(rgbPng1x1(235, 92, 90));
expect(bitmap.width).toBe(1);
expect(bitmap.height).toBe(1);
expect(lockSignalRatio(bitmap)).toBe(1);
});
});
@@ -0,0 +1,91 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
function writeCandidatePayload(dir: string) {
const inputPath = path.join(dir, "review-eval-candidates.json");
writeFileSync(
inputPath,
JSON.stringify({
summary: {},
candidates: [
{
id: "review-2026-07-08T15-02-39-765Z-152",
savedAt: "2026-07-08T15:02:39.765Z",
reason: "automatic:parser-notes:p1:r2c3",
resolution: "1920x1080",
ocr: {
"artifact-name": "Pristine Plume of the Blessed",
"artifact-slot": "Plume of Death",
"artifact-main-stat-label": "ATK",
"artifact-level": "+20",
"artifact-substats": "- Energy Recharge+10.4%",
},
},
],
}),
"utf8",
);
return inputPath;
}
describe("prepare confirmed review case script", () => {
it("creates a confirmed corpus snippet from a candidate and explicit labels", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
try {
const inputPath = writeCandidatePayload(dir);
const expectPath = path.join(dir, "expect.json");
const outputPath = path.join(dir, "snippet.ts");
writeFileSync(
expectPath,
`\uFEFF${JSON.stringify({
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
mainStat: "ATK",
setName: "Silken Moon's Serenade",
})}`,
"utf8",
);
execFileSync(
"node",
[
"scripts/prepare-confirmed-review-case.cjs",
`--input=${inputPath}`,
"--candidate=review-2026-07-08T15-02-39-765Z-152",
`--expect-file=${expectPath}`,
`--out=${outputPath}`,
],
{ cwd: process.cwd(), stdio: "pipe" },
);
const snippet = readFileSync(outputPath, "utf8");
expect(snippet).toContain("confirmed: true");
expect(snippet).toContain("Pristine Plume of the Blessed");
expect(snippet).toContain('"artifact-name": "Pristine Plume of the Blessed"');
expect(snippet).toContain("sourceCandidate=review-2026-07-08T15-02-39-765Z-152");
expect(snippet).not.toContain("confirmed: false");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects missing explicit labels", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
try {
const inputPath = writeCandidatePayload(dir);
expect(() =>
execFileSync(
"node",
["scripts/prepare-confirmed-review-case.cjs", `--input=${inputPath}`, "--candidate=review-2026-07-08T15-02-39-765Z-152"],
{ cwd: process.cwd(), stdio: "pipe" },
),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,90 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
function reviewRecord(savedAt: string, reason: string, ocr: Array<{ id: string; text: string }>, locked?: boolean) {
return {
savedAt,
sample: {
reason,
capture: {
width: 1920,
height: 1080,
ocr,
locked,
},
parsed: {
name: "Pristine Plume of the Blessed",
slot: ocr.some((entry) => entry.id === "artifact-slot") ? "Plume of Death" : "Unknown Slot",
level: 20,
mainStat: "ATK",
mainValue: "311",
setName: "Silken Moon's Serenade",
equipped: ocr.some((entry) => entry.id === "artifact-footer") ? "Aino" : "Not detected",
substats: ["Energy Recharge+10.4%"],
confidence: 0.9,
notes: [],
},
},
};
}
const completeOcr = [
{ id: "artifact-name", text: "Pristine Plume of the Blessed" },
{ id: "artifact-slot", text: "Plume of Death" },
{ id: "artifact-main-stat-label", text: "ATK" },
{ id: "artifact-level", text: "+20" },
{ id: "artifact-substats", text: "- Energy Recharge+10.4%" },
{ id: "artifact-footer", text: "Equipped: Aino" },
];
describe("review eval candidate exporter", () => {
it("exports deduplicated candidates with stats and stale markers", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-review-candidates-"));
try {
const inputPath = path.join(dir, "review-samples.jsonl");
const outDir = path.join(dir, "out");
const staleOcr = completeOcr.filter((entry) => entry.id !== "artifact-slot");
writeFileSync(
inputPath,
[
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
JSON.stringify(reviewRecord("2026-07-08T15:01:00.000Z", "automatic:capture-rejected:p1:r0c1", staleOcr, true)),
"{not json",
].join("\n"),
"utf8",
);
execFileSync("node", ["scripts/export-review-eval-candidates.cjs", `--input=${inputPath}`, `--out=${outDir}`, "--limit=10"], {
cwd: process.cwd(),
stdio: "pipe",
});
const payload = JSON.parse(readFileSync(path.join(outDir, "review-eval-candidates.json"), "utf8"));
const markdown = readFileSync(path.join(outDir, "review-eval-candidates.md"), "utf8");
expect(payload.summary.recordsRead).toBe(4);
expect(payload.summary.invalidRecords).toBe(1);
expect(payload.summary.uniqueCandidates).toBe(2);
expect(payload.summary.exportedCandidates).toBe(2);
expect(payload.summary.exportStats.completeFastFields).toBe(1);
expect(payload.summary.exportStats.likelyStaleCaptures).toBe(1);
expect(payload.summary.exportStats.equippedFooterCandidates).toBe(2);
expect(payload.summary.exportStats.lockedTrueCandidates).toBe(1);
expect(payload.candidates[0].missingFastFields).toEqual([]);
expect(payload.candidates[0].parsed.equipped).toBe("Aino");
expect(payload.candidates[0].reviewPrompt.expectedFields.equipped).toBe("Aino");
expect(payload.candidates[1].likelyStaleCapture).toBe(true);
expect(payload.candidates[1].locked).toBe(true);
expect(markdown).toContain("## Export stats");
expect(markdown).toContain("- artifact-slot: 1");
expect(markdown).toContain("equipped=Aino");
expect(markdown).toContain("Locked=true candidates: 1");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ import type { OcrEvalCase } from "./ocrEvalHarness";
// itself). The intended flow is:
// 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess.
// 2. A human confirms or corrects `expect` in the produced case.
// 3. The corrected case is committed into src/eval/corpus/.
// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts.
// The `confirmed` flag records whether step 2 happened.
export interface ReviewSampleEvalCase extends OcrEvalCase {
@@ -0,0 +1,398 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
function validAssessment() {
return {
createdAt: "2026-07-09T12:00:00.000Z",
goal100Decision: "qualified: winner=current",
goal100: {
limit: 100,
singleEngine: true,
winnerEngine: "current",
winnerQualified: true,
winnerActiveAverageMsPerParsed: 393,
winnerActiveProjectedMsFor100: 39300,
winnerAverageCaptureRoundTripMs: 333,
winnerAverageCaptureRoundTripOverheadMs: 148,
winnerMissRate: 0,
winnerReviewRate: 0,
engines: [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 333, averageCaptureRoundTripOverheadMs: 148 },
],
},
limits: [
{
limit: 20,
singleEngine: true,
winnerEngine: "current",
winnerQualified: true,
winnerActiveAverageMsPerParsed: 390,
winnerActiveProjectedMsFor100: 39000,
winnerAverageCaptureRoundTripMs: 364,
winnerAverageCaptureRoundTripOverheadMs: 152,
winnerMissRate: 0,
winnerReviewRate: 0.05,
engines: [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05, averageCaptureRoundTripMs: 364, averageCaptureRoundTripOverheadMs: 152 },
],
},
],
};
}
function writeAssessment(dir: string, payload: unknown) {
const inputPath = path.join(dir, "scan-performance-assessment.json");
writeFileSync(inputPath, JSON.stringify(payload), "utf8");
return inputPath;
}
describe("scan assessment validator", () => {
it("accepts a qualified 100-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(JSON.parse(output).ok).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts a matching expected winner", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(JSON.parse(output).ok).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("prints an opt-in human summary", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(output).toContain("scan assessment: PASS");
expect(output).toContain(`input: ${inputPath}`);
expect(output).toContain("createdAt: 2026-07-09T12:00:00.000Z");
expect(output).toContain("limit: 100");
expect(output).toContain("winner: current");
expect(output).toContain("activeAvg: 393ms/artifact");
expect(output).toContain("captureRoundTripOverhead: 148ms");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts a qualified 20-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(output).toContain("scan assessment: PASS");
expect(output).toContain("limit: 20");
expect(output).toContain("winner: current");
expect(output).toContain("activeAvg: 390ms/artifact");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts explicit speed and capture-overhead budgets when the winner stays inside them", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync(
"node",
[
"scripts/validate-scan-assessment.cjs",
`--input=${inputPath}`,
"--summary",
"--limit=20",
"--max-active-average-ms=400",
"--max-capture-roundtrip-overhead-ms=160",
],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(output).toContain("scan assessment: PASS");
expect(output).toContain("captureRoundTripOverhead: 152ms");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects speed and capture-overhead budgets when the winner exceeds them", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
let stdout = "";
try {
execFileSync(
"node",
[
"scripts/validate-scan-assessment.cjs",
`--input=${inputPath}`,
"--summary",
"--limit=20",
"--max-active-average-ms=333",
"--max-capture-roundtrip-overhead-ms=120",
],
{
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
},
);
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Winner active average timing exceeds 333ms");
expect(stdout).toContain("Winner capture roundtrip overhead exceeds 120ms");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects invalid requested limits", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=0"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("--limit must be a positive integer");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a requested limit that is missing from the assessment", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=45"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Missing limit=45 assessment");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => {
const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
expect(packageJson.scripts["scan:goal:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
);
expect(packageJson.scripts["scan:goal:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
);
expect(packageJson.scripts["scan:iterate"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
);
expect(packageJson.scripts["scan:iterate:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
);
expect(packageJson.scripts["scan:iterate:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
);
expect(packageJson.scripts["scan:repeatability"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
);
expect(packageJson.scripts["scan:repeatability:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
);
});
it("rejects an invalid expected winner", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const inputPath = writeAssessment(dir, validAssessment());
expect(() =>
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=old-engine"], {
cwd: process.cwd(),
stdio: "pipe",
}),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("prints errors in summary mode for rejected assessments", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
const inputPath = writeAssessment(dir, payload);
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("goal100Decision is not a qualified 100-artifact run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects an unqualified winner", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
payload.goal100.winnerQualified = false;
payload.goal100.engines[0].qualified = false;
payload.goal100.engines[0].reviewRate = 0.3;
const inputPath = writeAssessment(dir, payload);
expect(() =>
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
cwd: process.cwd(),
stdio: "pipe",
}),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a winner with missing quality rates", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
delete (payload.goal100.engines[0] as { missRate?: number }).missRate;
delete (payload.goal100.engines[0] as { reviewRate?: number }).reviewRate;
const inputPath = writeAssessment(dir, payload);
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Winner missRate must be a finite number");
expect(stdout).toContain("Winner reviewRate must be a finite number");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a winner with missing summary quality rates", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
delete (payload.goal100 as { winnerMissRate?: number }).winnerMissRate;
delete (payload.goal100 as { winnerReviewRate?: number }).winnerReviewRate;
const inputPath = writeAssessment(dir, payload);
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Winner summary missRate must be a finite number");
expect(stdout).toContain("Winner summary reviewRate must be a finite number");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a winner with missing speed timing", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
delete (payload.goal100 as { winnerActiveAverageMsPerParsed?: number }).winnerActiveAverageMsPerParsed;
delete (payload.goal100 as { winnerActiveProjectedMsFor100?: number }).winnerActiveProjectedMsFor100;
const inputPath = writeAssessment(dir, payload);
let stdout = "";
try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
} catch (error) {
stdout = String((error as { stdout?: string }).stdout || "");
}
expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Winner active average timing must be a positive finite number");
expect(stdout).toContain("Winner projected100 timing must be a positive finite number");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("validates the latest assessment under a live-soak root", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const older = path.join(dir, "2026-07-09T10-00-00");
const newer = path.join(dir, "2026-07-09T11-00-00");
mkdirSync(older);
mkdirSync(newer);
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
writeAssessment(newer, validAssessment());
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", "--latest", `--root=${dir}`], {
cwd: process.cwd(),
encoding: "utf8",
});
const parsed = JSON.parse(output);
expect(parsed.ok).toBe(true);
expect(parsed.inputPath).toContain("2026-07-09T11-00-00");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -144,13 +144,16 @@ export async function captureSelectedSourceAction(
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
}
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
setLatestCapture(capture);
const ocrStatus = capture.ocrSkipped
? "OCR skipped for fast scan."
: capture.ocrTimedOut
? "OCR timed out; review sample needed."
: "OCR handoff is next.";
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame;
if (!quietArtifactScanCapture) {
setLatestCapture(capture);
const ocrStatus = capture.ocrSkipped
? "OCR skipped for fast scan."
: capture.ocrTimedOut
? "OCR timed out; review sample needed."
: "OCR handoff is next.";
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
}
return capture;
} catch (error) {
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
+267
View File
@@ -0,0 +1,267 @@
import { RefreshCw } from "lucide-react";
import { useInventoryViewModel } from "./hooks/useInventoryViewModel";
import type { InventoryViewProps } from "./types";
export function InventoryView({ snapshot }: InventoryViewProps) {
const {
rows,
selectedRow,
selectedPreview,
previewStatus,
filter,
sort,
loading,
statusText,
promotionStatus,
promotionPending,
promotionConfirming,
reviewEditing,
reviewPending,
reviewStatus,
reviewDraft,
totalRows,
nativeCount,
storedCount,
reviewCount,
catalogSummary,
promotionSummary,
pipelineSummary,
filterOptions,
sortOptions,
setFilter,
setSort,
setSelectedId,
refresh,
requestSelectedPromotion,
cancelSelectedPromotion,
confirmSelectedPromotion,
startSelectedReview,
cancelSelectedReview,
setReviewDraft,
submitSelectedReview,
} = useInventoryViewModel({ snapshot });
return (
<section className="inventory-view">
<div className="inventory-toolbar">
<div>
<p className="eyebrow">Native Artifact Inventory</p>
<h2>Artifact Scan-Ergebnisse</h2>
</div>
<div className="inventory-toolbar-actions">
<div className="inventory-stats">
<span>{totalRows} gesamt</span>
<span>{nativeCount} native</span>
<span>{storedCount} store</span>
<span>{reviewCount} review</span>
</div>
<button className="ghost-button icon-button" onClick={() => void refresh()} disabled={loading} title="Inventory aktualisieren">
<RefreshCw size={16} />
</button>
</div>
</div>
<div className={`inventory-catalog-strip ${catalogSummary.className}`}>
<div>
<span>{catalogSummary.ok ? "Artifact IK bereit" : "IK Listen pruefen"}</span>
<strong>{catalogSummary.title}</strong>
<small>{catalogSummary.detail}</small>
</div>
{catalogSummary.counts.length > 0 && (
<div className="inventory-catalog-counts">
{catalogSummary.counts.map((entry) => (
<span key={entry.label}>
<strong>{entry.value}</strong>
<small>{entry.label}</small>
{entry.sample && <em>{entry.sample}</em>}
</span>
))}
</div>
)}
</div>
<div className="inventory-pipeline-strip" aria-label="Native artifact pipeline status">
{pipelineSummary.cards.map((card) => (
<span key={card.id} className={card.className} title={card.detail}>
<strong>{card.title}</strong>
<small>{card.value}</small>
<em>{card.detail}</em>
</span>
))}
</div>
<p className="inventory-pipeline-caption">{pipelineSummary.caption}</p>
<div className={`inventory-promotion-strip ${promotionSummary.className}`}>
<div>
<span>{promotionSummary.title}</span>
<strong>{promotionSummary.detail}</strong>
<small>Analyse aus scan-results.json und lokalem Artifact-Store. Store-Schreibzugriff bleibt ein expliziter naechster Schritt.</small>
</div>
<div className="inventory-promotion-counts">
<span><strong>{promotionSummary.ready}</strong><small>Speicherbar</small></span>
<span><strong>{promotionSummary.alreadyStored + promotionSummary.persisted}</strong><small>Im Store</small></span>
<span><strong>{promotionSummary.review}</strong><small>Review</small></span>
<span><strong>{promotionSummary.blocked}</strong><small>Blockiert</small></span>
</div>
</div>
<div className="inventory-controls">
<div className="segmented-control">
{filterOptions.map((option) => (
<button
key={option.id}
className={filter === option.id ? "active" : ""}
onClick={() => setFilter(option.id)}
>
{option.label} <span>{option.count}</span>
</button>
))}
</div>
<label className="inventory-sort">
<span>Sort</span>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
{sortOptions.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
</div>
<div className="inventory-browser">
<div className="inventory-list" aria-label="Artifact inventory results">
{rows.length > 0 ? rows.map((row) => (
<button
key={row.id}
className={`inventory-row ${selectedRow?.id === row.id ? "active" : ""}`}
onClick={() => setSelectedId(row.id)}
>
<span className="inventory-row-sequence">{row.sequenceLabel}</span>
<span className="inventory-row-main">
<strong>{row.title}</strong>
<span>{row.slot} - {row.mainStat} {row.mainValue} - {row.levelLabel}</span>
</span>
<span className="inventory-row-source">{row.sourceLabel}</span>
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
</button>
)) : (
<p className="inventory-empty">Keine Scan-Ergebnisse geladen.</p>
)}
</div>
<aside className="inventory-detail">
{selectedRow ? (
<>
<div className="inventory-detail-heading">
<p className="eyebrow">{selectedRow.sourceLabel} {selectedRow.sequenceLabel}</p>
<h3>{selectedRow.title}</h3>
<span className={`result-rail-status ${selectedRow.statusClassName}`}>{selectedRow.statusLabel}</span>
</div>
<div className={`inventory-action-card ${selectedRow.nextActionClassName}`}>
<span>Naechster Schritt</span>
<strong>{selectedRow.nextActionLabel}</strong>
<small>{selectedRow.nextActionDetail}</small>
{selectedRow.canPromote && !promotionConfirming && (
<button className="primary-button inventory-promote-button" onClick={requestSelectedPromotion} disabled={promotionPending}>
Ausgewaehltes Ergebnis promoten
</button>
)}
{selectedRow.canPromote && promotionConfirming && (
<div className="inventory-promotion-confirm">
<strong>Dieses gepruefte Ergebnis jetzt in den lokalen Store schreiben?</strong>
<div>
<button className="primary-button" onClick={() => void confirmSelectedPromotion()} disabled={promotionPending}>Bestaetigen</button>
<button className="ghost-button" onClick={cancelSelectedPromotion} disabled={promotionPending}>Abbrechen</button>
</div>
</div>
)}
{selectedRow.source === "native" && selectedRow.needsReview && !reviewEditing && (
<button className="primary-button inventory-promote-button" onClick={startSelectedReview} disabled={reviewPending}>
Review bearbeiten
</button>
)}
</div>
{reviewEditing && reviewDraft && (
<div className="inventory-review-editor">
<div className="inventory-review-heading">
<div>
<span>Manuelle Korrektur</span>
<strong>Crop pruefen, Felder korrigieren, dann freigeben oder ablehnen.</strong>
</div>
<button className="ghost-button" onClick={cancelSelectedReview} disabled={reviewPending}>Schliessen</button>
</div>
<div className="inventory-review-grid">
<label><span>Name</span><input value={reviewDraft.name} onChange={(event) => setReviewDraft({ ...reviewDraft, name: event.target.value })} /></label>
<label><span>Set</span><input value={reviewDraft.setName} onChange={(event) => setReviewDraft({ ...reviewDraft, setName: event.target.value })} /></label>
<label><span>Slot</span><select value={reviewDraft.slot} onChange={(event) => setReviewDraft({ ...reviewDraft, slot: event.target.value })}>
{["Flower of Life", "Plume of Death", "Sands of Eon", "Goblet of Eonothem", "Circlet of Logos"].map((slot) => <option key={slot}>{slot}</option>)}
</select></label>
<label><span>Level</span><input type="number" min="0" max="20" value={reviewDraft.level} onChange={(event) => setReviewDraft({ ...reviewDraft, level: event.target.value })} /></label>
<label><span>Main Stat</span><input value={reviewDraft.mainStat} onChange={(event) => setReviewDraft({ ...reviewDraft, mainStat: event.target.value })} /></label>
<label><span>Main Value</span><input value={reviewDraft.mainValue} onChange={(event) => setReviewDraft({ ...reviewDraft, mainValue: event.target.value })} /></label>
<label><span>Equipped</span><input value={reviewDraft.equipped} onChange={(event) => setReviewDraft({ ...reviewDraft, equipped: event.target.value })} /></label>
<label className="inventory-review-lock"><input type="checkbox" checked={reviewDraft.locked} onChange={(event) => setReviewDraft({ ...reviewDraft, locked: event.target.checked })} /><span>Locked</span></label>
<label className="inventory-review-wide"><span>Substats, eine Zeile pro Wert</span><textarea rows={4} value={reviewDraft.substats} onChange={(event) => setReviewDraft({ ...reviewDraft, substats: event.target.value })} /></label>
<label className="inventory-review-wide"><span>Review-Notiz</span><input value={reviewDraft.note} onChange={(event) => setReviewDraft({ ...reviewDraft, note: event.target.value })} /></label>
</div>
<div className="inventory-review-actions">
<button className="primary-button" onClick={() => void submitSelectedReview("approve")} disabled={reviewPending}>Korrektur freigeben</button>
<button className="ghost-button danger" onClick={() => void submitSelectedReview("reject")} disabled={reviewPending}>Ergebnis ablehnen</button>
</div>
</div>
)}
<div className={`inventory-preview ${selectedPreview ? "has-preview" : "is-empty"}`}>
{selectedPreview ? (
<img src={selectedPreview.dataUrl} alt={`Preview ${selectedRow.title}`} />
) : (
<span>{previewStatus || "Kein Crop-Preview geladen"}</span>
)}
</div>
{previewStatus && selectedPreview && <p className="inventory-preview-status">{previewStatus}</p>}
<div className="inventory-detail-grid">
<span>Set</span><strong>{selectedRow.setName}</strong>
<span>Slot</span><strong>{selectedRow.slot}</strong>
<span>Main</span><strong>{selectedRow.mainStat} {selectedRow.mainValue}</strong>
<span>Level</span><strong>{selectedRow.levelLabel}</strong>
<span>Value</span><strong>{selectedRow.valueLabel}</strong>
<span>IK</span><strong>{selectedRow.ikLabel}</strong>
<span>GOOD</span><strong>{selectedRow.ikGoodLabel}</strong>
<span>Promotion</span><strong>{selectedRow.promotionLabel}</strong>
<span>Confidence</span><strong>{selectedRow.confidence}%</strong>
<span>Equipped</span><strong>{selectedRow.equipped}</strong>
</div>
<div className="inventory-substats">
{selectedRow.substats.length > 0 ? selectedRow.substats.map((substat) => (
<span key={`${selectedRow.id}-${substat}`}>{substat}</span>
)) : <span>Keine Substats gelesen</span>}
</div>
{selectedRow.fieldConfidences.length > 0 && (
<div className="inventory-field-confidence">
{selectedRow.fieldConfidences.map((field) => (
<span key={`${selectedRow.id}-${field.key}`} className={field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"}>
<strong>{field.confidence}%</strong>
<small>{field.label} · {field.source}</small>
</span>
))}
</div>
)}
{(selectedRow.notes.length > 0 || selectedRow.promotionReasons.length > 0 || selectedRow.error || selectedRow.imagePath || selectedRow.promotionRecordId) && (
<div className="inventory-notes">
{selectedRow.imagePath && <span>{selectedRow.imagePath}</span>}
{selectedRow.promotionRecordId && <span>{selectedRow.promotionRecordId}</span>}
{selectedRow.error && <strong>{selectedRow.error}</strong>}
{selectedRow.promotionReasons.map((reason) => <span key={`${selectedRow.id}-promotion-${reason}`}>{reason}</span>)}
{selectedRow.notes.map((note) => <span key={`${selectedRow.id}-${note}`}>{note}</span>)}
</div>
)}
</>
) : (
<p className="inventory-empty">Waehle ein Ergebnis aus.</p>
)}
</aside>
</div>
<p className="inventory-status">{statusText}</p>
{promotionStatus && <p className="inventory-status">{promotionStatus}</p>}
{reviewStatus && <p className="inventory-status">{reviewStatus}</p>}
</section>
);
}
@@ -0,0 +1,353 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
buildArtifactPipelineSummary,
buildInventoryCatalogSummary,
buildInventoryRows,
filterInventoryRows,
sortInventoryRows,
type InventoryCatalogSummary,
type InventoryBrowserFilter,
type InventoryBrowserRow,
type InventoryBrowserSort,
type InventoryPipelineSummary,
} from "../../../lib/inventoryBrowser";
import { buildScanResultPromotionSummary, type ScanResultPromotionSummary } from "../../../lib/scanResultPromotion";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerReviewArtifactInput } from "../../../types/global";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
import type { InventoryViewProps } from "../types";
export interface InventoryViewModel {
rows: InventoryBrowserRow[];
selectedRow: InventoryBrowserRow | null;
selectedPreview: { dataUrl: string; width: number; height: number; path: string } | null;
previewStatus: string;
filter: InventoryBrowserFilter;
sort: InventoryBrowserSort;
loading: boolean;
statusText: string;
promotionStatus: string;
promotionPending: boolean;
promotionConfirming: boolean;
reviewEditing: boolean;
reviewPending: boolean;
reviewStatus: string;
reviewDraft: InventoryReviewDraft | null;
totalRows: number;
nativeCount: number;
storedCount: number;
reviewCount: number;
catalogSummary: InventoryCatalogSummary;
promotionSummary: ScanResultPromotionSummary;
pipelineSummary: InventoryPipelineSummary;
filterOptions: Array<{ id: InventoryBrowserFilter; label: string; count: number }>;
sortOptions: Array<{ id: InventoryBrowserSort; label: string }>;
setFilter: (filter: InventoryBrowserFilter) => void;
setSort: (sort: InventoryBrowserSort) => void;
setSelectedId: (id: string) => void;
refresh: () => Promise<void>;
requestSelectedPromotion: () => void;
cancelSelectedPromotion: () => void;
confirmSelectedPromotion: () => Promise<void>;
startSelectedReview: () => void;
cancelSelectedReview: () => void;
setReviewDraft: (draft: InventoryReviewDraft) => void;
submitSelectedReview: (action: "approve" | "reject") => Promise<void>;
}
export interface InventoryReviewDraft {
name: string;
slot: string;
level: string;
setName: string;
mainStat: string;
mainValue: string;
substats: string;
equipped: string;
locked: boolean;
note: string;
}
export function useInventoryViewModel({ snapshot }: InventoryViewProps): InventoryViewModel {
const repositories = useMemo(() => createRendererRepositories(), []);
const [storedArtifacts, setStoredArtifacts] = useState<StoredArtifactRecord[]>([]);
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
const [nativeRunDir, setNativeRunDir] = useState("");
const [nativeDataStatus, setNativeDataStatus] = useState<NativeScannerDataStatus | null>(null);
const [nativeCatalog, setNativeCatalog] = useState<NativeScannerCatalogStatus | null>(null);
const [selectedPreview, setSelectedPreview] = useState<{ dataUrl: string; width: number; height: number; path: string } | null>(null);
const [previewStatus, setPreviewStatus] = useState("");
const [filter, setFilter] = useState<InventoryBrowserFilter>("all");
const [sort, setSort] = useState<InventoryBrowserSort>("newest");
const [selectedId, setSelectedId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [promotionStatus, setPromotionStatus] = useState("");
const [promotionPending, setPromotionPending] = useState(false);
const [promotionConfirmingId, setPromotionConfirmingId] = useState("");
const [reviewEditingId, setReviewEditingId] = useState("");
const [reviewDraft, setReviewDraft] = useState<InventoryReviewDraft | null>(null);
const [reviewPending, setReviewPending] = useState(false);
const [reviewStatus, setReviewStatus] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [artifactResult, nativeResult, dataResult, catalogResult] = await Promise.allSettled([
repositories?.artifacts.loadAll(),
repositories?.automation.nativeScannerLoadResults({ limit: 500 }),
repositories?.automation.nativeScannerDataStatus(),
repositories?.automation.nativeScannerCatalog(),
]);
if (artifactResult.status === "fulfilled" && artifactResult.value?.ok) {
setStoredArtifacts(artifactResult.value.artifacts);
}
if (nativeResult.status === "fulfilled" && nativeResult.value?.ok) {
setNativeResults(nativeResult.value.results);
setNativeRunDir(nativeResult.value.runDir);
}
if (dataResult.status === "fulfilled" && dataResult.value?.valid !== undefined) {
setNativeDataStatus(dataResult.value);
}
if (catalogResult.status === "fulfilled" && catalogResult.value?.data?.valid !== undefined) {
setNativeCatalog(catalogResult.value);
setNativeDataStatus(catalogResult.value.data);
}
if (artifactResult.status === "rejected") {
setError(errorMessage(artifactResult.reason));
}
} finally {
setLoading(false);
}
}, [repositories]);
useEffect(() => {
void refresh();
}, [refresh]);
const allRows = useMemo(
() =>
buildInventoryRows({
nativeRunDir,
nativeResults,
storedArtifacts,
snapshotArtifacts: snapshot.artifacts,
}),
[nativeResults, nativeRunDir, snapshot.artifacts, storedArtifacts],
);
const rows = useMemo(
() => sortInventoryRows(filterInventoryRows(allRows, filter), sort),
[allRows, filter, sort],
);
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null;
const promotionConfirming = Boolean(selectedRow && promotionConfirmingId === selectedRow.id);
const reviewEditing = Boolean(selectedRow && reviewEditingId === selectedRow.id && reviewDraft);
const requestSelectedPromotion = useCallback(() => {
if (selectedRow?.canPromote) setPromotionConfirmingId(selectedRow.id);
}, [selectedRow]);
const cancelSelectedPromotion = useCallback(() => setPromotionConfirmingId(""), []);
const confirmSelectedPromotion = useCallback(async () => {
if (!selectedRow?.canPromote || !selectedRow.runDir || !selectedRow.scanResultId || !repositories?.automation) return;
setPromotionPending(true);
setPromotionStatus("Promotion wird geprueft und geschrieben...");
try {
const result = await repositories.automation.nativeScannerPromoteResults({
runDir: selectedRow.runDir,
resultIds: [selectedRow.scanResultId],
});
if (!result.ok) {
setPromotionStatus(result.error ?? "Promotion wurde blockiert.");
return;
}
setPromotionStatus(`${result.promoted} Ergebnis promotet, ${result.added} neu, ${result.updated} aktualisiert. Log: ${result.logPath}`);
setPromotionConfirmingId("");
await refresh();
} catch (promotionError) {
setPromotionStatus(errorMessage(promotionError));
} finally {
setPromotionPending(false);
}
}, [refresh, repositories, selectedRow]);
const startSelectedReview = useCallback(() => {
if (!selectedRow || selectedRow.source !== "native" || !selectedRow.needsReview) return;
setReviewEditingId(selectedRow.id);
setReviewDraft({
name: selectedRow.title,
slot: selectedRow.slot,
level: selectedRow.levelLabel.replace("+", ""),
setName: selectedRow.setName,
mainStat: selectedRow.mainStat,
mainValue: selectedRow.mainValue,
substats: selectedRow.substats.join("\n"),
equipped: selectedRow.equipped,
locked: Boolean(selectedRow.locked),
note: "",
});
}, [selectedRow]);
const cancelSelectedReview = useCallback(() => {
setReviewEditingId("");
setReviewDraft(null);
}, []);
const submitSelectedReview = useCallback(async (action: "approve" | "reject") => {
if (!selectedRow?.runDir || !selectedRow.scanResultId || !reviewDraft || !repositories?.automation) return;
setReviewPending(true);
setReviewStatus(action === "approve" ? "Korrektur wird validiert..." : "Ergebnis wird abgelehnt...");
try {
const artifact: NativeScannerReviewArtifactInput = {
name: reviewDraft.name,
slot: reviewDraft.slot,
level: Number(reviewDraft.level),
setName: reviewDraft.setName,
mainStat: reviewDraft.mainStat,
mainValue: reviewDraft.mainValue,
substats: reviewDraft.substats.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean),
equipped: reviewDraft.equipped,
locked: reviewDraft.locked,
};
const result = await repositories.automation.nativeScannerReviewResult({
runDir: selectedRow.runDir,
resultId: selectedRow.scanResultId,
action,
artifact: action === "approve" ? artifact : undefined,
note: reviewDraft.note,
});
if (!result.ok) {
setReviewStatus(result.error ?? "Review wurde blockiert.");
return;
}
setReviewStatus(action === "approve"
? `Review freigegeben. ${result.correctedFields.length} Feld(er) korrigiert. Eval-Sample: ${result.evalSampleSaved ? "gespeichert" : "ohne OCR-Payload"}.`
: "Ergebnis wurde abgelehnt und bleibt von Promotion ausgeschlossen.");
setReviewEditingId("");
setReviewDraft(null);
await refresh();
} catch (reviewError) {
setReviewStatus(errorMessage(reviewError));
} finally {
setReviewPending(false);
}
}, [refresh, repositories, reviewDraft, selectedRow]);
useEffect(() => {
let canceled = false;
setSelectedPreview(null);
const automation = repositories?.automation;
if (!automation) {
setPreviewStatus("Electron bridge nicht verfuegbar.");
return () => {
canceled = true;
};
}
if (!selectedRow?.runDir || !selectedRow.imagePath || selectedRow.source !== "native") {
setPreviewStatus(selectedRow?.imagePath ? "Preview nur fuer native Run-Crops verfuegbar." : "");
return () => {
canceled = true;
};
}
setPreviewStatus("Preview wird geladen...");
void automation.nativeScannerLoadImage({
runDir: selectedRow.runDir,
imagePath: selectedRow.imagePath,
}).then((result) => {
if (canceled) return;
if (result?.ok) {
setSelectedPreview({
dataUrl: result.dataUrl,
width: result.width,
height: result.height,
path: result.path,
});
setPreviewStatus(`${result.width}x${result.height}`);
} else {
setPreviewStatus(result?.error ?? "Preview konnte nicht geladen werden.");
}
}).catch((error: unknown) => {
if (!canceled) setPreviewStatus(errorMessage(error));
});
return () => {
canceled = true;
};
}, [repositories, selectedRow?.id, selectedRow?.imagePath, selectedRow?.runDir, selectedRow?.source]);
const reviewCount = allRows.filter((row) => row.needsReview).length;
const nativeCount = allRows.filter((row) => row.source === "native").length;
const storedCount = allRows.filter((row) => row.source === "store").length;
const catalogSummary = useMemo(
() => buildInventoryCatalogSummary(nativeDataStatus, nativeCatalog),
[nativeCatalog, nativeDataStatus],
);
const promotionSummary = useMemo(
() => buildScanResultPromotionSummary(nativeResults, storedArtifacts),
[nativeResults, storedArtifacts],
);
const pipelineSummary = useMemo(
() => buildArtifactPipelineSummary(nativeDataStatus, nativeResults, promotionSummary),
[nativeDataStatus, nativeResults, promotionSummary],
);
const statusText = loading
? "Inventory wird geladen..."
: error || `${rows.length}/${allRows.length} Eintraege sichtbar`;
return {
rows,
selectedRow,
selectedPreview,
previewStatus,
filter,
sort,
loading,
statusText,
promotionStatus,
promotionPending,
promotionConfirming,
reviewEditing,
reviewPending,
reviewStatus,
reviewDraft,
totalRows: allRows.length,
nativeCount,
storedCount,
reviewCount,
catalogSummary,
promotionSummary,
pipelineSummary,
filterOptions: [
{ id: "all", label: "Alle", count: allRows.length },
{ id: "native", label: "Native", count: nativeCount },
{ id: "parsed", label: "Gelesen", count: allRows.length - reviewCount },
{ id: "review", label: "Review", count: reviewCount },
{ id: "promotable", label: "Speicherbar", count: promotionSummary.ready },
{ id: "stored", label: "Store", count: storedCount },
],
sortOptions: [
{ id: "newest", label: "Neueste" },
{ id: "name", label: "Name" },
{ id: "status", label: "Status" },
],
setFilter,
setSort,
setSelectedId,
refresh,
requestSelectedPromotion,
cancelSelectedPromotion,
confirmSelectedPromotion,
startSelectedReview,
cancelSelectedReview,
setReviewDraft,
submitSelectedReview,
};
}
function errorMessage(value: unknown) {
return value instanceof Error ? value.message : String(value);
}
+5
View File
@@ -0,0 +1,5 @@
import type { AppSnapshot } from "../../types/domain";
export interface InventoryViewProps {
snapshot: AppSnapshot;
}
+1 -10
View File
@@ -90,7 +90,7 @@ export function AppTopbar({
<header className="topbar">
<div>
<p className="eyebrow">Lokaler Windows-Assistent</p>
<h1>Scanne dein Inventar, triff einfache Artifact-Entscheidungen.</h1>
<h1>Inventar scannen, Artifacts entscheiden.</h1>
</div>
<div className="topbar-actions">
{topbarStatus && <span className="topbar-status">{topbarStatus}</span>}
@@ -107,15 +107,6 @@ export function AppTopbar({
{overlayIcon}
<span>{overlayButtonLabel}</span>
</button>
<button
className="ghost-button"
onClick={handleDemoScan}
disabled={isDemoDisabled}
title={demoButtonTitle}
>
{demoIcon}
{demoButtonLabel}
</button>
</div>
</header>
);
+3 -2
View File
@@ -1,10 +1,11 @@
import { Layers3, Radar, Wand2, Eye } from "lucide-react";
import { Archive, Layers3, Radar, Wand2, Eye, Wrench } from "lucide-react";
import type { AppNavigationItem } from "./types";
export const appNavigationItems: AppNavigationItem[] = [
{ id: "scan", label: "Scan", icon: Radar },
{ id: "inventory", label: "Inventory", icon: Archive },
{ id: "triage", label: "Triage", icon: Layers3 },
{ id: "builds", label: "Builds", icon: Wand2 },
{ id: "overlay", label: "Overlay", icon: Eye },
{ id: "diagnose", label: "Diagnose", icon: Wrench },
];
+1 -1
View File
@@ -1,7 +1,7 @@
import { type ComponentType, type ReactNode } from "react";
import type { LucideProps } from "lucide-react";
export type NavigationId = "scan" | "triage" | "builds" | "overlay";
export type NavigationId = "scan" | "inventory" | "triage" | "builds" | "overlay" | "diagnose";
export interface AppNavigationItem {
id: NavigationId;
+22 -2
View File
@@ -1,9 +1,29 @@
import { ScanViewLayout } from "./components/ScanViewLayout";
import { ScanViewLayout } from "./components/ScanViewLayout";
import { DiagnosticsView } from "./components/DiagnosticsView";
import { useScanViewController } from "./hooks/useScanViewController";
import type { ScanViewProps } from "./types";
export function ScanView(props: ScanViewProps) {
interface ScanViewExtraProps {
/** "workspace" shows the clean scan surface; "diagnose" shows all dev info. */
mode?: "workspace" | "diagnose";
onDemoScan?: () => void;
canDemoScan?: boolean;
}
export function ScanView({ mode = "workspace", onDemoScan, canDemoScan, ...props }: ScanViewProps & ScanViewExtraProps) {
const controller = useScanViewController(props);
if (mode === "diagnose") {
return (
<DiagnosticsView
controller={controller}
latestCapture={props.latestCapture}
captureStatus={props.captureStatus}
onDemoScan={onDemoScan}
canDemoScan={canDemoScan}
/>
);
}
return <ScanViewLayout {...props} controller={controller} />;
}
@@ -0,0 +1,393 @@
import { useState } from "react";
import { AlertTriangle, BadgeCheck, Camera, ClipboardList, Download, Gauge, Play, Target, Upload, Wrench } from "lucide-react";
import type { CaptureResult } from "../../../types/global";
import type { ScanViewControllerResult } from "../types";
import { FieldConfidenceList } from "./ScanResultCards";
import { useScanDiagnosticsModalModel } from "./modals/hooks/useScanDiagnosticsModalModel";
import { useScanDetailsModalModel } from "./modals/hooks/useScanDetailsModalModel";
interface DiagnosticsViewProps {
controller: ScanViewControllerResult;
latestCapture: CaptureResult | null;
captureStatus: string;
onDemoScan?: () => void;
canDemoScan?: boolean;
}
const appDiagnosisSections = [
{
title: "Was die App kann",
tone: "ok",
icon: BadgeCheck,
items: [
"Genshin-Fenster erkennen, Smart Capture ausfuehren und fokussierte Artifact-Crops erzeugen.",
"Artifact-Felder deterministisch gegen das lokale Genshin-Datenpaket parsen.",
"Auto-Scan read-only aus der sichtbaren Inventory-Seite starten, inklusive Grid, Verifikation, Dedupe und Store.",
"Review-Samples, lokale Lernregeln, GOOD Import/Export, Equipped-Footer und Lock-Status im Store nutzen.",
],
},
{
title: "Was noch fehlt",
tone: "warn",
icon: ClipboardList,
items: [
"Empfehlungen und Build-UX sind bewusst noch nicht der naechste Hauptfokus.",
"Auto-Entry-Modi wie paimon-menu und direct-inventory bleiben Dev-Control Experimente, nicht Produktionspfad.",
"3 Artifacts/Sekunde ist noch nicht bewiesen; der sichtbare Pfad liegt knapp darunter.",
"Mehr bestaetigte OCR-/Review-Corpus-Faelle fehlen, bevor wir breite Qualitaet behaupten.",
],
},
{
title: "Wo es Probleme macht",
tone: "risk",
icon: AlertTriangle,
items: [
"Scanner ist stark im aktuellen sichtbaren Inventory-Pfad; Repeatability ueber spaetere Sessions ist der naechste Pruefpunkt.",
"OCR ist schnell genug fuer gute Runs, bleibt aber der groesste Qualitaets- und Speed-Hebel.",
"Capture-Roundtrip ist sichtbar: Roundtrip und Overhead muessen bei 20/45/100 Runs mit bewertet werden.",
"Groessere Runs brauchen weiter Beobachtung auf Scroll-Uebergaenge, Wiederholseiten, Duplicate-Rate und Review-Quote.",
],
},
{
title: "Naechste Verbesserungen",
tone: "next",
icon: Target,
items: [
"Mehr Live-Wiederholungen: 20/45/100 Runs in spaeteren Sessions als Repeatability dokumentieren.",
"Review-Samples sauber labeln und ueber `npm run eval:prepare-confirmed` ins Eval-Corpus uebernehmen.",
"Scanner-UI weiter beruhigen, aber den funktionierenden visible-inventory Pfad nicht umbauen.",
"Capture-Roundtrip-Overhead als eigenen Optimierungspunkt pruefen, bevor weitere OCR-Engine-Wechsel priorisiert werden.",
"Erst danach Empfehlungen wieder staerker nach vorne ziehen.",
],
},
];
// All developer / diagnostic surfaces live here, separated from the Scan
// workspace: runtime + rights, grid detection, learning + data-package status,
// fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/
// confidence dump. Read-only; it drives no scan action except the demo snapshot.
export function DiagnosticsView({ controller, latestCapture, captureStatus, onDemoScan, canDemoScan }: DiagnosticsViewProps) {
const {
statusTitle,
rightsClassName,
rightsValue,
genshinClassName,
genshinValue,
shouldShowAdminBanner,
gridSourceClass,
gridMainValue,
gridMetaValue,
learningRulesText,
learningRulesSubtext,
autoScanModeLabel,
fingerprintText,
runtimeRows,
scanLimitText,
autoScanStatsLines,
playerProgress,
reviewStatus,
automationLogLines,
diagnosticEvents,
canSaveReviewSample,
handleSaveReviewSample,
} = useScanDiagnosticsModalModel({
setDetailsOpen: controller.setDetailsOpen,
setDiagnosticsOpen: controller.setDiagnosticsOpen,
saveReviewSample: controller.saveReviewSample,
canSaveReviewSample: controller.canSaveReviewSample,
latestCapture,
controller,
captureStatus,
});
const { parsedNotes, showParsedNotes, cropRows, ocrRows, debugText, showCrops, showOcr } = useScanDetailsModalModel({
setDetailsOpen: controller.setDetailsOpen,
parsedArtifact: controller.parsedArtifact,
latestCapture,
});
const [interopStatus, setInteropStatus] = useState("");
const handleExportGood = async () => {
setInteropStatus("Exportiere GOOD...");
const result = await controller.exportGoodFromStore();
setInteropStatus(
result.ok
? `GOOD exportiert: ${result.count} Artifacts${result.path ? ` -> ${result.path}` : ""}`
: "GOOD-Export fehlgeschlagen (App im Electron-Fenster oeffnen).",
);
};
const handleImportGood = async () => {
setInteropStatus("Waehle GOOD-Datei...");
const result = await controller.importGoodFromFile();
if (result.canceled) {
setInteropStatus("GOOD-Import abgebrochen.");
return;
}
setInteropStatus(
result.ok
? `Importiert: ${result.added} neu, ${result.updated} aktualisiert (${result.count} gelesen).`
: result.error === "No valid GOOD artifacts found."
? "Keine gueltigen Artifacts in der Datei gefunden."
: "Import fehlgeschlagen (Datei ist kein gueltiges GOOD/JSON oder Bridge fehlt).",
);
};
return (
<section className="diagnose-view">
<div className="diagnose-header">
<div>
<p className="eyebrow">Diagnose &amp; Dev</p>
<h2>Laufzeit, Erkennung &amp; Rohdaten</h2>
</div>
<div className="diagnose-header-actions">
<button className="ghost-button" onClick={controller.toggleDevMode}>
<Wrench size={15} />
{statusTitle}
</button>
{onDemoScan && (
<button className="ghost-button" onClick={onDemoScan} disabled={!canDemoScan}>
<Play size={15} />
Demo-Daten laden
</button>
)}
</div>
</div>
<div className="diagnose-card app-diagnosis-card">
<div className="diagnose-card-heading">
<div>
<p className="eyebrow">Aktueller App-Stand</p>
<h3>Scanner zuerst, Empfehlungen danach</h3>
</div>
<span className="diagnosis-source">
<Gauge size={14} />
Quelle: Docs + Live-Status
</span>
</div>
<div className="app-diagnosis-grid">
{appDiagnosisSections.map((section) => {
const Icon = section.icon;
return (
<article className={`app-diagnosis-section ${section.tone}`} key={section.title}>
<div className="app-diagnosis-title">
<Icon size={16} />
<strong>{section.title}</strong>
</div>
<ul>
{section.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
);
})}
</div>
</div>
<div className="diagnose-grid">
<div className="diagnose-card">
<p className="eyebrow">Status</p>
<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.
</p>
)}
<div className={`grid-detection-strip ${gridSourceClass}`}>
<span>Tile grid</span>
<strong>{gridMainValue}</strong>
<small>{gridMetaValue}</small>
</div>
<div className="learning-strip">
<span>Learning &amp; Daten</span>
<strong>{learningRulesText}</strong>
<small>{learningRulesSubtext}</small>
</div>
<div className="learning-strip">
<span>Fingerprint</span>
<strong>{fingerprintText}</strong>
<small>Aktiver Capture-Fingerprint fuer die Duplikat-Erkennung.</small>
</div>
</div>
<div className="diagnose-card">
<p className="eyebrow">{autoScanModeLabel}</p>
{playerProgress.show ? (
<div className="auto-scan-strip">
{autoScanStatsLines.map((entry) => (
<span className="auto-scan-stat" key={entry.label}>
<strong>{entry.value}</strong>
<small>{entry.label}</small>
</span>
))}
</div>
) : (
<p className="result-empty">Noch keine Scan-Aktivitaet.</p>
)}
<p className="scanner-result-caption">{scanLimitText}</p>
<div className="scanner-status-row diagnostics-status">
{runtimeRows.map((row) => (
<span key={row}>{row}</span>
))}
</div>
{reviewStatus && <p className="review-status">{reviewStatus}</p>}
</div>
</div>
<div className="diagnose-card">
<div className="diagnose-card-heading">
<p className="eyebrow">GOOD Interop</p>
<div className="diagnose-header-actions">
<button className="ghost-button" onClick={handleExportGood} disabled={!controller.canGoodInterop}>
<Download size={15} />
GOOD exportieren
</button>
<button className="ghost-button" onClick={handleImportGood} disabled={!controller.canGoodInterop}>
<Upload size={15} />
GOOD importieren
</button>
</div>
</div>
<p className="scanner-subcopy">
Exportiert den Scan-Store als GOOD-kompatible Datei oder importiert eine GOOD-Datei in den Store.
</p>
{interopStatus && <p className="review-status">{interopStatus}</p>}
</div>
<div className="diagnose-card">
<p className="eyebrow">Automation log</p>
<div className="automation-log-lines">
{automationLogLines.length > 0 ? (
automationLogLines.map((line, index) => <span key={`${index}-${line}`}>{line}</span>)
) : (
<strong>Keine Scan-Aktivitaet.</strong>
)}
</div>
</div>
<div className="diagnose-card">
<div className="diagnose-card-heading">
<div>
<p className="eyebrow">Evidence timeline</p>
<h3>Scan-Flugschreiber</h3>
</div>
<span className="diagnosis-source">
<Camera size={14} />
letzte {diagnosticEvents.length}
</span>
</div>
{diagnosticEvents.length > 0 ? (
<div className="scan-evidence-timeline">
{diagnosticEvents.slice().reverse().map((event) => (
<article className={`scan-evidence-event ${event.severity}`} key={event.id}>
<div className="scan-evidence-header">
<span>{new Date(event.at).toLocaleTimeString()}</span>
<strong>{event.phase}</strong>
<em>{event.severity}</em>
</div>
<p>{event.message}</p>
{event.details && (
<div className="scan-evidence-details">
{Object.entries(event.details).map(([key, value]) => (
<span key={key}>{key}: {String(value ?? "-")}</span>
))}
</div>
)}
{event.capture && (
<div className="scan-evidence-capture">
<div>
<strong>{event.capture.name}</strong>
<span>{event.capture.width}x{event.capture.height} · {event.capture.target ?? "capture"} · fp {event.capture.fingerprint}</span>
{event.capture.grid && (
<span>grid {event.capture.grid.cols}x{event.capture.grid.rows} · {event.capture.grid.targets} targets · {event.capture.grid.confidence}% · {event.capture.grid.source}</span>
)}
{event.capture.count && (
<span>count {event.capture.count.current}/{event.capture.count.total || "?"} · {event.capture.count.confidence}% · {event.capture.count.text || "-"}</span>
)}
{event.capture.artifactDetail && (
<span>detail {event.capture.artifactDetail.present ? "yes" : "no"} · {event.capture.artifactDetail.confidence}% · orange {event.capture.artifactDetail.orangeHits} · text {event.capture.artifactDetail.textHits}</span>
)}
{event.capture.paimonMenu && (
<span>paimon {event.capture.paimonMenu.present ? "yes" : "no"} · {event.capture.paimonMenu.confidence}%</span>
)}
{event.capture.layoutWarning && <span className="evidence-warning">{event.capture.layoutWarning}</span>}
</div>
<div className="scan-evidence-images">
{event.capture.screenshots?.inventory && <img src={event.capture.screenshots.inventory} alt={`${event.phase} inventory`} />}
{event.capture.screenshots?.detail && <img src={event.capture.screenshots.detail} alt={`${event.phase} detail`} />}
</div>
</div>
)}
</article>
))}
</div>
) : (
<p className="result-empty">Noch keine Evidence-Events. Starte einen Capture oder Auto-Scan, dann erscheinen hier Schritte mit Screenshots.</p>
)}
</div>
<div className="diagnose-card">
<div className="diagnose-card-heading">
<p className="eyebrow">Crops, OCR &amp; Confidence</p>
<button className="ghost-button" onClick={handleSaveReviewSample} disabled={!canSaveReviewSample}>
<AlertTriangle size={15} />
Review-Sample speichern
</button>
</div>
{controller.parsedArtifact ? (
<>
<FieldConfidenceList parsedArtifact={controller.parsedArtifact} />
{showParsedNotes && (
<div className="parsed-notes compact">
{parsedNotes.map((note) => (
<span key={note}>{note}</span>
))}
</div>
)}
</>
) : (
<p className="result-empty">Noch kein Artifact gelesen. Lies ein Artifact im Scan-Tab, um Crops und OCR zu sehen.</p>
)}
{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>
)}
{latestCapture && <p className="capture-debug">{debugText}</p>}
</div>
</section>
);
}
@@ -1,4 +1,4 @@
import { AlertTriangle, Camera, Eye } from "lucide-react";
import { AlertTriangle, Camera } from "lucide-react";
import { ArtifactResultCard } from "./ScanResultCards";
import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel";
import type { ScanMainSectionProps } from "./types";
@@ -7,49 +7,50 @@ export function ScanMainSection({
latestCapture,
captureStatus,
parsedArtifact,
sourceLabel,
gridLabel,
inventoryLabel,
recentArtifacts,
nativeScanResults,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
autoScanRunning,
canOpenReviewQueue,
openReviewQueue,
openInventory,
}: ScanMainSectionProps) {
const {
canOpenDetails,
handleOpenDetails,
handleOpenInventory,
handleOpenReviewQueue,
captureImageSrc,
captureImageAlt,
hasCapture,
captureModeText,
resultHeading,
noArtifactText,
noCaptureMessage,
targetLabel,
dbLabel,
reviewLabel,
rulesLabel,
recentRows,
recentEmptyText,
canOpenInventory,
} = useScanMainSectionModel({
latestCapture,
parsedArtifact,
recentArtifacts,
nativeScanResults,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
openReviewQueue,
openInventory,
});
return (
<div className="scanner-main-grid">
<div className="capture-stage-shell">
<div className={`capture-stage-shell ${hasCapture ? "has-capture" : "is-empty"}`}>
<div className="capture-stage">
{hasCapture ? (
<img src={captureImageSrc} alt={captureImageAlt} />
@@ -61,12 +62,6 @@ export function ScanMainSection({
</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">
@@ -77,24 +72,47 @@ export function ScanMainSection({
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
<p className="result-empty">{noArtifactText}</p>
)}
<div className="scanner-result-rail">
<div className="result-rail-heading">
<p className="eyebrow">Letzte Ergebnisse</p>
<span>{recentRows.length}</span>
</div>
{recentRows.length > 0 ? (
<div className="result-rail-list">
{recentRows.map((row) => (
<button
className="result-rail-row"
key={row.id}
onClick={handleOpenInventory}
disabled={!canOpenInventory}
title="Im Inventory oeffnen"
>
<span className="result-rail-index">{row.sequence}</span>
<span className="result-rail-main">
<strong>{row.title}</strong>
<span>{row.meta}</span>
</span>
<span className="result-rail-score">{row.score}</span>
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
</button>
))}
</div>
) : (
<p className="result-empty compact">{recentEmptyText}</p>
)}
</div>
<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} />
<button className="ghost-button" onClick={handleOpenDetails} disabled={!canOpenDetails}>
Details
</button>
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
<AlertTriangle size={15} />
Review Queue
Review
</button>
</div>
<p className="scanner-result-caption">{captureStatus}</p>
@@ -1,46 +1,27 @@
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";
// Diagnostics + crop/OCR details moved to the dedicated Diagnose view; only the
// core workspace modals live here.
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}
@@ -5,7 +5,6 @@ import {
Radar,
RefreshCw,
SlidersHorizontal,
Wrench,
} from "lucide-react";
import type { ScanTopControlsSectionProps } from "./types";
import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel";
@@ -33,7 +32,7 @@ export function ScanTopControlsSection({
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runGuidedAutoScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
@@ -47,7 +46,6 @@ export function ScanTopControlsSection({
refreshCaptureSourcesTitle,
diagnosticsButtonTitle,
scanSetupButtonTitle,
showPlayerProgress,
progressWidth,
progressStats,
} = useScanTopControlsModel({
@@ -65,8 +63,7 @@ export function ScanTopControlsSection({
<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>
<h2>Artifact Scan</h2>
</div>
<div className="scanner-header-pills">
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
@@ -118,57 +115,44 @@ export function ScanTopControlsSection({
<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
<div className="player-scan-lower">
<div className="player-scan-actions">
<button
className="primary-button scan-cta"
onClick={runGuidedAutoScan}
disabled={!canStartAutoScan}
title={autoScanButtonTitle}
>
<Play size={16} />
{autoScanButtonLabel}
</button>
)}
</div>
<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
</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}%` }} />
@@ -181,7 +165,11 @@ export function ScanTopControlsSection({
))}
</div>
</div>
)}
</div>
<p className="player-status">
{playerStatusText}
</p>
</div>
</>
);
@@ -14,6 +14,7 @@ export function ScanViewLayout({
bridgeReady,
controller,
isScanning,
onOpenInventory,
}: ScanViewLayoutProps) {
const {
setDetailsOpen,
@@ -24,6 +25,8 @@ export function ScanViewLayout({
reviewSampleTotal,
autoScanRunning,
storedTotal,
recentArtifacts,
nativeScanResults,
learningRulesLoaded,
parsedArtifact,
learningRuleCount,
@@ -52,6 +55,8 @@ export function ScanViewLayout({
latestCapture={latestCapture}
captureStatus={captureStatus}
parsedArtifact={parsedArtifact}
recentArtifacts={recentArtifacts}
nativeScanResults={nativeScanResults}
sourceLabel={sourceLabel}
gridLabel={gridLabel}
inventoryLabel={inventoryLabel}
@@ -64,6 +69,7 @@ export function ScanViewLayout({
openReviewQueue={openReviewQueue}
autoScanRunning={autoScanRunning}
setDetailsOpen={setDetailsOpen}
openInventory={onOpenInventory}
/>
<ScanModalsSection
@@ -1,50 +1,68 @@
import { useCallback } from "react";
import type { ScanMainSectionProps } from "../types";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../../types/storage";
export interface ScanResultRailRow {
id: string;
sequence: string;
title: string;
meta: string;
score: string;
statusLabel: string;
statusClassName: string;
}
export interface ScanMainSectionModel {
canOpenDetails: boolean;
handleOpenDetails: () => void;
handleOpenInventory: () => 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;
recentRows: ScanResultRailRow[];
recentEmptyText: string;
canOpenInventory: boolean;
}
type UseScanMainSectionModelProps = Pick<
ScanMainSectionProps,
| "latestCapture"
| "parsedArtifact"
| "recentArtifacts"
| "nativeScanResults"
| "activeTargetCount"
| "storedTotal"
| "reviewSampleTotal"
| "learningRulesLoaded"
| "learningRuleCount"
| "setDetailsOpen"
| "openReviewQueue"
| "openInventory"
>;
export function useScanMainSectionModel({
latestCapture,
parsedArtifact,
recentArtifacts,
nativeScanResults,
activeTargetCount,
storedTotal,
reviewSampleTotal,
learningRulesLoaded,
learningRuleCount,
setDetailsOpen,
openReviewQueue,
openInventory,
}: UseScanMainSectionModelProps): ScanMainSectionModel {
const handleOpenDetails = useCallback(() => {
setDetailsOpen(true);
}, [setDetailsOpen]);
const handleOpenInventory = useCallback(() => {
openInventory?.();
}, [openInventory]);
const handleOpenReviewQueue = useCallback(() => {
void openReviewQueue();
}, [openReviewQueue]);
@@ -53,29 +71,85 @@ export function useScanMainSectionModel({
const captureImageAlt = latestCapture
? `Latest capture from ${latestCapture.name}`
: "Latest capture is not available yet";
const captureModeText = latestCapture ? "Erkannt" : "Warte";
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
const noCaptureMessage = noArtifactText;
const targetLabel = `Ziel ${activeTargetCount}`;
const dbLabel = `DB ${storedTotal ?? "-"}`;
const reviewLabel = `Review ${reviewSampleTotal}`;
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
const nativeRows = nativeScanResults
.slice(-6)
.reverse()
.map(nativeScanResultToRailRow);
const storedRows = recentArtifacts.map(storedArtifactToRailRow);
const recentRows = nativeRows.length > 0 ? nativeRows : storedRows;
const recentEmptyText = nativeRows.length > 0
? "Native Ergebnisse werden geladen."
: "Noch keine gespeicherten Scan-Ergebnisse.";
return {
canOpenDetails,
handleOpenDetails,
handleOpenInventory,
handleOpenReviewQueue,
captureImageSrc,
captureImageAlt,
hasCapture: Boolean(latestCapture),
captureModeText,
resultHeading,
noArtifactText,
noCaptureMessage,
targetLabel,
dbLabel,
reviewLabel,
rulesLabel,
recentRows,
recentEmptyText,
canOpenInventory: Boolean(openInventory && recentRows.length > 0),
};
}
function storedArtifactToRailRow(artifact: StoredArtifactRecord, index: number): ScanResultRailRow {
const statusLabel = artifact.needsReview
? "Review"
: (artifact.timesSeen ?? 1) > 1
? "Duplikat"
: "Gelesen";
const statusClassName = artifact.needsReview
? "review"
: (artifact.timesSeen ?? 1) > 1
? "duplicate"
: "stored";
const levelText = typeof artifact.level === "number" ? `+${artifact.level}` : "+?";
return {
id: artifact.id,
sequence: `#${index + 1}`,
title: artifact.name || `${artifact.slot} ${artifact.setName}`,
meta: `${artifact.slot} - ${artifact.mainStat} - ${levelText}`,
score: artifact.needsReview ? "Review" : "Wert offen",
statusLabel,
statusClassName,
};
}
function nativeScanResultToRailRow(entry: StoredScanResultEntry): ScanResultRailRow {
const artifact = entry.artifact;
const levelText = typeof artifact?.level === "number" ? `+${artifact.level}` : "+?";
const isClean = entry.extractionStatus === "parsed" && !entry.needsReview;
const statusLabel = isClean
? entry.persistedArtifact ? "Gespeichert" : "Geparst"
: entry.extractionStatus === "missing_crop"
? "Crop fehlt"
: "Review";
const statusClassName = isClean ? entry.persistedArtifact ? "stored" : "parsed" : "review";
return {
id: entry.id,
sequence: `#${entry.sequence}`,
title: artifact?.name || `Artifact ${entry.sequence}`,
meta: artifact
? `${artifact.slot} - ${artifact.mainStat} - ${levelText}`
: entry.error || entry.extractionStatus,
score: entry.valueStatus === "deferred" ? "Wert offen" : "Review",
statusLabel,
statusClassName,
};
}
@@ -60,19 +60,20 @@ export function useScanResultCardModel({
parsed,
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
const substats = parsed.substats;
const rows: Array<[string, ParsedField]> = [
["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],
];
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]) => ({
fieldRows: rows.map(([label, field]) => ({
label,
field,
confidenceClassName: resolveFieldConfidenceClass(field),
@@ -1,4 +1,12 @@
import type { ScanSummaryFooterProps } from "../types";
import type { ScanSummaryFooterProps } from "../types";
function formatDuration(ms: number) {
if (ms <= 0) return "0s";
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
}
export interface ScanSummaryFooterModel {
summaryCopy: string;
@@ -12,10 +20,10 @@ export function useScanSummaryFooterModel({
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen in ${formatDuration(scanSummary.elapsedMs)} (${scanSummary.averageMsPerParsed || 0} ms/Artifact). Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
const devCopy = devMode
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | roundtrip ${scanSummary.averageCaptureRoundTripMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
: null;
return {
@@ -8,13 +8,14 @@ export interface ScanTopControlsModel {
canStartAutoScan: boolean;
canStartManualScan: boolean;
canCaptureSingle: boolean;
autoScanRunning: boolean;
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
selectGenshinSource: () => void;
openSettings: () => void;
openDiagnostics: () => void;
captureSingleArtifact: () => void;
stopScan: () => void;
runVisibleGridScan: () => void;
runGuidedAutoScan: () => void;
runAutoReviewScan: () => void;
bridgeStatusText: string;
bridgePillClass: string;
@@ -41,12 +42,12 @@ export function useScanTopControlsModel({
bridgeReady,
isScanning,
controller,
}: ScanTopControlsSectionProps): ScanTopControlsModel {
}: Omit<ScanTopControlsSectionProps, "refreshCaptureSources">): ScanTopControlsModel {
const {
setSettingsOpen,
setDiagnosticsOpen,
requestScanStop,
runVisibleGridScan,
runGuidedAutoScan,
runAutoReviewScan,
autoScanRunning,
canCaptureSource,
@@ -76,17 +77,20 @@ export function useScanTopControlsModel({
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
const startGuidedAutoScan = useCallback(() => {
void runGuidedAutoScan();
}, [runGuidedAutoScan]);
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
const bridgePillClass = bridgeReady ? "elevated" : "standard";
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
const playerStatusText = reviewStatus
|| (runtimeInfo?.isElevated
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
? "App laeuft als Administrator. Auto-Scan prueft den Screen ohne OCR und startet erst, wenn eine Artifact-Detailkarte offen ist."
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
const autoScanButtonTitle = requiresAdminForAutoScan
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
: "Prueft zuerst ohne OCR den Screen, oeffnet bei Bedarf per ESC/B-Fallback das Artifact-Inventar und scannt erst mit sichtbarer Detailkarte.";
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
@@ -103,11 +107,20 @@ export function useScanTopControlsModel({
{ label: "Klicks", value: autoScanStats.clicked },
{ label: "Positionen", value: autoScanStats.attempted },
{ label: "Verifiziert", value: autoScanStats.verified },
{ label: "ms/Artifact", value: autoScanStats.averageMsPerParsed || "-" },
{ label: "Gespeichert", value: autoScanStats.stored },
{ label: "Review", value: autoScanStats.review },
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
],
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
[
autoScanStats.clicked,
autoScanStats.attempted,
autoScanStats.verified,
autoScanStats.averageMsPerParsed,
autoScanStats.stored,
autoScanStats.review,
storedTotal,
],
);
return {
@@ -116,13 +129,14 @@ export function useScanTopControlsModel({
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
autoScanRunning,
handleSourceChange,
selectGenshinSource,
openSettings,
openDiagnostics,
captureSingleArtifact,
stopScan,
runVisibleGridScan,
runGuidedAutoScan: startGuidedAutoScan,
runAutoReviewScan,
bridgeStatusText,
bridgePillClass,
@@ -1,83 +0,0 @@
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>
);
}
@@ -1,172 +0,0 @@
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>
);
}
@@ -1,5 +1,6 @@
import type { ScanSettingsModalProps } from "./types";
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
import type { StepperControlModel } from "./hooks/useScanSettingsModalModel";
export function ScanSettingsModal({
open,
@@ -16,9 +17,9 @@ export function ScanSettingsModal({
const {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
scanLimitControl,
skipRowsControl,
inventoryCountText,
inventoryClassName,
scanLimitClassName,
@@ -50,33 +51,17 @@ export function ScanSettingsModal({
<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 className="scan-settings-layout">
<div className="scan-settings-controls">
<StepperControl control={scanLimitControl} />
<StepperControl control={skipRowsControl} />
</div>
<div className="scan-settings-note">
<strong>Manuelle Werte bleiben erhalten.</strong>
<span>Die erkannte Inventar-Anzahl ist nur ein Vorschlag und oberer Deckel. Startzeilen brauchst du nur, wenn du nicht oben beginnst.</span>
</div>
</div>
<div className="scanner-preflight diagnostics-preflight">
<div className="scanner-preflight settings-preflight">
<div className={inventoryClassName}>
<span>Inventarzaehler</span>
<strong>{inventoryCountText}</strong>
@@ -97,3 +82,42 @@ export function ScanSettingsModal({
</div>
);
}
function StepperControl({ control }: { control: StepperControlModel }) {
return (
<section className="settings-stepper" aria-label={control.label}>
<div className="settings-stepper-head">
<div>
<span>{control.label}</span>
<small>{control.helper}</small>
</div>
</div>
<div className="settings-stepper-row">
<button type="button" className="stepper-button" onClick={control.decrement} aria-label={`${control.label} verringern`}>
</button>
<input
inputMode="numeric"
pattern="[0-9]*"
min={control.min}
max={control.max}
value={control.value}
onChange={control.onChange}
onBlur={control.onBlur}
onKeyDown={control.onKeyDown}
aria-label={control.label}
/>
<button type="button" className="stepper-button" onClick={control.increment} aria-label={`${control.label} erhoehen`}>
+
</button>
</div>
<div className="settings-presets" aria-label={`${control.label} Presets`}>
{control.presets.map((value) => (
<button type="button" key={value} onClick={() => control.applyPreset(value)}>
{value}
</button>
))}
</div>
</section>
);
}
@@ -1,6 +1,6 @@
import { useCallback, type MouseEvent } from "react";
import type { ScanDetailsModalProps } from "../types";
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
import type { ParsedArtifactCandidate } from "../../../../../lib/artifactOcrParser";
export interface ScanDetailsModalModel {
closeDetails: () => void;
@@ -33,21 +33,24 @@ export function useScanDetailsModalModel({
const crops = latestCapture?.crops ?? [];
const ocr = latestCapture?.ocr ?? [];
const cropRows = crops
.filter((crop) => Boolean(crop.dataUrl))
.map((crop) => ({
id: crop.id,
dataUrl: crop.dataUrl ?? "",
label: crop.label,
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
}));
return {
closeDetails,
stopPropagation,
parsedNotes,
showParsedNotes: parsedNotes.length > 0,
cropRows: crops.map((crop) => ({
id: crop.id,
dataUrl: crop.dataUrl,
label: crop.label,
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
})),
cropRows,
ocrRows: ocr.map((entry) => ({
id: entry.id,
label: entry.label,
@@ -55,7 +58,7 @@ export function useScanDetailsModalModel({
text: entry.text || "No text detected",
})),
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
showCrops: crops.length > 0,
showCrops: cropRows.length > 0,
showOcr: ocr.length > 0,
};
}
@@ -1,7 +1,10 @@
import { detailFingerprint } from "../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../lib/genshinData";
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData";
import { dataPackageStatus } from "../../../../../lib/dataPackageStatus";
import { validateLookupPackage } from "../../../../../lib/genshinLookup";
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types";
import type { ScanDiagnosticEvent } from "../../../../../lib/scanDiagnosticsLog";
export interface ScanDiagnosticsModelProgress {
width: number;
@@ -36,19 +39,24 @@ export interface ScanDiagnosticsModalModel {
showDevRows: boolean;
reviewStatus: string;
automationLogLines: string[];
diagnosticEvents: ScanDiagnosticEvent[];
canSaveReviewSample: boolean;
}
type ScanDiagnosticsController = ScanDiagnosticsModalProps["controller"];
interface UseScanDiagnosticsModalModelInput extends Pick<
ScanDiagnosticsModalProps,
| "setDetailsOpen"
| "setDiagnosticsOpen"
| "saveReviewSample"
| "canSaveReviewSample"
| "latestCapture"
| "controller"
> {
captureStatus: string;
// saveReviewSample / canSaveReviewSample live on the controller, not the modal
// props; the component wires them through from controller.* .
saveReviewSample: ScanDiagnosticsController["saveReviewSample"];
canSaveReviewSample: ScanDiagnosticsController["canSaveReviewSample"];
}
export function useScanDiagnosticsModalModel({
@@ -92,7 +100,12 @@ export function useScanDiagnosticsModalModel({
: "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 dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion);
const lookupStatus = validateLookupPackage();
const lookupText = lookupStatus.valid
? `Lookup OK: ${lookupStatus.summary.artifactSets} sets / ${lookupStatus.summary.artifactPieces} pieces`
: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown error"}`;
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. ${lookupText}. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
const playerProgress = useMemo(() => {
const width = Math.min(
@@ -122,6 +135,27 @@ export function useScanDiagnosticsModalModel({
{ label: "duplicates", value: controller.autoScanStats.duplicates },
{ label: "misses", value: controller.autoScanStats.misses },
{ label: "pages", value: controller.autoScanStats.pages },
{ label: "elapsedMs", value: controller.autoScanStats.elapsedMs },
{ label: "activeScanMs", value: controller.autoScanStats.activeScanMs },
{ label: "writeFlushMs", value: controller.autoScanStats.writeFlushMs },
{ label: "avgMs", value: controller.autoScanStats.averageMsPerParsed },
{ label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed },
{ label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs },
{ label: "avgCaptureRoundTripMs", value: controller.autoScanStats.averageCaptureRoundTripMs },
{ label: "avgCaptureRoundTripOverheadMs", value: controller.autoScanStats.averageCaptureRoundTripOverheadMs },
{ label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms },
{ label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms },
{ label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs },
{ label: "ocrP50Ms", value: controller.autoScanStats.ocrP50Ms },
{ label: "ocrP90Ms", value: controller.autoScanStats.ocrP90Ms },
{ label: "cardReadyAvgMs", value: controller.autoScanStats.averageCardReadyMs },
{ label: "cardReadyCount", value: controller.autoScanStats.cardReadyCount },
{ label: "scrollReadyAvgMs", value: controller.autoScanStats.averageScrollReadyMs },
{ label: "scrollReadyCount", value: controller.autoScanStats.scrollReadyCount },
{ label: "perMin x10", value: Math.round(controller.autoScanStats.artifactsPerMinute * 10) },
{ label: "activePerMin x10", value: Math.round(controller.autoScanStats.activeArtifactsPerMinute * 10) },
{ label: "projected100Ms", value: controller.autoScanStats.projectedMsFor100 },
{ label: "activeProjected100Ms", value: controller.autoScanStats.activeProjectedMsFor100 },
],
[
controller.autoScanStats.clicked,
@@ -133,6 +167,27 @@ export function useScanDiagnosticsModalModel({
controller.autoScanStats.duplicates,
controller.autoScanStats.misses,
controller.autoScanStats.pages,
controller.autoScanStats.elapsedMs,
controller.autoScanStats.activeScanMs,
controller.autoScanStats.writeFlushMs,
controller.autoScanStats.averageMsPerParsed,
controller.autoScanStats.activeAverageMsPerParsed,
controller.autoScanStats.averageCaptureMs,
controller.autoScanStats.averageCaptureRoundTripMs,
controller.autoScanStats.averageCaptureRoundTripOverheadMs,
controller.autoScanStats.captureP50Ms,
controller.autoScanStats.captureP90Ms,
controller.autoScanStats.averageOcrMs,
controller.autoScanStats.ocrP50Ms,
controller.autoScanStats.ocrP90Ms,
controller.autoScanStats.averageCardReadyMs,
controller.autoScanStats.cardReadyCount,
controller.autoScanStats.averageScrollReadyMs,
controller.autoScanStats.scrollReadyCount,
controller.autoScanStats.artifactsPerMinute,
controller.autoScanStats.activeArtifactsPerMinute,
controller.autoScanStats.projectedMsFor100,
controller.autoScanStats.activeProjectedMsFor100,
],
);
@@ -180,6 +235,7 @@ export function useScanDiagnosticsModalModel({
showDevRows: controller.devMode,
reviewStatus: controller.reviewStatus,
automationLogLines: controller.automationLog,
diagnosticEvents: controller.diagnosticEvents,
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
};
}
@@ -1,5 +1,5 @@
import { useCallback, useMemo, type MouseEvent } from "react";
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
import type { ReviewSampleAnalysis } from "../../../../../lib/reviewSampleAnalysis";
import type { ScanReviewQueueModalProps } from "../types";
export interface ScanReviewQueueRow {
@@ -18,8 +18,10 @@ export interface ScanReviewQueueModalModel {
interface UseScanReviewQueueModalModelInput extends Pick<
ScanReviewQueueModalProps,
"setReviewQueueOpen" | "loadReviewQueue"
"setReviewQueueOpen"
> {
// loadReviewQueue is wired through from controller.* by the component.
loadReviewQueue: ScanReviewQueueModalProps["controller"]["loadReviewQueue"];
reviewAnalysis: ReviewSampleAnalysis;
reviewSampleTotal: number;
}
@@ -1,12 +1,27 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
import { useCallback, useEffect, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
export interface StepperControlModel {
label: string;
value: string;
helper: string;
min: number;
max: number;
presets: number[];
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onBlur: (event: FocusEvent<HTMLInputElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
decrement: () => void;
increment: () => void;
applyPreset: (value: number) => void;
}
export interface ScanSettingsModalModel {
closeSettings: () => void;
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
scanLimitControl: StepperControlModel;
skipRowsControl: StepperControlModel;
inventoryCountText: string;
inventoryClassName: string;
scanLimitClassName: string;
@@ -36,17 +51,71 @@ export function useScanSettingsModalModel({
setScanLimitTouched: (touched: boolean) => void;
setSkipRows: (rows: number) => void;
}): ScanSettingsModalModel {
const [scanLimitText, setScanLimitText] = useState(String(scanLimit));
const [skipRowsText, setSkipRowsText] = useState(String(skipRows));
useEffect(() => {
setScanLimitText(String(scanLimit));
}, [scanLimit]);
useEffect(() => {
setSkipRowsText(String(skipRows));
}, [skipRows]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const handleScanLimitChange = useCallback((event: ChangeEvent<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 applyScanLimit = useCallback((value: number) => {
const next = clampScanLimit(value);
setScanLimitTouched(true);
setScanLimit(next);
setScanLimitText(String(next));
}, [setScanLimit, setScanLimitTouched]);
const applySkipRows = useCallback((value: number) => {
const next = clampSkipRows(value);
setSkipRows(next);
setSkipRowsText(String(next));
}, [setSkipRows]);
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setScanLimitText(event.target.value.replace(/\D/g, "").slice(0, 4));
}, []);
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSkipRowsText(event.target.value.replace(/\D/g, "").slice(0, 2));
}, []);
const commitScanLimit = useCallback((rawValue: string) => {
applyScanLimit(rawValue.trim() === "" ? scanLimit : Number(rawValue));
}, [applyScanLimit, scanLimit]);
const commitSkipRows = useCallback((rawValue: string) => {
applySkipRows(rawValue.trim() === "" ? skipRows : Number(rawValue));
}, [applySkipRows, skipRows]);
const handleScanLimitBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
commitScanLimit(event.target.value);
}, [commitScanLimit]);
const handleSkipRowsBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
commitSkipRows(event.target.value);
}, [commitSkipRows]);
const handleScanLimitKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
commitScanLimit(event.currentTarget.value);
event.currentTarget.blur();
}, [commitScanLimit]);
const handleSkipRowsKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
commitSkipRows(event.currentTarget.value);
event.currentTarget.blur();
}, [commitSkipRows]);
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
@@ -56,14 +125,40 @@ export function useScanSettingsModalModel({
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
const activeTargetText = `${activeTargetCount} Ziele · ${focusModeText}`;
const scanSummaryText = "Auto-Scan zaehlt Positionen, verifizierte Ansichten und gespeicherte Artifacts getrennt.";
return {
closeSettings,
handleScanLimitChange,
handleSkipRowsChange,
stopPropagation,
scanLimitControl: {
label: "Scan-Ziel",
value: scanLimitText,
helper: "Wie viele sichtbare Positionen verarbeitet werden.",
min: 1,
max: 1800,
presets: [16, 20, 50, 100],
onChange: handleScanLimitChange,
onBlur: handleScanLimitBlur,
onKeyDown: handleScanLimitKeyDown,
decrement: () => applyScanLimit(scanLimit - 1),
increment: () => applyScanLimit(scanLimit + 1),
applyPreset: applyScanLimit,
},
skipRowsControl: {
label: "Startzeilen ueberspringen",
value: skipRowsText,
helper: "Nur nutzen, wenn du mitten in der Liste beginnst.",
min: 0,
max: 8,
presets: [0, 1, 2, 3],
onChange: handleSkipRowsChange,
onBlur: handleSkipRowsBlur,
onKeyDown: handleSkipRowsKeyDown,
decrement: () => applySkipRows(skipRows - 1),
increment: () => applySkipRows(skipRows + 1),
applyPreset: applySkipRows,
},
inventoryCountText,
inventoryClassName,
scanLimitClassName,
+4
View File
@@ -33,12 +33,15 @@ export interface ScanViewLayoutProps {
bridgeReady: boolean;
controller: ScanViewControllerResult;
isScanning: boolean;
onOpenInventory?: ScanViewProps["onOpenInventory"];
}
export interface ScanMainSectionProps {
latestCapture: ScanViewProps["latestCapture"];
captureStatus: string;
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
recentArtifacts: ScanViewControllerResult["recentArtifacts"];
nativeScanResults: ScanViewControllerResult["nativeScanResults"];
sourceLabel: string;
gridLabel: string;
inventoryLabel: string;
@@ -51,6 +54,7 @@ export interface ScanMainSectionProps {
autoScanRunning: boolean;
openReviewQueue: () => void;
setDetailsOpen: (value: boolean) => void;
openInventory?: ScanViewProps["onOpenInventory"];
}
export interface ScanTopControlsSectionProps {
@@ -3,6 +3,7 @@ import type { ScannerLearningRules } from "../../../lib/scannerLearning";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
import type { ScanActionContext } from "./scanViewScanActions";
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type {
ReviewStateContext,
} from "./scanViewReviewHelpers";
@@ -42,6 +43,7 @@ export interface ScanActionContextInput {
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (
capture: CaptureResult | null,
@@ -49,6 +51,14 @@ export interface ScanActionContextInput {
source: string,
needsReview: boolean,
) => Promise<boolean>;
persistParsedArtifactsBatch?: (
items: Array<{
capture: CaptureResult | null;
parsed: ParsedArtifactCandidate;
source: string;
needsReview: boolean;
}>,
) => Promise<number>;
saveReviewSample: (
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
@@ -60,7 +70,11 @@ export interface ScanActionContextInput {
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
captureFastSelectedSource: (
delayMs?: number,
focusGenshin?: boolean,
options?: CaptureOptions,
) => Promise<CaptureResult | null>;
}
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
@@ -98,8 +112,10 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
setReviewStatus: input.setReviewStatus,
appendAutomationLog: input.appendAutomationLog,
appendClickDiagnostics: input.appendClickDiagnostics,
appendDiagnosticEvent: input.appendDiagnosticEvent,
parseArtifact: input.parseArtifact,
persistParsedArtifact: input.persistParsedArtifact,
persistParsedArtifactsBatch: input.persistParsedArtifactsBatch,
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
saveReviewSample: input.saveReviewSample,
focusDashboard: input.focusDashboard,
@@ -0,0 +1,254 @@
import {
artifactTabClickTarget,
keyPressBlocked,
validateAutoScanEntryPreflight,
type ScanEntryMode,
} from "../../../lib/autoScanEntry";
import { wait } from "../../../lib/scanReviewUtils";
import {
summarizeClickResult,
summarizeKeyPressResult,
type createScanDiagnosticEvent,
} from "../../../lib/scanDiagnosticsLog";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { CaptureOptions, CaptureResult } from "../../../types/global";
export interface PrepareAutoScanEntryInput {
mode: ScanEntryMode;
automationRepo: AutomationRepositoryPort;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
appendAutomationLog: (line: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
}
export async function prepareAutoScanEntry({
mode,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
}: PrepareAutoScanEntryInput) {
if (mode === "visible-inventory") {
const capture = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 900,
predicate: (candidate) => validateAutoScanEntryPreflight(candidate).ok,
});
appendDiagnosticEvent({
phase: "entry-visible",
severity: capture ? "ok" : "error",
message: capture && validateAutoScanEntryPreflight(capture).ok
? "Visible inventory preflight capture ready."
: "Visible inventory preflight capture failed.",
capture,
});
return capture;
}
if (mode === "direct-inventory") {
return tryInventoryEntrySequence({
label: "direct",
sendEscapeFirst: false,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
}
if (mode === "auto-entry") {
const directCapture = await tryInventoryEntrySequence({
label: "auto-direct",
sendEscapeFirst: false,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
const directPreflight = validateAutoScanEntryPreflight(directCapture);
if (directPreflight.ok) return directCapture;
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
appendDiagnosticEvent({
phase: "entry-fallback",
severity: "info",
message: `Direct inventory entry did not reach an artifact detail card. Trying ESC/B fallback. ${directPreflight.reason}`,
capture: directCapture,
});
}
return tryInventoryEntrySequence({
label: "paimon",
sendEscapeFirst: true,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
}
async function tryInventoryEntrySequence({
label,
sendEscapeFirst,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
}: {
label: string;
sendEscapeFirst: boolean;
automationRepo: AutomationRepositoryPort;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
appendAutomationLog: (line: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
}) {
if (sendEscapeFirst) {
const escapeResult = await automationRepo.keyPress?.("ESC");
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
message: `${label} entry key ESC`,
details: summarizeKeyPressResult(escapeResult),
});
if (keyPressBlocked(escapeResult)) return null;
const menuProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 750,
predicate: (capture) => Boolean(capture),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: menuProbe ? "info" : "warn",
message: `${label} capture after ESC step.`,
capture: menuProbe,
});
if (menuProbe?.paimonMenu?.present) {
const closeMenuResult = await automationRepo.keyPress?.("ESC");
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
message: `${label} entry key ESC close menu`,
details: summarizeKeyPressResult(closeMenuResult),
});
if (keyPressBlocked(closeMenuResult)) return menuProbe;
const worldProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 750,
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: worldProbe ? "info" : "warn",
message: `${label} capture after closing Paimon menu.`,
capture: worldProbe,
});
if (worldProbe?.paimonMenu?.present) {
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
return worldProbe;
}
}
}
const inventoryResult = await automationRepo.keyPress?.("B");
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
appendDiagnosticEvent({
phase: "entry-key",
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
message: `${label} entry key B`,
details: summarizeKeyPressResult(inventoryResult),
});
if (keyPressBlocked(inventoryResult)) return null;
const tabProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 1200,
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: tabProbe ? "info" : "warn",
message: `${label} capture after Inventory-key step.`,
capture: tabProbe,
});
if (tabProbe?.paimonMenu?.present) {
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
appendDiagnosticEvent({
phase: "entry-capture",
severity: "info",
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
capture: tabProbe,
});
return tabProbe;
}
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
const target = artifactTabClickTarget(tabProbe);
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
const click = await automationRepo.clickScreen(target.x, target.y);
appendDiagnosticEvent({
phase: "entry-click",
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
message: `${label} artifact tab click at ${target.x},${target.y}`,
details: summarizeClickResult(click),
capture: tabProbe,
});
if (click.inputBlocked || click.clicked === false) return null;
const gridProbe = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 900,
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: gridProbe ? "info" : "warn",
message: `${label} capture after artifact-tab click before first tile selection.`,
capture: gridProbe,
});
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
if (!firstTarget) return gridProbe;
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
appendDiagnosticEvent({
phase: "entry-click",
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
details: summarizeClickResult(firstTileClick),
capture: gridProbe,
});
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
const finalCapture = await waitForEntryCapture({
captureFastSelectedSource,
timeoutMs: 900,
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
});
appendDiagnosticEvent({
phase: "entry-capture",
severity: finalCapture ? "info" : "warn",
message: `${label} final capture after first artifact selection.`,
capture: finalCapture,
});
return finalCapture;
}
async function waitForEntryCapture({
captureFastSelectedSource,
timeoutMs,
pollMs = 150,
predicate,
}: {
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
timeoutMs: number;
pollMs?: number;
predicate: (capture: CaptureResult | null) => boolean;
}) {
const startedAt = Date.now();
let latest: CaptureResult | null = null;
while (Date.now() - startedAt <= timeoutMs) {
latest = await captureFastSelectedSource(0, true);
if (predicate(latest)) return latest;
const remaining = timeoutMs - (Date.now() - startedAt);
if (remaining <= 0) break;
await wait(Math.min(pollMs, remaining));
}
return latest;
}
+123 -28
View File
@@ -110,7 +110,7 @@ function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, inco
}
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
return mergeLearningRulePayloads({}, loadedRules?.rules);
}
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
@@ -213,17 +213,47 @@ export async function mergeLearningRules(
context: ReviewStateContext,
) {
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
const merged = {
textReplacements: {
...currentRules.textReplacements,
...(nextRules.textReplacements ?? {}),
},
};
const merged = mergeLearningRulePayloads(currentRules, nextRules);
context.setScannerLearningRules(merged);
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
return { result, merged };
}
function mergeLearningRulePayloads(
current: Partial<ScannerLearningRules> | null | undefined,
next: Partial<ScannerLearningRules> | null | undefined,
): ScannerLearningRules {
return {
textReplacements: {
...(current?.textReplacements ?? {}),
...(next?.textReplacements ?? {}),
},
fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases),
constrainedFixes: {
...(current?.constrainedFixes ?? {}),
...(next?.constrainedFixes ?? {}),
},
cropAdjustments: {
...(current?.cropAdjustments ?? {}),
...(next?.cropAdjustments ?? {}),
},
uiProfileAdjustments: {
...(current?.uiProfileAdjustments ?? {}),
...(next?.uiProfileAdjustments ?? {}),
},
};
}
function mergeNestedRuleMap(
current: Record<string, Record<string, string>> | undefined,
next: Record<string, Record<string, string>> | undefined,
) {
const merged: Record<string, Record<string, string>> = {};
for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) };
for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
return merged;
}
export async function persistParsedArtifact(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
@@ -244,7 +274,7 @@ export async function persistParsedArtifact(
return false;
}
try {
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview, capture?.locked)]);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
@@ -256,6 +286,47 @@ export async function persistParsedArtifact(
}
}
export async function persistParsedArtifactsBatch(
items: Array<{
capture: CaptureResult | null;
parsed: ParsedArtifactCandidate;
source: string;
needsReview: boolean;
}>,
context: ReviewStateContext,
) {
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
if (!artifactRepo?.saveMany || items.length === 0) return 0;
const records: StoredArtifactRecord[] = [];
for (const item of items) {
const rejection = captureRejectionReason(item.capture, item.parsed);
if (rejection) {
appendAutomationLog(`persist skip: ${rejection}`);
continue;
}
if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) {
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`);
continue;
}
records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked));
}
if (records.length === 0) return 0;
try {
const result = await artifactRepo.saveMany(records);
if (result?.ok) {
setStoredTotal(result.total);
void onStoredArtifactsChanged?.();
return records.length;
}
return 0;
} catch {
return 0;
}
}
export async function saveReviewSample(
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
@@ -277,28 +348,52 @@ export async function saveReviewSample(
}
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
const compactAutomaticSample = /^automatic:/i.test(reason);
const sampleCapture = compactAutomaticSample
? {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
detailDataUrl: capture.detailDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
locked: capture.locked,
ocr: capture.ocr,
}
: {
id: capture.id,
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,
locked: capture.locked,
ocr: capture.ocr,
};
const result = await reviewSamplesRepo.saveSample({
reason,
capture: {
id: capture.id,
name: capture.name,
width: capture.width,
height: capture.height,
dataUrl: capture.dataUrl,
detailDataUrl: capture.detailDataUrl,
inventoryDataUrl: capture.inventoryDataUrl,
captureTarget: capture.captureTarget,
capturedAt: capture.capturedAt,
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
id: crop.id,
label: crop.label,
rect: crop.rect,
dataUrl: crop.dataUrl,
})),
inventoryGrid: capture.inventoryGrid,
inventoryCount: capture.inventoryCount,
ocr: capture.ocr,
},
capture: sampleCapture,
parsed,
});
+152 -19
View File
@@ -1,13 +1,24 @@
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
import { validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
import { type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type {
AutomationGuard,
BooleanResult,
CaptureOptions,
CaptureResult,
ClickResult,
FocusGenshinResult,
RuntimeInfo,
ScrollResult,
} from "../../../types/global";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { MutableRefObject } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { prepareAutoScanEntry } from "./scanViewEntryActions";
export interface ScanActionContext {
autoScanRunning: boolean;
@@ -27,15 +38,23 @@ export interface ScanActionContext {
setReviewStatus: Dispatch<SetStateAction<string>>;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
persistParsedArtifactsBatch?: (items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>) => Promise<number>;
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
focusDashboard: () => Promise<void>;
}
export interface VisibleGridScanOptions {
scanLimit?: number;
scanEntryMode?: ScanEntryMode;
processInitialSelection?: boolean;
}
function buildScanSignature(parsed: ParsedArtifactCandidate) {
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
}
@@ -55,6 +74,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
captureSelectedSource,
parseArtifact,
persistParsedArtifact,
persistParsedArtifactsBatch,
saveReviewSample,
shouldFlagArtifactForReview,
focusDashboard,
@@ -70,6 +90,11 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
const seen = new Set<string>();
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
const startedAt = Date.now();
const updateManualStats = () => {
updateScanTiming(stats, startedAt);
setAutoScanStats({ ...stats });
};
let idleTicks = 0;
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
const maxIdleTicks = 90;
@@ -83,7 +108,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
if (capture && rejection) {
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
stats.review++;
setAutoScanStats({ ...stats });
updateManualStats();
}
idleTicks++;
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
@@ -104,6 +129,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
stats.attempted++;
stats.verified++;
stats.parsed++;
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
const reason = getAutoReviewReason(capture, parsed);
const needsReview = shouldFlagArtifactForReview(parsed);
@@ -114,12 +140,13 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
stats.stored++;
}
setAutoScanStats({ ...stats });
updateManualStats();
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
await wait(700);
}
setAutoScanRunning(false);
updateScanTiming(stats, startedAt);
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
await focusDashboard();
@@ -137,7 +164,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
}
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
export async function runVisibleGridScan(context: ScanActionContext, options: VisibleGridScanOptions = {}): Promise<void> {
const {
autoScanRunning,
bridgeReady,
@@ -152,19 +179,27 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
setReviewStatus,
appendAutomationLog,
appendClickDiagnostics,
appendDiagnosticEvent,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
persistParsedArtifactsBatch,
saveReviewSample,
shouldFlagArtifactForReview,
scanLimit,
scanLimit: configuredScanLimit,
skipRows,
detectedInventoryCount,
focusDashboard,
} = context;
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
const scanEntryMode = options.scanEntryMode ?? "visible-inventory";
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) {
setReviewStatus("Auto-Scan-Einstieg ist nicht verfuegbar: Keypress-Bridge fehlt.");
return;
}
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
if (requiresAdminForAutoScan) {
@@ -186,6 +221,12 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
setScanSummary(null);
setAutoScanStats(emptyAutoScanStats);
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
appendDiagnosticEvent({
phase: "scan-start",
severity: "info",
message: `Auto-scan start requested (${scanEntryMode})`,
details: { scanLimit, skipRows, detectedInventoryCount },
});
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
const adminBlockReason = automationBlockReason(freshRuntime);
@@ -193,6 +234,12 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
setAutoScanRunning(false);
setReviewStatus(adminBlockReason);
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
appendDiagnosticEvent({
phase: "preflight",
severity: "error",
message: adminBlockReason,
details: { elevated: freshRuntime?.isElevated, genshinFound: freshRuntime?.genshinFound },
});
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
@@ -206,21 +253,65 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
if (freshRuntime) {
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
appendDiagnosticEvent({
phase: "runtime",
severity: freshRuntime.genshinFound ? "ok" : "warn",
message: `Runtime ping: ${required}`,
details: {
elevated: freshRuntime.isElevated,
foreground: freshRuntime.foregroundProcess,
target: freshRuntime.targetProcess,
helperPid: freshRuntime.helperPid,
},
});
}
const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin;
setReviewStatus("Genshin wird in den Vordergrund geholt...");
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
if (focusResult) {
appendAutomationLog(
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
);
let focusResult: FocusGenshinResult | null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const current = await focusGenshinForScanStart().catch(() => null);
if (!current) {
appendAutomationLog(`focus attempt ${attempt}/3: exception`);
} else {
focusResult = current;
appendAutomationLog(
`focus attempt ${attempt}/3: ${current.focused ? "ok" : "failed"} found:${current.genshinFound ? "yes" : "no"} setForeground:${current.setForegroundResult ?? "n/a"} target:${current.targetProcess || "?"} fg:${current.foregroundProcess || "?"}`,
);
appendDiagnosticEvent({
phase: "focus",
severity: current.focused ? "ok" : "warn",
message: `Focus attempt ${attempt}/3 ${current.focused ? "succeeded" : "failed"}`,
details: {
found: current.genshinFound,
setForeground: current.setForegroundResult,
target: current.targetProcess,
foreground: current.foregroundProcess,
alreadyForeground: current.alreadyForeground,
},
});
if (current.focused) break;
if (!current.genshinFound) break;
}
if (attempt < 3) await wait(300);
}
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);
appendDiagnosticEvent({
phase: "focus",
severity: "error",
message: reason,
details: {
found: focusResult?.genshinFound,
target: focusResult?.targetProcess,
foreground: focusResult?.foregroundProcess,
},
});
setScanSummary({
mode: "Automatischer Scan",
status: "blocked",
@@ -231,7 +322,46 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
return;
}
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
setReviewStatus(scanEntryMode !== "visible-inventory"
? "Genshin ist im Vordergrund. Oeffne Artifact-Inventar und warte auf Detailkarte..."
: "Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
const entryCapture = await prepareAutoScanEntry({
mode: scanEntryMode,
automationRepo,
captureFastSelectedSource,
appendAutomationLog,
appendDiagnosticEvent,
});
const entryPreflight = validateAutoScanEntryPreflight(entryCapture);
if (!entryPreflight.ok) {
setAutoScanRunning(false);
setReviewStatus(entryPreflight.reason);
appendAutomationLog(`entry blocked: ${entryPreflight.reason}`);
appendDiagnosticEvent({
phase: "entry-preflight",
severity: "error",
message: entryPreflight.reason,
capture: entryCapture,
});
setScanSummary({
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
status: "blocked",
...emptyAutoScanStats,
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
gridLabel: entryPreflight.reason,
});
await focusDashboard();
return;
}
setReviewStatus("Artifact-Inventar ist bereit. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
appendDiagnosticEvent({
phase: "entry-preflight",
severity: "ok",
message: "Artifact inventory preflight passed.",
capture: entryCapture,
});
const result = await runAutoScanLoop(
{
@@ -260,10 +390,11 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
automationRepo?.getAutomationGuard?.() ??
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
},
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, options),
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
persistParsedArtifactsBatch,
saveReviewSample,
getAutoReviewReason,
shouldFlagArtifactForReview,
@@ -277,6 +408,8 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
scanLimit,
skipRows,
detectedInventoryCount,
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
skipInitialGridTarget: scanEntryMode !== "visible-inventory",
},
);
@@ -285,7 +418,7 @@ export async function runVisibleGridScan(context: ScanActionContext): Promise<vo
await focusDashboard();
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
setScanSummary({
mode: "Automatischer Scan",
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
status: result.status,
...result.stats,
targetCount: result.targetCount,
@@ -1,5 +1,7 @@
import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { ScannerCommand } from "../../../types/global";
import type { VisibleGridScanOptions } from "./scanViewScanActions";
interface ScanCommandListenerInput {
automationRepo?: AutomationRepositoryPort;
@@ -7,7 +9,8 @@ interface ScanCommandListenerInput {
isScanning: boolean;
selectedSourceId: string;
requestScanStop: (reason: string) => void;
runVisibleGridScan: () => Promise<void>;
runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
}
export function useScanCommandListener({
@@ -16,19 +19,25 @@ export function useScanCommandListener({
isScanning,
selectedSourceId,
requestScanStop,
runGuidedAutoScan,
runVisibleGridScan,
}: ScanCommandListenerInput) {
useEffect(() => {
if (!automationRepo?.onCommand) return;
return automationRepo.onCommand((command: "start-auto" | "stop") => {
return automationRepo.onCommand((command: ScannerCommand) => {
if (command === "stop") {
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
return;
}
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
void runVisibleGridScan();
const commandType = typeof command === "string" ? command : command.type;
const canStartNativeScan = Boolean(automationRepo.nativeScannerStart);
if (commandType === "start-auto" && !autoScanRunning && !isScanning && (selectedSourceId || canStartNativeScan)) {
if (typeof command === "string" || !command.scanEntryMode) {
void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit });
return;
}
void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode });
}
});
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]);
}

Some files were not shown because too many files have changed in this diff Show More