Compare commits
24 Commits
b7dbc618b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2ab61c97 | |||
| 8b9f948c6b | |||
| 639b0b7f59 | |||
| 28d60eb915 | |||
| eb666febb6 | |||
| c10f17b4ef | |||
| 13fd46c104 | |||
| c025daa4f1 | |||
| 8b73c01e46 | |||
| f791d1464c | |||
| 8ebbe91c39 | |||
| ef65c3e6a0 | |||
| 7930e369a7 | |||
| b8309af377 | |||
| c8ae0dd7bf | |||
| 113601f031 | |||
| 39691fd40b | |||
| 8d4d24f0bb | |||
| b8a38ba547 | |||
| dcac155887 | |||
| 6ea3d9e714 | |||
| 2c6c1a8b31 | |||
| c7138b541d | |||
| 92345ef51a |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "vite",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:web"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-1
@@ -2,9 +2,14 @@
|
||||
node_modules/
|
||||
|
||||
# Builds
|
||||
build/
|
||||
dist/
|
||||
dist-electron/
|
||||
outputs/dist/
|
||||
outputs/
|
||||
|
||||
# Native .NET build artifacts
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -61,3 +61,18 @@ A task is done when:
|
||||
- Relevant checks passed or skipped checks are explained.
|
||||
- Scanner/OCR changes fail safely into review or uncertainty.
|
||||
- The final summary names the changed areas and remaining risks.
|
||||
|
||||
## Obsidian Handoff
|
||||
|
||||
For durable project changes, follow:
|
||||
|
||||
`C:\Users\vmbao\Desktop\Ordner\Obsidian-Vault\Resources\Obsidian\Agent Project Sync Protocol.md`
|
||||
|
||||
The compact project mirror is:
|
||||
|
||||
`C:\Users\vmbao\Desktop\Ordner\Obsidian-Vault\Projects\Genshin Artifact Assistant\README.md`
|
||||
|
||||
- Update this repository's canonical docs first, then the Vault mirror at semantic checkpoints.
|
||||
- Sync material changes to scope, status, architecture, decisions, verified scanner state, major risks, next priorities, and repeatable workflows.
|
||||
- Do not mirror every source edit, raw log, or transient debugging result.
|
||||
- If the Vault is unavailable, report the exact pending handoff instead of claiming synchronization.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@AGENTS.md
|
||||
|
||||
# Claude Code
|
||||
|
||||
`AGENTS.md` is authoritative for shared repository instructions. Do not duplicate those rules here. Claude auto memory is non-authoritative; persist durable project changes through the repository documentation and Obsidian handoff defined there.
|
||||
@@ -1,28 +1,46 @@
|
||||
# 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.
|
||||
- Packaged full-owned-inventory acceptance at 2,211 Artifacts over 70 pages,
|
||||
followed by current-package reprocessing at 136 Review (6.15%), 0 errors,
|
||||
and 0 store writes.
|
||||
- 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.
|
||||
- Deterministic roll-efficiency evaluation with reason codes and no fake build-fit claim.
|
||||
- Score-free, versioned Build-Fit V1 evidence contract plus deterministic
|
||||
five-slot/Set/aggregate/conflict verification, a four-profile sourced corpus
|
||||
(Furina, Neuvillette, Raiden Hyperbloom, Nahida Deepwood), explicit source
|
||||
expiry, and a separate read-only evidence-coverage ranker. The Builds view
|
||||
shows at most three explainable native-scan suggestions; it never treats
|
||||
Roll-Efficiency, OCR confidence, character ownership, team, weapon, or
|
||||
rotation as a guessed fit score.
|
||||
- Detail-only Worst/Middle/Best upgrade projection for under-leveled, unambiguously identified 5-star Artifacts with four known legal substats.
|
||||
- Offline replay gate for existing native scan results and an unpacked-package resource/ASAR verifier.
|
||||
- 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)
|
||||
- [docs/DECISIONS.md](docs/DECISIONS.md)
|
||||
- [docs/CHECKLISTS.md](docs/CHECKLISTS.md)
|
||||
- [docs/ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](docs/ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md)
|
||||
- [docs/NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](docs/NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md)
|
||||
- [docs/TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](docs/TOMORROW_LIVE_TEST_PLAN_2026-07-10.md)
|
||||
- [docs/BUILD_FIT_CONTRACT_V1.md](docs/BUILD_FIT_CONTRACT_V1.md)
|
||||
- [docs/workflow.md](docs/workflow.md)
|
||||
|
||||
## Run
|
||||
@@ -34,6 +52,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:
|
||||
@@ -49,8 +81,18 @@ Der UAC-Prompt lässt sich nicht dauerhaft abschalten; das ist Windows-Design. D
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
npm run scan:native:replay
|
||||
npm run package:offline-check
|
||||
npm run acceptance:offline
|
||||
```
|
||||
|
||||
`scan:native:replay` only reads existing `scan-results.json`; it does not start
|
||||
Genshin or send input. `package:offline-check` builds and verifies the unpacked
|
||||
Windows app without launching it.
|
||||
`acceptance:offline` is the full non-live gate: cleanup/helper safety tests,
|
||||
TypeScript, all unit/eval tests, saved 20/50/100 validation, a fresh packaged
|
||||
build, package verification, dependency audits, and `git diff --check`.
|
||||
|
||||
## Safety Boundaries
|
||||
|
||||
- No memory reads.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
6.7.0
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Design QA
|
||||
|
||||
## Evidence Recorded At Validation Time
|
||||
|
||||
The image files listed below were ephemeral local test outputs. They were
|
||||
intentionally removed during the 2026-07-11 workspace cleanup; the viewport,
|
||||
runtime, interaction coverage, and findings documented here are the retained
|
||||
durable record.
|
||||
|
||||
- Source visual truth: `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||
- Rendered implementation: `outputs/ui-qa/packaged-live-stream20-results-ui-redesign.png`
|
||||
- Full-view comparison: `outputs/ui-qa/comparison-scanner-live-before-after.png`
|
||||
- Focused scanner comparison: `outputs/ui-qa/comparison-scanner-live-focus.png`
|
||||
- Inventory modal post-fix evidence: `outputs/ui-qa/responsive-inventory-tech-1104-fixed.png`
|
||||
- Remaining rendered views: `outputs/ui-qa/final-inventory.png`, `final-triage.png`, `final-builds.png`, `final-overlay.png`, and `final-diagnose.png`
|
||||
- Viewport: 1304x821 CSS pixels for the matched live scanner comparison; additional reflow checks at 1104x681 and 652x410.
|
||||
- State: both scanner comparison images show a completed, non-persisting 20-Artifact run with 20 parsed results and zero Review/errors. The revised implementation was captured from the packaged elevated runtime after live run `20260710-214406`.
|
||||
|
||||
## Primary Interactions Checked
|
||||
|
||||
- Scanner navigation and fresh empty result state.
|
||||
- Scanoptionen open/close, initial focus, focus loop, scope selection, Artifact/row unit selection, and stepper controls.
|
||||
- Inventory list roving focus, technical modal open/close and focus loop.
|
||||
- Review deep-link into the filtered Inventory.
|
||||
- Overlay open action and toast feedback.
|
||||
- Diagnostics disclosures.
|
||||
- Fresh packaged Settings-UI streaming run with result insertion motion, independent evaluation progress, 20 result rows, and no store writes.
|
||||
- Renderer exception, `console.error`, and error-log checks on Scanner, Inventory, and Overlay: zero errors.
|
||||
|
||||
## Findings
|
||||
|
||||
No actionable P0, P1, or P2 findings remain.
|
||||
|
||||
- The Scanner result surface is now the dominant work region: approximately 634 px wide at the accepted desktop viewport versus approximately 388 px for the guide/capture surface.
|
||||
- Readiness checks are compact inline status chips, the advanced disclosure no longer consumes a layout row, and Scanoptionen opens in a focus-trapped modal.
|
||||
- Evaluation status is a flat progress strip rather than a nested card, leaving the result rail visibly primary during and after scanning.
|
||||
- Collection-level technical evidence and GOOD interop no longer compete with core browsing or diagnostics status.
|
||||
|
||||
## Required Fidelity Surfaces
|
||||
|
||||
- Fonts and typography: the existing Inter/system sans stack, optical weights, heading hierarchy, line heights, truncation, and dense 12 px metadata treatment remain consistent. No clipped primary label was found in the accepted desktop or reflow screenshots.
|
||||
- Spacing and layout rhythm: the 0.76/1.24 scanner split, compact readiness row, flat status strips, and modalized secondary controls create a clearer large-to-small hierarchy. Document dimensions matched the viewport for every accepted case.
|
||||
- Colors and visual tokens: the implementation stays on the existing Galaxy tokens for void/nebula surfaces, violet/cyan accents, mint success, amber warning, borders, focus, shadows, and motion.
|
||||
- Image quality and asset fidelity: existing Lucide icons and real Artifact crop previews are retained. No placeholder artwork, handcrafted SVG, emoji, or replacement asset was introduced.
|
||||
- Copy and content: Scanner, Review, Builds, and safety language remain Artifact-first, read-only, and explicit about Review versus roll efficiency versus future Build-Fit.
|
||||
|
||||
## Comparison History
|
||||
|
||||
### Iteration 1
|
||||
|
||||
- P1: at 1104x681 the Inventory technical modal could render under the sidebar because it lived inside the animated view stacking context.
|
||||
- Fix: render the modal through a `document.body` portal while retaining the shared focus trap and Galaxy modal styling.
|
||||
- Post-fix evidence: `outputs/ui-qa/responsive-inventory-tech-1104-fixed.png`; the entire 934 px dialog is visible, centered, focus-contained, and within the viewport.
|
||||
- P2: six pipeline stages in one row truncated useful technical descriptions even after moving them into the modal.
|
||||
- Fix: use a three-column/two-row modal grid with two-line descriptions.
|
||||
- Post-fix evidence: the same post-fix modal screenshot shows all six stages without accidental horizontal clipping.
|
||||
|
||||
### Iteration 2
|
||||
|
||||
- The matched live 20-result comparison found no remaining P0/P1/P2 hierarchy, clipping, density, or affordance issue.
|
||||
- The packaged runner observed live capture/evaluation overlap, result slide-in motion, up to 9 visible result rows and 12 evaluated results while capture was still running, then reconciled 20/20 final rows.
|
||||
- Focused regions were required because the readiness controls, evaluation strip, row density, and panel-width change are too small to judge reliably from a reduced full-app image alone.
|
||||
|
||||
## Residual Test Gaps
|
||||
|
||||
- A complete full-owned-inventory soak and non-16:9/Genshin-language profiles remain scanner-scope acceptance work, not design mismatches in this QA.
|
||||
- The live runner records the mid-capture overlap and motion metrics, while the durable screenshot captures the matched completed state rather than a transient frame.
|
||||
|
||||
final result: passed
|
||||
@@ -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
|
||||
+439
-8
@@ -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,67 @@ 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` | Streaming native-result processor; tails crop jobs, runs bounded ordered OCR/parse/evaluation, publishes result deltas, writes durable processing outputs, and safely loads native crop previews |
|
||||
| `electron/services/nativeScannerResultWorkflowService.ts` | Authoritative promotion, review, and local-deletion workflow for native results; reloads run state, validates edits, appends local tombstones, safely removes contained crops, and writes/removes the artifact store only after explicit confirmation |
|
||||
| `electron/services/nativeScannerResultTombstones.ts` | Append-only local tombstone contract that hides removed native rows without rewriting their raw scan evidence |
|
||||
| `electron/services/nativeScannerRunTiming.ts` | Atomic, first-observation timing record for request, native scanner, capture, processing, durable-result, and reconciliation milestones |
|
||||
| `electron/repositories/artifactStoreRepository.ts` | Local JSON artifact-store persistence including exact single-record removal through the main-process boundary |
|
||||
| `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 versioned Artifact 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/layout/*` | Scanner-first navigation, grouped workspace/tool items, shell, topbar readiness, and view-level metadata |
|
||||
| `src/features/feedback/*` | Renderer-only feedback primitives: queued mutation toasts, inline spinner, and content skeletons |
|
||||
| `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/features/settings/*` | Application settings modal, including persisted renderer-language selection |
|
||||
| `src/i18n/*` | Renderer locale contract, English/German message catalogs, local persistence, document-language synchronization, and translation hook |
|
||||
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
|
||||
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
|
||||
| `src/lib/scoring.ts` | Recommendation and build scoring logic |
|
||||
| `src/lib/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` | Deterministic legal-roll efficiency, review/unknown safety, reason codes, stored-result enrichment, and conservative 5-star Worst/Middle/Best under-level projection |
|
||||
| `src/lib/buildFitContract*.ts`, `src/lib/buildFitEligibility.ts`, and `src/types/buildFit.ts` | Versioned Build-Fit facade, contract validation including source expiry, native-result input mapping, hard extraction/Review eligibility gates, conflict evidence, and score-free V1 assessment output |
|
||||
| `src/lib/buildFitCombination.ts` | Deterministic score-free five-Artifact verifier for unique slots, Set-plan shapes, sourced full-build aggregate context, nested eligibility, and assignment/equipment conflicts |
|
||||
| `src/lib/buildFitEvidenceRanking.ts` | Bounded, deterministic, read-only selection of up to three source-bound complete combinations; it uses Set/Main-Stat/Substat evidence only and never Roll-Efficiency or OCR confidence as a rank weight |
|
||||
| `src/lib/buildFitExplicitContext.ts` | Converts a user-confirmed, session-only full-build value into a source-bound aggregate context without inferring weapon/team/rotation values |
|
||||
| `src/data/buildFitProfiles.ts` | Versioned curated profile corpus with explicit target assumptions, direct source references, expiry, safe evidence thresholds, and no authority outside its documented scope |
|
||||
| `src/eval/nativeScanReplay.ts` | Node-only deterministic replay/report contract for repeatedly evaluating existing native scan results without capture or input |
|
||||
| `scripts/replay-native-results.ts` | CLI wrapper that locates saved scan results and writes ignored offline replay evidence |
|
||||
| `scripts/validate-saved-native-runs.ts` | Read-only accepted-target validator (including 2,211) for run contracts, PNG containment, non-persistence, review provenance, deterministic evaluation, and projection boundaries |
|
||||
| `scripts/analyze-native-review.ts` | Read-only reparse and reason-distribution analyzer for a saved native run, including current Review-rate gating without capture or persistence |
|
||||
| `scripts/run-offline-acceptance.mjs` | Sequential non-live acceptance runner that records lint, tests, OCR/assessment, saved-run validation, package build/verification, audits, and diff-check outcomes |
|
||||
| `scripts/run-packaged-builds-acceptance.mjs` | Built-renderer acceptance runner for fresh English locale, German switching, Builds state, focus, reduced motion, overflow, screenshots, diagnostics, and local-delete confirmation probing |
|
||||
| `scripts/native-live-smoke.ps1` | Non-persisting native smoke runner that validates result reconciliation and complete shared-clock timing evidence |
|
||||
| `scripts/verify-packaged-app.mjs` | Static and unpacked-app verifier for executable, ASAR entries, relative renderer assets, current runtime signature, preload, C# helper, IK resources, and version consistency |
|
||||
| `electron/runtimePaths.ts` | Pure runtime-resource policy: packaged builds use `process.resourcesPath`; repository fallbacks exist only in development unless an explicit environment override is set |
|
||||
| `src/lib/scoring.ts` | Legacy demo-only recommendation/build scorer; real local account snapshots never call it, while Build-Fit reads source-safe native evidence through its separate ranker |
|
||||
| `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` | Legacy stylesheet entrypoint importing the base and diagnostics modules |
|
||||
| `src/styles/tokens.css` | Galaxy color, surface, typography, spacing, depth, motion, and layer tokens |
|
||||
| `src/styles/motion.css` and `src/styles/feedback.css` | Focus-visible, reduced-motion, page/micro-motion, toast, spinner, and skeleton presentation |
|
||||
| `src/styles/*-galaxy.css` and `src/features/inventory/inventory-galaxy.css` | Feature-scoped Galaxy shell, scanner, secondary-view, and inventory presentation |
|
||||
| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay compatibility styles |
|
||||
| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles |
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
@@ -67,7 +126,16 @@ flowchart LR
|
||||
- Electron main process must not import React renderer modules.
|
||||
- Pure parsing and scoring modules must not depend on Electron APIs.
|
||||
- OCR uncertainty must be represented in data, not hidden in UI only.
|
||||
- Feedback primitives may reflect an operation but must not become the source of
|
||||
truth for scan, review, promotion, or persistence state.
|
||||
- Generated folders must not be treated as source of truth.
|
||||
- Application copy is rendered through the locale provider. New, missing, or
|
||||
invalid persisted locale values resolve to English; German is a user-selected
|
||||
renderer preference. This does not expand the English-only Genshin OCR/crop
|
||||
profile.
|
||||
- Local-delete flows must remain behind the Electron main-process boundary.
|
||||
They may remove app-local records and contained crop files only; they must not
|
||||
call the input helper or dispatch game input.
|
||||
|
||||
## Smart Capture Flow
|
||||
|
||||
@@ -99,8 +167,20 @@ 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 |
|
||||
| Application locale | Local renderer storage key `gaa-ui-locale` | `src/i18n` | App shell and feature copy; defaults to English |
|
||||
| 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 |
|
||||
| Native-result tombstone | Confirmed local deletion | Native run `deleted-results.jsonl` | Native-result loaders; raw `scan-results.json` remains replay evidence |
|
||||
| Native run timing | First observed lifecycle events | Native run `run-timing.json` | Live-smoke validator, diagnostics, timing reports |
|
||||
| Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
|
||||
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only |
|
||||
| Build-Fit suggestions | Latest complete native `scan-results.json` plus explicit session context | Renderer-only Build-Fit model | Builds surface; read-only profile evidence, never legacy demo scoring |
|
||||
| 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 +191,362 @@ 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.
|
||||
- Native and PowerShell helper discovery accepts only the exact supported game
|
||||
process names `GenshinImpact` and `YuanShen`. Cached HWNDs are revalidated
|
||||
against that whitelist before reuse, so the assistant cannot become its own
|
||||
capture/input target merely because its title contains `Genshin`.
|
||||
- `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.
|
||||
- Inventory cleanup is separate from game interaction: a confirmed local removal may tombstone one native scan result, delete only its contained PNG crop, and optionally remove its explicitly linked local Store record. It never invokes the input helper or changes Genshin data.
|
||||
|
||||
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 starts/stops capture and starts one
|
||||
downstream processor when the run directory becomes available. OCR, parsing,
|
||||
review gating, and artifact value evaluation run concurrently on bounded
|
||||
workers so capture speed is not blocked by UI work; GOOD persistence remains a
|
||||
separate explicit action.
|
||||
|
||||
Before the first native Artifact click, the helper moves the cursor to the grid
|
||||
anchor, sends a fixed bounded upward wheel sequence that covers the supported
|
||||
2,400-item inventory capacity, and waits for the inventory to stabilize. Total,
|
||||
top-reset, and active capture timings remain separate so reset cost does not
|
||||
distort capture throughput. For streaming end-to-end timing, the app must use
|
||||
one wall-clock span from native start through final result reconciliation;
|
||||
capture and processing durations may overlap and must never be added. Genshin may be
|
||||
focused once during scan startup; after that, the helper compares the actual
|
||||
foreground HWND with the original Genshin HWND immediately before every click
|
||||
and every wheel event. Focus loss blocks the run without refocusing or sending
|
||||
further input. Stop, ESC, Enter, and F9 use the same guard path.
|
||||
|
||||
The bounded wheel reset remains the production start strategy. Closing or
|
||||
toggling the inventory with `B` and then selecting the Artifact tab is only a
|
||||
candidate experiment: it depends on the current game/menu state and an
|
||||
additional coordinate click, and it has not been live benchmarked against the
|
||||
accepted wheel reset. It must not replace that reset without a guarded A/B live
|
||||
test that proves both reliability and a meaningful timing benefit.
|
||||
|
||||
### Scan Scope Resolution
|
||||
|
||||
The normal renderer scan starts with `{ mode: "all" }`. Before starting the
|
||||
native helper it reads the inventory counter and resolves the target from the
|
||||
currently owned Artifact count (`current`), never from the slot capacity
|
||||
(`total`). Capacity is only a plausibility bound. A missing, invalid, or
|
||||
out-of-range owned count blocks a full-inventory run instead of guessing a
|
||||
target, because the native helper does not yet have a reliable end-of-list
|
||||
detector.
|
||||
|
||||
The optional bounded scopes are normalized before they reach the helper:
|
||||
|
||||
- an Artifact limit becomes `min(requested artifacts, owned artifacts)`;
|
||||
- a row limit becomes `min(requested rows * detected grid columns, owned
|
||||
artifacts)`; the accepted 16:9 profile currently has eight columns;
|
||||
- every resolved target stays within the supported 2,400-Artifact safety bound.
|
||||
|
||||
Scope selection is session state. A new app session returns to the full owned
|
||||
inventory default rather than silently reusing an earlier partial limit.
|
||||
|
||||
The Artifact-limit branch has one real Settings-UI acceptance run at limit 5:
|
||||
Genshin/detail/grid preflight passed; target, captured, processed, and parsed
|
||||
all reached 5; review and processing errors stayed at 0; persistence remained
|
||||
disabled; and the session rail showed five results. A later packaged Settings-
|
||||
UI run resolved one row to eight Artifacts and completed 8/8. The packaged
|
||||
full-owned-inventory run then resolved 2,211 from the OCR-owned count, captured
|
||||
2,211 crops over 70 pages, and populated 2,211 session rows with live capture/
|
||||
evaluation overlap. The initial 490-Review result correctly failed the 15% gate;
|
||||
current-package reprocessing of the same complete corpus passed at 136 Review
|
||||
(6.15%), zero errors, and zero persistence writes. Saved-run validation passed
|
||||
with zero issues.
|
||||
|
||||
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, extraction status, versioned value
|
||||
evaluation, reason codes, roll breakdown, and score. Clean legal Artifact
|
||||
extraction writes `evaluated`; uncertain extraction writes `review`;
|
||||
inconsistent/incomplete value data writes `unknown`. `deferred` remains a
|
||||
compatibility state and for non-evaluated category/build context boundaries.
|
||||
- `processing-report.json`: incremental and final OCR/parse report generated
|
||||
from the tailed `capture-jobs.jsonl`. It records `queueConcurrency` for the
|
||||
bounded worker pool and is non-persisting by default until native crop OCR
|
||||
has live validation evidence.
|
||||
- `run-timing.json`: atomic `native-scanner-run-timing-v1` evidence. It records
|
||||
first-observed request, scanner, capture, processing, durable-result, and
|
||||
result-reconciliation timestamps, then derives request-to-durable and
|
||||
request-to-reconciled durations directly from those endpoints.
|
||||
- `deleted-results.jsonl`: append-only tombstones for confirmed local removals.
|
||||
Loaders hide tombstoned rows while preserving the original result array for
|
||||
replay/audit. A tombstone can name one safely contained crop and an optional
|
||||
exact linked Store record; it is not a game action.
|
||||
|
||||
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 flushes each crop job immediately; the
|
||||
downstream processor tails, OCRs, parses, evaluates, and publishes that job
|
||||
without waiting for the full capture target. 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. It evaluates legacy
|
||||
saved results on load, displays roll-efficiency reasons, and computes
|
||||
under-level projection without requiring a rescan.
|
||||
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`.
|
||||
|
||||
### Run Timing Evidence
|
||||
|
||||
The timing contract is intentionally separate from scanner status polling. The
|
||||
first valid timestamp for each milestone is immutable, so retries and later
|
||||
renderer polls cannot rewrite a measured interval. `requestToResultsReconciledMs`
|
||||
is calculated only from `requestStartedAt` to `resultsReconciledAt`; capture and
|
||||
processing can overlap and are never summed to create a synthetic end-to-end
|
||||
duration. A live timing claim requires all terminal markers, a valid ordering,
|
||||
and final result reconciliation.
|
||||
|
||||
### Local Artifact Removal
|
||||
|
||||
The detail delete control is an app-data correction tool, not an in-game action.
|
||||
It is unavailable while the relevant native scan or processor is active and
|
||||
requires a user confirmation. For a native result, the authoritative workflow
|
||||
first appends a tombstone, filters the row from the local view, then removes a
|
||||
PNG only after verifying that its resolved path stays within the selected run
|
||||
directory. The raw `scan-results.json` evidence is never rewritten. A linked
|
||||
Store record is removable only when it exactly matches the selected local
|
||||
record and the user explicitly includes it; a Store-only row uses the same
|
||||
main-process-scoped local removal. None of these paths invoke the input helper,
|
||||
focus Genshin, or modify game data.
|
||||
|
||||
## Scanner-First Information Architecture
|
||||
|
||||
The renderer follows the user's task sequence instead of exposing every
|
||||
technical subsystem at the same level:
|
||||
|
||||
1. **Scanner** is the default workspace and owns readiness, start/stop,
|
||||
progress, preview, the completed-result rail, and access to review.
|
||||
2. **Artifacts** owns collection browsing, filters, selected detail, explicit
|
||||
single-result promotion/review, and secondary technical evidence. Collection-
|
||||
level pipeline evidence opens in a modal; selected-result OCR evidence stays
|
||||
in a local disclosure.
|
||||
3. **Review** summarizes extraction uncertainty and routes the user back to the
|
||||
relevant Artifact detail. It does not make trash, feed, or deletion claims.
|
||||
4. **Builds** reads only the newest complete native result run because it
|
||||
retains canonical IK Set keys and per-field confidence. It can show at most
|
||||
three source-bound, read-only profile suggestions from the separate evidence
|
||||
ranker. Profile expiry, Review gates, complete Set shape, required aggregate
|
||||
context, conflict evidence, and non-overlap remain explicit. Current roll
|
||||
efficiency is never presented as build fit, and character ownership/team/
|
||||
weapon/rotation are not inferred from Artifact data.
|
||||
5. **Overlay** and **Diagnostics** are secondary tools. They remain available
|
||||
without competing with the primary scan-to-review journey.
|
||||
6. **Settings** is a shell-level secondary modal. It currently owns the
|
||||
persisted app-language preference; changing it updates renderer copy only
|
||||
and does not imply support for another Genshin OCR language.
|
||||
|
||||
On the Scanner surface, app/Genshin readiness and one primary start/stop action
|
||||
come before source selection or manual capture. Advanced source, scope, and
|
||||
manual-read controls stay in one focus-trapped modal so opening them never
|
||||
reflows the scanner workspace. This is a presentation rule; it does not weaken
|
||||
preflight, focus, review, or persistence gates.
|
||||
|
||||
## 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 readiness, one primary start/stop action, the left preview/work surface,
|
||||
the right-side live result rail, progress/status, and review access. While a
|
||||
scan is active, the left work surface owns capture phase and target progress;
|
||||
the right result surface owns the independent OCR/parse/evaluation progress
|
||||
and completed-result stream. A second standalone progress card would
|
||||
duplicate that state and is not part of the layout.
|
||||
- The live result rail receives fully evaluated artifacts from the current app
|
||||
scan session only, in ascending capture sequence, and starts empty on app
|
||||
launch. Intermediate OCR/debug payloads stay in diagnostics or detail, while
|
||||
older persisted records remain available in the Artifacts view.
|
||||
- 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. Pipeline and IK
|
||||
evidence should be collapsed unless the user asks for it.
|
||||
- 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.
|
||||
- `build-fit-evidence-ranking-v1` is not a damage formula or optimizer. It
|
||||
ranks only documented Set/Main-Stat/Substat rule coverage after the scanner
|
||||
confidence gate. It never consumes Roll-Efficiency, roll magnitude, or OCR
|
||||
confidence as a ranking component.
|
||||
- A profile with an expired source review fails closed. Required aggregate
|
||||
targets remain deferred unless an explicit, user-confirmed session context
|
||||
references a declared local/user contract source.
|
||||
|
||||
Implemented result flow:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ScanLoop["Native capture producer"]
|
||||
Queue["capture-jobs.jsonl"]
|
||||
Workers["Bounded OCR/parse workers"]
|
||||
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 --> Queue
|
||||
Queue --> Workers
|
||||
Workers --> Parsed
|
||||
Parsed --> ReviewGate
|
||||
ReviewGate --> Value
|
||||
Value --> Store
|
||||
Value --> Rail
|
||||
Store --> Inventory
|
||||
Rail --> Detail
|
||||
Inventory --> Detail
|
||||
```
|
||||
|
||||
Queue and replay state:
|
||||
|
||||
- One capture/game-control producer may click, scroll, focus, poll failsafes,
|
||||
and append flushed crop jobs.
|
||||
- Up to four OCR/parse/evaluation workers tail complete queue records while the
|
||||
producer runs. Temporary EOF is non-terminal; producer terminal plus a fully
|
||||
drained queue is terminal.
|
||||
- Queueing must preserve stop behavior, duplicate handling, review decisions,
|
||||
and the existing read-only safety boundary.
|
||||
- Results are sequence-deduplicated, reordered to capture order, evaluated
|
||||
before publication, exposed to the renderer as monotonic deltas, and written
|
||||
incrementally to `scan-results.json` before final reconciliation.
|
||||
- Saved `scan-results.json` can be replayed repeatedly through the same value
|
||||
and projection code. Replay hashes the complete derived payload and fails
|
||||
when a clean parsed Artifact remains unevaluated; it never touches Genshin.
|
||||
|
||||
## UI Feedback And Motion
|
||||
|
||||
- Scan progress is durable view state and remains visible while a scan runs;
|
||||
toast messages never replace progress, blockers, or final result state.
|
||||
- Scan progress is measured against the resolved owned-inventory target or the
|
||||
user-selected Artifact/row limit and distinguishes capture/read, parse,
|
||||
persistence, review, duplicate, and error outcomes.
|
||||
- Skeletons reserve the Inventory list/detail layout during initial reads.
|
||||
Inline spinners indicate bounded refresh, preview, promotion, and review work.
|
||||
- Toasts report the outcome of explicit renderer mutations such as promotion,
|
||||
review approval/rejection, and GOOD import/export. Errors use alert semantics;
|
||||
informational and success messages use polite live regions.
|
||||
- Shared Galaxy design tokens own semantic colors, surfaces, spacing, depth,
|
||||
focus, and motion timing. Feature styles consume those tokens instead of
|
||||
inventing scanner-state colors independently.
|
||||
- Micro-motion may clarify entry, progress, and state changes, but functionality
|
||||
must remain complete with `prefers-reduced-motion: reduce`; focus-visible
|
||||
treatment and text/icons must carry meaning without motion or color alone.
|
||||
|
||||
## Evaluation Boundaries
|
||||
|
||||
- `roll-efficiency-v1` measures legal substat roll-tier quality only.
|
||||
- Set preference, main-stat desirability, character role, and substat usefulness
|
||||
are build context and remain `deferred`.
|
||||
- Locked/equipped state affects workflow, not intrinsic roll quality.
|
||||
- Evaluation uses rarity-specific roll tables and legal total roll counts.
|
||||
- Review or incomplete data receives no confident normal score.
|
||||
- `roll-efficiency-projection-v1` is available only for unambiguously identified
|
||||
5-star Artifacts with four known legal substats below +20. It models legal
|
||||
tier bounds, not guaranteed future rolls; 4-star or rarity-ambiguous data is
|
||||
deliberately unavailable.
|
||||
|
||||
## Packaging Boundary
|
||||
|
||||
- Production packaging uses Electron 43 and electron-builder.
|
||||
- Renderer/main/preload live inside `app.asar`; the native helper and IK lists
|
||||
are explicit unpacked `extraResources`.
|
||||
- Vite uses the relative base `./`, because production windows load
|
||||
`dist/index.html` with `BrowserWindow.loadFile` instead of an HTTP origin.
|
||||
- In packaged mode helper and IK lookup use `process.resourcesPath` and do not
|
||||
search `process.cwd()` or the repository implicitly. Explicit
|
||||
`INPUT_HELPER_EXE` and `IK_INVENTORYLISTS_DIR` overrides remain available.
|
||||
- `npm run package:offline-check` builds and verifies those paths without
|
||||
launching the app or sending game input. The verifier rejects absolute
|
||||
renderer asset references and a packaged runtime signature that differs from
|
||||
current source.
|
||||
- The built-renderer execution of `npm run package:live:builds` passed the
|
||||
current English/German locale flow, Builds loading/context/focus, reduced
|
||||
motion, overflow, and local-delete confirmation probe. It is renderer
|
||||
evidence only: it did not launch an elevated packaged executable or prove its
|
||||
UAC/bridge/native-runtime path, and it is not a substitute for native Genshin
|
||||
smoke evidence.
|
||||
- Packaged runtime, UAC, preload calls, native capture, result loading, and
|
||||
Inventory rendering passed live acceptance from `%TEMP%` on 2026-07-10.
|
||||
The packaged 5er remained non-persistent; see
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
## Security And Safety
|
||||
|
||||
@@ -141,4 +568,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.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Artifact Evaluation And Offline Validation - 2026-07-10
|
||||
|
||||
Historical pre-live snapshot. The deferred live work described here was
|
||||
completed later on 2026-07-10; see
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
This report records the implementation of the five post-ingestion phases. No
|
||||
live Genshin smoke test was run during this work. Existing native scan results
|
||||
were used as the only real Artifact input.
|
||||
|
||||
## Implemented Phases
|
||||
|
||||
### 1. Deterministic Artifact evaluation
|
||||
|
||||
- `src/lib/artifactEvaluation.ts` implements `roll-efficiency-v1`.
|
||||
- The score measures legal substat roll-tier efficiency from `0-100`; it does
|
||||
not pretend to be universal character or build value.
|
||||
- Main stat validity, extraction confidence, roll legality, and build fit stay
|
||||
separate.
|
||||
- Clean results become `evaluated`; uncertain extraction remains `review`;
|
||||
inconsistent or incomplete clean data becomes `unknown` instead of receiving
|
||||
a fabricated score.
|
||||
- Locked and equipped state do not change Artifact quality.
|
||||
|
||||
### 2. Inventory and detail integration
|
||||
|
||||
- Native `scan-results.json` entries now carry a versioned value evaluation,
|
||||
score, reason codes, legal roll counts, and per-substat efficiency.
|
||||
- Legacy saved results are evaluated on load without requiring a rescan.
|
||||
- The scan result rail shows compact roll-efficiency values.
|
||||
- Inventory adds evaluated/open filters and roll-value sorting.
|
||||
- Artifact detail shows score reasons, roll breakdown, and the explicit
|
||||
`Build-Fit offen` boundary.
|
||||
- Review results never receive a normal quality score.
|
||||
|
||||
### 3. Offline packaged-app verification
|
||||
|
||||
- Electron was upgraded from 33 to `43.1.0` because the old runtime was listed
|
||||
by the production dependency audit.
|
||||
- Deprecated `NativeImage.getBitmap()` calls were migrated to `toBitmap()`.
|
||||
- `electron-builder`, `@electron/asar`, and repeatable package scripts were
|
||||
added.
|
||||
- `npm run package:offline-check` builds the C# helper, renderer, Electron main
|
||||
process, and an unpacked Windows app without launching it.
|
||||
- `scripts/verify-packaged-app.mjs` verifies the executable, ASAR entries,
|
||||
relative renderer assets, current runtime signature, preload, helper, IK
|
||||
lists, and matching IK version.
|
||||
- Packaged helper and IK lookup now use only `process.resourcesPath` by default;
|
||||
repository paths remain development-only and cannot mask missing packaged
|
||||
resources.
|
||||
- The unpacked app passed `27/27` project/package checks. Runtime launch and
|
||||
Genshin interaction remain deliberately deferred to the next live session.
|
||||
|
||||
### 4. Offline repeatability replay
|
||||
|
||||
- `npm run scan:native:replay -- --run-dir=<saved-run> --repeats=5` evaluates a
|
||||
saved `scan-results.json` repeatedly and compares deterministic SHA-256
|
||||
payload hashes.
|
||||
- It writes `outputs/native-replay/<run>/native-replay-report.json` and fails if
|
||||
a clean parsed Artifact cannot be evaluated.
|
||||
- The replay never focuses, captures, clicks, scrolls, or starts Genshin.
|
||||
|
||||
| Saved run | Repeats | Results | Evaluated | Review | Unknown | Projection | Deterministic |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |
|
||||
| `20260709-223006` | 5 | 20 | 20 | 0 | 0 | 20 complete | yes |
|
||||
| `20260709-223041` | 5 | 50 | 49 | 1 historical review | 0 | 49 complete | yes |
|
||||
| `20260709-223441` | 5 | 100 | 100 | 0 | 0 | 9 available, 91 complete | yes |
|
||||
|
||||
The 100-result run produced a score range of `76.1-92.5` with average `84.3`.
|
||||
This is roll-tier efficiency, not build fit.
|
||||
|
||||
### 5. Upgrade projection
|
||||
|
||||
- Under-leveled, unambiguously identified 5-star Artifacts with four known legal
|
||||
substats receive deterministic worst/middle/best roll-efficiency bounds up to
|
||||
+20.
|
||||
- The calculation uses remaining +4 steps and legal rarity-specific roll tiers.
|
||||
- Three-substat, 4-star, ambiguous-rarity, review, incomplete, or illegal data
|
||||
disables projection.
|
||||
- +20 Artifacts are marked complete and do not show invented future rolls.
|
||||
- Every visible projection states that target substat and roll tier are random
|
||||
and that the result is not guaranteed.
|
||||
|
||||
## Static Evidence
|
||||
|
||||
- `npm run lint`: passed
|
||||
- `npm test`: passed, `262/262` tests in `40/40` files
|
||||
- `npm run build`: passed
|
||||
- `npm run eval`: `26/26` exact cases, `77/77` fields
|
||||
- `npm run scan:assessment:test`: passed
|
||||
- `npm run package:verify`: `27/27` checks passed
|
||||
- `npm audit`: `0` vulnerabilities
|
||||
- `npm audit --omit=dev`: `0` vulnerabilities
|
||||
- `git diff --check`: passed; only the known line-ending warnings may be shown
|
||||
|
||||
## Additional Non-Live Runtime Evidence
|
||||
|
||||
The current source build was started once through the normal non-elevated dev
|
||||
runtime without calling capture, preflight, probe, click, scroll, or scan
|
||||
endpoints. It proved the following boundaries without sending Genshin input:
|
||||
|
||||
- `/health` reported the current runtime signature, one main window, and all
|
||||
four registered hotkeys.
|
||||
- `/scanner/status` found `GenshinImpact`, reported `isElevated: false`, and
|
||||
reached the C# helper while the native scanner remained `idle`.
|
||||
- IK data `6.7.0` loaded as valid with 61 Artifact sets and 289 pieces;
|
||||
weapons, characters, and materials remained `catalog_only`.
|
||||
- Four OCR workers warmed successfully in `212 ms`.
|
||||
- The saved 100-result run loaded through the runtime and legacy `deferred`
|
||||
values were enriched to `evaluated` without rewriting the source run.
|
||||
- A valid native PNG crop loaded successfully. An outside path, a JSON file,
|
||||
and a traversal path were all blocked with HTTP `409`.
|
||||
- The runtime was then shut down through `/dev/shutdown`; no stale Electron
|
||||
process or port listener remained.
|
||||
|
||||
`npm run scan:native:validate:saved` now validates the newest complete
|
||||
20/50/100 evidence set in one read-only command. It checks run contracts, PNG
|
||||
containment, non-persistence, review-log provenance, five-repeat determinism,
|
||||
scores, and projection boundaries. The current evidence set passed with zero
|
||||
issues.
|
||||
|
||||
`npm run acceptance:offline` is the durable umbrella gate for work that does
|
||||
not require Genshin input. It runs lint, all unit tests, OCR eval, assessment
|
||||
self-test, saved native evidence validation, a fresh helper/app package build,
|
||||
27 package checks, full and production dependency audits, and
|
||||
`git diff --check`. The current run passed every step and writes its machine-
|
||||
readable report under `outputs/offline-acceptance/`.
|
||||
|
||||
## Deliberately Not Claimed Yet
|
||||
|
||||
- No packaged executable was launched.
|
||||
- No live Smart Capture or native scan was started.
|
||||
- No later-session 20/50/100 live repeatability claim was added.
|
||||
- No Inventory Kamera parity or 3-artifacts/second end-to-end claim was added.
|
||||
- Build-fit scoring and account recommendations remain separate later work.
|
||||
|
||||
The required live and visual checks are listed in
|
||||
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||
@@ -0,0 +1,425 @@
|
||||
# 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).
|
||||
- On 2026-07-10, a fresh later-session chain passed with the hardened native
|
||||
helper: `5/20/50/100`, all parsed/evaluated, `0` Review, `0` processing
|
||||
errors, and `0` store writes. The new 100er captured `100/100` over four
|
||||
pages at `4.37/s` and processed at `3.15/s`, with no duplicate PNG hash group,
|
||||
repeated page, or identical page boundary.
|
||||
- A later real Settings-UI acceptance selected `Eigenes Limit -> Artefakte ->
|
||||
5`. Genshin, visible detail, and the 4x8 grid passed preflight;
|
||||
target/captured/processed/parsed were all 5, Review and processing errors were
|
||||
0, persistence stayed disabled, and the current-session result rail contained
|
||||
five entries. A later packaged row-limit run resolved one detected row to
|
||||
eight Artifacts and passed 8/8 with the same non-persistence boundary.
|
||||
- The packaged full-owned-inventory run `20260710-223233` resolved the target
|
||||
from the OCR-confirmed owned count `2,211`, captured all 2,211 crops over 70
|
||||
pages, streamed results during capture, and produced zero processing errors
|
||||
and zero store writes. Its first parser pass correctly failed the 15% Review
|
||||
gate at 490 Review. After the structurally safe five-star +0 repair, the
|
||||
current package reprocessed the same complete corpus at 136 Review (6.15%);
|
||||
the saved-run validator then passed with zero issues.
|
||||
- The bounded inventory-top reset now completes in about `1.7 s` instead of
|
||||
about `50.7 s`; the 3,200-event range and per-event foreground/stop guards
|
||||
remain intact.
|
||||
- A packaged build started from `%TEMP%` and completed a native 5er through
|
||||
renderer, preload, IPC, packaged helper, and packaged IK resources with
|
||||
`persist=false`. See
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.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
|
||||
```
|
||||
|
||||
For the gated live scale chain, run and validate each size before continuing:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 20
|
||||
npm run scan:native:validate:saved -- --targets=20
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 50
|
||||
npm run scan:native:validate:saved -- --targets=50
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 100
|
||||
npm run scan:native:validate:saved -- --targets=100
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
For a deliberate packaged-runtime acceptance, first build with
|
||||
`npm run package:offline-check`, then launch the unpacked EXE from a foreign
|
||||
working directory with a loopback-only temporary CDP port:
|
||||
|
||||
```powershell
|
||||
$exe = (Resolve-Path "outputs\dist\win-unpacked\Genshin Artifact Assistant.exe").Path
|
||||
Start-Process -FilePath $exe `
|
||||
-ArgumentList "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9223" `
|
||||
-WorkingDirectory $env:TEMP -Verb RunAs
|
||||
npm run package:live:inspect
|
||||
```
|
||||
|
||||
`package:live:inspect` is read-only. The following command is a real bounded
|
||||
five-card Genshin scan and therefore requires the same live authorization and
|
||||
preconditions as every other input test:
|
||||
|
||||
```powershell
|
||||
npm run package:live:scan5
|
||||
```
|
||||
|
||||
The runner calls packaged renderer/preload/IPC methods serially, validates the
|
||||
5-result non-persistence contract, and writes JSON plus a screenshot below
|
||||
`outputs/packaged-live/`. Close the packaged app after testing so the temporary
|
||||
CDP port disappears.
|
||||
|
||||
For the streaming pipeline, use the real Settings-UI path with a bounded target
|
||||
large enough to observe overlap:
|
||||
|
||||
```powershell
|
||||
npm run package:live:ui-stream20
|
||||
```
|
||||
|
||||
This acceptance is stricter than a correct final count. It requires native
|
||||
capture to be observed as running while at least one processing worker has
|
||||
finished and at least one current-session result card is already visible. It
|
||||
also records the maximum evaluated/result counts seen during capture, verifies
|
||||
all 20 final entries, and checks the insertion animation unless the operating
|
||||
system requests reduced motion. Persistence remains disabled.
|
||||
|
||||
The same packaged Settings-UI runner exposes all accepted scope paths:
|
||||
|
||||
```powershell
|
||||
npm run package:live:ui-scan5
|
||||
npm run package:live:ui-row1
|
||||
npm run package:live:ui-stream20
|
||||
npm run package:live:ui-full-inventory
|
||||
```
|
||||
|
||||
`package:live:ui-full-inventory` explicitly selects `Gesamtes Inventar` and
|
||||
never supplies a numeric fallback. It accepts the target only from the running
|
||||
scanner status after the app has read the current owned Artifact count. The
|
||||
runner rejects targets outside `1..2400`, requires exact captured/processed/
|
||||
parsed/result/UI-row counts, checks `pages = ceil(target / 32)`, enforces at
|
||||
most 15% Review with zero processing errors, verifies streaming overlap and
|
||||
document fit, and requires `persist=false` with zero store writes. Its timeout
|
||||
is 15 minutes. The real elevated run `20260710-223233` reached 2,211/2,211 over
|
||||
70 pages. Because the initial parser produced 490 Review, the gate rejected the
|
||||
first result honestly; current-package reprocessing reduced that to 136 Review
|
||||
(6.15%), and `scan:native:validate:saved -- --targets=2211` passed with zero
|
||||
issues. This two-stage evidence is the accepted full-inventory baseline.
|
||||
|
||||
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.
|
||||
|
||||
At native Artifact-scan startup, the helper first resets the inventory to the
|
||||
top with a bounded upward wheel sequence at the calibrated grid anchor and then
|
||||
waits for the UI to stabilize. Genshin is focused only for initial startup. The
|
||||
native loop checks that the same Genshin HWND is still foreground immediately
|
||||
before every click and every wheel event; focus loss stops the run without an
|
||||
automatic refocus. Stop, ESC, Enter, and F9 remain active during the reset,
|
||||
stabilization, clicks, captures, and page scrolling. Native status reports
|
||||
`initialTopResetMs` and `totalMs` separately; `activeMs` starts after the reset
|
||||
and remains the capture-rate denominator.
|
||||
|
||||
Do not replace this production reset with `B` plus an Artifact-tab click based
|
||||
on event count alone. Reopening depends on the current menu/inventory state and
|
||||
adds a key toggle plus a coordinate target. It has not been live benchmarked
|
||||
against the accepted bounded wheel reset. If evaluated later, run it as a
|
||||
separate guarded A/B probe and compare successful top-state detection, focus/
|
||||
stop behavior, coordinate robustness, and elapsed reset time before changing
|
||||
the default.
|
||||
|
||||
## Evidence Commands
|
||||
|
||||
The commands in this first subsection exercise the older visible-inventory
|
||||
engine and its assessment format. They remain useful for regression work on
|
||||
that engine, but they do not post-process or validate the current native C#
|
||||
capture path. Use `native-live-smoke.ps1` plus
|
||||
`scan:native:validate:saved` for current native acceptance.
|
||||
|
||||
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 historical `native-live-smoke.ps1` path is intentionally serial: it waits
|
||||
for native capture to finish and only then invokes post-capture processing.
|
||||
Do not divide a target by the sum of those two durations and describe the
|
||||
result as the current streaming pipeline's end-to-end rate. The 2026-07-11
|
||||
read-only streaming-20 check (`20260711-090019`) proved overlap (a result was
|
||||
visible before capture completed) and observed 20/20 clean results at 4.43
|
||||
capture/s and 2.39/s from request issue to result reconciliation. That bounded
|
||||
observation is not a general performance claim: native start, capture start/end,
|
||||
processing start/end, and final reconciliation still need one persisted shared
|
||||
clock before publishing an end-to-end budget result.
|
||||
|
||||
The assessment self-test does not need Genshin:
|
||||
|
||||
```powershell
|
||||
npm run scan:assessment:test
|
||||
```
|
||||
|
||||
Saved native results can be re-evaluated without Genshin, focus changes, or
|
||||
input automation:
|
||||
|
||||
```powershell
|
||||
npm run scan:native:replay -- --run-dir=<native-run-dir> --repeats=5
|
||||
```
|
||||
|
||||
This is an offline determinism and result-contract check. It does not replace a
|
||||
live capture smoke test. The ordered checklist was executed on 2026-07-10; its
|
||||
results are in
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
## 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 choose a candidate marked
|
||||
`visualEvidence: available`: it had a retrievable local PNG at export time.
|
||||
The export exposes only safe capture/run references, never the local crop path.
|
||||
After expected fields are confirmed or corrected against the real artifact, a
|
||||
case may 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
|
||||
```
|
||||
|
||||
The preparer refuses candidates without explicit labels or retrievable visual
|
||||
evidence. Re-export after retaining a crop rather than promoting an OCR-only
|
||||
legacy record.
|
||||
|
||||
## 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 helper:test` when native input safety changed.
|
||||
2. Run `npm run lint`.
|
||||
3. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
|
||||
4. Run `npm test`.
|
||||
5. Run `npm run build`.
|
||||
6. If Genshin is available, run `/automation/probe-click?index=1`.
|
||||
7. For native scan-loop changes, run `npm run scan:native:smoke:5` before any
|
||||
20/50/100 progression. Use the explicit visible-inventory endpoint only
|
||||
when that legacy engine itself changed.
|
||||
8. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Build-Fit Contract V1
|
||||
|
||||
Status: the versioned contract, hard extraction gates, deterministic five-piece
|
||||
verifier, four source-bound profiles, source expiry, a separate explainable
|
||||
evidence-coverage ranker, session-only aggregate context, and a read-only Builds
|
||||
surface are implemented and offline-tested. This document does not claim a new
|
||||
live or packaged UI acceptance.
|
||||
|
||||
## Purpose
|
||||
|
||||
`build-fit-contract-v1` defines what must be known before an Artifact may enter
|
||||
a character/build evaluation. It remains separate from:
|
||||
|
||||
- OCR/extraction confidence, which is a hard eligibility gate;
|
||||
- `roll-efficiency-v1`, which describes legal roll-tier quality without build
|
||||
context;
|
||||
- `build-fit-evidence-ranking-v1`, a separate product rule that ranks only
|
||||
source-bound profile evidence and is not a damage, optimizer, OCR-confidence,
|
||||
or Roll-Efficiency score.
|
||||
|
||||
The foundation lives in:
|
||||
|
||||
- `src/types/buildFit.ts` for versioned contracts and ranking payloads;
|
||||
- `src/lib/buildFitContractValidation.ts` for schema, safety-floor, and source-
|
||||
expiry validation;
|
||||
- `src/lib/buildFitEligibility.ts` for native-result mapping and deterministic
|
||||
eligibility evidence;
|
||||
- `src/lib/buildFitCombination.ts` for complete-build shape, aggregate-context,
|
||||
and conflict verification;
|
||||
- `src/lib/buildFitEvidenceRanking.ts` for bounded deterministic selection;
|
||||
- `src/lib/buildFitExplicitContext.ts` for session-only user-confirmed
|
||||
non-Artifact context;
|
||||
- `src/data/buildFitProfiles.ts` for curated assumptions, provenance, expiry,
|
||||
and direct source references;
|
||||
- `src/features/builds/*` for the read-only renderer surface.
|
||||
|
||||
## Contract Inputs
|
||||
|
||||
Every character/build target provides:
|
||||
|
||||
| Area | V1 requirement |
|
||||
| --- | --- |
|
||||
| Identity | Stable id, positive revision, character id/name, target id/label/role/description, and explicit unique assumptions |
|
||||
| Provenance | Game version, review timestamp, explicit expiry, and at least one versioned curated, user, or imported source |
|
||||
| Set plans | Explained preferred or alternative legal 2-piece, 4-piece, or 2+2 shapes |
|
||||
| Main stats | Explained priority 1-3 choices for Sands, Goblet, and Circlet; Flower and Plume stay fixed |
|
||||
| Substats | Unique canonical stats with explained relative weights in `(0, 1]` |
|
||||
| Aggregate targets | Optional full-build minimum/maximum targets; never inferred from one Artifact |
|
||||
| Evidence gates | Overall extraction confidence, required field confidences, and all identity/value fields |
|
||||
| Conflicts | Explicit `warn` or `block` behavior for equipment and cross-build assignment conflicts |
|
||||
|
||||
V1 single-Artifact and combination assessments deliberately continue to emit
|
||||
`score: null` and `recommendationStatus: deferred`. The separate ranker can
|
||||
only work with a complete safe combination and a non-expired profile.
|
||||
|
||||
## Curated Profile Corpus
|
||||
|
||||
`build-fit-profile-corpus-v2` has four deliberately narrow candidates. Every
|
||||
profile has a direct KQM reference, game-version label, review timestamp, and
|
||||
`validUntil` date. A stale profile fails closed before it can rank a build.
|
||||
|
||||
| Profile | Scope | Sourced Set plan | Guide version |
|
||||
| --- | --- | --- | --- |
|
||||
| Furina Off-field Solo Hydro C0-C1 | Ousia off-field damage and Burst support; complete ER context required | 4pc Golden Troupe | KQM `Luna II` |
|
||||
| Neuvillette On-field Charged Attack | On-field Charged Attack damage, not driver-only | 4pc Marechaussee Hunter | KQM `Luna IV` |
|
||||
| Raiden Off-field Hyperbloom Trigger | Off-field Hyperbloom trigger only; no Burst/Aggravate/Quickbloom claim | 4pc Flower of Paradise Lost or 4pc Gilded Dreams | KQM `5.7` |
|
||||
| Nahida Off-field Deepwood Support | Off-field Skill application while Nahida is the intended Deepwood holder | 4pc Deepwood Memories | KQM `5.6` |
|
||||
|
||||
The direct guide references are stored in `src/data/buildFitProfiles.ts`:
|
||||
|
||||
- `https://keqingmains.com/q/furina-quickguide/`
|
||||
- `https://keqingmains.com/q/neuvillette-quickguide/`
|
||||
- `https://keqingmains.com/q/raiden-quickguide/`
|
||||
- `https://keqingmains.com/q/nahida-quickguide/`
|
||||
|
||||
Tests validate every profile against vendored IK 6.7.0 character and Artifact-
|
||||
set keys. Each profile has a Golden complete-set fixture; Furina also has the
|
||||
context-pending fixture, and every additional profile has a Review adversarial
|
||||
fixture. No profile source is treated as a universal build rule outside its
|
||||
documented assumptions.
|
||||
|
||||
## Eligibility And Five-Piece Verification
|
||||
|
||||
1. A persisted native Artifact result maps to `build-fit-input-v1`; store-only
|
||||
rows are intentionally not used because they do not retain IK set keys and
|
||||
per-field confidence evidence.
|
||||
2. Non-Artifact categories, Review, non-final extraction, missing fields,
|
||||
confidence below the contract floor, and free-text Set guesses block before
|
||||
profile evaluation.
|
||||
3. `build-fit-combination-input-v1` verifies exactly five unique canonical
|
||||
slots, nested Artifact gates, complete 2pc/4pc/2+2 Set forms, sourced
|
||||
aggregate context, and conflicts in deterministic slot order.
|
||||
4. Missing full-build context remains `pending`; a missed aggregate target is
|
||||
fit evidence, not an OCR repair opportunity.
|
||||
|
||||
The verifier remains order-independent, source-bound, and score-free.
|
||||
|
||||
## Evidence Ranking And Explicit Context
|
||||
|
||||
`build-fit-evidence-ranking-v1` is a separate, deterministic, read-only layer
|
||||
over complete eligible combinations from the newest complete native scanner run.
|
||||
It returns at most three non-overlapping profile suggestions.
|
||||
|
||||
Its displayed `evidenceCoverage` is intentionally narrow and explainable:
|
||||
|
||||
- a visible component for the sourced preferred or alternative Set plan;
|
||||
- the documented priority for the three variable main-stat slots;
|
||||
- presence/ordinal coverage of the profile's documented substats;
|
||||
- required aggregate targets only when they are met.
|
||||
|
||||
It never uses Roll-Efficiency, artifact roll value, OCR confidence, a guessed
|
||||
character roster, team, weapon, rotation, or hidden damage constants as a rank
|
||||
component. OCR confidence remains a gate only. Conflicts stay visible as
|
||||
warnings and do not become a concealed damage penalty.
|
||||
|
||||
Furina's 200%+ Solo-Hydro Energy Recharge target is the current aggregate
|
||||
context example. The user may enter and explicitly confirm the complete
|
||||
non-Artifact contribution for the current session. The value is not inferred
|
||||
from a scan, is not persisted, and is accepted only through the declared
|
||||
`local-build-context-v1` source. Without confirmation, Furina stays deferred.
|
||||
|
||||
## Builds Surface Boundary
|
||||
|
||||
The Builds surface:
|
||||
|
||||
- reads only the newest complete native result run, up to the 2,400-Artifact
|
||||
scan safety bound;
|
||||
- shows profile, Set path, five selected artifacts, rule coverage, sources,
|
||||
reasons, warnings, and blocked-context explanations;
|
||||
- exposes at most three non-overlapping candidates;
|
||||
- repeats that character ownership, team, weapon, and rotation are not proven
|
||||
by an Artifact scan;
|
||||
- never starts a scan, writes the Artifact store, edits a review, or sends
|
||||
in-game input.
|
||||
|
||||
`src/lib/scoring.ts`, `data/presets.json`, and `createLocalAccountSnapshot`
|
||||
remain legacy/demo boundaries. Real local snapshots still do not run through
|
||||
the legacy scorer or emit legacy builds/recommendations.
|
||||
|
||||
## Next Acceptance Steps
|
||||
|
||||
1. The saved 20/50/100/2,211 validation gates passed for this checkpoint;
|
||||
preserve them for later Build-Fit changes and do not weaken scanner review
|
||||
gates.
|
||||
2. The packaged renderer/UI acceptance passed. Keep it distinct from the
|
||||
optional elevated packaged-executable runtime/UAC claim.
|
||||
3. Refresh every profile before its explicit expiry and add Golden plus
|
||||
Review/low-confidence adversarial coverage for every new profile.
|
||||
4. The historical 136 Review crops were visually audited. For all new Review
|
||||
cases, require visual confirmation before changing parser or profile rules;
|
||||
do not relax gates from aggregate statistics.
|
||||
5. Add later profiles only when their team/weapon/rotation assumptions can be
|
||||
represented explicitly without hidden constants. No suggestion may imply
|
||||
deletion, feeding, enhancement, or automatic in-game mutation.
|
||||
+288
-1
@@ -4,23 +4,181 @@
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Native Artifact scan resets the inventory to the top with a bounded grid-anchor scroll and stabilization wait.
|
||||
- [ ] A user-facing full-inventory run resolves its target from the OCR-confirmed
|
||||
current owned Artifact count, never from inventory capacity; missing or
|
||||
invalid current count blocks safely before native start.
|
||||
- [ ] Artifact and row limits both clamp to the owned count and the 2,400 target
|
||||
safety bound; row conversion uses the detected grid column count.
|
||||
- [ ] A `B` plus Artifact-tab reopen is not promoted over the bounded wheel reset
|
||||
without a guarded live A/B test for state safety, targeting, and timing.
|
||||
- [ ] Native automated clicks and wheel events require Genshin to remain foreground; focus loss stops without refocusing.
|
||||
- [ ] Stop, ESC, Enter, and F9 remain active during native reset, capture, and scrolling.
|
||||
- [ ] Capture failures are shown to the user.
|
||||
- [ ] OCR uncertainty remains inspectable in Details.
|
||||
- [ ] Parser output does not silently trust low-confidence text.
|
||||
- [ ] Current native capture jobs preserve direct visual `starCount`,
|
||||
`starConfidence`, and `starSource`; an absent, invalid, or conflicting star
|
||||
field is never silently assumed to be 5★.
|
||||
- [ ] Direct visual rarity ambiguity enters `Review` before value evaluation.
|
||||
- [ ] Scanner controls contain no automatic in-game salvage, Quick Select,
|
||||
deletion, feed, enhance, lock/unlock, or resource-spending action.
|
||||
- [ ] Any Inventory local-result removal is explicitly confirmed, tombstones the
|
||||
result before crop cleanup, preserves raw `scan-results.json` evidence,
|
||||
remains contained to app-local files, and never invokes game input. An
|
||||
optional Store-record removal is limited to the exact linked local record.
|
||||
- [ ] A native-result deletion is blocked while capture or processing is active,
|
||||
remains idempotent after its tombstone, and cannot remove a crop outside the
|
||||
selected run directory.
|
||||
- [ ] `npm run lint` passes.
|
||||
- [ ] `npm run helper:test` passes when native input safety changed.
|
||||
- [ ] `npm test` passes when parser/scoring logic changed.
|
||||
- [ ] `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%.
|
||||
- [ ] Capture rate, processing rate, and end-to-end rate are reported as separate metrics.
|
||||
- [ ] A streaming end-to-end claim records native start, capture start/end,
|
||||
processing start/end, and final result reconciliation on one shared wall clock.
|
||||
- [ ] `run-timing.json` is present, uses `native-scanner-run-timing-v1`, and
|
||||
contains valid ordered first-observed lifecycle markers through durable result
|
||||
reconciliation.
|
||||
- [ ] The smoke report validates target/capture/queue/parse/durable-result
|
||||
reconciliation together with the timing record; a historical timing report is
|
||||
not reused for a current-build claim.
|
||||
- [ ] Overlapping capture and processing durations are never added to derive an
|
||||
end-to-end rate.
|
||||
- [ ] Speed reporting uses quality gates, not click count alone.
|
||||
|
||||
## Revised Scope Live Acceptance
|
||||
|
||||
- [x] Current elevated-dev 50-artifact smoke `20260711-154827`: target/captured/
|
||||
clicked/queued/processed/parsed/loaded `50/50`, 2 pages, 0 Review, 0 errors,
|
||||
0 Store writes, `persist=false`, and 50 jobs/non-empty PNGs. A valid
|
||||
`native-scanner-run-timing-v1` record reconciled at `28,022 ms`; it is not a
|
||||
3/s performance claim.
|
||||
|
||||
- [x] Real Settings-UI Artifact limit 5: Genshin/detail/grid preflight passed;
|
||||
target/captured/processed/parsed `5/5`, Review/errors `0`, `persist=false`, and
|
||||
five current-session result rows.
|
||||
- [x] Complete packaged full-inventory baseline resolves OCR-owned target 2,211,
|
||||
captures 2,211 crops over 70 pages, reprocesses all 2,211 with the current
|
||||
package at 136 Review (6.15%), 0 errors, `persist=false`, and passes saved-run
|
||||
validation with 0 issues.
|
||||
- [x] Real packaged Settings-UI row limit 1 resolves the detected eight-column
|
||||
grid to target 8 and finishes captured/processed/parsed `8/8`, Review/errors
|
||||
`0`, `persist=false`; foreground loss is proven to block with zero clicks.
|
||||
- [ ] Any `B`-reopen candidate is benchmarked separately and does not replace
|
||||
the accepted wheel reset without stronger reliability and timing evidence.
|
||||
|
||||
## UI Change
|
||||
|
||||
- [ ] Scanner, Artifacts, Review, and Builds preview follow the primary user
|
||||
journey; Overlay and Diagnostics remain secondary tools.
|
||||
- [ ] The Scanner page shows app/Genshin readiness and one unambiguous primary
|
||||
start/stop action before advanced controls.
|
||||
- [ ] The main workflow remains visible without unnecessary scrolling.
|
||||
- [ ] Debug or secondary information is moved behind buttons/modals where appropriate.
|
||||
- [ ] Builds stays visibly labeled as a preview until Build-Fit is implemented;
|
||||
roll efficiency is not presented as character/build suitability.
|
||||
- [ ] Review describes extraction correction and never implies trash, feed,
|
||||
deletion, or an in-game mutation.
|
||||
- [ ] Disabled states prevent actions without required data.
|
||||
- [ ] Initial content loading reserves layout with a skeleton; bounded actions
|
||||
use a spinner; scan progress remains durable view state.
|
||||
- [ ] During scanning, the left main work surface shows phase,
|
||||
processed-versus-target progress, and read/parsed/stored/review/duplicate/error
|
||||
outcomes without a duplicate standalone progress card.
|
||||
- [ ] Toasts confirm explicit mutations and failures without replacing durable
|
||||
progress, review, blocked, or error state.
|
||||
- [ ] Status meaning is available through text/icons and not color or animation alone.
|
||||
- [ ] Keyboard focus is visible for navigation, buttons, disclosures, fields,
|
||||
dialogs, and toast dismissal.
|
||||
- [ ] `prefers-reduced-motion: reduce` disables non-essential motion without
|
||||
hiding state changes or feedback.
|
||||
- [ ] Text fits in controls and panels.
|
||||
- [ ] Desktop viewport is checked manually.
|
||||
- [ ] The Scanner, Artifacts, Review, Builds preview, Overlay, and Diagnostics
|
||||
views are checked at the packaged desktop viewport for clipping, overflow,
|
||||
accidental page-level scroll, nested-scroll traps, and empty/loading/error states.
|
||||
- [ ] Primary scanner controls remain usable at the smallest supported desktop
|
||||
window; narrower layouts reflow instead of clipping cards or action labels.
|
||||
- [ ] Before/after screenshots or equivalent current-build visual evidence are
|
||||
inspected for the changed views.
|
||||
- [ ] A fresh/missing/invalid app locale starts in English, German can be
|
||||
selected in Settings, and the preference persists locally without changing
|
||||
the English-only Genshin scanner contract.
|
||||
- [ ] Localized UI acceptance uses stable semantic hooks where possible and
|
||||
verifies that translated labels do not cause clipping, focus, or overflow.
|
||||
- [ ] The artifact detail local-delete control states its app-local scope,
|
||||
requires confirmation, and remains unavailable for an active native run.
|
||||
- [ ] `npm run lint` and relevant renderer tests pass.
|
||||
- [ ] `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 result rail is empty on a fresh app launch, contains only results from
|
||||
the current app scan session, and leaves persisted history in Artifacts.
|
||||
- [ ] Progress and completion use the resolved owned-inventory target or the
|
||||
selected Artifact/row limit consistently.
|
||||
- [ ] Capture and evaluation progress advance independently: the left bar uses
|
||||
captured/target while the right result panel uses evaluated/target.
|
||||
- [ ] Processing starts once when the native run first exposes `runDir`, keeps
|
||||
waiting across temporary JSONL EOF, and finishes only after producer terminal
|
||||
plus queue drain.
|
||||
- [ ] The live rail shows fully evaluated artifact rows only, not intermediate
|
||||
OCR/debug state, and appends every current-session row in capture order.
|
||||
- [ ] Result delta polling deduplicates stable IDs/sequences and a final full
|
||||
load reconciles captured, processed, and durable-result counts.
|
||||
- [ ] Newly inserted result rows use a left-to-right mount cue; reduced-motion
|
||||
users receive the same content and status without the animation.
|
||||
- [ ] 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.
|
||||
- [ ] Explicit visual 1–4★ native results use `excluded`, receive no score,
|
||||
projection, or store promotion, and remain visible for manual inspection.
|
||||
- [ ] The separate read-only `Nicht 5★` filter contains only explicitly
|
||||
recognized 1–4★ entries and retains screenshot/crop plus parsed details.
|
||||
- [ ] The `Nicht 5★` view does not call entries safe to salvage and exposes no
|
||||
automatic salvage or Quick Select control.
|
||||
- [ ] 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.
|
||||
@@ -28,10 +186,139 @@
|
||||
- [ ] Character, set, stat, and slot dictionaries are updated deliberately.
|
||||
- [ ] Tests cover changed behavior when practical.
|
||||
|
||||
## Artifact Roll Evaluation
|
||||
|
||||
- [ ] Extraction status and value status remain separate fields.
|
||||
- [ ] Clean legal results become `evaluated`; Review receives no normal score.
|
||||
- [ ] Only explicitly confirmed 5★ evidence enters `roll-efficiency-v1` for
|
||||
current native jobs.
|
||||
- [ ] Explicit visual 1–4★ evidence becomes `excluded`, not a weak score;
|
||||
missing, invalid, or conflicting direct star evidence becomes `Review`.
|
||||
- [ ] Incomplete or inconsistent clean data becomes `unknown`, not a guessed score.
|
||||
- [ ] Score is labeled as roll efficiency, not universal Artifact or build value.
|
||||
- [ ] Build fit stays explicitly deferred without character/build context.
|
||||
- [ ] Locked and equipped state do not change roll efficiency.
|
||||
- [ ] Rarity-specific legal values and total roll counts are enforced.
|
||||
- [ ] Reason codes and per-substat roll breakdown explain the result.
|
||||
- [ ] Legacy saved scan results are enriched on load without requiring a rescan.
|
||||
- [ ] Unit tests cover evaluated, review, unknown, ambiguity, and rarity edges.
|
||||
|
||||
## Build-Fit Contract
|
||||
|
||||
- [x] Input, contract, and assessment schemas are explicitly versioned.
|
||||
- [x] Contracts require character/target identity, revision, game-versioned
|
||||
provenance, explained Set/Main-Stat/Substat rules, aggregate targets, and
|
||||
explicit conflict behavior.
|
||||
- [x] V1 safety floors cannot be lowered below 80% overall extraction and 70%
|
||||
for every required identity/value field.
|
||||
- [x] Review, non-parsed, incomplete, low-confidence, or non-canonical Set data
|
||||
blocks before downstream fit work.
|
||||
- [x] Native Artifact results map into the contract without guessing a Set key;
|
||||
non-Artifact categories are rejected.
|
||||
- [x] Roll-Efficiency is recorded only as separate context and is never used as
|
||||
Build-Fit eligibility or presented as a fit score.
|
||||
- [x] V1 assessments retain `score: null` and deferred recommendations even
|
||||
when the separate evidence-ranking layer is available.
|
||||
- [x] Real local account snapshots do not call the legacy demo scorer.
|
||||
- [x] Four current profiles (Furina, Neuvillette, Raiden Hyperbloom, Nahida
|
||||
Deepwood) are source-bound, game-versioned, IK-6.7.0-key validated, have
|
||||
explicit expiry, and are covered by Golden plus Review/low-confidence or
|
||||
pending-context adversarial fixtures.
|
||||
- [ ] Every further character/build profile is sourced, game-versioned,
|
||||
reviewed, expiry-bound, and covered by Golden/adversarial fixtures before
|
||||
product use.
|
||||
- [x] Five-piece evaluation verifies unique slots, Set shape, sourced aggregate
|
||||
targets, nested extraction gates, and cross-character/build conflicts together;
|
||||
missing non-Artifact context remains `pending` rather than guessed.
|
||||
- [x] The Builds UI reads only the newest complete native run and exposes at
|
||||
most three non-overlapping, read-only suggestions with profile, source,
|
||||
Set/Main-Stat/Substat evidence, conflicts, uncertainty, and selection reason.
|
||||
- [x] The ranker never uses `roll-efficiency-v1`, roll magnitude, or OCR
|
||||
confidence as a rank weight; confidence stays a hard gate.
|
||||
- [x] Required aggregate targets accept only explicit, user-confirmed,
|
||||
session-only local context and otherwise defer the profile.
|
||||
- [x] The built-renderer Builds/UI acceptance verifies fresh English, German
|
||||
switching/restoration, loading, deferred/context, suggestion, focus,
|
||||
reduced-motion, overflow, and native plus Store local-delete confirmation probes.
|
||||
- [ ] An elevated packaged-executable run separately verifies the same renderer
|
||||
behavior when making a package-runtime claim; built-renderer evidence alone
|
||||
does not prove UAC, bridge, or native runtime behavior.
|
||||
|
||||
## Upgrade Projection
|
||||
|
||||
- [ ] Projection appears only below +20 with four known legal substats.
|
||||
- [ ] Worst is less than or equal to Middle; Middle is less than or equal to Best.
|
||||
- [ ] Remaining roll count matches the current Artifact level.
|
||||
- [ ] Review, illegal, incomplete, three-substat, 4-star, and rarity-ambiguous
|
||||
data disables projection.
|
||||
- [ ] +20 is complete and does not invent future rolls.
|
||||
- [ ] UI states that target substat and tier are random and not guaranteed.
|
||||
- [ ] Projection does not claim character/build usefulness.
|
||||
|
||||
## Native Offline Replay
|
||||
|
||||
- [ ] Replay reads an existing `scan-results.json` and never starts capture/input.
|
||||
- [ ] At least three repeats produce the same full derived-payload hash.
|
||||
- [ ] Every clean parsed Artifact is evaluated or the command fails.
|
||||
- [ ] Explicit 1–4★ entries replay as stable `excluded` results and are not
|
||||
counted as clean-but-unevaluated failures.
|
||||
- [ ] Review remains Review and is not counted as an evaluation failure.
|
||||
- [ ] Report includes value/projection counts and score range.
|
||||
- [ ] Accepted bounded 20/50/100 runs and the 2,211 full-inventory corpus are
|
||||
replayed before changing the evaluation contract.
|
||||
- [ ] `npm run scan:native:validate:saved` passes for the latest complete
|
||||
evidence set, including `-- --targets=2211` for full-inventory-sensitive work.
|
||||
- [ ] Saved-run validation confirms manifest/status/job/result counts, PNG
|
||||
containment, zero processing errors, non-persistence, review provenance,
|
||||
and projection ordering.
|
||||
|
||||
## Release Or Packaging
|
||||
|
||||
- [ ] `npm run acceptance:offline` passes before the live acceptance session.
|
||||
- [ ] Production build succeeds.
|
||||
- [ ] Electron preload bridge is copied to `dist-electron/preload.cjs`.
|
||||
- [ ] App starts outside browser preview.
|
||||
- [ ] Smart Capture bridge is available.
|
||||
- [ ] No generated debug artifacts are included accidentally.
|
||||
- [ ] `npm audit --omit=dev` reports zero production vulnerabilities.
|
||||
- [ ] `npm run package:offline-check` passes before packaged runtime testing.
|
||||
- [ ] ASAR contains renderer, Electron main, preload, and package manifest.
|
||||
- [ ] Packaged `dist/index.html` uses relative `./assets/...` references; the
|
||||
verifier rejects root-absolute `/assets/...` references.
|
||||
- [ ] `resources/input-helper/InputHelper.exe` exists in the unpacked app.
|
||||
- [ ] `resources/ik-inventorylists` exists and its version matches source data.
|
||||
- [ ] Packaged helper/IK resolution uses `process.resourcesPath` without an
|
||||
implicit `process.cwd()` repository fallback.
|
||||
- [ ] Packaged and source `APP_RUNTIME_SIGNATURE` values match, so evidence from
|
||||
an older runtime is rejected as stale.
|
||||
- [x] `npm run package:live:builds` passed against the built renderer: fresh
|
||||
English, German selection, restored English, Builds loading/context/focus,
|
||||
reduced motion, no document overflow, and native plus Store delete-confirmation UI probes.
|
||||
- [ ] The same UI evidence is collected from an elevated packaged executable
|
||||
before claiming current package-runtime/UAC behavior.
|
||||
- [ ] Packaged runtime launch, UAC, bridge, helper, and crop preview are tested
|
||||
separately; offline resource checks do not prove runtime behavior.
|
||||
|
||||
## Next Live Acceptance Session
|
||||
|
||||
Use [TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md)
|
||||
in order. Start with packaged launch and existing Inventory data, then one
|
||||
Smart Capture, native 5, and only then gated 20/50/100 runs.
|
||||
|
||||
## 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.
|
||||
|
||||
+37
-1
@@ -11,18 +11,39 @@ 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.
|
||||
- The design direction is dark purple fintech glassmorphism with premium, focused controls.
|
||||
- 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 a premium, cinematic Galaxy system with restrained
|
||||
glass surfaces, semantic contrast, and focused controls; reuse shared tokens
|
||||
instead of feature-local state colors.
|
||||
- Use skeletons for initial content layout, inline spinners for bounded work,
|
||||
and toasts only for explicit mutation outcomes. Durable progress, blockers,
|
||||
and errors stay in the owning view.
|
||||
- Respect `prefers-reduced-motion`, keep focus-visible styling intact, and never
|
||||
rely on color or animation as the only status signal.
|
||||
- Disable buttons when their required data does not exist.
|
||||
- Route user-facing renderer copy through `src/i18n`. English is the default
|
||||
application locale; German is an explicit settings choice. Do not use an app
|
||||
language switch to imply that another Genshin OCR/capture language is
|
||||
supported.
|
||||
- Keep stable `data-*` hooks for scripted renderer acceptance. Tests should not
|
||||
depend on localized visible strings when a semantic hook is available.
|
||||
- A destructive-looking local-data control must say what it removes and state
|
||||
that it does not change Genshin. Require confirmation before local deletion.
|
||||
|
||||
## Scanner Rules
|
||||
|
||||
@@ -30,6 +51,10 @@ 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.
|
||||
- Treat global aliases as unsafe when the OCR text is ambiguous across pieces.
|
||||
Canonicalize a partial artifact name only when independent high-confidence
|
||||
field evidence, such as a direct slot read, leaves one valid match.
|
||||
- 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
|
||||
|
||||
@@ -42,9 +67,20 @@ This document defines project engineering standards.
|
||||
- Use unit tests for parser and scoring logic.
|
||||
- Use build/type checks for Electron IPC contract changes.
|
||||
- Use manual Smart Capture smoke tests for crop and capture changes.
|
||||
- For native timing claims, validate a complete `run-timing.json` and derive
|
||||
end-to-end time from one request-to-reconciliation interval. Never add
|
||||
overlapping capture and processing durations.
|
||||
- Test local native-result removal for active-run blocking, tombstone filtering,
|
||||
raw-evidence preservation, crop-path containment, and exact Store-record
|
||||
scope.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `docs/PROJECT.md` when product scope changes.
|
||||
- Update `docs/ARCHITECTURE.md` when module boundaries or flows change.
|
||||
- Add an ADR to `docs/DECISIONS.md` for durable technical trade-offs.
|
||||
- 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`.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Current App Status
|
||||
|
||||
Updated: 2026-07-11 — current-build live-smoke checkpoint for the
|
||||
English-default renderer, local artifact removal, shared-clock timing evidence,
|
||||
and visually reviewed OCR corpus corrections.
|
||||
|
||||
Historical live evidence remains documented in
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
The current checkpoint and its explicit remaining release gates are documented
|
||||
in
|
||||
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md).
|
||||
The fresh current-build smoke is documented in
|
||||
[NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||
|
||||
## What It Can Do
|
||||
|
||||
- Read-only Artifact scanning remains scoped to the accepted English 16:9
|
||||
Genshin Artifact inventory with a visible detail card. The scanner guards the
|
||||
target process/window, foreground state, stop controls, and input safety.
|
||||
- The native path captures card crops while bounded OCR/parse/evaluation workers
|
||||
stream ordered safe results into the session rail. Review, roll efficiency,
|
||||
crop evidence, parser confidence, and local promotion/review actions remain
|
||||
separate concepts.
|
||||
- Artifacts can be browsed, filtered, sorted, and inspected with local crop,
|
||||
parser, IK, review, and value evidence. Explicit 1–4★ native results remain
|
||||
excluded/manual-review entries; they do not become a salvage or Quick Select
|
||||
workflow.
|
||||
- The Builds surface reads only the newest complete native run and shows at
|
||||
most three read-only, source-bound evidence suggestions. It is not an
|
||||
optimizer, DPS score, ownership inference, or game-action surface.
|
||||
- The application UI now defaults to English. German is selectable in Settings
|
||||
and persists locally. The preference updates the renderer/document language
|
||||
only; it does not change the English-Genshin Smart Capture contract.
|
||||
- Artifact detail now has a confirmed local-delete action for a wrong scan,
|
||||
screenshot, or Store row. Native deletion appends a tombstone, keeps raw
|
||||
`scan-results.json` evidence intact, deletes only a PNG proven to be within
|
||||
the selected run directory, and can remove only an explicitly chosen exact
|
||||
linked Store record. It never focuses Genshin, invokes the input helper, or
|
||||
performs an in-game action.
|
||||
- Native runs now write `run-timing.json` using
|
||||
`native-scanner-run-timing-v1`. It records first-observed request, scanner,
|
||||
capture, processing, durable-result, and reconciliation milestones so the
|
||||
end-to-end duration is measured from one shared clock.
|
||||
- The former 136-item full-inventory Review set has been visually audited for
|
||||
the current corpus work: 128 are explicit 1–4★ entries, six have the same
|
||||
exact duplicated-decimal OCR defect, one requires slot-safe name resolution,
|
||||
and one incomplete crop intentionally remains `Review`.
|
||||
- The review-corpus exporter now exposes only safe capture/run references and
|
||||
prioritizes candidates with a retrievable local crop. The confirmation
|
||||
preparer refuses OCR-only records, so a parser guess cannot become ground
|
||||
truth without retained visual evidence.
|
||||
|
||||
## Verified Evidence
|
||||
|
||||
The historical scanner acceptance is still useful regression evidence, but it
|
||||
does not certify the current change set by itself:
|
||||
|
||||
- Historical dev runs completed 5/20/50/100 with zero Review, errors, and Store
|
||||
writes; the historical packaged paths include bounded 5/8/20 and the 2,211
|
||||
full-owned-inventory soak.
|
||||
- The saved 50-artifact evidence set still validates at `50/50`, with zero
|
||||
replay issues, zero Review, zero unknown, and no projection failures.
|
||||
- The current checkpoint has focused coverage for locale persistence/catalog
|
||||
parity, artifact Store removal, native-result tombstones/crop containment,
|
||||
shared-clock timing validation, and the new confirmed OCR corpus cases.
|
||||
- The final current-build offline gate passed: `npm run acceptance:offline`
|
||||
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||
validation, package verification (27/27), both dependency audits, and the
|
||||
diff check.
|
||||
- The built-renderer Builds/UI acceptance passed with stable semantic hooks:
|
||||
fresh English, German selection, restored English, Builds context/focus,
|
||||
reduced motion, overflow, and native plus Store local-delete confirmation
|
||||
probes. This is deliberately not described as an elevated packaged-executable
|
||||
run.
|
||||
- The elevated development current-build smoke `20260711-154827` passed with
|
||||
50/50 target/capture/click/queue/parse/load reconciliation, 0 Review,
|
||||
0 processing errors, 0 Store writes, `persist=false`, and a complete
|
||||
`native-scanner-run-timing-v1` record. Request-to-reconciliation was
|
||||
`28,022 ms` (`~1.78 Artifacts/s`); that is a functional/safety result, not a
|
||||
3/s performance claim.
|
||||
- The follow-on review-evidence workflow passed focused exporter/preparer
|
||||
checks plus `npm run lint`, `npm test` (72 files / 446 tests), and
|
||||
`npm run build`. A read-only export of the current local queue found 177
|
||||
records and three retrievable crops; all three are already-confirmed
|
||||
`native-review-approved` cases, so no new label was promoted.
|
||||
|
||||
## What Still Needs Testing
|
||||
|
||||
- If making a current package-runtime claim, separately launch the elevated
|
||||
packaged executable and verify UAC, preload bridge, helper, and renderer
|
||||
behavior. The passing built-renderer acceptance does not prove that boundary.
|
||||
- Repeat the passing native and Store local-delete confirmation probes during an
|
||||
elevated packaged-executable acceptance if a package-runtime release claim is
|
||||
needed.
|
||||
Unit coverage already protects the authoritative tombstone, active-run,
|
||||
containment, and exact-Store boundaries.
|
||||
- Do not claim German Genshin OCR/capture support: only the application UI is
|
||||
localized. Non-16:9 layouts, other Genshin UI languages, and other inventory
|
||||
categories remain outside accepted scanner scope.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- OCR can still fail on a new game layout or unreadable crop. The app must route
|
||||
uncertainty to `Review`; it must not relax matching thresholds or invent a
|
||||
canonical name from ambiguous text.
|
||||
- The current parser fixes only the observed exact duplicate decimal punctuation
|
||||
and uses slot-based artifact-name resolution only when independent
|
||||
high-confidence slot evidence gives one valid match. The incomplete audited
|
||||
crop stays `Review` by design.
|
||||
- Build-Fit remains evidence ranking, not character/build optimization. Team,
|
||||
weapon, rotation, character ownership, and unsourced aggregate values are not
|
||||
inferred from Artifacts.
|
||||
- The current live 50-artifact smoke measured `28,022 ms` request-to-result
|
||||
reconciliation (`~1.78 Artifacts/s`). The functional gate passed, but the
|
||||
separate 3/s performance target remains unproven.
|
||||
|
||||
## Next Priorities
|
||||
|
||||
1. Keep the passing built-renderer acceptance distinct from the optional
|
||||
elevated packaged-executable runtime/UAC check.
|
||||
2. The generated workspace outputs were cleaned and the coherent checkpoint
|
||||
was committed and pushed as `8b9f948` on 2026-07-11. The active admin
|
||||
runtime's locked log was intentionally left untouched.
|
||||
3. Re-export future Review cases and only label candidates marked with
|
||||
retrievable visual evidence. No currently retrievable local candidate needs
|
||||
a new corpus label.
|
||||
4. Continue the next quality phase with fail-closed parser behavior,
|
||||
profile-source refreshes, and packaged UI regression coverage. Do not
|
||||
broaden game-category scope or add in-game mutation.
|
||||
+826
-8
@@ -15,6 +15,23 @@ 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-014 | Separate roll efficiency from build fit and project only legal roll bounds | Accepted | 2026-07-10 |
|
||||
| ADR-015 | Adopt a scanner-first Galaxy UI and explicit feedback primitives | Accepted | 2026-07-10 |
|
||||
| ADR-016 | Default to the owned inventory and keep scan feedback session-bound | Accepted | 2026-07-10 |
|
||||
| ADR-017 | Stream native capture jobs into bounded evaluation workers | Accepted | 2026-07-10 |
|
||||
| ADR-018 | Gate Build-Fit behind a versioned evidence contract before scoring | Accepted | 2026-07-10 |
|
||||
| ADR-019 | Repair five-star +0 main values only from structural rarity evidence | Accepted | 2026-07-10 |
|
||||
| ADR-020 | Target only exact supported Genshin process names | Accepted | 2026-07-10 |
|
||||
| ADR-021 | Require explicit profile assumptions and source-bound aggregate context | Accepted | 2026-07-10 |
|
||||
| ADR-022 | Rank only source-bound Build-Fit evidence with profile expiry | Accepted | 2026-07-11 |
|
||||
| ADR-023 | Fail-closed visual rarity gate and read-only non-five-star inventory | Accepted | 2026-07-11 |
|
||||
| ADR-024 | Default renderer copy to English while preserving the English scanner boundary | Accepted | 2026-07-11 |
|
||||
| ADR-025 | Make artifact deletion an app-local tombstone workflow | Accepted | 2026-07-11 |
|
||||
| ADR-026 | Persist first-observed native run timing on one shared clock | Accepted | 2026-07-11 |
|
||||
|
||||
## ADR-001: Build A Local Electron App First
|
||||
|
||||
@@ -44,7 +61,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 +199,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 +229,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 +251,805 @@ 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.
|
||||
- Use one game-control producer for click/scroll/capture and bounded downstream
|
||||
OCR/parse/evaluation workers. Publish only fully evaluated entries into the
|
||||
current-session result rail.
|
||||
|
||||
### 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 native result processor that tails
|
||||
`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 versioned value evaluation. `processing-report.json`
|
||||
remains the diagnostic OCR/parse report. The worker may OCR, parse, and evaluate
|
||||
while capture is still producing jobs, then drains the remaining queue after a
|
||||
done or stopped producer. It 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 remains downstream and cannot block 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.
|
||||
|
||||
## ADR-014: Separate Roll Efficiency From Build Fit And Project Only Legal Roll Bounds
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
A universal Artifact quality score is misleading without a character, role,
|
||||
team, and build target. However, the visible substat values do contain one
|
||||
objective signal: how efficient their legal roll tiers were. Extraction
|
||||
confidence is a third concept and must not be collapsed into either one.
|
||||
|
||||
Under-leveled Artifacts also invite false precision. A projection cannot know
|
||||
which substat receives the next roll or which legal tier is selected.
|
||||
|
||||
### Decision
|
||||
|
||||
Implement `roll-efficiency-v1` as a context-free `0-100` score derived only
|
||||
from rarity-specific legal roll values, legal total roll counts, and displayed
|
||||
rounding. Persist reason codes and per-substat breakdown. Keep build fit
|
||||
explicitly `deferred`; do not add set, main-stat preference, locked state, or
|
||||
equipped state to roll efficiency.
|
||||
|
||||
Review, incomplete, or inconsistent results receive `review` or `unknown` and
|
||||
no normal score. For under-leveled Artifacts, show a detail-only
|
||||
Worst/Middle/Best bound from remaining +4 steps and legal roll tiers, only for
|
||||
an unambiguously identified 5-star Artifact when all four current substats are
|
||||
known. Label it as non-guaranteed; 4-star or rarity-ambiguous data stays
|
||||
unavailable in V1.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Users can compare roll-tier luck without being told that every high-roll
|
||||
Artifact fits every build.
|
||||
- Build-aware recommendation scoring remains a separate future contract.
|
||||
- Existing saved native results can be evaluated without rescanning.
|
||||
- Offline replay can hash derived results and detect non-determinism.
|
||||
- Projection is conservative and unavailable when the fourth substat or clean
|
||||
extraction is missing.
|
||||
|
||||
## ADR-015: Adopt A Scanner-First Galaxy UI And Explicit Feedback Primitives
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. The original Builds lock is narrowed by ADR-022: the surface may now
|
||||
show only source-bound, read-only evidence suggestions, never a general build
|
||||
or optimizer claim.
|
||||
|
||||
### Context
|
||||
|
||||
The application exposed scanner setup, technical pipeline state, inventory,
|
||||
review, prototype recommendations, overlay, and diagnostics with similar visual
|
||||
weight. The default scan path was harder to understand than the underlying
|
||||
workflow, while the Builds and triage surfaces could imply confidence that the
|
||||
current Build-Fit contract and review data do not support.
|
||||
|
||||
The renderer also lacked one consistent contract for loading, mutation
|
||||
feedback, focus, and motion. Local status text alone made successful promotion,
|
||||
review, import, or export actions easy to miss.
|
||||
|
||||
### Decision
|
||||
|
||||
Adopt a scanner-first information architecture and tokenized Galaxy visual
|
||||
system:
|
||||
|
||||
- Group Scanner, Artifacts, Review, and the explicitly labeled Builds preview
|
||||
as the main workspace. Keep Overlay and Diagnostics as secondary tools.
|
||||
- Lead the Scanner page with app/Genshin readiness and one primary start/stop
|
||||
action. Keep source selection, scan scope, and manual capture under advanced
|
||||
options while preserving all preflight and safety gates.
|
||||
- Treat Review as scan-data correction. Do not derive or display trash, feed,
|
||||
deletion, or universal quality judgments from extraction uncertainty.
|
||||
- Keep Builds clearly bounded until the versioned Build-Fit contract exists.
|
||||
Under ADR-022 it may show only source-bound, read-only evidence suggestions;
|
||||
`roll-efficiency-v1` remains visible in Artifact detail but is not relabeled
|
||||
as build suitability.
|
||||
- Centralize semantic colors, surfaces, focus, depth, spacing, and motion in
|
||||
Galaxy design tokens. Use shared skeleton, spinner, and toast primitives for
|
||||
content loading and explicit mutation outcomes.
|
||||
- Preserve durable progress and error state in the owning view. Toasts are
|
||||
transient confirmation only. Respect `prefers-reduced-motion` and keep state
|
||||
understandable through text/icons without relying on animation or color.
|
||||
|
||||
### Consequences
|
||||
|
||||
- A first-time user can follow the scan-to-review journey without opening
|
||||
diagnostics or understanding the native pipeline.
|
||||
- Technical evidence remains available but no longer dominates the default
|
||||
inventory and scan surfaces.
|
||||
- Loading and mutation feedback becomes consistent across features without
|
||||
changing the scanner, review, or persistence authority boundaries.
|
||||
- New renderer views must reuse the shared tokens and feedback semantics and
|
||||
pass keyboard, reduced-motion, overflow, and desktop visual acceptance.
|
||||
|
||||
## ADR-016: Default To The Owned Inventory And Keep Scan Feedback Session-Bound
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. The real Settings-UI Artifact-limit path is live accepted at 5/5, the
|
||||
packaged row-limit path at one detected eight-column row is accepted at 8/8,
|
||||
and the packaged full-owned-inventory baseline is accepted at 2,211/2,211 over
|
||||
70 pages with current-package reprocessing at 136 Review (6.15%).
|
||||
|
||||
### Context
|
||||
|
||||
A small numeric default makes the main scan action behave like a sample even
|
||||
when the user's intent is to import the whole owned Artifact collection. At the
|
||||
same time, using the inventory capacity as the target would be incorrect: it is
|
||||
not the number of owned Artifacts, and the native scanner does not yet detect
|
||||
the physical end of the list reliably enough to stop an oversized target
|
||||
without risking repeated bottom rows.
|
||||
|
||||
The Scanner also showed progress in a separate card while the larger left work
|
||||
surface displayed onboarding or a capture. That split made the most important
|
||||
running state harder to follow. Loading previous stored/native results into the
|
||||
right rail at startup further blurred the distinction between this run and the
|
||||
long-lived Artifact collection.
|
||||
|
||||
Reopening the inventory with `B` could theoretically replace many upward wheel
|
||||
events, but it also introduces menu-toggle state, tab-selection coordinates,
|
||||
and another guarded key/click sequence. It has not been live benchmarked as a
|
||||
more reliable reset.
|
||||
|
||||
### Decision
|
||||
|
||||
- Default the user-facing scan scope to the entire currently owned Artifact
|
||||
inventory.
|
||||
- Read and validate the current owned count at scan start. Use that `current`
|
||||
value as the hard target basis; never substitute the inventory capacity
|
||||
`total`.
|
||||
- If the current owned count is unavailable or invalid, block a full-inventory
|
||||
scan with a reviewable reason instead of guessing.
|
||||
- Offer one optional limit with either Artifacts or rows. Convert rows through
|
||||
the detected grid column count and clamp both forms to the owned count and the
|
||||
2,400-Artifact safety bound.
|
||||
- Keep scope selection session-local so a new app launch returns to the full
|
||||
owned inventory default.
|
||||
- During a run, use the left main work surface for capture phase and
|
||||
captured-versus-target progress. Keep the independently advancing OCR/parse/
|
||||
evaluation progress beside the live results on the right. Remove the
|
||||
duplicate standalone progress card.
|
||||
- Keep the right result rail current-session only and empty on app launch.
|
||||
Persisted history remains available in the Artifacts view.
|
||||
- Retain the bounded, foreground-guarded wheel-to-top reset as the production
|
||||
path. Treat `B` plus Artifact-tab reopening only as a future live A/B
|
||||
experiment.
|
||||
|
||||
### Consequences
|
||||
|
||||
- The primary action matches the common collection-import intent without
|
||||
hiding a partial numeric default.
|
||||
- A row limit remains understandable while the helper still receives one
|
||||
bounded Artifact count.
|
||||
- Full-inventory start now depends on a trustworthy owned-count read and fails
|
||||
safely when that evidence is missing.
|
||||
- The Scanner separates ephemeral run feedback from the durable Artifact
|
||||
collection and has one clear progress surface.
|
||||
- The accepted 5/20/50/100 evidence validates the wheel reset and bounded native
|
||||
engine. Real packaged Settings-UI runs additionally validate Artifact limit 5,
|
||||
one detected row at 8/8, streaming overlap at 20/20, and the complete owned
|
||||
target at 2,211/2,211 over 70 pages. The complete corpus passes the current
|
||||
Review gate at 136/2,211 (6.15%), zero errors, and zero writes. A `B`-based
|
||||
reset remains only an unaccepted experiment.
|
||||
|
||||
## ADR-017: Stream Native Capture Jobs Into Bounded Evaluation Workers
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. Packaged 20-result and 2,211-result runs prove that evaluated result
|
||||
cards become visible while native capture is still running; deterministic
|
||||
service/UI tests cover ordering, deltas, reconciliation, and reduced motion.
|
||||
|
||||
### Context
|
||||
|
||||
Native capture already appends and flushes one line per crop to
|
||||
`capture-jobs.jsonl`, but Electron previously opened that queue only after the
|
||||
helper had captured the complete target. Users therefore saw no results during
|
||||
long runs and then waited for a second, fully sequential phase. The right-side
|
||||
result rail also could not explain evaluation independently from capture.
|
||||
|
||||
Starting the whole processor repeatedly on every poll would duplicate OCR,
|
||||
race durable files, and make ordering non-deterministic. Treating a temporary
|
||||
end-of-file as completion would lose jobs that the still-running producer adds
|
||||
later.
|
||||
|
||||
### Decision
|
||||
|
||||
- Start one single-flight processor as soon as the active native run first
|
||||
exposes a non-empty `runDir`.
|
||||
- Tail complete JSONL records while the native producer is running. A temporary
|
||||
empty queue means `waiting`, not completion; a partial final line is retried.
|
||||
- Deduplicate jobs by sequence, run at most four OCR/parse/evaluation workers,
|
||||
and publish completed results in canonical capture-sequence order.
|
||||
- A result reaches the renderer only after extraction review gating and
|
||||
`roll-efficiency-v1` evaluation have produced its final safe status.
|
||||
- Keep capture and evaluation counters independent. The left workspace reports
|
||||
capture progress; the right result surface reports evaluated/target plus
|
||||
parsed, review, and error counts.
|
||||
- Load live results as monotonic sequence deltas and reconcile against the full
|
||||
durable result set before declaring a successful session complete.
|
||||
- Do not finish processing until capture is terminal and every observed job is
|
||||
drained. A user stop ends game input immediately but keeps already captured
|
||||
jobs available for evaluation; the final session outcome remains `stopped`.
|
||||
- Persist intermediate ordered `scan-results.json` snapshots so a renderer
|
||||
refresh or process boundary does not make completed work disappear.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Long scans provide useful, reviewable results before capture ends.
|
||||
- Capture speed is isolated from OCR latency while bounded concurrency prevents
|
||||
unbounded CPU/memory growth.
|
||||
- Single-flight processing, sequence deltas, and final reconciliation prevent
|
||||
duplicate cards, stale-run leakage, and false-complete summaries.
|
||||
- The current-session rail can keep all results in top-to-bottom capture order;
|
||||
newly mounted cards use motion only as an insertion cue and respect reduced
|
||||
motion.
|
||||
- Live acceptance must measure actual phase overlap, not infer streaming from a
|
||||
correct final result count.
|
||||
- A streaming end-to-end claim must use one shared wall clock from native start
|
||||
through final result reconciliation. Capture and processing durations are
|
||||
retained as separate diagnostics and must never be added when they overlap.
|
||||
|
||||
## ADR-018: Gate Build-Fit Behind A Versioned Evidence Contract Before Scoring
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. The contract, validator, native-result adapter, deterministic five-
|
||||
piece evidence verifier, four sourced profiles, safety tests, legacy production-
|
||||
path separation, and the separate read-only ranker are implemented. ADR-022
|
||||
defines the visible ranking/expiry policy; the V1 assessments remain score-free.
|
||||
|
||||
### Context
|
||||
|
||||
The repository already contained an early preset scorer that could emit
|
||||
character recommendations and builds from local Artifact records. Its weights,
|
||||
thresholds, inferred characters, and partial builds were useful as a prototype,
|
||||
but they had no versioned provenance or validated Build-Fit semantics. Hiding
|
||||
those suggestions in the Builds UI was not enough while real account snapshots
|
||||
still calculated them internally.
|
||||
|
||||
Roll-Efficiency, OCR confidence, and character/build suitability are different
|
||||
concepts. A high legal-roll score does not prove that a Set, Main Stat, or
|
||||
Substat mix fits a target, while low or Review extraction data must never enter
|
||||
downstream fit logic.
|
||||
|
||||
### Decision
|
||||
|
||||
- Introduce `build-fit-contract-v1`, `build-fit-input-v1`, and
|
||||
`build-fit-assessment-v1` as separate shared contracts.
|
||||
- Require stable identity/revision, game-versioned provenance, explained legal
|
||||
Set plans, variable-slot Main-Stat priorities, relative Substat weights,
|
||||
optional full-build aggregate targets, and explicit conflict policies.
|
||||
- Enforce V1 safety floors of 80% overall extraction confidence and 70% on all
|
||||
required identity/value fields. Contracts may be stricter but cannot weaken
|
||||
those floors.
|
||||
- Require final parsed/non-Review data and a canonical IK Set key. Missing or
|
||||
uncertain evidence produces reason-coded blockers.
|
||||
- Map safe facts and conflicts into structured evidence. Verify complete
|
||||
five-piece slot/Set shape and sourced aggregate context without ranking it,
|
||||
and keep V1 output at `score: null` plus `recommendationStatus: deferred`
|
||||
until a sourced profile corpus and ranking formula are validated.
|
||||
- Record Roll-Efficiency as separate context and never fold it into Build-Fit
|
||||
eligibility or imply that it is a fit score.
|
||||
- Stop running real local account snapshots through the legacy scorer. Keep
|
||||
that implementation limited to explicit demo/test fixtures.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Sourced profiles and ranking fixtures can evolve on the combination verifier
|
||||
without changing the scanner/OCR authority boundary.
|
||||
- Review, low-confidence, incomplete, or non-canonical data fails closed before
|
||||
any character recommendation.
|
||||
- The Builds page can show source-bound evidence candidates while remaining
|
||||
explicit about scope, uncertainty, and missing non-Artifact context.
|
||||
- No user-facing fit score exists yet; `eligible` means only safe input for a
|
||||
future ranker.
|
||||
- Complete-build aggregate targets stay `pending` unless an explicit non-
|
||||
Artifact contribution references known contract sources; no character,
|
||||
weapon, or team baseline is guessed.
|
||||
- The detailed contract and next gates are documented in
|
||||
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||
|
||||
## ADR-019: Repair Five-Star +0 Main Values Only From Structural Rarity Evidence
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. The rule is implemented in the OCR parser, covered by positive and
|
||||
ambiguity-preserving tests, and validated against the complete 2,211-crop corpus.
|
||||
|
||||
### Context
|
||||
|
||||
The first full-inventory parser pass routed 490 results to Review. Of the 388
|
||||
low-confidence Main-Value cases, 360 were level 0 and already had four parsed
|
||||
Substats. A lower-rarity Artifact cannot begin at level 0 with four Substats, so
|
||||
that combination is structural evidence for five-star rarity. Without that
|
||||
evidence, a +0 Main Value can be valid at several rarities and must stay
|
||||
reviewable.
|
||||
|
||||
### Decision
|
||||
|
||||
- Derive the canonical five-star +0 Main Value only when `level === 0` and
|
||||
exactly four Substats were parsed.
|
||||
- Mark the repaired Main Value with explicit parser confidence; do not silently
|
||||
generalize the rule to three, two, or incomplete Substats.
|
||||
- Keep all ambiguous rarity cases in Review and retain reason-coded analysis of
|
||||
the saved corpus.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Current-package reprocessing reduced Review from 490 to 136 without
|
||||
reclassifying any previously clean result into Review.
|
||||
- 360 Main Values were repaired from structural evidence; ambiguous three-
|
||||
Substat +0 Artifacts remain visible for review.
|
||||
- Future rarity extraction can replace this inference, but it must preserve the
|
||||
same fail-closed behavior when rarity evidence is uncertain.
|
||||
|
||||
## ADR-020: Target Only Exact Supported Genshin Process Names
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. Both the native helper and PowerShell fallback use the exact whitelist;
|
||||
the final packaged no-Genshin safety smoke passed with no source and no errors.
|
||||
|
||||
### Context
|
||||
|
||||
A broad process-name match containing `Genshin` allowed the assistant process
|
||||
itself to look like a game target after the real client closed. Reusing a cached
|
||||
HWND without validating its owning process could preserve the same unsafe state.
|
||||
|
||||
### Decision
|
||||
|
||||
- Recognize only `GenshinImpact` and `YuanShen` as supported game processes.
|
||||
- Revalidate every cached HWND against the exact whitelist before returning it;
|
||||
clear invalid cache state immediately.
|
||||
- Apply the same behavior to the PowerShell fallback and keep the allow-list
|
||||
covered by helper/static tests.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Closing Genshin produces `genshinFound=false`, HWND `0`, an empty target
|
||||
process, and no capture source even while the assistant remains open.
|
||||
- New official client process names require an explicit reviewed allow-list
|
||||
change instead of being picked up accidentally.
|
||||
|
||||
## ADR-021: Require Explicit Profile Assumptions And Source-Bound Aggregate Context
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. The contract requires target assumptions and source-bound aggregate
|
||||
context; the profile corpus, expiry gate, and Golden/adversarial context tests
|
||||
are implemented.
|
||||
|
||||
### Context
|
||||
|
||||
Character recommendations vary with constellation, weapon, teammates, rotation,
|
||||
and game version. A single ER threshold or Main-Stat order without those
|
||||
assumptions would look authoritative while silently applying the wrong build
|
||||
context. Artifact contributions alone also cannot prove a total-build stat such
|
||||
as Energy Recharge because character and weapon values are outside the scan.
|
||||
|
||||
### Decision
|
||||
|
||||
- Require every V1 target to list non-empty, unique assumptions alongside its
|
||||
role and description.
|
||||
- Store curated candidates in a versioned corpus with game version, review time,
|
||||
source version, and direct reference.
|
||||
- Start narrowly with Furina off-field/Solo Hydro C0-C1 from the KQM Furina
|
||||
Quick Guide labeled `Luna II`; do not generalize its 200%+ ER requirement to
|
||||
other Hydro counts, C2+, no-Burst teams, weapons, or rotations.
|
||||
- Treat Substat weights as documented ordinal source order only, never as damage
|
||||
multipliers or a ranking formula.
|
||||
- Evaluate a full-build aggregate target only when a complete non-Artifact value
|
||||
references an allowed curated/user/imported source. Otherwise keep it
|
||||
`pending`.
|
||||
- Cross-check profile character and Set keys against vendored IK 6.7.0 data.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Profiles remain auditable without turning hidden team/weapon assumptions into
|
||||
Artifact-derived facts.
|
||||
- Team, weapon, Favonius, and rotation inputs cannot be smuggled in as hidden
|
||||
constants; callers must provide them explicitly.
|
||||
- Profile expansion now carries a clear maintenance cost: source review, version
|
||||
update, catalog validation, Golden fixtures, and adversarial safety cases.
|
||||
|
||||
## ADR-022: Rank Only Source-Bound Build-Fit Evidence With Profile Expiry
|
||||
|
||||
### Status
|
||||
|
||||
Accepted. `build-fit-evidence-ranking-v1`, four profile fixtures, explicit
|
||||
source expiry, session-only aggregate context, and the read-only Builds surface
|
||||
are implemented and offline-tested. Packaged renderer acceptance remains open.
|
||||
|
||||
### Context
|
||||
|
||||
The score-free V1 verifier can prove that five scanned Artifacts are structurally
|
||||
safe and satisfy a profile's facts, but it intentionally cannot emit a damage
|
||||
score. Leaving the Builds page locked forever would strand the validated
|
||||
evidence, while turning Roll-Efficiency, raw OCR confidence, or arbitrary
|
||||
substat weights into a fit score would overstate what the data proves.
|
||||
|
||||
Guide advice also changes with game versions. A version label and review date
|
||||
alone do not prevent an old profile from silently continuing to rank an account.
|
||||
Finally, native results are the only renderer-visible data source that preserves
|
||||
both canonical IK Set IDs and per-field confidence; store records do not.
|
||||
|
||||
### Decision
|
||||
|
||||
- Keep `build-fit-assessment-v1` and
|
||||
`build-fit-combination-assessment-v1` score-free (`score: null`).
|
||||
- Add `build-fit-evidence-ranking-v1` as a separate, bounded, deterministic,
|
||||
read-only ranker. It considers only complete eligible five-piece combinations
|
||||
and returns at most three non-overlapping profile suggestions.
|
||||
- Make the visible `evidenceCoverage` a product-rule coverage measure: sourced
|
||||
Set-plan tier, variable Main-Stat priority, and ordinal Substat presence. It
|
||||
is not a DPS, optimizer, Roll-Efficiency, or OCR-confidence score.
|
||||
- Treat OCR confidence strictly as a hard gate. Do not use roll values, roll
|
||||
efficiency, character ownership, team, weapon, rotation, or unrecorded
|
||||
constants as ranking inputs.
|
||||
- Require `provenance.validUntil` on every profile. Expired profiles fail closed
|
||||
until a maintainer refreshes their direct source review.
|
||||
- Permit required aggregate targets only from explicit, user-confirmed,
|
||||
session-only non-Artifact context that references a declared local/user
|
||||
source. Missing context keeps the profile deferred; it is never guessed or
|
||||
persisted.
|
||||
- Read Builds data only from the newest complete native result run. The Builds
|
||||
surface does not scan, mutate review/store data, or send game input.
|
||||
|
||||
### Consequences
|
||||
|
||||
- The app can expose useful, inspectable profile candidates without pretending
|
||||
that it is a complete optimizer.
|
||||
- Users see why a candidate ranked and why a profile is deferred, including
|
||||
missing full-build context or conflicts.
|
||||
- Profile maintenance has an explicit renewal deadline and source fixture cost.
|
||||
- A renderer/package acceptance is still required before this UI is called
|
||||
live-accepted; scanner live evidence is not reused as UI evidence.
|
||||
|
||||
## ADR-023: Visuelle Seltenheit fail-closed und Nicht-5★-Ansicht ohne Spielaktion
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
Die bisherige Roll-Efficiency-Auswertung kennt rarity-spezifische Regeln, hatte
|
||||
bei neuen nativen Kartencrops aber keine direkte, persistierte Evidenz für die
|
||||
sichtbare Sternreihe. Eine aus Level, Main-Value oder Substats abgeleitete
|
||||
Seltenheit ist für einzelne etablierte Legacy-Regeln nützlich, darf aber eine
|
||||
fehlende oder widersprüchliche aktuelle Sternreihe nicht stillschweigend in ein
|
||||
5★-Ergebnis verwandeln.
|
||||
|
||||
Gleichzeitig sind explizit erkannte 1–4★-Artefakte für die Sammlung und
|
||||
manuelle Sichtung relevant. Daraus folgt jedoch keine sichere
|
||||
Bergungsentscheidung: Sperr-/Ausgerüstet-Status, Crop, Detaildaten und die
|
||||
Spieloberfläche müssen weiterhin durch den Nutzer geprüft werden. Automatische
|
||||
Bergung oder Quick Select wäre eine irreversible Spielaktion und widerspricht
|
||||
dem Scanner-Sicherheitsrahmen aus ADR-004, ADR-010 und ADR-013.
|
||||
|
||||
### Decision
|
||||
|
||||
- Aktuelle native Capture-Jobs schreiben die direkte visuelle Stern-Evidenz als
|
||||
`starCount`, `starConfidence` und `starSource` in den dauerhaften
|
||||
Ergebnispfad.
|
||||
- Nur explizit bestätigte 5★-Evidenz darf in `roll-efficiency-v1` eingehen.
|
||||
Explizit bestätigte 1–4★-Ergebnisse erhalten `valueStatus: "excluded"` und
|
||||
keinen Wert, keine Projektion und keine Store-Promotion.
|
||||
- Wenn visuelle Stern-Evidenz vorliegt, aber `starCount` fehlt, ungültig oder
|
||||
widersprüchlich ist, wird nicht auf 5★ geraten. Das Ergebnis bleibt `Review`
|
||||
und seine Evidenz bleibt sichtbar.
|
||||
- Legacy-Ergebnisse ohne das neue visuelle Sternfeld werden durch diese
|
||||
Entscheidung nicht rückwirkend als visueller Beweis ausgegeben; für sie
|
||||
gelten bis zum Rescan die bestehenden strukturellen Regeln.
|
||||
- Die Inventory-Ansicht bietet einen separaten schreibgeschützten Filter
|
||||
`Nicht 5★` nur für explizit erkannte 1–4★-Ergebnisse. Er zeigt Crop/Bild und
|
||||
Detaildaten zur manuellen Prüfung, nicht die Behauptung "sicher bergbar".
|
||||
- Es gibt weder eine Scan-Checkbox noch einen Ablauf für automatische Bergung
|
||||
oder Quick Select. Diese Aktion bleibt verboten und aufgeschoben; ein
|
||||
zukünftiger Vorschlag bräuchte eine neue explizite Sicherheits- und
|
||||
Produktentscheidung und dürfte den bestehenden Nicht-Mutationsrahmen nicht
|
||||
umgehen.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Nicht-5★-Artefakte bleiben als vollständige, nachvollziehbare
|
||||
Scan-Ergebnisse sichtbar, ohne die Wertung oder Empfehlungen zu verzerren.
|
||||
- Die UI trennt klar zwischen `excluded` (direkt erkannte 1–4★) und `Review`
|
||||
(unsichere Evidenz), statt beide als schwache 5★-Artefakte darzustellen.
|
||||
- Replay-, Detail- und Promotion-Pfade müssen Stern-Evidenz, Ausschluss und
|
||||
Review deterministisch erhalten.
|
||||
- Der einzige zulässige nächste Schritt für einen Nutzer in der `Nicht 5★`-
|
||||
Ansicht ist die manuelle Prüfung im Spiel; die App sendet dafür keine
|
||||
Bergungs-, Auswahl- oder sonstige mutierende Eingabe.
|
||||
|
||||
## ADR-024: Default Renderer Copy To English While Preserving The English Scanner Boundary
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
The application previously exposed a largely German renderer while its only
|
||||
validated OCR/crop profile is the English Genshin UI. Treating the two language
|
||||
concerns as one setting would either make a German-speaking user unable to
|
||||
choose their application language or incorrectly imply that German Genshin OCR
|
||||
is supported.
|
||||
|
||||
### Decision
|
||||
|
||||
- Make English the default renderer locale for a fresh, missing, or invalid
|
||||
local preference.
|
||||
- Offer German as a user-selectable Settings preference and persist it locally
|
||||
under one application-scoped key.
|
||||
- Keep message catalogs structurally aligned and route renderer copy through the
|
||||
locale provider rather than hard-coded feature strings.
|
||||
- Keep the scanner contract explicit: changing the application language changes
|
||||
application copy only; Smart Capture remains validated for English Genshin.
|
||||
- Preserve stable semantic UI hooks for renderer acceptance so tests do not
|
||||
depend on a particular translated label.
|
||||
|
||||
### Consequences
|
||||
|
||||
- New users receive an English interface by default while German remains
|
||||
available without a restart.
|
||||
- The document language and accessible renderer labels track the selected
|
||||
locale.
|
||||
- Future Genshin OCR-language support requires separate crop, parser, corpus,
|
||||
and live-acceptance evidence; it cannot be inferred from this setting.
|
||||
|
||||
## ADR-025: Make Artifact Deletion An App-Local Tombstone Workflow
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
A wrong OCR read or wrong screenshot needs a way to disappear from the local
|
||||
Artifact inventory. Directly mutating the game's inventory is prohibited, and
|
||||
rewriting native `scan-results.json` would destroy the raw evidence needed for
|
||||
replay, debugging, and corpus review. A delete control also needs to prevent an
|
||||
active scan from racing a local cleanup.
|
||||
|
||||
### Decision
|
||||
|
||||
- Expose a confirmed local-delete control only in artifact detail.
|
||||
- For native results, append an immutable entry to `deleted-results.jsonl` and
|
||||
filter it at load time instead of rewriting `scan-results.json`.
|
||||
- Delete a crop only after its resolved path is proved to be a PNG contained by
|
||||
the selected native run directory.
|
||||
- Block deletion while the target native run or its processor is active; make a
|
||||
repeat deletion idempotent.
|
||||
- Allow Store deletion only for one exact local record: either a Store-only row
|
||||
or an explicitly selected record linked to the deleted native result.
|
||||
- Keep the whole workflow behind main-process IPC. It must never focus Genshin,
|
||||
call the input helper, or dispatch a game action.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Users can correct local inventory mistakes without jeopardizing the game or
|
||||
losing raw scan evidence.
|
||||
- Tombstones become part of the local run contract and loaders/replay tooling
|
||||
must apply them deliberately.
|
||||
- The UI must communicate that the action removes app-local data only, and
|
||||
confirmation remains mandatory because local data and screenshots can be
|
||||
removed.
|
||||
|
||||
## ADR-026: Persist First-Observed Native Run Timing On One Shared Clock
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
Native capture and downstream OCR/parse work overlap. Earlier reports could
|
||||
show phase rates but did not retain one durable, validated endpoint-to-endpoint
|
||||
clock for the current run. Adding capture and processing durations would double
|
||||
count their overlap and produce a misleading end-to-end claim.
|
||||
|
||||
### Decision
|
||||
|
||||
- Write atomic `run-timing.json` records using
|
||||
`native-scanner-run-timing-v1` in each native run directory.
|
||||
- Record the first valid observation of request start, native scanner start,
|
||||
capture start/end, processing start/end, durable result write, and final
|
||||
result reconciliation. Later polling or retries cannot replace a marker.
|
||||
- Derive request-to-durable and request-to-reconciled milliseconds directly from
|
||||
their timestamps and validate marker ordering.
|
||||
- Require a complete valid timing record and final count reconciliation for a
|
||||
live streaming/end-to-end claim.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Capture, processing, and end-to-end rate can be reported as distinct metrics
|
||||
without double counting overlap.
|
||||
- A historical timing observation cannot stand in for current-build live
|
||||
evidence.
|
||||
- Timing files are durable diagnostic evidence and must be included in native
|
||||
smoke validation, not merely displayed in the renderer.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Localization, Local Deletion, Timing, and OCR Corpus Checkpoint
|
||||
|
||||
Date: 2026-07-11
|
||||
|
||||
Status: implementation, offline-validation, and current-build live-smoke
|
||||
checkpoint. The non-persisting 50-artifact development smoke passed on
|
||||
2026-07-11; its durable evidence is in
|
||||
[NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||
An elevated packaged executable remains a separate package-runtime boundary and
|
||||
is not claimed here.
|
||||
|
||||
## Delivered Scope
|
||||
|
||||
### English-default renderer with German Settings option
|
||||
|
||||
- `en` is the default for a fresh, missing, or invalid persisted locale.
|
||||
- Settings exposes English and German; the selected app locale is persisted
|
||||
locally and keeps the document language in sync.
|
||||
- Visible app-shell, navigation, Scanner, Artifacts, Review, Builds, Overlay,
|
||||
Diagnostics, Settings, and local-delete chrome use the renderer translation
|
||||
catalog. Source/domain evidence remains deliberately distinct from translated
|
||||
UI chrome.
|
||||
- The setting changes application copy only. Smart Capture remains validated
|
||||
for the English Genshin UI; it does not claim German Genshin OCR/crop support.
|
||||
- The package UI runner uses stable `data-*` hooks instead of translated text
|
||||
for core interaction checks.
|
||||
|
||||
### Local artifact removal
|
||||
|
||||
The delete control corrects app-local data only. It is not a Genshin action.
|
||||
|
||||
| Selected row | Local effect | Preserved / prohibited behavior |
|
||||
| --- | --- | --- |
|
||||
| Native scan result | Appends a `deleted-results.jsonl` tombstone, hides the row, and removes its crop only if the resolved PNG stays inside the selected run directory. | Raw `scan-results.json` remains replay evidence. Active capture/processing blocks removal. No input helper or game action is called. |
|
||||
| Exact Store record linked to native row | Removed only when the user explicitly includes that exact local record in the confirmed removal. | No broad Store deletion and no inferred record matching. |
|
||||
| Store-only row | Removes that one explicit local Store record through main-process IPC. | No native scan evidence or Genshin state is touched. |
|
||||
|
||||
The workflow is idempotent for an already tombstoned native row. It retains the
|
||||
native run's raw result array for audit/replay and limits file deletion to
|
||||
contained PNG paths.
|
||||
|
||||
### Shared-clock native run timing
|
||||
|
||||
Each native run can now write atomic `run-timing.json` evidence with version
|
||||
`native-scanner-run-timing-v1`.
|
||||
|
||||
- Markers are first-observed and immutable after a valid value is written.
|
||||
- The record covers request start, native scanner start, capture start/end,
|
||||
processing start/end, durable results, and final reconciliation.
|
||||
- `requestToResultsDurableMs` and `requestToResultsReconciledMs` are derived
|
||||
directly from their endpoints and validated against timestamp ordering.
|
||||
- Capture and processing remain separate metrics. They may overlap and are
|
||||
never added to create an end-to-end duration.
|
||||
|
||||
### Confirmed OCR corpus changes
|
||||
|
||||
The historical 136-result Review population was inspected against saved crops,
|
||||
not treated as self-labeled training data:
|
||||
|
||||
| Audit result | Count | Current behavior |
|
||||
| --- | ---: | --- |
|
||||
| Explicit visual 1–4★ | 128 | Remain excluded/manual-inspection entries, not value-scored parser labels. |
|
||||
| Confirmed `HP+4..1%` OCR punctuation defect | 6 | Exact duplicated decimal punctuation is repaired to `HP+4.1%`; the regression cases are in the confirmed corpus. |
|
||||
| Confirmed 5★ partial-name case with direct slot evidence | 1 | The unsafe global alias was removed. Canonicalization is now allowed only when independent high-confidence slot evidence makes one piece valid. |
|
||||
| Incomplete/malformed 5★ crop | 1 | Remains `Review`; no heuristic converts it into a clean artifact. |
|
||||
|
||||
This preserves the fail-closed rule: ambiguous OCR becomes unknown or Review,
|
||||
not a guessed canonical artifact.
|
||||
|
||||
## Offline Evidence Recorded For This Checkpoint
|
||||
|
||||
- Focused localization tests cover English fallback, persisted locale behavior,
|
||||
and English/German catalog-key parity.
|
||||
- Native deletion tests cover tombstone filtering, raw-evidence preservation,
|
||||
active-run blocking, contained crop cleanup, idempotency, and exact Store
|
||||
scope.
|
||||
- Native timing tests cover first-observation behavior, ordering validation, and
|
||||
derived durations.
|
||||
- Parser/corpus evaluation covers the duplicate-decimal repair and slot-safe
|
||||
name behavior. The latest saved 50-artifact evidence validation completed at
|
||||
`50/50` with zero validation issues.
|
||||
- The final current-build offline gate passed after all localization, deletion,
|
||||
timing, corpus, and acceptance-runner changes: `npm run acceptance:offline`
|
||||
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||
validation, package verification (27/27), both dependency audits, and the
|
||||
diff check.
|
||||
- The built-renderer Builds/UI acceptance passed fresh English, German
|
||||
selection, English restoration, Builds context/focus, reduced motion,
|
||||
overflow, and native plus Store local-delete confirmation probes. It did not
|
||||
launch an elevated packaged executable, so it is not
|
||||
UAC/preload/helper/native-runtime evidence.
|
||||
|
||||
## Remaining Release Gates
|
||||
|
||||
1. If a package-runtime/UAC claim is needed, separately launch the elevated
|
||||
packaged executable and verify its bridge/helper/runtime behavior. Do not
|
||||
substitute the passed built-renderer UI acceptance for that evidence.
|
||||
|
||||
2. The required elevated development 50-artifact smoke now passes: 50/50
|
||||
capture, click, queue, parse, durable-result reconciliation, zero Review,
|
||||
zero processing errors, zero Store writes, and a complete valid
|
||||
`run-timing.json`. It measured `28,022 ms` request-to-reconciliation, so it
|
||||
does not claim the separate 3/s performance target.
|
||||
|
||||
3. Completed on 2026-07-11: generated workspace outputs were removed and the
|
||||
coherent checkpoint was committed and pushed as `8b9f948`. The running
|
||||
admin runtime's locked log was intentionally preserved.
|
||||
|
||||
## Follow-on Quality Phase
|
||||
|
||||
- Extend the corpus only from new visually confirmed crops and preserve
|
||||
ambiguity as `Review`. The historical 136-item Review population has already
|
||||
been audited; it is not an outstanding relabeling task.
|
||||
- Refresh Build-Fit source profiles before their explicit expiry and add Golden
|
||||
plus adversarial fixtures for each profile expansion.
|
||||
- Keep the bounded wheel reset as the production baseline; test any alternate
|
||||
inventory-entry/reset choreography as a separate guarded experiment.
|
||||
- Keep Builds read-only and Artifact-only until a separately sourced product
|
||||
decision introduces character, weapon, team, or rotation inputs.
|
||||
@@ -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,238 @@
|
||||
# Native Scanner Live Acceptance - 2026-07-10
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Die Artifact-first Scanner-Kette ist fuer den validierten Zustand `1920x1080`,
|
||||
16:9, englische Artifact-Inventaransicht mit sichtbarer Detailkarte live
|
||||
abgenommen. Dev- und Packaged-Runtime nutzten denselben Runtime-Vertrag
|
||||
`2026-07-10-packaged-runtime-acceptance`. Alle Scans blieben read-only und
|
||||
liefen mit `persist=false`.
|
||||
|
||||
Die Abnahme umfasst inzwischen auch das komplette OCR-bestaetigte Inventar:
|
||||
Run `20260710-223233` erfasste exakt 2.211 Artifacts ueber 70 Seiten. Der erste
|
||||
Parserstand verfehlte den Review-Grenzwert ehrlich; nach einer strukturell
|
||||
belegten +0-Korrektur reprozessierte das aktuelle Paket denselben vollstaendigen
|
||||
Corpus mit 136 Review (6,15%), 0 Fehlern und 0 Store-Writes. Der gespeicherte
|
||||
Run-Validator meldet 0 Issues.
|
||||
|
||||
Der Scanner-Core darf damit in die naechste Produktphase uebergehen. Der
|
||||
strikte End-to-End-Zielwert von drei Artifacts pro Sekunde ist dagegen nicht
|
||||
erreicht und wird nicht behauptet.
|
||||
|
||||
## Live-Matrix
|
||||
|
||||
| Runtime | Run | Ziel | Seiten | Reset | Capture | Capture/s | Processing | Processing/s | Review | Fehler | Store |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Dev | `20260710-102344` | 5 | 1 | 1.701 ms | 1.123 ms | 4,45 | 1.806 ms | 2,77 | 0 | 0 | 0 |
|
||||
| Dev | `20260710-102421` | 20 | 1 | 1.704 ms | 4.443 ms | 4,50 | 6.430 ms | 3,11 | 0 | 0 | 0 |
|
||||
| Dev | `20260710-102450` | 50 | 2 | 1.744 ms | 11.328 ms | 4,41 | 15.579 ms | 3,21 | 0 | 0 | 0 |
|
||||
| Dev | `20260710-102547` | 100 | 4 | 1.718 ms | 22.877 ms | 4,37 | 31.731 ms | 3,15 | 0 | 0 | 0 |
|
||||
| Packaged | `20260710-103736` | 5 | 1 | 1.702 ms | 1.162 ms | 4,30 | 1.801 ms | 2,78 | 0 | 0 | 0 |
|
||||
| Packaged streaming UI | `20260710-201614` | 20 | 1 | 1.701 ms | 4.486 ms | 4,46 | 8.032 ms | 2,49 | 0 | 0 | 0 |
|
||||
| Packaged full inventory | `20260710-223233` | 2.211 | 70 | 1.739 ms | 522.005 ms | 4,24 | 722.164 ms | 3,06 | 490* | 0 | 0 |
|
||||
|
||||
`*` Der Vollinventar-Runner lehnte diesen ersten Endstand wegen 22,16% Review
|
||||
korrekt ab. Die anschliessende Reprozessierung mit dem reparierten aktuellen
|
||||
Parser verarbeitete 2.211/2.211 in 689.049 ms (3,21/s) und endete bei 136 Review
|
||||
(6,15%), 0 Fehlern, 0 Writes und 2.211 Results. Capture/Streaming und aktuelle
|
||||
Parserqualitaet sind damit als zwei explizite, gespeicherte Evidenzschritte
|
||||
akzeptiert; ein nachtraeglich behaupteter Ein-Pass-Lauf wird nicht erfunden.
|
||||
|
||||
Die fruehere `1,77/s`-Aussage beschreibt ausschliesslich den historischen,
|
||||
seriellen Dev-100-Smoke: `100 / (24.623 ms Reset/Capture + 31.731 ms
|
||||
Batch-Processing)`. `native-live-smoke.ps1` wartete dort bewusst auf das
|
||||
Capture-Ende und startete erst danach den Processor. Die Zahl ist rechnerisch
|
||||
korrekt, aber keine aktuelle End-to-End-Messung der Streaming-Pipeline.
|
||||
|
||||
Der erneute erhoehte Streaming-Livetest am 2026-07-11 (`20260711-090019`)
|
||||
lief dry mit 20/20 Capture, Parse und Resultaten, 0 Review, 0 Fehlern und 0
|
||||
Writes. Das erste Resultat war sichtbar, bevor Capture endete. Die gemessenen
|
||||
Teilwerte waren `4,43 Capture/s` (`4.517 ms` active) und `7.668 ms`
|
||||
Processor-Laufzeit; die beobachtete Zeit vom Startrequest bis zur finalen
|
||||
Result-Reconciliation betrug `8.370 ms` (`2,39/s`). Diese kleine,
|
||||
clientseitige Beobachtung ist kein allgemeiner Durchsatzclaim, weil der
|
||||
gemeinsame Clock-Contract noch nicht als dauerhafter Run-Wert gespeichert wird.
|
||||
|
||||
Bei ueberlappenden Phasen duerfen Capture- und Processing-Dauer nie addiert
|
||||
werden. Kuenftige End-to-End-Claims muessen Start, Capture-Start/-Ende,
|
||||
Processing-Start/-Ende und finale Result-Reconciliation auf einer gemeinsamen
|
||||
Wall-Clock belegen; Capture- und Processing-Raten bleiben getrennte Werte. Das
|
||||
3/s-Gesamtziel ist weiterhin nicht bewiesen.
|
||||
|
||||
## Qualitaets- und Dateivertraege
|
||||
|
||||
- Alle neuen 5/20/50/100-Runs hatten exakt so viele Jobs, PNGs, Klicks,
|
||||
verarbeitete Zeilen und geladene Results wie angefordert.
|
||||
- Jeder Job war an genau eine nichtleere `artifact-NNNN.png`, eine eindeutige
|
||||
Sequenz, den erwarteten absoluten Pfad und genau zwei Click-Events gebunden.
|
||||
- Der 100er verteilte sich auf `32/32/32/4` Captures ueber vier Seiten.
|
||||
- Der Validator fand im 100er keine PNG-Hash-Duplikatgruppen, keine komplett
|
||||
wiederholten Seiten und keine identischen Seiten-Grenzpaare.
|
||||
- Der Vollinventar-Run enthielt exakt 2.211 Jobs, PNGs und Results. Der letzte
|
||||
Job war Sequenz 2.211 auf Seite 70, Zeile 0, Spalte 2, passend zur partiellen
|
||||
Schlussseite nach 69 vollen 32er-Seiten.
|
||||
- Der gespeicherte Vollinventar-Validator fand zwar vier Gruppen identischer
|
||||
Einzel-Crops, aber keine wiederholten Seitenpaare und keine identischen
|
||||
Seiten-Grenzpaare; sein gesamter Run-Vertrag bestand mit 0 Issues. Identische
|
||||
Einzelkarten werden daher nicht mit einem haengenden Scroll verwechselt.
|
||||
- Der aktuelle Vollinventar-Replay ist ueber fuenf Wiederholungen deterministisch:
|
||||
2.075 Results sind `evaluated`, 136 bleiben `review`, 0 sind `unknown`.
|
||||
1.952 unterlevelte Results haben eine Projektion, 123 sind bereits komplett.
|
||||
- Fuenf Offline-Replays des neuen 100ers waren hashidentisch. Alle 100 Results
|
||||
waren `parsed` und `evaluated`; Scorebereich `76.1-92.5`, 0 Review, 0 unknown.
|
||||
- Die neuen 100 Artifacts waren alle +20, daher korrekt 0 offene Projektionen.
|
||||
Die Projektionslogik bleibt durch die vorhandene Offline-Evidenz fuer
|
||||
unterlevelte Artifacts abgedeckt.
|
||||
- Artifact-Store und `review-samples.jsonl` waren innerhalb jedes Dry-Runs vor
|
||||
und nach dem Scan bytegleich. Die Review-Datei blieb bei
|
||||
`83A222F49E7246054B16363C0CA7083168F7CE0AB595916313B44CB279BF9B8C`.
|
||||
|
||||
## Safety-Nachweise
|
||||
|
||||
- Probe-Klick: Genshin-Fenster, erkanntes 4x8-Grid, sichtbare Detailkarte und
|
||||
geaenderter Detail-Fingerprint wurden vor dem breiten Lauf bewiesen.
|
||||
- Fokusverlust-Run `20260710-101820`: `blocked`, 0 Klicks, 0 Captures; der
|
||||
Helper refokussierte nicht automatisch.
|
||||
- Stop-Run `20260710-102735`: Stop waehrend des Initial-Resets nach `87 ms`,
|
||||
Status `stopped`, 0 Klicks, 0 Captures.
|
||||
- Foreground-HWND wird vor jedem Reset-/Scroll-Event, Klick und Crop-Capture
|
||||
geprueft. ESC, Enter, F9 und expliziter Stop bleiben auch im Reset aktiv.
|
||||
- Der vollstaendige Top-Reset bleibt auf 3.200 Events begrenzt, pausiert aber
|
||||
nur noch nach jeweils 39 Events. Live sank er dadurch von `50.698 ms` auf
|
||||
stabil rund `1.7 s`, ohne Guard oder Reichweite zu lockern.
|
||||
- Native Helper und PowerShell-Fallback erkennen nur die exakten Prozessnamen
|
||||
`GenshinImpact` oder `YuanShen` und validieren gecachte HWNDs erneut. Nachdem
|
||||
das Spiel waehrend der Reprozessierung extern geschlossen war, konnte die App
|
||||
sich dadurch nicht mehr selbst als Genshin-Ziel erfassen. Der finale
|
||||
No-Genshin-Smoke meldete `genshinFound=false`, HWND 0, leeren Zielprozess,
|
||||
keine Capture-Quelle und 0 Renderer-/Konsolenfehler.
|
||||
|
||||
## Packaged-Runtime
|
||||
|
||||
- Start aus `%TEMP%`, `isDev=false`, Runtime und Helper-Guard beide
|
||||
`isElevated=true`.
|
||||
- Renderer wurde aus `resources/app.asar` geladen; relative Vite-Assets waren
|
||||
vorhanden.
|
||||
- Helper lief aus `resources/input-helper/InputHelper.exe`.
|
||||
- IK 6.7.0 wurde aus `resources/ik-inventorylists` geladen: 61 Sets,
|
||||
289 Pieces.
|
||||
- Die Preload-Bridge stellte 36 erwartete Methoden bereit.
|
||||
- Vor dem neuen Scan wurde automatisch der neueste vollstaendige 100er geladen;
|
||||
neuere gestoppte oder unprozessierte Runs wurden korrekt ignoriert.
|
||||
- Der paketierte 5er lief ueber Renderer -> Preload -> IPC -> C# Helper und
|
||||
bestand Capture, Processing, Result-Load und Non-Persistence.
|
||||
- Finale Inventory-Ansicht: 5/5 geparst, 0 Review, 5 bewertet,
|
||||
`0 speicherbar - 5 im Store - 0 Review - 0 blockiert`.
|
||||
- Der aktuelle paketierte Renderer bestand zusaetzlich den echten
|
||||
Settings-UI-Pfad `Eigenes Limit -> Reihen -> 1`: das erkannte 8-Spalten-Raster
|
||||
ergab Ziel 8; Run `20260710-180851` endete mit 8/8 Captures, 8/8 Processing,
|
||||
8/8 Parsing, 0 Review, 0 Fehlern und `persist=false`. Der Top-Reset brauchte
|
||||
1.715 ms. Zwei vorherige Vordergrundverluste blockierten sicher mit 0 Klicks.
|
||||
- Der echte Settings-UI-Streamingpfad mit Artifact-Limit 20 bewies die
|
||||
Phasenueberlappung: waehrend der Helper noch `running` meldete, waren bereits
|
||||
11 Ergebnisse komplett geparst, bewertet und in der Rail sichtbar. Neue
|
||||
Karten verwendeten die konfigurierte Slide-in-Mountanimation. Final standen
|
||||
20 eindeutige Sequenzen 1-20, 20 evaluierte Results, 0 Review, 0 Fehler und
|
||||
0 Persistenzwrites. Der Dokument-Viewport blieb 1304x821 ohne Overflow; die
|
||||
Ergebnis-Rail scrollte intern.
|
||||
- Der Vollinventar-Settings-Pfad las als Ziel ausschliesslich den aktuellen
|
||||
OCR-Besitzwert 2.211, nie die Kapazitaet. Capture erreichte 2.211/2.211 auf 70
|
||||
Seiten, waehrend der laufenden Erfassung waren bereits bis zu 1.656 Result-
|
||||
Rows und 1.659 Auswertungen sichtbar, alle 2.211 Zeilen standen am Ende in der
|
||||
Rail, die Slide-in-Mountanimation wurde beobachtet und der 1304x821-Dokument-
|
||||
Viewport blieb ohne Overflow.
|
||||
- Das paketierte `reprocess-run`-Gate reprozessierte denselben Corpus mit dem
|
||||
aktuellen Parser vollstaendig und non-persistent: 2.211 processed/parsed,
|
||||
136 Review, 0 Fehler, 0 gespeichert, 2.211 Result-Eintraege.
|
||||
|
||||
Lokale Evidenz zum Abnahmezeitpunkt:
|
||||
|
||||
Die folgenden Pfade bezeichneten temporaere lokale Testartefakte. Sie wurden
|
||||
bei der angeforderten Workspace-Bereinigung am 2026-07-11 entfernt; die oben
|
||||
dokumentierten Run-IDs, Messwerte und Vertragspruefungen bleiben der dauerhafte
|
||||
Nachweis.
|
||||
|
||||
- `outputs/offline-acceptance/offline-acceptance-report.json`
|
||||
- `outputs/packaging/offline-package-report.json`
|
||||
- `outputs/native-live-smoke/20260710-102341/`
|
||||
- `outputs/native-live-smoke/20260710-102418/`
|
||||
- `outputs/native-live-smoke/20260710-102447/`
|
||||
- `outputs/native-live-smoke/20260710-102544/`
|
||||
- `outputs/native-replay/saved-native-validation-report.json`
|
||||
- `outputs/packaged-live/packaged-final-scan5.json`
|
||||
- `outputs/packaged-live/packaged-final-inventory-fixed.json`
|
||||
- `outputs/packaged-live/packaged-final-inventory-fixed.png`
|
||||
- `outputs/packaged-live/final-artifact-probe.json`
|
||||
- `outputs/packaged-live/final-ui-row1-pass.json`
|
||||
- `outputs/packaged-live/final-ui-row1-pass.png`
|
||||
- `outputs/packaged-live/streaming-ui-20.json`
|
||||
- `outputs/packaged-live/streaming-ui-20.png`
|
||||
- `outputs/packaged-live/streaming-ui-20-results-view.json`
|
||||
- `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||
- `outputs/full-inventory-phase/packaged-preflight-restored.json`
|
||||
- `outputs/full-inventory-phase/packaged-ui-full-inventory.json`
|
||||
- `outputs/full-inventory-phase/packaged-ui-full-inventory.png`
|
||||
- `outputs/native-review-analysis/20260710-223233/native-review-analysis.json`
|
||||
- `outputs/full-inventory-phase/packaged-reprocess-2211.json`
|
||||
- `outputs/full-inventory-phase/packaged-reprocess-2211.png`
|
||||
- `outputs/full-inventory-phase/packaged-no-genshin-safety.json`
|
||||
- `outputs/full-inventory-phase/packaged-no-genshin-safety.png`
|
||||
|
||||
## Waehrend der Abnahme behobene Fehler
|
||||
|
||||
1. Eigene App-Titel mit `Genshin` konnten als Capture-Quelle erscheinen.
|
||||
2. Dev-Cleanup konnte den Codex-CUA-Node allein wegen seines Working Directory
|
||||
als alte App-Instanz klassifizieren.
|
||||
3. Native Runs starteten nicht garantiert am Inventaranfang und prueften den
|
||||
Foreground nicht waehrend jedes Input-Schritts.
|
||||
4. Der sichere Initial-Reset dauerte wegen Windows-Timer-Rundung etwa 50 s.
|
||||
5. Smoke-Gates akzeptierten zu schwache Summen-/Cleanup-Vertraege.
|
||||
6. Run-Aufloesung konnte ohne explizites `runDir` unvollstaendige Runs waehlen.
|
||||
7. Packaged Runtime-Pfade und absolute Vite-Assets konnten durch das Repo-CWD
|
||||
kaschiert werden.
|
||||
8. Helper-Guard verlor das echte Elevation-Signal.
|
||||
9. Inventory-Metriken lagen wegen einer impliziten dritten Grid-Zeile ueber der
|
||||
View; ein Store-Duplikat mit unsicherem optionalem Feld erschien irrefuehrend
|
||||
als Promotion-Review.
|
||||
10. Capture und Auswertung liefen seriell; die Result-Rail blieb bis zum Ende
|
||||
leer. Der Processor tailt jetzt JSONL, ist pro Run single-flight, liefert
|
||||
Sequenz-Deltas und reconciled vor dem Abschluss den Vollbestand.
|
||||
11. Vier-Substat-+0-Fuenfsterne wurden wegen OCR-unsicherem Main Value
|
||||
uebermaessig in Review geschickt. Nur die strukturell eindeutige Kombination
|
||||
`level === 0` plus vier Substats erhaelt jetzt den kanonischen Fuenfsterne-
|
||||
Startwert; Drei-Substat-Faelle bleiben absichtlich unsicher.
|
||||
12. Eine breite Prozessnamensuche konnte die eigene App als Genshin erkennen.
|
||||
Exakte Allowlist und gecachte-HWND-Revalidierung schliessen dieses Ziel aus.
|
||||
13. Die Scan-Zusammenfassung addierte Capture- und Processing-Zeit trotz echter
|
||||
Ueberlappung. Sie verwendet jetzt die groessere Wall-Clock-Zeit.
|
||||
|
||||
## Offene Grenzen und naechste Phase
|
||||
|
||||
- Aktiv bleibt nur Artifact-Scanning. Weapons, Materials und Character Details
|
||||
sind weiterhin Katalogdaten, keine Scannerfeatures.
|
||||
- Nicht-16:9, andere Sprachen und ungewoehnliche UI-Zustaende bleiben bewusst
|
||||
blockiert, bis eigene Profile live validiert sind.
|
||||
- Der Renderer-Chunk von rund 586 kB und das Default-Electron-Icon sind
|
||||
nicht-blockierende technische/cosmetische Arbeit.
|
||||
- Eine echte Review-Korrektur oder Promotion wurde nicht absichtlich in den
|
||||
Store geschrieben. Die statischen und Main-Process-Gates bestehen; der
|
||||
Mutation-Pfad bleibt ein separater, explizit bestaetigter QA-Fall.
|
||||
- Die damals verbliebenen 136 Review-Faelle des kompletten Corpus waren der
|
||||
naechste Scanner-Qualitaetsbestand. Sie wurden am 2026-07-11 visuell gegen
|
||||
die gespeicherten Crops auditiert: 128 sind explizite 1–4★-Ausschluesse,
|
||||
sechs belegen die exakte doppelte Dezimalpunkt-Korrektur, ein Fall erhielt
|
||||
eine slot-sichere Namensaufloesung und ein unvollstaendiger Crop bleibt
|
||||
bewusst `Review`. Details stehen in
|
||||
`LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md`; neue Fälle
|
||||
werden weiterhin nicht automatisch als sauber umetikettiert.
|
||||
|
||||
Die score-freie Grundlage von `build-fit-contract-v1` ist inzwischen
|
||||
implementiert: gesourcte Eingaben, OCR-/Review-Gates, Set-/Mainstat-/Substat-
|
||||
Regeln, Gesamtziele und Konflikte sind versioniert, waehrend echte lokale
|
||||
Snapshots keine Legacy-Demo-Empfehlungen mehr erzeugen. Der deterministische
|
||||
Fuenfer-Verifier prueft inzwischen Slot-/Set-Form, verschachtelte Gates,
|
||||
gesourcte Gesamtwerte und Konflikte, bleibt aber score-frei. Ein erster enger
|
||||
Furina-`Luna II`-Candidate ist samt IK-Key- und Kontext-Fixtures versioniert.
|
||||
Vor sichtbaren charakterbezogenen Empfehlungen fehlen weiterhin breitere
|
||||
Profile, eine validierte Rangfolge und weitere Golden Fixtures fuer sichere
|
||||
sowie adversarielle Eingaben. Das ist jetzt die aktive Produktphase.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Native Scanner Current-Build Live Smoke - 2026-07-11
|
||||
|
||||
## Result
|
||||
|
||||
The required current-build, elevated development smoke passed against the
|
||||
accepted English 16:9 Genshin Artifact inventory with a visible detail card.
|
||||
The scanner remained read-only and ran with `persist=false`.
|
||||
|
||||
| Contract | Result |
|
||||
| --- | --- |
|
||||
| Run ID | `20260711-154827` |
|
||||
| Requested / target / captured / clicked / queued | `50 / 50 / 50 / 50 / 50` |
|
||||
| Processed / parsed / loaded | `50 / 50 / 50` |
|
||||
| Pages | `2` |
|
||||
| Review / processing errors | `0 / 0` |
|
||||
| Store writes / persisted | `0 / false` |
|
||||
| Jobs / non-empty PNGs | `50 / 50` |
|
||||
| Visual preflight | Genshin found, 16:9, 4x8 grid, visible Artifact inventory |
|
||||
| Read-only probe | Detail fingerprint changed before the run |
|
||||
|
||||
The run wrote a complete `native-scanner-run-timing-v1` record. Its
|
||||
request-to-durable-results duration was `27,916 ms`; final reconciliation
|
||||
completed at `28,022 ms` from the request start. That is approximately
|
||||
`1.78 Artifacts/s` end-to-end for this exact smoke. Capture and processing are
|
||||
reported separately; they are not added together.
|
||||
|
||||
## Scope And Non-Claims
|
||||
|
||||
- This is an elevated **development** runtime smoke, not an elevated packaged
|
||||
executable acceptance.
|
||||
- It proves the current 50-item functional/safety/reconciliation gate, not the
|
||||
three-Artifacts-per-second product target.
|
||||
- It did not write to the local Artifact Store and did not modify Genshin.
|
||||
- The raw run remains local runtime evidence. The generated workspace summary
|
||||
is intentionally disposable after this durable record is updated.
|
||||
|
||||
## Follow-Up
|
||||
|
||||
The required scanner release gate and its checkpoint publication are complete:
|
||||
the workspace was cleaned and commit `8b9f948` was pushed on 2026-07-11. The
|
||||
remaining quality work is new visually confirmed corpus growth, source-profile
|
||||
refresh, and a separate elevated packaged-runtime check only if that specific
|
||||
binary-runtime claim is needed.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Native Artifact Scanner Validation - 2026-07-09
|
||||
|
||||
Historical first-session evidence. The later-session and packaged acceptance
|
||||
that was still deferred here passed on 2026-07-10; see
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
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.
|
||||
|
||||
## Offline Follow-Up - 2026-07-10
|
||||
|
||||
The saved 20/50/100 native runs were replayed through the new deterministic
|
||||
roll-efficiency evaluator without opening Genshin or sending input:
|
||||
|
||||
| Saved run | Results | Evaluated | Review | Projection | Deterministic repeats |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `20260709-223006` | 20 | 20 | 0 | 0 | 5/5 |
|
||||
| `20260709-223041` | 50 | 49 | 1 historical | 0 | 5/5 |
|
||||
| `20260709-223441` | 100 | 100 | 0 | 9 | 5/5 |
|
||||
|
||||
This proves deterministic offline processing of the saved evidence, not a new
|
||||
live scanner run. The complete implementation and package evidence is recorded
|
||||
in
|
||||
[ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md).
|
||||
The next live and packaged-app acceptance pass is intentionally deferred to
|
||||
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||
+361
-37
@@ -3,13 +3,27 @@
|
||||
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).
|
||||
For the completed evaluation/offline phases and executed live acceptance, see
|
||||
[ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md)
|
||||
and [NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md),
|
||||
plus the current-build [NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||
For the score-free Build-Fit V1 data and safety contract, see
|
||||
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||
For the current English-default UI, local-result removal, timing, and reviewed
|
||||
OCR-corpus checkpoint, see
|
||||
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md).
|
||||
|
||||
## Project Identity
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Project name | Genshin Artifact Assistant |
|
||||
| Status | Scanner rebuild in progress |
|
||||
| Status | Artifact-first native scanner with historical live-accepted dev 5/20/50/100, packaged bounded/UI paths, and a complete packaged 2,211-Artifact inventory soak; scanner-first Galaxy UI, explicit review/promotion gates, deterministic roll-efficiency, an English-default/Deutsch-selectable app UI, local-only artifact removal, durable timing evidence, and an offline-tested source-bound Build-Fit evidence-ranking layer. The current-build elevated-dev 50-artifact smoke now passes its functional/safety gate; the 3/s performance target remains separate and unproven. |
|
||||
| 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,13 +40,25 @@ 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.
|
||||
- Make scan readiness, the primary scan action, progress, and safe next steps understandable without opening technical views.
|
||||
- Make the primary scan cover the recognized owned Artifact inventory by
|
||||
default, while allowing an explicit limit by Artifact count or grid rows.
|
||||
- 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.
|
||||
- Die sichtbare Sternreihe einer nativen Artefaktkarte direkt erfassen: Nur
|
||||
explizit bestätigte 5★-Artefakte erhalten eine Roll-Efficiency-Bewertung;
|
||||
bestätigte 1–4★-Artefakte bleiben als manuell prüfbare Ergebnisse erhalten.
|
||||
- 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
|
||||
|
||||
- No memory reads, process hooks, game modification, packet inspection, or anti-cheat bypassing.
|
||||
- No automatic deleting, feeding, enhancing, or spending resources.
|
||||
- Keine automatische Bergung, Quick-Select-Auswahl, Löschung, Fütterung,
|
||||
Verbesserung, Sperr-/Entsperraktionen oder Ressourcenverbrauch — auch nicht
|
||||
für erkannte 1–4★-Artefakte.
|
||||
- No advanced formula editor or full power-user optimizer in the MVP.
|
||||
- No cloud sync by default.
|
||||
|
||||
@@ -40,40 +66,52 @@ 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: readiness and primary action first, preview plus result rail during scanning, debug in collapsed or secondary surfaces. | Must | Implemented scanner-first 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 | Implemented baseline |
|
||||
| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | Should | Implemented baseline |
|
||||
| FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Implemented; packaged visual acceptance passed, under-level projection remains corpus-backed |
|
||||
| FR-015 | Provide clear loading, success, review, blocked, and error feedback through progress, spinners, skeletons, and mutation toasts without hiding durable state. | Should | Implemented UI baseline |
|
||||
| FR-016 | Die sichtbare Sternreihe je nativer Artefaktkarte erfassen; nur explizit bestätigte 5★-Artefakte bewerten, bestätigte 1–4★-Artefakte ohne Wertung mit Bild und Details in einer separaten schreibgeschützten `Nicht 5★`-Ansicht zeigen. | Must | Implementiert; aktueller 50er-Smoke bestanden |
|
||||
| FR-017 | Provide an English-default application interface and let the user switch the renderer to German in Settings without changing the scanner contract. | Should | Implemented; built-renderer acceptance passed, elevated packaged-executable runtime remains separate |
|
||||
| FR-018 | Let the user remove an incorrectly scanned artifact from local app data only, including its local crop when safe, without any Genshin mutation. | Should | Implemented, unit tested, and confirmation paths exercised; no data-removing test was run |
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
| Category | Requirement | Measurement |
|
||||
| --- | --- | --- |
|
||||
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
|
||||
| Evaluation safety | Explizit visuelle 1–4★-Evidenz wird ausgeschlossen; fehlende, widersprüchliche oder unsichere Stern-Evidenz geht in `Review`. | Result-/Replay-Tests und Detailprüfung |
|
||||
| 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 |
|
||||
| Usability | The default path should expose one primary next action and keep advanced controls secondary. | Scanner-first navigation and desktop visual acceptance |
|
||||
| Accessibility | Focus, status, loading, and motion must remain understandable without relying on animation or color alone. | Keyboard review, ARIA status semantics, and `prefers-reduced-motion` |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Area | Choice | Notes |
|
||||
| --- | --- | --- |
|
||||
| Desktop shell | Electron | Windows local app and overlay windows |
|
||||
| Desktop shell | Electron 43 | Windows local app and overlay windows |
|
||||
| Frontend | React + TypeScript + Vite | UI and client state |
|
||||
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
||||
| Styling | CSS design tokens with dark Galaxy glass surfaces | Cinematic depth with restrained violet, cyan, gold, and semantic state accents |
|
||||
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
||||
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
||||
| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path |
|
||||
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
||||
| 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 4 + TypeScript checks | OCR eval, helper/cleanup safety, Build-Fit contract/combination/profile/ranking/context gates, localization/deletion/timing tests, packaged acceptance runners, and saved-run replay |
|
||||
| Packaging | electron-builder | Configured in `package.json` |
|
||||
|
||||
## Runtime
|
||||
@@ -83,6 +121,10 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
| Local dev | `npm run dev` | Starts Vite and Electron |
|
||||
| Local dev with automation | `npm run dev:admin` | Required when `GenshinImpact.exe` is elevated; Windows blocks lower-integrity cursor/click input |
|
||||
| Production build | `npm run build` | Builds React and Electron main process |
|
||||
| Complete non-live gate | `npm run acceptance:offline` | Runs tests, OCR/assessment gates, saved-run validation, fresh package build/verification, audits, and diff checks |
|
||||
| Unpacked package | `npm run package:offline-check` | Builds helper/app and verifies ASAR/resources without launching Genshin |
|
||||
| Saved-run replay | `npm run scan:native:replay` | Re-evaluates the newest existing native scan without capture or input |
|
||||
| Saved-run evidence set | `npm run scan:native:validate:saved` | Validates requested accepted targets (bounded 20/50/100 or full 2,211), files, review provenance, replay, and projection boundaries without capture or input |
|
||||
| Preview | `npm run preview` | Browser preview only; capture bridge is unavailable |
|
||||
|
||||
## Current State Review
|
||||
@@ -94,28 +136,182 @@ 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 a current-session result rail that starts empty; older
|
||||
persisted Artifacts remain available in the dedicated inventory view.
|
||||
- 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 result processor tails flushed capture jobs while the helper is
|
||||
still scanning, evaluates them with four bounded workers, publishes ordered
|
||||
live deltas, and writes incremental `scan-results.json` plus
|
||||
`processing-report.json`. Parser confidence and IK set/piece/slot conflicts
|
||||
remain reviewable, and store persistence stays 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.
|
||||
- Clean Artifact results now receive deterministic `roll-efficiency-v1`
|
||||
evaluations. Inventory/detail show score reasons and legal roll breakdown;
|
||||
build fit remains explicitly deferred.
|
||||
- Under-leveled, unambiguously identified 5-star Artifacts with four known
|
||||
substats receive a detail-only Worst/Middle/Best projection. 4-star,
|
||||
rarity-ambiguous, incomplete, or review data receives none.
|
||||
- Existing 20/50/100 native results passed five-repeat offline determinism
|
||||
replay; the approved 100-result run evaluated 100/100 with zero unknown.
|
||||
- An Electron 43 unpacked app passed 27/27 static package checks and live launch
|
||||
from `%TEMP%`. ASAR renderer, preload, IPC, packaged C# helper, IK resources,
|
||||
elevation telemetry, Inventory UI, and a packaged 5-result dry run passed.
|
||||
- The renderer now uses a scanner-first information architecture: Scanner,
|
||||
Artifacts, Review, and the clearly labeled Builds preview form the workspace;
|
||||
Overlay and Diagnostics remain secondary tools.
|
||||
- The Scanner surface leads with Genshin/app readiness, one primary start/stop
|
||||
action, a three-step empty state, explicit scan phases, and one modal for
|
||||
scan scope, capture source, and manual tools. Inventory loading uses
|
||||
skeletons/spinners, and confirmed
|
||||
promotion, review, import, and export mutations report through accessible
|
||||
toasts.
|
||||
- Shared Galaxy tokens, focus-visible treatment, micro-motion, and a
|
||||
`prefers-reduced-motion` fallback now define the visual/feedback baseline.
|
||||
- The renderer now defaults to English on a fresh, missing, or invalid locale
|
||||
setting. Users can choose German from Settings; the preference is local to the
|
||||
app and updates the document language. This changes application copy only:
|
||||
Smart Capture remains validated for the English Genshin UI.
|
||||
- Artifact detail now exposes a small, confirmed local-delete action. Native
|
||||
scan results are tombstoned rather than rewritten, their crop is removed only
|
||||
when it is contained in the selected run directory, and an exact linked Store
|
||||
record is removable only through an explicit local confirmation. No delete
|
||||
path calls the input helper or changes Genshin.
|
||||
- Native runs now retain an atomic `run-timing.json` record from request start
|
||||
through durable result reconciliation. It measures capture and processing
|
||||
separately and derives end-to-end duration from one wall-clock interval
|
||||
instead of adding overlapping phases.
|
||||
- The 136 historical full-inventory Review samples were audited against their
|
||||
crops. The confirmed corpus now distinguishes 128 explicit non-five-star
|
||||
entries, six exact duplicated-decimal repairs, one slot-safe artifact-name
|
||||
correction, and one incomplete entry that intentionally remains `Review`.
|
||||
|
||||
### 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, pipeline risk/status, roll-efficiency reasons, and upgrade
|
||||
projection. Single-result review/edit/approve is implemented and batch
|
||||
review is intentionally unavailable.
|
||||
- The native IK-style path has fresh later-session 5/20/50/100 scale evidence,
|
||||
deterministic replay, focus-loss and stop evidence, plus a packaged 5-result
|
||||
runtime smoke. Non-16:9 and other UI-language profiles 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 build-aware fit must be designed
|
||||
separately from the now-implemented context-free roll-efficiency score.
|
||||
- The Builds navigation item now reads only the newest complete native run and
|
||||
can surface up to three source-bound, read-only profile suggestions. It must
|
||||
not imply character ownership, team, weapon, rotation, DPS, or optimizer
|
||||
certainty; the built-renderer acceptance now passes, while an elevated
|
||||
packaged-executable runtime remains a separate packaging check.
|
||||
- The historical 136-item Review queue is not a source of labels by itself.
|
||||
Its audit added only visually confirmed parser/corpus cases; the one
|
||||
incomplete crop remains fail-closed in `Review`.
|
||||
- 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. Packaged/live acceptance passed on
|
||||
2026-07-10 for the bounded 5/20/50/100 scanner and packaged 5er. A later real
|
||||
Settings-UI run also accepted the revised Artifact-limit path at 5/5 through
|
||||
preflight, capture, processing, parsing, and the session result rail with no
|
||||
review, processing error, or persistence. The packaged Settings UI then
|
||||
accepted a one-row limit as an eight-Artifact target at 8/8 with the same safe
|
||||
outcome. The complete Settings-UI full-owned-inventory soak then accepted the
|
||||
OCR-owned target 2,211 with exact 2,211 captures/results across 70 pages. After
|
||||
one evidence-driven parser correction, the current packaged reprocess completed
|
||||
2,211/2,211 with 136 Review (6.15%), zero errors/writes, and zero saved-run
|
||||
validation issues. The current Build-Fit checkpoint now has four sourced
|
||||
profiles, profile-expiry gates, a deterministic non-Roll-Efficiency evidence
|
||||
ranker, explicit session-only Furina context, and a read-only Builds surface.
|
||||
The renderer now defaults to English with a German Settings option, and local
|
||||
artifact removal is deliberately confined to app-local result/store records and
|
||||
contained crops. The current scanner also writes durable shared-clock timing
|
||||
evidence and has a visually reviewed corpus correction for the audited
|
||||
136-entry Review set. These current-build changes are offline/unit validated,
|
||||
and the built-renderer Builds/UI acceptance passed, including the local-delete
|
||||
confirmation probes. That is not an elevated packaged-executable claim. The
|
||||
current elevated development 50-artifact native smoke now passes with complete
|
||||
timing evidence and zero writes; its `28,022 ms` end-to-end result does not
|
||||
prove the separate 3/s target. The remaining Review corpus continues to grow
|
||||
only from visually confirmed labels. The review exporter now prioritizes
|
||||
retrievable retained crops and serializes only safe capture/run references; its
|
||||
confirmation preparer refuses OCR-only candidates so parser guesses cannot be
|
||||
promoted without visual evidence.
|
||||
|
||||
## 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: the left area shows preview
|
||||
when idle and owns capture-versus-target progress during a run; the right
|
||||
rail contains current-session results plus independent evaluation progress.
|
||||
Stop, status, and review access remain visible. Detailed debug evidence
|
||||
belongs in diagnostics, summaries, or artifact detail.
|
||||
- Primary navigation should follow the user journey: scan, inspect artifacts,
|
||||
review uncertainty, then enter future build guidance. Overlay and diagnostics
|
||||
remain tools instead of competing with that journey.
|
||||
- Feedback should match the action: progress for long-running scans, skeletons
|
||||
for content loading, inline state for durable blockers, and toasts for the
|
||||
outcome of explicit mutations.
|
||||
- Artifact value scoring must not hide OCR uncertainty. `Review` is a distinct
|
||||
outcome, not just a weak artifact score.
|
||||
- Explizit bestätigte visuelle 1–4★-Artefakte sind keine
|
||||
Bergungs-/Lösch-Empfehlung: Sie erscheinen nur in der schreibgeschützten
|
||||
`Nicht 5★`-Ansicht mit Bild und Details zur manuellen Prüfung. Eine
|
||||
fehlende oder widersprüchliche Sternreihe bleibt `Review`.
|
||||
|
||||
## Execution Plan
|
||||
|
||||
@@ -127,7 +323,15 @@ Outcome:
|
||||
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
|
||||
|
||||
Status:
|
||||
- In progress
|
||||
- Implemented as the scanner-first Galaxy baseline. Readiness and the primary
|
||||
scan action lead the page, the left surface owns capture progress, and the
|
||||
larger right surface owns ordered live results plus compact independent
|
||||
evaluation progress. The current-session result rail starts empty, advanced
|
||||
controls open in a modal without shifting the workspace, and Diagnose remains
|
||||
a separate scrollable tool. The
|
||||
Artifact-limit path and consolidated progress/result layout passed a real
|
||||
five-result Settings-UI run, the one-row path at 8/8, and the full-owned path
|
||||
at 2,211/2,211 with current-package Review below the 15% gate.
|
||||
|
||||
### Phase 1 - Canonical game data package
|
||||
|
||||
@@ -145,7 +349,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 +364,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 +390,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 +414,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 +433,134 @@ 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. The current
|
||||
review-corpus checkpoint adds only visually confirmed cases: exact duplicate
|
||||
decimal punctuation is repaired, and a partial artifact name is canonicalized
|
||||
only when independent, high-confidence slot evidence leaves one safe match.
|
||||
Ambiguous or incomplete crops remain `Review`.
|
||||
|
||||
### 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 shows native `scan-results.json` entries from the current app scan
|
||||
session after post-processing and does not preload older stored/native rows
|
||||
at startup. 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,
|
||||
deterministic roll-efficiency reasons, evaluated/open filters, score sorting,
|
||||
and under-level projection are implemented. The browser prioritizes the
|
||||
collection, filters, selected detail, and explicit next actions; technical
|
||||
pipeline evidence remains available in collapsible detail. See
|
||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
|
||||
for the implementation phases and acceptance criteria. The separate,
|
||||
read-only `Nicht 5★` filter retains explicitly recognized 1–4★ native
|
||||
results with their crop and details, excludes them from roll evaluation, and
|
||||
exposes no in-game action. A confirmed local-delete action can remove a
|
||||
wrong native result from the visible local inventory through an append-only
|
||||
tombstone and safely contained crop cleanup; it can remove a Store-only row
|
||||
or exact linked Store record only locally and never changes Genshin.
|
||||
|
||||
### 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 for unambiguously identified 5-star data
|
||||
with four known legal substats 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:
|
||||
- Implemented as `roll-efficiency-v1` plus detail-only
|
||||
`roll-efficiency-projection-v1`. Static/build/replay, fresh dev 5/20/50/100,
|
||||
packaged runtime, and final Inventory visual acceptance pass. The new live
|
||||
100 contained only +20 Artifacts, so under-level projections remain covered
|
||||
by the confirmed saved-run corpus rather than a new live sample.
|
||||
|
||||
### 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
|
||||
- `build-fit-contract-v1` continues to keep per-Artifact and five-piece
|
||||
assessments score-free. On top of it, the offline-tested
|
||||
`build-fit-evidence-ranking-v1` selects at most three non-overlapping safe
|
||||
combinations from the newest complete native run. The sourced corpus now
|
||||
covers Furina Solo-Hydro C0-C1, Neuvillette Charged Attack, Raiden off-field
|
||||
Hyperbloom, and Nahida off-field Deepwood. Profiles expire explicitly;
|
||||
Roll-Efficiency and OCR confidence are not ranking inputs. Furina's required
|
||||
whole-build ER target accepts only explicit, session-only user context and
|
||||
otherwise stays deferred. The Builds surface explains Set/Main-Stat/Substat
|
||||
coverage, sources, warnings, and uncertainty without writing data or sending
|
||||
game input. A built-renderer acceptance now covers fresh English locale,
|
||||
German switching/restoration, Builds loading/context/focus, reduced motion,
|
||||
overflow, and native plus Store local-delete confirmation probes. It is not evidence of an
|
||||
elevated packaged-executable launch; that runtime boundary remains separate.
|
||||
See
|
||||
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||
|
||||
## Immediate Next Implementation Order
|
||||
## Immediate Next Validation 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. The final current-build offline gate passed after the localization, deletion,
|
||||
timing, corpus, and acceptance-runner changes: `npm run acceptance:offline`
|
||||
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||
validation, package verification (27/27), both dependency audits, and the
|
||||
diff check.
|
||||
2. Preserve the passing built-renderer acceptance as renderer evidence, but
|
||||
separately verify an elevated packaged-executable launch/UAC bridge before a
|
||||
binary release claim. It is not a substitute for scanner smoke evidence.
|
||||
3. The current elevated development 50-artifact smoke passed with full
|
||||
reconciliation, zero Review/errors/writes, and
|
||||
`requestToResultsReconciledMs=28,022`. This is a functional/safety gate;
|
||||
it does not claim 3 Artifacts/s.
|
||||
4. Completed on 2026-07-11: generated workspace output was cleaned and the
|
||||
coherent checkpoint was committed and pushed as `8b9f948`. A
|
||||
package-runtime/UAC test is required only for that separate claim.
|
||||
5. The visual-evidence review exporter/preparer passed focused checks plus
|
||||
`npm run lint`, `npm test` (72 files / 446 tests), and `npm run build`.
|
||||
Its read-only export of the current 177-record local queue found three
|
||||
retrievable crops, all already-confirmed `native-review-approved` cases;
|
||||
no new corpus label was promoted.
|
||||
6. Continue review-corpus growth only from new visually confirmed crops. Keep
|
||||
explicit 1–4★ evidence excluded, ambiguous evidence in `Review`, and reject
|
||||
aliases that cannot be justified by independent field evidence.
|
||||
7. Refresh source-bound profiles before expiry and add Golden plus
|
||||
Review/low-confidence adversarial fixtures for each additional profile.
|
||||
8. Keep the bounded wheel reset as the production baseline. Benchmark a `B`
|
||||
plus Artifact-tab reopen only as a separate guarded experiment before any
|
||||
reset strategy change.
|
||||
9. Keep Build-Fit profile suggestions read-only and Artifact-only until an
|
||||
explicit product decision adds owned-character, weapon, team, and rotation
|
||||
inputs with their own sources.
|
||||
|
||||
## 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 scan-quality threshold is high enough before profile suggestions can be called live-accepted? | Scanner gate passed for accepted 16:9 English Artifact scope; Build-Fit unit/repository gate and built-renderer acceptance passed, while elevated packaged-executable runtime and current scanner smoke remain separate |
|
||||
| What exact first-pass value formula should drive the `0-100` artifact score before build-aware recommendations exist? | Resolved for V1: legal roll-tier efficiency only; build fit remains separate |
|
||||
| Which upgrade projection model is honest enough for early UX: deterministic roll buckets, probability-weighted outcomes, or a deliberately simple best/middle/worst estimate? | Resolved for V1: legal Worst/Middle/Best roll-efficiency bounds with explicit uncertainty |
|
||||
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Tomorrow Live Test Plan - 2026-07-10
|
||||
|
||||
> Historical execution plan. Its bounded acceptance matrix was completed and
|
||||
> later extended by the packaged 2,211-Artifact full-owned-inventory baseline.
|
||||
> The current authoritative result is
|
||||
> [NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
This is the ordered acceptance checklist for the first live session after the
|
||||
five evaluation phases. It intentionally separates packaged-app, UI, scanner,
|
||||
safety, quality, and performance evidence. Do not skip directly to a 100-item
|
||||
run.
|
||||
|
||||
Executed on 2026-07-10. The result table below and
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md)
|
||||
are authoritative; the unchecked boxes above remain the original reusable
|
||||
operator checklist rather than a claim that the session is still pending.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] PC started fresh; no stale Electron, Vite, helper, or old dev-server process.
|
||||
- [ ] Genshin uses borderless `1920x1080` 16:9.
|
||||
- [ ] Artifact inventory is open and one Artifact detail card is visible.
|
||||
- [ ] No enhancement, feeding, deletion, locking, or resource-spending UI is involved.
|
||||
- [ ] Current source worktree and commit are recorded before testing.
|
||||
- [ ] `outputs/dist/win-unpacked/Genshin Artifact Assistant.exe` exists.
|
||||
- [ ] Existing `%APPDATA%/genshin-artifact-assistant/native-scans` data stays intact.
|
||||
|
||||
## 1. Static Recheck After Boot
|
||||
|
||||
```powershell
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
npm run eval
|
||||
npm run package:verify
|
||||
npm audit
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- [ ] `262/262` or more tests pass.
|
||||
- [ ] OCR eval remains `26/26` exact and `77/77` fields.
|
||||
- [ ] Package verifier remains `27/27`.
|
||||
- [ ] Dependency audit remains at zero.
|
||||
|
||||
## 2. Packaged-App Launch
|
||||
|
||||
Launch the unpacked executable and approve its UAC prompt:
|
||||
|
||||
```powershell
|
||||
& "outputs\dist\win-unpacked\Genshin Artifact Assistant.exe"
|
||||
```
|
||||
|
||||
- [ ] App opens without a blank window, crash, or native menu bar.
|
||||
- [ ] Scanner, Inventory, Diagnose, Triage, Builds, and Overlay navigation render.
|
||||
- [ ] Runtime reports Electron `43.1.0` and the current app signature.
|
||||
- [ ] Elevation/automation guard reports the packaged app as elevated.
|
||||
- [ ] IK data reports version `6.7.0`, 61 sets, and 289 pieces.
|
||||
- [ ] Packaged C# helper resolves from `resources/input-helper`.
|
||||
- [ ] Browser-preview-only bridge warnings do not appear in the packaged app.
|
||||
|
||||
Stop immediately if helper resolution, preload, IK data, or elevation is broken.
|
||||
|
||||
## 3. Existing Scanned-Artifact UI
|
||||
|
||||
Open Inventory before starting any new scan.
|
||||
|
||||
- [ ] Existing native results load without changing Genshin.
|
||||
- [ ] Latest approved 100-result run shows 100 parsed/evaluated and zero Review.
|
||||
- [ ] Result rows show `xx% Roll`, extraction status, and source separately.
|
||||
- [ ] `Bewertet` filter returns evaluated rows.
|
||||
- [ ] `Wert offen` does not include normally evaluated clean rows.
|
||||
- [ ] `Roll-Wert` sorting orders numeric scores descending and leaves unknown last.
|
||||
- [ ] A +20 detail shows score reasons and per-substat roll/efficiency breakdown.
|
||||
- [ ] Detail explicitly says build fit is still open.
|
||||
- [ ] Locked/equipped state does not change the same Artifact's roll score.
|
||||
- [ ] Native PNG crop preview loads only from the selected run directory.
|
||||
|
||||
Projection samples from run `20260709-223441`:
|
||||
|
||||
- [ ] Select an under-leveled Artifact such as sequence `#96` (+16) or `#100` (+4).
|
||||
- [ ] Projection appears only in detail, not as a guaranteed result in the list.
|
||||
- [ ] `Worst <= Middle <= Best`.
|
||||
- [ ] Remaining roll count matches the current level.
|
||||
- [ ] Disclaimer states random target/roll tier and no guarantee.
|
||||
- [ ] +20 Artifacts show no future projection card.
|
||||
|
||||
## 4. Review And Promotion Safety
|
||||
|
||||
- [ ] A Review result has no normal numeric value score.
|
||||
- [ ] Invalid manual substat totals are rejected even if each value is individually plausible.
|
||||
- [ ] A 4-star-only roll value cannot be approved on a +20 Artifact.
|
||||
- [ ] Reject keeps the result blocked.
|
||||
- [ ] Approve requires IK identity, canonical main value, and legal total roll count.
|
||||
- [ ] Promotion still requires explicit selection and second confirmation.
|
||||
- [ ] Canceling promotion writes nothing.
|
||||
- [ ] If one promotion is deliberately tested, store count changes exactly once and the durable log is written.
|
||||
- [ ] No action changes anything inside the Genshin inventory.
|
||||
|
||||
## 5. Manual Smart Capture Smoke
|
||||
|
||||
This is the first live capture of the session.
|
||||
|
||||
- [ ] Genshin focus/preflight succeeds with the visible detail card.
|
||||
- [ ] One Artifact is captured and parsed.
|
||||
- [ ] Crop preview matches the selected Artifact.
|
||||
- [ ] Name, set, slot, level, main stat/value, substats, equipped, and locked state are checked visually.
|
||||
- [ ] Clean extraction receives a roll-efficiency score.
|
||||
- [ ] Uncertain extraction goes to Review and receives no normal score.
|
||||
- [ ] Stop/failsafe remains responsive.
|
||||
|
||||
## 6. Native Five-Artifact Smoke
|
||||
|
||||
```powershell
|
||||
npm run scan:native:smoke:5
|
||||
```
|
||||
|
||||
- [ ] Exactly 5 capture jobs, PNG crops, and durable results.
|
||||
- [ ] Zero processing errors and zero automatic store writes.
|
||||
- [ ] Result order and capture timestamps are preserved.
|
||||
- [ ] Evaluation is present for every clean parsed result.
|
||||
- [ ] Run the offline gate against the new run:
|
||||
|
||||
```powershell
|
||||
npm run scan:native:replay -- --run-dir="<new-run-dir>" --repeats=5
|
||||
```
|
||||
|
||||
- [ ] Replay is deterministic and has zero clean unevaluated rows.
|
||||
|
||||
## 7. Bounded 20/50/100 Progression
|
||||
|
||||
Only continue when the previous size passes.
|
||||
|
||||
For each run:
|
||||
|
||||
- [ ] Requested crop/job/result count is complete.
|
||||
- [ ] Processing errors are zero.
|
||||
- [ ] Store writes remain zero unless explicitly requested separately.
|
||||
- [ ] Review reason is inspectable for every Review row.
|
||||
- [ ] Miss rate is `<=2%` and Review rate is `<=15%`.
|
||||
- [ ] Stop works during capture and processing.
|
||||
- [ ] Offline replay with five repeats is deterministic.
|
||||
- [ ] Evidence directory and timings are recorded.
|
||||
|
||||
Final 100-item checks:
|
||||
|
||||
- [ ] Pages and scrolling advance without duplicates or skipped visible tiles.
|
||||
- [ ] Previously confirmed decimal-loss cases are repaired only when unique.
|
||||
- [ ] Ambiguous OCR stays Review.
|
||||
- [ ] Eligible under-leveled 5-star Artifacts receive projection; 4-star or
|
||||
ambiguous-rarity data does not; +20 Artifacts are complete.
|
||||
- [ ] Capture, processing, and end-to-end rates are reported separately.
|
||||
- [ ] Do not claim 3 artifacts/second unless measured end to end with quality gates.
|
||||
|
||||
## 8. Packaged Runtime Regression
|
||||
|
||||
Repeat the key five-item smoke from the packaged executable, not only dev mode:
|
||||
|
||||
- [ ] Packaged preload bridge exposes all native scanner methods.
|
||||
- [ ] Packaged helper can focus/click/capture under UAC.
|
||||
- [ ] Packaged IK resource path resolves.
|
||||
- [ ] Packaged crop preview bridge accepts valid PNG and blocks outside paths/non-PNG.
|
||||
- [ ] Close/reopen preserves saved Inventory and review/promotion state.
|
||||
|
||||
## Abort Criteria
|
||||
|
||||
Stop the current run immediately when any of these occurs:
|
||||
|
||||
- Wrong Genshin category or no visible Artifact detail card.
|
||||
- Blank/too-uniform capture passes preflight.
|
||||
- Click lands outside the validated Artifact grid.
|
||||
- Genshin loses focus and the worker continues clicking.
|
||||
- Result count exceeds requested limit.
|
||||
- Any automatic store write occurs during a dry run.
|
||||
- Review data receives a confident normal score.
|
||||
- Projection appears with missing fourth substat or claims certainty.
|
||||
- Helper/preload/IK packaged resource is missing.
|
||||
- App attempts delete, feed, enhance, lock/unlock, or resource spending.
|
||||
|
||||
## Result Record
|
||||
|
||||
Recorded after the session:
|
||||
|
||||
| Check | Result | Evidence path / note |
|
||||
| --- | --- | --- |
|
||||
| Static suite | passed | Final `npm run acceptance:offline`: 296/296 tests, 27/27 package checks, both audits clean |
|
||||
| Packaged launch | passed | Started elevated from `%TEMP%`; ASAR renderer, preload, helper and IK resources resolved |
|
||||
| Existing Inventory UI | passed | `outputs/packaged-live/packaged-final-inventory-fixed.png`; layout and latest complete-run discovery accepted |
|
||||
| Evaluation reasons | passed | New 20/50/100 five-repeat replay; 100/100 evaluated, 0 unknown |
|
||||
| Upgrade projection | passed offline, no new live sample | Existing under-level corpus passes; new live 100 contained only +20 Artifacts |
|
||||
| Review/promotion safety | passed non-mutating boundary | Main-process/tests pass; duplicates show `5 im Store, 0 Review`; no deliberate store mutation performed |
|
||||
| Single Smart Capture | passed | Real source `Genshin Impact`, `captureTarget=genshin-client`, 4x8 detected grid |
|
||||
| Native 5 | passed | Dev `20260710-102344`: 5/5, 0 Review/errors/writes |
|
||||
| Native 20 | passed | `20260710-102421`: 20/20, capture 4.50/s, processing 3.11/s |
|
||||
| Native 50 | passed | `20260710-102450`: 50/50, capture 4.41/s, processing 3.21/s |
|
||||
| Native 100 | passed | `20260710-102547`: 100/100, four pages, no duplicate page/hash evidence |
|
||||
| Focus/stop safety | passed | Focus-loss `20260710-101820` blocked at 0; stop `20260710-102735` stopped at 0 |
|
||||
| Packaged native smoke | passed | `20260710-103736`: 5/5 via renderer/preload/IPC/packaged helper, persist=false |
|
||||
|
||||
Full evidence and the next-phase decision are in
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
+86
-5
@@ -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,18 +41,86 @@ 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 candidates with retrievable local visual
|
||||
evidence first, then preserves the existing complete-OCR/staleness ordering
|
||||
within each evidence group. It 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.
|
||||
|
||||
Each candidate records a safe `captureId`, `capturedAt`, and
|
||||
`visualEvidence.status`. `available` means the referenced local PNG existed at
|
||||
export time; `unavailable` is an OCR-only legacy/no-path case. The export never
|
||||
serializes the local PNG path. Only label an `available` candidate after opening
|
||||
that retained crop and checking the real artifact. Re-export if the crop was
|
||||
removed; the preparer deliberately refuses `unavailable` candidates.
|
||||
|
||||
Current local snapshot re-exported on 2026-07-11:
|
||||
|
||||
- 177 records read, 0 invalid
|
||||
- 138 unique candidates
|
||||
- 80 exported candidates
|
||||
- 36 candidates with complete fast-profile fields
|
||||
- 41 likely stale or partial captures
|
||||
- 14 equipped-footer candidates
|
||||
- 3 candidates with retrievable visual evidence, all
|
||||
`native-review-approved` cases already represented in
|
||||
`confirmedReviewCorpus.ts`
|
||||
- 77 OCR-only/unavailable exported cases, which must not be prepared as
|
||||
confirmed corpus labels until a retained crop is available
|
||||
|
||||
These counts describe the current local queue and may grow after later live
|
||||
sessions. Do not treat the 36 complete-field candidates as automatically
|
||||
correct; complete OCR is still only a review candidate until visually checked.
|
||||
|
||||
After manually checking one `visualEvidence: available` 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 or retrievable visual evidence, so
|
||||
parser guesses and OCR-only legacy records are not silently promoted to ground
|
||||
truth. The generated provenance note includes the safe capture/run reference
|
||||
and timestamp, never an absolute local crop path. 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:
|
||||
|
||||
1. Convert review samples with `reviewSamplesToEvalCases(records)`.
|
||||
2. Open each produced case and confirm or correct the `expect` values against
|
||||
1. Export review samples and choose a candidate marked
|
||||
`visualEvidence: available`.
|
||||
2. Open its retained crop 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
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
# 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).
|
||||
For the current localization, local-deletion, timing, and reviewed-corpus
|
||||
checkpoint, see
|
||||
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.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.
|
||||
- Treat direct visual star evidence as an evaluation gate: explicitly confirmed
|
||||
1–4★ results remain visible but are excluded from roll evaluation; missing or
|
||||
conflicting star evidence fails closed to `Review`.
|
||||
- The `Nicht 5★` inventory filter is a read-only manual-inspection view, not a
|
||||
salvage decision. It must never expose automatic salvage or Quick Select.
|
||||
- The app renderer defaults to English; German is a Settings preference only.
|
||||
The setting must not be presented as German Genshin OCR support.
|
||||
- A wrong local artifact may be removed only through a confirmed app-local
|
||||
workflow. It must preserve raw scan evidence, restrict crop cleanup to the
|
||||
selected run directory, and never send game input.
|
||||
- Prioritize extracting correct artifact content over another speed pass unless
|
||||
live timings regress materially.
|
||||
|
||||
## Current 2026-07-11 Checkpoint
|
||||
|
||||
The implementation now has an English-default/Deutsch-selectable renderer,
|
||||
confirmed local artifact removal, a shared-clock native run timing record, and
|
||||
new visually reviewed OCR corpus cases. The required non-persisting current
|
||||
build 50-artifact native smoke passed with a complete `run-timing.json`, and
|
||||
the resulting checkpoint was published as `8b9f948`. The built-renderer
|
||||
Builds/UI acceptance also passed; an elevated packaged-executable runtime
|
||||
remains a separate packaging boundary.
|
||||
|
||||
## 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 safe
|
||||
artifact result.
|
||||
5. Each evaluated 5★ row shows only:
|
||||
- scan number,
|
||||
- artifact name or compact slot/set fallback,
|
||||
- artifact value score from `0` to `100`,
|
||||
- a colored result pill.
|
||||
6. An explicitly recognized 1–4★ row shows `Nicht bewertet`; an unconfirmed or
|
||||
conflicting star row stays `Review` rather than assuming 5★.
|
||||
7. After the scan, the user opens the inventory menu to browse all scanned
|
||||
artifacts, including the separate read-only `Nicht 5★` filter.
|
||||
8. 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. |
|
||||
| Visual star evidence | Direct `starCount`, confidence, and source from the native card crop. | Confirmed 1–4★ is `excluded`; missing, invalid, or conflicting evidence is `Review`; only confirmed 5★ proceeds to evaluation. |
|
||||
| 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.
|
||||
- An explicit 1–4★ result gets no value score, projection, or promotion. It is
|
||||
visible only as an `excluded` manual-review item with its crop and details.
|
||||
- Never infer a 5★ result from an absent, invalid, or conflicting direct visual
|
||||
star field in a current native job; it must stay `Review`.
|
||||
- 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, direct visual rarity evidence, duplicate/review flags, and timestamps. Initial native entries use
|
||||
`valueStatus: "deferred"` for clean parses, `valueStatus: "review"` for
|
||||
uncertain extraction, and `valueStatus: "excluded"` for explicit direct
|
||||
1–4★ evidence.
|
||||
- Preserve `starCount`, `starConfidence`, and `starSource` for current native
|
||||
jobs so the downstream gate can distinguish explicit 1–4★ exclusion from an
|
||||
ambiguous result that must remain `Review`.
|
||||
- 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: implemented for current-session native post-capture results. The rail
|
||||
starts empty on app launch and receives only results loaded for a scan started
|
||||
in the current app session. Older stored/native history stays in the Inventory
|
||||
surface. Result rail rows can open that surface for crop/IK/detail inspection.
|
||||
A real Settings-UI Artifact-limit-5 run completed with 5 captured, processed,
|
||||
and parsed results, 0 review/errors, `persist=false`, and five rail rows. A
|
||||
real row-limit run completed at 8/8. The packaged full-owned-inventory baseline
|
||||
then captured 2,211/2,211 over 70 pages and showed all 2,211 live-session rows;
|
||||
current-package reprocessing passed at 136 Review (6.15%), 0 errors, and 0
|
||||
store writes.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Rework the scan main section into a left preview/progress work surface plus a
|
||||
right-side current-session result rail.
|
||||
- Keep phase and outcome counts in that left work surface during a run; remove
|
||||
the duplicate standalone progress card and noisy debug 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. Deterministic roll-efficiency evaluation and
|
||||
single-result review/edit/approve are implemented in the detail phases below.
|
||||
The current checkpoint also adds a confirmed local-delete path. A native
|
||||
result is hidden by an append-only tombstone rather than an in-place result
|
||||
rewrite; its PNG crop is removed only after run-directory containment
|
||||
succeeds. A Store-only row, or the exact Store record linked to a native row,
|
||||
can be removed locally after confirmation. Active scans/processors block the
|
||||
action, and no delete path sends Genshin input.
|
||||
|
||||
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.
|
||||
- Provide a separate read-only `Nicht 5★` filter for explicitly recognized
|
||||
1–4★ native results. It must retain screenshot/crop and parsed detail for
|
||||
manual inspection, use no "safe to salvage" language, and offer no automatic
|
||||
salvage or Quick Select action.
|
||||
- Provide a small confirmed local-delete action in artifact detail. It must
|
||||
describe its app-local scope, preserve raw native result evidence, limit crop
|
||||
removal to a contained PNG, and keep Store removal exact and opt-in.
|
||||
- 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: implemented baseline for native scan results. Inventory detail shows 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. It now also shows roll-efficiency reasons,
|
||||
per-substat legal roll breakdown, build-fit deferral, and under-level
|
||||
projection when safe.
|
||||
|
||||
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: implemented as `roll-efficiency-v1`. Clean legal Artifacts are
|
||||
evaluated; Review and unknown data receive no confident score. For current
|
||||
native jobs, only explicitly confirmed 5★ evidence enters evaluation;
|
||||
explicit 1–4★ evidence becomes `excluded` with no score or projection, while
|
||||
ambiguous direct star evidence remains `Review`. Existing saved results are
|
||||
enriched on load and replayed offline without rescanning.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Add a deterministic artifact value evaluator before promoting build
|
||||
recommendations.
|
||||
- Explain the score through legal roll-tier quality and total roll counts.
|
||||
- Keep set/main-stat desirability and character/build context separate.
|
||||
- Keep locked/equipped state out of intrinsic roll quality.
|
||||
- The evaluator must accept incomplete data and return review/unknown instead
|
||||
of confident nonsense.
|
||||
- Do not turn an `excluded` 1–4★ result into an evaluation, store-promotion,
|
||||
projection, or automatic in-game action.
|
||||
|
||||
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: implemented as detail-only `roll-efficiency-projection-v1` for
|
||||
under-leveled, unambiguously identified 5-star Artifacts with four known legal
|
||||
substats. Packaged visual/live acceptance passed on 2026-07-10; the new live
|
||||
100er contained only +20 Artifacts, so under-level behavior remains covered by
|
||||
the confirmed saved-run corpus.
|
||||
|
||||
Outcome:
|
||||
|
||||
- For unambiguously identified 5-star 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/artifactEvaluation.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: implemented and live accepted. Native capture writes crop jobs without
|
||||
waiting for OCR. The downstream processor tails those jobs with bounded
|
||||
parallelism, streams stable ordered deltas into the result rail, and writes
|
||||
durable reports. Packaged 20-result and 2,211-result runs proved live capture/
|
||||
evaluation overlap; the full corpus also passed five-repeat deterministic replay
|
||||
and saved-run validation after current-package reprocessing. The current build
|
||||
also writes immutable first-observed lifecycle markers to `run-timing.json`.
|
||||
It derives end-to-end time directly from request to result reconciliation;
|
||||
capture and processing durations remain separate and must not be added.
|
||||
|
||||
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: offline implementation checkpoint completed. The Builds surface reads
|
||||
the newest complete native result run, validates source-bound profiles and
|
||||
five-piece evidence, and exposes up to three read-only, non-overlapping
|
||||
suggestions. The current package acceptance runner covers the fresh
|
||||
English/German locale flow, Builds context/focus, reduced motion, and overflow,
|
||||
and its built-renderer execution passed together with the local-delete
|
||||
confirmation probe. It is not evidence of an elevated packaged-executable
|
||||
launch, UAC, preload bridge, or native runtime; those remain separate from the
|
||||
renderer result and the required current-build scanner smoke.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Promote only source-bound profile suggestions after scan result quality,
|
||||
inventory browsing, detail review, and value scoring are trustworthy.
|
||||
- Read Build-Fit inputs from complete native results, not metadata-poor store
|
||||
records, because canonical IK Set keys and field-confidence evidence are
|
||||
required.
|
||||
- Keep `roll-efficiency-v1` and OCR confidence out of ranking. The visible
|
||||
rule-coverage value may use only documented Set/Main-Stat/Substat evidence;
|
||||
required full-build context must be explicit.
|
||||
- Keep suggestions read-only, explain uncertainty/conflicts, and never infer
|
||||
character ownership, team, weapon, rotation, or an in-game action.
|
||||
|
||||
## 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.
|
||||
- Explicit 1–4★ results are visibly excluded rather than scored; ambiguous
|
||||
visual rarity is visibly `Review`.
|
||||
- The `Nicht 5★` inventory filter presents only manual review evidence
|
||||
(screenshot/crop and details), never a salvage or Quick Select control.
|
||||
- A freshly initialized renderer is English; the Settings locale control can
|
||||
select German and restore English without implying German Genshin OCR support.
|
||||
- Artifact detail can remove a wrong local row only after confirmation. Native
|
||||
removal preserves raw result evidence through a tombstone, constrains crop
|
||||
cleanup to the run directory, and never sends game input.
|
||||
- Streaming/end-to-end reports use a complete, ordered shared-clock
|
||||
`run-timing.json` rather than summed capture and processing durations.
|
||||
- Upgrade projection never implies a guaranteed future roll.
|
||||
- Build suggestions use only complete native evidence, source-valid profiles,
|
||||
complete Set shapes, and explicit required aggregate context.
|
||||
- Builds explains profile, sources, chosen five Artifacts, rule coverage,
|
||||
conflicts, and deferrals without presenting an optimizer or DPS score.
|
||||
- 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:replay` against accepted bounded runs and the 2,211 corpus
|
||||
- `npm run package:offline-check`
|
||||
- Preserve the passing built-renderer `npm run package:live:builds` evidence
|
||||
for locale/Builds/delete UI behavior. Separately launch an elevated packaged
|
||||
executable before making a package-runtime/UAC claim.
|
||||
- `npm audit`
|
||||
- `npm run scan:native:smoke` before claiming native capture plus
|
||||
post-capture processing on live Genshin data.
|
||||
- For this checkpoint, run the smoke at exactly 50 artifacts with
|
||||
`scripts/native-live-smoke.ps1 -Limit 50`; require reconciliation, zero
|
||||
processing errors, the review-rate gate, and a complete valid
|
||||
`run-timing.json` before cleanup, commit, or push.
|
||||
- Test local deletion offline for active-run blocking, raw-result preservation,
|
||||
tombstone filtering, crop containment, and exact Store scope. The built
|
||||
renderer already passed the visible confirmation probe; repeat it only for a
|
||||
separate elevated packaged-executable runtime claim.
|
||||
- Test fresh English/default, German selection, and English restoration through
|
||||
the app Settings modal; do not use translated text as the primary automation
|
||||
selector.
|
||||
- Preserve the accepted 20/50/100 and 2,211 evidence before changing capture,
|
||||
parser, Review, or evaluation contracts.
|
||||
- Add unit tests for score/status derivation and upgrade projection.
|
||||
- Add direct visual 5★/4★/3★ and ambiguous-star fixtures. Verify that 1–4★ is
|
||||
stable `excluded`, ambiguity is `Review`, and neither path can be promoted or
|
||||
expose an in-game mutation.
|
||||
- For scanner-facing changes, run a low-limit visible-inventory live scan before
|
||||
broad validation, then use the saved full corpus whenever new input is not
|
||||
required. The original execution plan is retained in
|
||||
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||
- Keep `npm run scan:repeatability:wait` for later regression checks, not for
|
||||
every UI-only pass.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Scanner Rework Status
|
||||
|
||||
Updated: 2026-07-11 after the 2,211-Artifact full-inventory acceptance,
|
||||
review-gate correction, strict game-process targeting, and the offline-tested
|
||||
Build-Fit evidence-ranking checkpoint
|
||||
|
||||
Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||
Detailed evidence:
|
||||
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||
|
||||
## Was Es Kann
|
||||
|
||||
- Der native Artifact-Scanner setzt das 2.400-Slot-Inventar begrenzt an den
|
||||
Anfang, erfasst ein validiertes 4x8-Grid ueber mehrere Seiten und streamt
|
||||
jeden Crop direkt in vier begrenzte OCR-/Parse-/Evaluierungsworker.
|
||||
- Genshin-HWND, Stop-Signale und Input-Erfolg werden vor jedem relevanten
|
||||
Wheel-, Klick- und Capture-Schritt geprueft. Fokusverlust refokussiert nicht.
|
||||
- Die frische Live-Kette `5/20/50/100` bestand mit 0 Review, 0 Fehlern und
|
||||
0 Store-Writes. Der 100er `20260710-102547` erreichte 4,37 Captures/s und
|
||||
3,15 verarbeitete Results/s.
|
||||
- Gespeicherte Runs werden auf exakte Jobs/PNGs/Results, Sequenzen, Pfade,
|
||||
Click-Events, Seitenwiederholung, PNG-Hashes und Replay-Determinismus geprueft.
|
||||
- Inventory zeigt Native- und Store-Zeilen mit getrennten Signalen fuer
|
||||
Extraktion, Review, IK, Roll-Efficiency, Promotion und spaeteren Build-Fit.
|
||||
- Die unpacked App ist aus `%TEMP%` live gestartet. Renderer, Preload, IPC,
|
||||
paketierter C#-Helper und IK 6.7.0 funktionierten; der paketierte 5er
|
||||
`20260710-103736` bestand ohne Persistenz.
|
||||
|
||||
## Was Zuletzt Gemacht Wurde
|
||||
|
||||
- Capture-Quellenklassifizierung schliesst die eigene App trotz `Genshin` im
|
||||
Titel aus.
|
||||
- Dev-Cleanup beendet nur echte Repo-Vite/Electron/Helper-Prozesse und nicht
|
||||
mehr den Codex-CUA-Node.
|
||||
- Native Runs starten immer mit einem begrenzten Top-Reset und pruefen den
|
||||
Foreground vor jedem Input/Capture. Reset-Pacing senkte die Live-Zeit von
|
||||
50,7 s auf rund 1,7 s.
|
||||
- Der Smoke-Gate prueft exakte Counts, Dateivertraege, Reviewrate,
|
||||
Non-Persistence und bestaetigten Stop-Cleanup.
|
||||
- Run-Aufloesung waehlt nur den neuesten vollstaendigen Run; gestoppte oder
|
||||
unprozessierte Runs ersetzen die Inventory-Evidenz nicht.
|
||||
- Packaged Pfade verwenden `resourcesPath`, Vite relative Assets und eine
|
||||
verifizierte Runtime-Signatur.
|
||||
- Elevation wird sowohl im Runtime- als auch im Helper-Guard korrekt angezeigt.
|
||||
- Inventory-Layout und Promotion-Duplikatstatus wurden im finalen Paket visuell
|
||||
korrigiert und abgenommen.
|
||||
- Der aktuelle Renderer-Stand setzt den Scanumfang standardmaessig auf die
|
||||
erkannte Besitzanzahl und bietet optional ein Artifact- oder Reihenlimit. Die
|
||||
linke Hauptflaeche uebernimmt den Lauf-Fortschritt; die separate
|
||||
Fortschrittskarte entfaellt und die Ergebnisleiste startet sitzungsleer.
|
||||
- Der bestehende gebundene Wheel-Top-Reset bleibt Produktionsstandard. Ein
|
||||
`B`-Reopen mit Artifact-Tab ist nur eine spaeter live zu benchmarkende
|
||||
Alternative, nicht Teil des aktuellen Produktionspfads.
|
||||
- Ein realer Settings-UI-Lauf mit Artifact-Limit 5 bestand Genshin-, Detail-
|
||||
und Grid-Preflight. Ziel/Capture/Processing/Parsing endeten bei 5/5,
|
||||
Review/Fehler bei 0, `persist=false`; die sitzungsgebundene Ergebnisleiste
|
||||
zeigte alle 5 Resultate.
|
||||
- Der paketierte Settings-UI-Reihenlauf `20260710-180851` loeste eine Reihe
|
||||
ueber das erkannte 8-Spalten-Raster zu Ziel 8 auf. Capture, Processing und
|
||||
Parsing endeten bei 8/8, Review/Fehler bei 0 und der Top-Reset bei 1.715 ms.
|
||||
Zwei Fokusverlust-Versuche blockierten vorher korrekt mit 0 Klicks.
|
||||
- Der paketierte Streaming-Lauf `20260710-201614` startete Processing beim
|
||||
ersten verfuegbaren Run-Verzeichnis. Noch waehrend Capture `running` war,
|
||||
zeigte die Session-Rail bereits 11 ausgewertete Ergebnisse. Final waren
|
||||
Capture/Processing/Parsing/Evaluation 20/20, Review/Fehler/Store-Writes 0;
|
||||
alle Sequenzen 1-20 waren eindeutig und chronologisch sichtbar.
|
||||
- Die Scanner-Hierarchie nutzt jetzt mehr Breite fuer die sitzungsgebundenen
|
||||
Ergebnisse als fuer Guide/Capture. Scanbereitschaft und Live-Auswertung sind
|
||||
kompakte Statusleisten statt verschachtelter Karten.
|
||||
- Scanumfang, Aufnahmequelle und manuelle Werkzeuge liegen in einem
|
||||
fokusgefangenen `Scanoptionen`-Modal und verschieben den Arbeitsbereich nicht
|
||||
mehr. Collection-Pipeline-Details im Inventory liegen ebenfalls in einem
|
||||
Modal; Diagnose-Import/-Export ist standardmaessig eingeklappt.
|
||||
- Der frisch paketierte UI-Lauf `20260710-214406` bestaetigte die neue
|
||||
Hierarchie erneut live: 20/20 Capture, Processing, Parsing und Evaluation,
|
||||
0 Review/Fehler/Store-Writes, bis zu 9 sichtbare Ergebniszeilen und 12 bereits
|
||||
ausgewertete Resultate waehrend Capture noch lief. Der erste Startversuch
|
||||
blockierte korrekt an der doppelten Detailkarten-Pruefung; nach stabiler
|
||||
Detailkarte lief derselbe begrenzte Pfad durch.
|
||||
- `build-fit-contract-v1` trennt gesourcte Character-/Zielregeln,
|
||||
Set-Pfade, Main-Stat-Prioritaeten, Substat-Gewichte, Gesamtziele, Konflikte
|
||||
und Profil-Ablaufdaten von OCR und Roll-Efficiency. Unsichere oder nicht
|
||||
kanonische Scanergebnisse blockieren; V1 gibt absichtlich noch keinen
|
||||
Fit-Score aus.
|
||||
- Reale lokale Account-Snapshots rufen den alten Demo-Scorer nicht mehr auf.
|
||||
Der getrennte `build-fit-evidence-ranking-v1` liest stattdessen nur den
|
||||
neuesten vollstaendigen nativen Run und kann bis zu drei read-only
|
||||
Profilvorschlaege ausgeben.
|
||||
- Der score-freie Fuenfer-Verifier prueft genau einen Eintrag pro Slot,
|
||||
vollstaendige Set-Formen, verschachtelte OCR-/Review-Gates, Konflikte und
|
||||
optional gesourcte Gesamtwerte. Fehlender Nicht-Artifact-Kontext bleibt
|
||||
`pending` und wird nicht geraten.
|
||||
- Der Profil-Corpus umfasst Furina Off-field/Solo Hydro C0-C1, Neuvillette
|
||||
Charged Attack, Raiden Off-field Hyperbloom und Nahida Off-field Deepwood.
|
||||
Jede Regel ist quellen-/versions-/ablaufgebunden, gegen reale IK-6.7.0-Keys
|
||||
geprueft und durch Golden plus adversarial Fixture abgesichert.
|
||||
- Die sichtbare Regelabdeckung kombiniert nur Set-Form, Main-Stat-Prioritaet
|
||||
und dokumentierte Substat-Praesenz. Roll-Efficiency, Rollhoehe und OCR-
|
||||
Confidence sind keine Rankinggewichte; Charakterbesitz, Team, Waffe und
|
||||
Rotation werden nicht geraten. Furinas ER-Kontext ist nur explizit und
|
||||
sitzungsgebunden bestaetigbar.
|
||||
- Der reale Vollinventar-Lauf `20260710-223233` nahm den OCR-Besitzwert 2.211,
|
||||
erfasste exakt 2.211 Karten auf 70 Seiten mit 4,24/s und schrieb nichts in
|
||||
den Store. Die aktuelle paketierte Reprozessierung erreichte 2.211/2.211,
|
||||
136 Review (6,15%), 0 Fehler und 0 Writes; der Saved-Run-Validator meldet
|
||||
`issues=0`.
|
||||
- Der erste Parse desselben Laufs wurde bei 490 Review (22,16%) korrekt nicht
|
||||
akzeptiert. 360 widerspruechliche `+0`-Main-Values werden jetzt nur dann aus
|
||||
der kanonischen 5-Sterne-Tabelle repariert, wenn vier Anfangs-Substats die
|
||||
Seltenheit strukturell beweisen; Drei-Substat-Faelle bleiben Review.
|
||||
- Der Review-Corpus-Exporter priorisiert jetzt nur noch Kandidaten mit
|
||||
abrufbarem lokalem Crop und gibt sichere Capture-/Run-Referenzen statt lokaler
|
||||
Pfade aus. Der Snippet-Preparer blockiert OCR-only Kandidaten ohne visuelle
|
||||
Evidenz. Ein read-only Export der aktuellen 177 Eintraege fand drei abrufbare,
|
||||
bereits bestaetigte `native-review-approved` Faelle; keine neue Label-Promotion
|
||||
wurde vorgenommen.
|
||||
- Native Helper und PowerShell-Fallback akzeptieren nur noch die exakten
|
||||
Prozessnamen `GenshinImpact` oder `YuanShen`. Bei geschlossenem Spiel bestand
|
||||
der Package-Smoke mit `genshinFound=false`, HWND 0 und leerem Zielprozess.
|
||||
|
||||
## Was Noch Nicht
|
||||
|
||||
- Roll-Efficiency ist weiterhin kein Build-Fit. Die offline getestete Builds-
|
||||
Oberflaeche ist keine Optimizer- oder DPS-Aussage und wurde noch nicht als
|
||||
frischer paketierter Renderer-Fall live abgenommen.
|
||||
- Weapons, Materials und Character Details bleiben ausserhalb des aktiven
|
||||
Scanner-Scope.
|
||||
- Andere Aufloesungen, Seitenverhaeltnisse und UI-Sprachen haben noch kein
|
||||
live validiertes Profil.
|
||||
- Batch-Review/-Promotion und automatische Store-Persistenz bleiben aus.
|
||||
- Der absichtlich schreibende Review-/Promotion-Pfad wurde nicht live
|
||||
ausgefuehrt; er bleibt zustimmungspflichtige separate QA.
|
||||
|
||||
## Wo Es Noch Probleme Macht
|
||||
|
||||
- Die fruehere `1,77/s`-Zahl ist nur der historische serielle 100er-Smoke:
|
||||
Capture/Reset und danach Batch-Processing wurden addiert. Sie darf nicht als
|
||||
aktuelle Streaming-End-to-End-Rate gelesen werden. Der dry Streaming-20er
|
||||
`20260711-090019` lief 20/20 mit 0 Review/Fehlern/Writes, lieferte das erste
|
||||
Ergebnis vor Capture-Ende, mass `4,43 Capture/s` und beobachtet `2,39/s`
|
||||
vom Startrequest bis zur Result-Reconciliation. Der gemeinsame Clock-Contract
|
||||
ist jetzt persistiert: der aktuelle 50er `20260711-154827` bestand mit
|
||||
50/50 Capture/Parse/Results, 0 Review/Fehler/Writes und `28.022 ms`
|
||||
Request-to-Reconciliation (`~1,78/s`). Der 3/s-Gesamtclaim bleibt offen.
|
||||
- Neue OCR-Zustaende koennen weiterhin Review ausloesen; das ist ein sicherer
|
||||
Fallback, kein Grund die Plausibilitaetsgates zu lockern.
|
||||
- Der Renderer hat weiter einen nicht-blockierenden ~630-kB-Chunk.
|
||||
- Die neuen Live-Artifacts waren alle +20, deshalb entstand kein neuer echter
|
||||
Under-Level-Projektionsfall.
|
||||
- Ohne plausiblen OCR-Wert fuer die aktuelle Besitzanzahl blockiert der
|
||||
Full-Inventory-Lauf absichtlich; Kapazitaet darf das Ziel nicht ersetzen und
|
||||
der native Helper erkennt das Listenende noch nicht verlaesslich selbst.
|
||||
|
||||
## Was Noch Verbesserungsfaehig Ist
|
||||
|
||||
1. Die Builds-Oberflaeche hat nun eine gebaute Renderer-Abnahme fuer frisches
|
||||
Englisch, Deutsch-Umschaltung, Kontext, Fokus und Overflow bestanden. Ein
|
||||
elevated Paket-Start bleibt nur fuer einen gesonderten Runtime/UAC-Claim
|
||||
offen; der frische native 50er-Smoke bestand als Scanner-Release-Gate.
|
||||
2. Profilquellen vor Ablauf erneuern und jedes weitere Profil wieder mit
|
||||
Golden-/adversarial Fixtures absichern.
|
||||
3. Neue Review-Faelle nur nach visueller Crop-Bestaetigung in Parser-Corpus
|
||||
oder Lernregeln uebernehmen. Die historische 136er-Population ist bereits
|
||||
auditiert; der eine unvollstaendige Crop bleibt bewusst `Review`.
|
||||
4. Result-Klarheit und explizite Mutation-QA weiter staerken.
|
||||
5. Erst nach eigenen Quellen fuer Charakterbesitz, Team, Waffe und Rotation
|
||||
eine breitere Optimizer- oder Empfehlungsphase erwägen.
|
||||
|
||||
## Aktueller Nachweis
|
||||
|
||||
- `npm run acceptance:offline`: PASS
|
||||
- `npm test`: 446/446 in 72 Dateien
|
||||
- `npm run acceptance:offline`: final PASS nach Lokalisierung, lokaler
|
||||
Loeschung, Timing, Korpus und UI-Acceptance-Runner; Package-Verifier 27/27
|
||||
- Build-Fit-Ranker/Kontext/Profile: offline PASS; gebaute Renderer-UI PASS,
|
||||
aber kein elevated Paket- oder neuer nativer Live-Claim
|
||||
- `npm run package:verify`: 27/27
|
||||
- `npm audit` und `npm audit --omit=dev`: 0 Findings
|
||||
- Dev live: 5/20/50/100 PASS
|
||||
- Settings UI live: Artifact-Limit 5, 5/5 Capture/Processing/Parsing,
|
||||
0 Review/Fehler, `persist=false`, Ergebnisleiste 5 PASS
|
||||
- Packaged Settings UI live: Reihenlimit 1 -> Ziel 8, 8/8
|
||||
Capture/Processing/Parsing, 0 Review/Fehler, `persist=false` PASS
|
||||
- Packaged Settings UI streaming: Artifact-Limit 20, maximal 11 Live-Resultate
|
||||
waehrend Capture, final 20/20, 0 Review/Fehler/Writes PASS
|
||||
- Packaged UI-Dichteabnahme `20260710-214406`: maximal 9 sichtbare Resultate
|
||||
und 12 Evaluierungen waehrend Capture, final 20/20, 0 Review/Fehler/Writes;
|
||||
Ergebnisflaeche 634 px bei 1304x821 ohne Dokument-Overflow PASS
|
||||
- Packaged Full Inventory `20260710-223233`: OCR-Ziel/Capture/Results
|
||||
2.211/2.211, 70 Seiten, 4,24 Capture/s, aktueller Package-Reprocess
|
||||
2.211/2.211 mit 136 Review (6,15%), 0 Fehler/Writes; Saved-Run-Validator
|
||||
2.075 evaluated, 0 unknown, `issues=0` PASS
|
||||
- Packaged No-Genshin Safety: `genshinFound=false`, HWND 0, leerer
|
||||
Zielprozess, keine Capture-Quelle und 0 Renderer-Fehler PASS
|
||||
- Packaged live: Start/Bridge/Resources/Inventory und nativer 5er PASS
|
||||
- Finale visuelle Evidenz:
|
||||
`outputs/ui-qa/final-scan.png`, `outputs/ui-qa/final-scan-settings.png`,
|
||||
`outputs/ui-qa/final-inventory.png` und die weiteren `final-*.png`-Ansichten
|
||||
unter `outputs/ui-qa/`.
|
||||
sowie `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||
(temporäre lokale Dateien zum Abnahmezeitpunkt; bei der angeforderten
|
||||
Workspace-Bereinigung am 2026-07-11 entfernt).
|
||||
@@ -0,0 +1,146 @@
|
||||
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() {
|
||||
if (!overlayWindow || overlayWindow.isDestroyed()) return { ok: false };
|
||||
overlayWindow.close();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
createMainWindow,
|
||||
focusMainWindow,
|
||||
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
getMainWindow: () => mainWindow,
|
||||
sendScannerCommand,
|
||||
createOverlayWindow,
|
||||
hideOverlayWindow,
|
||||
};
|
||||
}
|
||||
@@ -14,9 +14,26 @@ import type {
|
||||
ClickResult,
|
||||
AutomationGuard,
|
||||
ScrollResult,
|
||||
KeyPressResult,
|
||||
SaveResultWithPath,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerDeleteResultStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessOptions,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerProcessingProgressStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadOptions,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
ScannerStatusPayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type {
|
||||
@@ -31,6 +48,19 @@ 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?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerProcessingStatus: () => Promise<NativeScannerProcessingProgressStatus>;
|
||||
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => 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>;
|
||||
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => Promise<NativeScannerDeleteResultStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -51,6 +81,7 @@ interface PersistenceHandlersDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
interface CaptureHandlersDependencies {
|
||||
@@ -58,6 +89,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 +101,19 @@ 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,
|
||||
nativeScannerProcessingStatus: dependencies.nativeScannerProcessingStatus,
|
||||
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
|
||||
nativeScannerDeleteResult: dependencies.nativeScannerDeleteResult,
|
||||
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
|
||||
getRuntimeInfo: dependencies.readRuntimeInfo,
|
||||
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
||||
saveSnapshot: dependencies.saveSnapshotToDisk,
|
||||
@@ -86,6 +131,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
||||
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
||||
exportGood: dependencies.exportGood,
|
||||
importGoodFile: dependencies.importGoodFile,
|
||||
});
|
||||
|
||||
registerCaptureHandlers({
|
||||
@@ -93,6 +139,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
captureSource: dependencies.captureSource,
|
||||
clickScreen: dependencies.clickScreen,
|
||||
scrollScreen: dependencies.scrollScreen,
|
||||
keyPress: dependencies.keyPress,
|
||||
getAutomationGuard: dependencies.getAutomationGuard,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
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,
|
||||
NativeScannerProcessOptions,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerResultsLoadOptions,
|
||||
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?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => 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 stream = url.searchParams.get("stream") === "1";
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const expectedTotal = Number(url.searchParams.get("expectedTotal") ?? Number.NaN);
|
||||
deps.nativeScannerProcessRun({
|
||||
runDir,
|
||||
persist,
|
||||
stream,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
expectedTotal: Number.isFinite(expectedTotal) && expectedTotal > 0 ? expectedTotal : 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);
|
||||
const afterSequence = Number(url.searchParams.get("afterSequence") ?? Number.NaN);
|
||||
deps.nativeScannerLoadResults({
|
||||
runDir,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
afterSequence: Number.isFinite(afterSequence) && afterSequence >= 0 ? afterSequence : 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;
|
||||
}
|
||||
@@ -2,6 +2,21 @@ import { ipcMain } from "electron";
|
||||
import type {
|
||||
BooleanResult,
|
||||
FocusGenshinResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerDeleteResultStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessOptions,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerProcessingProgressStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadOptions,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
RuntimeInfo,
|
||||
SaveSnapshotResult,
|
||||
ScannerStatusPayload,
|
||||
@@ -13,6 +28,19 @@ 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?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerProcessingStatus: () => Promise<NativeScannerProcessingProgressStatus>;
|
||||
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => 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>;
|
||||
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => Promise<NativeScannerDeleteResultStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshot: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -26,6 +54,19 @@ export function registerAppHandlers({
|
||||
moveMainWindowOffGenshin,
|
||||
focusGenshinForScanStart,
|
||||
publishScannerStatus,
|
||||
nativeScannerDataStatus,
|
||||
nativeScannerCatalog,
|
||||
nativeScannerPreflight,
|
||||
nativeScannerStart,
|
||||
nativeScannerStop,
|
||||
nativeScannerStatus,
|
||||
nativeScannerProcessRun,
|
||||
nativeScannerProcessingStatus,
|
||||
nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult,
|
||||
nativeScannerDeleteResult,
|
||||
nativeScannerLoadImage,
|
||||
getRuntimeInfo,
|
||||
loadSnapshot,
|
||||
saveSnapshot,
|
||||
@@ -42,6 +83,19 @@ 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?: NativeScannerProcessOptions) => nativeScannerProcessRun(options));
|
||||
ipcMain.handle("scanner:nativeProcessingStatus", async () => nativeScannerProcessingStatus());
|
||||
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: NativeScannerResultsLoadOptions) => 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:nativeDeleteResult", async (_event, options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => nativeScannerDeleteResult(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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from "electron";
|
||||
import type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreRemoveResult,
|
||||
ArtifactStoreRepositoryPort,
|
||||
ArtifactStoreSaveResult,
|
||||
ReviewSampleListResult,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
SaveScannerLearningRulesResult,
|
||||
ScannerLearningRulePayload,
|
||||
SaveResultWithPath,
|
||||
GoodImportFileResult,
|
||||
} from "../../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||
|
||||
@@ -28,6 +30,7 @@ interface PersistenceDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export function registerPersistenceHandlers({
|
||||
@@ -39,6 +42,7 @@ export function registerPersistenceHandlers({
|
||||
loadScannerLearningRules,
|
||||
writeScannerLearningRules,
|
||||
exportGood,
|
||||
importGoodFile,
|
||||
}: PersistenceDependencies) {
|
||||
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
||||
try {
|
||||
@@ -78,7 +82,38 @@ export function registerPersistenceHandlers({
|
||||
}
|
||||
});
|
||||
|
||||
// Intentionally accepts one exact local Store id only. Native result/crop
|
||||
// tombstones use their separate scanner workflow, and this handler never
|
||||
// invokes the input helper or sends any action to Genshin.
|
||||
ipcMain.handle("artifacts:removeOne", async (_event, id: unknown): Promise<ArtifactStoreRemoveResult> => {
|
||||
const artifactId = typeof id === "string" ? id.trim() : "";
|
||||
if (!artifactId) {
|
||||
return {
|
||||
ok: false,
|
||||
removed: 0,
|
||||
total: 0,
|
||||
path: artifactStorePath(),
|
||||
error: "A local artifact Store id is required.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
return (await getArtifactStoreRepository().removeByIds([artifactId])) as ArtifactStoreRemoveResult;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
removed: 0,
|
||||
total: 0,
|
||||
path: artifactStorePath(),
|
||||
error: "The local artifact Store record could not be removed.",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||
return exportGood(payload);
|
||||
});
|
||||
|
||||
ipcMain.handle("good:importFile", async () => {
|
||||
return importGoodFile();
|
||||
});
|
||||
}
|
||||
|
||||
+1220
-388
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
@@ -18,8 +20,23 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
removeArtifact: (id) => ipcRenderer.invoke("artifacts:removeOne", id),
|
||||
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),
|
||||
nativeScannerProcessingStatus: () => ipcRenderer.invoke("scanner:nativeProcessingStatus"),
|
||||
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||
nativeScannerDeleteResult: (options) => ipcRenderer.invoke("scanner:nativeDeleteResult", options),
|
||||
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback) => {
|
||||
|
||||
+20
-3
@@ -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, NativeScannerProcessOptions, NativeScannerResultsLoadOptions, 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),
|
||||
@@ -21,12 +23,27 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
removeArtifact: (id: string) => ipcRenderer.invoke("artifacts:removeOne", id),
|
||||
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?: NativeScannerProcessOptions) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||
nativeScannerProcessingStatus: () => ipcRenderer.invoke("scanner:nativeProcessingStatus"),
|
||||
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => 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),
|
||||
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => ipcRenderer.invoke("scanner:nativeDeleteResult", 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);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,12 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||
import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js";
|
||||
import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js";
|
||||
import type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreRemoveResult,
|
||||
ArtifactStoreRepositoryPort,
|
||||
ArtifactStoreSaveResult,
|
||||
} from "./contracts.js";
|
||||
interface ArtifactStoreFile {
|
||||
version?: number;
|
||||
artifacts?: StoredArtifactRecord[];
|
||||
@@ -54,6 +59,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),
|
||||
@@ -78,9 +84,26 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
|
||||
return { ok: true, added, updated, total: store.size, path: this.filePath };
|
||||
}
|
||||
|
||||
async removeByIds(ids: string[]): Promise<ArtifactStoreRemoveResult> {
|
||||
const requestedIds = new Set((ids ?? []).map((id) => String(id ?? "").trim()).filter(Boolean));
|
||||
const store = await this.loadMap();
|
||||
let removed = 0;
|
||||
for (const id of requestedIds) {
|
||||
if (store.delete(id)) removed += 1;
|
||||
}
|
||||
if (removed > 0) await this.writeRecords([...store.values()]);
|
||||
return { ok: true, removed, total: store.size, path: this.filePath };
|
||||
}
|
||||
|
||||
private async writeRecords(records: StoredArtifactRecord[]) {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
|
||||
const temporaryPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
|
||||
await fs.rename(temporaryPath, this.filePath);
|
||||
} finally {
|
||||
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +159,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 +207,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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreRemoveResult,
|
||||
ArtifactStoreSaveResult,
|
||||
ReviewSampleListResult,
|
||||
ReviewSamplePayload,
|
||||
@@ -23,6 +24,7 @@ import type { AppSnapshot } from "../../src/types/domain.js";
|
||||
|
||||
export type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreRemoveResult,
|
||||
ArtifactStoreSaveResult,
|
||||
ReviewSampleListResult,
|
||||
ReviewSamplePayload,
|
||||
@@ -34,6 +36,7 @@ export interface ArtifactStoreRepositoryPort {
|
||||
loadAll(): Promise<ArtifactStoreLoadResult>;
|
||||
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
|
||||
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
|
||||
removeByIds(ids: string[]): Promise<ArtifactStoreRemoveResult>;
|
||||
}
|
||||
|
||||
export interface ReviewSamplesRepositoryPort {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from "node:path";
|
||||
|
||||
export interface RuntimeResourcePathOptions {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
currentWorkingDirectory: string;
|
||||
appPath: string;
|
||||
overridePath?: string;
|
||||
}
|
||||
|
||||
function uniqueCandidates(candidates: Array<string | undefined>) {
|
||||
return candidates.filter((candidate, index, all): candidate is string => (
|
||||
Boolean(candidate) && all.indexOf(candidate) === index
|
||||
));
|
||||
}
|
||||
|
||||
export function inputHelperPathCandidates({
|
||||
isPackaged,
|
||||
resourcesPath,
|
||||
currentWorkingDirectory,
|
||||
appPath,
|
||||
overridePath,
|
||||
}: RuntimeResourcePathOptions) {
|
||||
const packagedPath = path.join(resourcesPath, "input-helper", "InputHelper.exe");
|
||||
const developmentPaths = [
|
||||
path.join(currentWorkingDirectory, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||
path.join(appPath, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||
];
|
||||
|
||||
return uniqueCandidates([
|
||||
overridePath,
|
||||
...(isPackaged ? [packagedPath] : [...developmentPaths, packagedPath]),
|
||||
]);
|
||||
}
|
||||
|
||||
export function ikInventoryListsPathCandidates({
|
||||
isPackaged,
|
||||
resourcesPath,
|
||||
currentWorkingDirectory,
|
||||
appPath,
|
||||
overridePath,
|
||||
}: RuntimeResourcePathOptions) {
|
||||
const packagedPath = path.join(resourcesPath, "ik-inventorylists");
|
||||
const developmentPaths = [
|
||||
path.join(currentWorkingDirectory, "data", "ik-inventorylists"),
|
||||
path.join(appPath, "data", "ik-inventorylists"),
|
||||
];
|
||||
|
||||
return uniqueCandidates([
|
||||
overridePath,
|
||||
...(isPackaged ? [packagedPath] : [...developmentPaths, packagedPath]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function isLikelyGenshinSourceName(sourceName: string) {
|
||||
const normalized = sourceName.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return (
|
||||
normalized === "genshin"
|
||||
|| /\bgenshin\s*impact\b/.test(normalized)
|
||||
|| normalized.includes("genshinimpact")
|
||||
|| normalized.includes("yuanshen")
|
||||
|| sourceName.includes("\u539f\u795e")
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+130
-380
@@ -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,32 @@ 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 automationGuardFromHelperResponse(result: HelperOperationResponse): AutomationGuard {
|
||||
return {
|
||||
ok: true,
|
||||
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
||||
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
||||
escapePressed: Boolean(result.escapePressed),
|
||||
enterPressed: Boolean(result.enterPressed),
|
||||
f9Pressed: Boolean(result.f9Pressed),
|
||||
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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,30 +269,47 @@ 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 {
|
||||
ok: true,
|
||||
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
||||
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
||||
escapePressed: Boolean(result.escapePressed),
|
||||
enterPressed: Boolean(result.enterPressed),
|
||||
f9Pressed: Boolean(result.f9Pressed),
|
||||
};
|
||||
return automationGuardFromHelperResponse(result);
|
||||
}
|
||||
|
||||
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 +318,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 +358,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,440 @@
|
||||
// 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)) {
|
||||
$cachedName = Get-ProcessNameFromHwnd -hwnd $script:genshinHwnd
|
||||
if ($cachedName -in @('GenshinImpact', 'YuanShen')) { return $script:genshinHwnd }
|
||||
$script:genshinHwnd = [IntPtr]::Zero
|
||||
}
|
||||
$proc = Get-Process | Where-Object { $_.ProcessName -in @('GenshinImpact', 'YuanShen') -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
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
}
|
||||
"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)
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Native runs remain replayable evidence. Instead of rewriting their raw result
|
||||
* array, locally removed rows are recorded here and filtered at load time.
|
||||
*/
|
||||
export const NATIVE_SCANNER_RESULT_TOMBSTONES_FILE = "deleted-results.jsonl";
|
||||
|
||||
export interface NativeScannerResultTombstone {
|
||||
version: 1;
|
||||
resultId: string;
|
||||
deletedAt: string;
|
||||
imagePath?: string;
|
||||
removeLinkedStoreRecord: boolean;
|
||||
storeRecordId?: string;
|
||||
}
|
||||
|
||||
export function nativeScannerResultTombstonePath(runDir: string) {
|
||||
return path.join(runDir, NATIVE_SCANNER_RESULT_TOMBSTONES_FILE);
|
||||
}
|
||||
|
||||
export async function loadNativeScannerResultTombstones(runDir: string): Promise<NativeScannerResultTombstone[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(nativeScannerResultTombstonePath(runDir), "utf8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(parseTombstone)
|
||||
.filter((entry): entry is NativeScannerResultTombstone => Boolean(entry));
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNativeScannerDeletedResultIds(runDir: string) {
|
||||
const tombstones = await loadNativeScannerResultTombstones(runDir);
|
||||
return new Set(tombstones.map((entry) => entry.resultId));
|
||||
}
|
||||
|
||||
export async function appendNativeScannerResultTombstone(
|
||||
runDir: string,
|
||||
tombstone: NativeScannerResultTombstone,
|
||||
) {
|
||||
const filePath = nativeScannerResultTombstonePath(runDir);
|
||||
await fs.appendFile(filePath, `${JSON.stringify(tombstone)}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function parseTombstone(value: string): NativeScannerResultTombstone | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<NativeScannerResultTombstone>;
|
||||
const resultId = typeof parsed.resultId === "string" ? parsed.resultId.trim() : "";
|
||||
const deletedAt = typeof parsed.deletedAt === "string" ? parsed.deletedAt : "";
|
||||
if (!resultId || !deletedAt) return null;
|
||||
return {
|
||||
version: 1,
|
||||
resultId,
|
||||
deletedAt,
|
||||
...(typeof parsed.imagePath === "string" ? { imagePath: parsed.imagePath } : {}),
|
||||
removeLinkedStoreRecord: Boolean(parsed.removeLinkedStoreRecord),
|
||||
...(typeof parsed.storeRecordId === "string" ? { storeRecordId: parsed.storeRecordId } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
||||
import { evaluateArtifactValue, evaluateStoredScanResult } from "../../src/lib/artifactEvaluation.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,
|
||||
NativeScannerDeleteResultStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
ReviewSamplePayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type { ArtifactRarity, ScanResultArtifactIdentity, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
||||
import {
|
||||
appendNativeScannerResultTombstone,
|
||||
loadNativeScannerDeletedResultIds,
|
||||
loadNativeScannerResultTombstones,
|
||||
nativeScannerResultTombstonePath,
|
||||
} from "./nativeScannerResultTombstones.js";
|
||||
|
||||
export interface NativeScannerResultWorkflowDependencies {
|
||||
resolveRunDir(runDir?: string): string;
|
||||
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
||||
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
||||
removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>;
|
||||
isProcessingRunActive?: (runDir: string) => boolean;
|
||||
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>;
|
||||
deleteResult(options: {
|
||||
runDir?: string;
|
||||
resultId: string;
|
||||
removeLinkedStoreRecord?: boolean;
|
||||
}): Promise<NativeScannerDeleteResultStatus>;
|
||||
}
|
||||
|
||||
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 deletedIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||
if (requestedIds.some((id) => deletedIds.has(id))) {
|
||||
return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Deleted scan results cannot be promoted.");
|
||||
}
|
||||
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 deletedIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||
if (deletedIds.has(resultId)) {
|
||||
return emptyReviewStatus(runDir, logPath, resultId, options.action, "Deleted scan results cannot be reviewed.");
|
||||
}
|
||||
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, current.artifact?.rarity);
|
||||
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 deleteResult(options) {
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
const resultId = String(options.resultId ?? "").trim();
|
||||
const tombstonePath = runDir ? nativeScannerResultTombstonePath(runDir) : "";
|
||||
if (!runDir || !resultId) {
|
||||
return emptyDeleteStatus(runDir, tombstonePath, resultId, "Deleting a local scan result requires a run directory and result ID.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isNativeRunActive(runDir) || deps.isProcessingRunActive?.(runDir)) {
|
||||
return emptyDeleteStatus(runDir, tombstonePath, resultId, "An active native scan cannot be changed. Stop and finish the scan first.");
|
||||
}
|
||||
|
||||
const existingTombstones = await loadNativeScannerResultTombstones(runDir);
|
||||
if (existingTombstones.some((entry) => entry.resultId === resultId)) {
|
||||
return {
|
||||
ok: true,
|
||||
runDir,
|
||||
tombstonePath,
|
||||
resultId,
|
||||
deletedResult: false,
|
||||
alreadyDeleted: true,
|
||||
cropPath: "",
|
||||
deletedCrop: false,
|
||||
deletedStoreRecord: false,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { results } = await loadScanResults(runDir);
|
||||
const current = results.find((entry) => entry.id === resultId);
|
||||
if (!current) {
|
||||
return emptyDeleteStatus(runDir, tombstonePath, resultId, "Selected scan result was not found.");
|
||||
}
|
||||
|
||||
const removeLinkedStoreRecord = Boolean(options.removeLinkedStoreRecord);
|
||||
const storeRecordId = removeLinkedStoreRecord ? current.artifactRecordId?.trim() : undefined;
|
||||
await appendNativeScannerResultTombstone(runDir, {
|
||||
version: 1,
|
||||
resultId,
|
||||
deletedAt: new Date().toISOString(),
|
||||
...(current.imagePath ? { imagePath: current.imagePath } : {}),
|
||||
removeLinkedStoreRecord,
|
||||
...(storeRecordId ? { storeRecordId } : {}),
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
const crop = await removeLocalCrop(runDir, current.imagePath);
|
||||
if (crop.warning) warnings.push(crop.warning);
|
||||
|
||||
let deletedStoreRecord = false;
|
||||
if (removeLinkedStoreRecord) {
|
||||
if (!storeRecordId) {
|
||||
warnings.push("This scan result has no explicitly linked local store record to remove.");
|
||||
} else if (!deps.removeArtifacts) {
|
||||
warnings.push("The linked local store record was retained because local-store deletion is unavailable.");
|
||||
} else {
|
||||
try {
|
||||
const removal = await deps.removeArtifacts([storeRecordId]);
|
||||
if (!removal.ok) {
|
||||
warnings.push("The scan result was removed, but the linked local store record could not be removed.");
|
||||
} else {
|
||||
deletedStoreRecord = removal.removed > 0;
|
||||
if (!deletedStoreRecord) warnings.push("The linked local store record was already absent.");
|
||||
}
|
||||
} catch {
|
||||
warnings.push("The scan result was removed, but the linked local store record could not be removed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
runDir,
|
||||
tombstonePath,
|
||||
resultId,
|
||||
deletedResult: true,
|
||||
alreadyDeleted: false,
|
||||
cropPath: crop.path,
|
||||
deletedCrop: crop.deleted,
|
||||
...(storeRecordId ? { storeRecordId } : {}),
|
||||
deletedStoreRecord,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return emptyDeleteStatus(runDir, tombstonePath, resultId, 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).map(evaluateStoredScanResult) : [];
|
||||
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 evaluateStoredScanResult({
|
||||
...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 {
|
||||
const preservedRarity = current.artifact?.rarity;
|
||||
const preservedRarityMetadata = preservedRarity === undefined
|
||||
? {}
|
||||
: {
|
||||
rarity: preservedRarity,
|
||||
...(current.artifact?.rarityConfidence === undefined ? {} : { rarityConfidence: current.artifact.rarityConfidence }),
|
||||
...(current.artifact?.raritySource === undefined ? {} : { raritySource: current.artifact.raritySource }),
|
||||
};
|
||||
const approvedArtifact: ScanResultArtifactIdentity = {
|
||||
...artifact,
|
||||
...preservedRarityMetadata,
|
||||
};
|
||||
return evaluateStoredScanResult({
|
||||
...current,
|
||||
extractionStatus: "parsed",
|
||||
extractionConfidence: 100,
|
||||
needsReview: false,
|
||||
valueStatus: "deferred",
|
||||
valueScore: null,
|
||||
artifact: approvedArtifact,
|
||||
ikMatch,
|
||||
fieldConfidences: reviewedFieldConfidences(artifact, current.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 emptyDeleteStatus(
|
||||
runDir: string,
|
||||
tombstonePath: string,
|
||||
resultId: string,
|
||||
error: string,
|
||||
): NativeScannerDeleteResultStatus {
|
||||
return {
|
||||
ok: false,
|
||||
runDir,
|
||||
tombstonePath,
|
||||
resultId,
|
||||
deletedResult: false,
|
||||
alreadyDeleted: false,
|
||||
cropPath: "",
|
||||
deletedCrop: false,
|
||||
deletedStoreRecord: false,
|
||||
warnings: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async function isNativeRunActive(runDir: string) {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(path.join(runDir, "status.json"), "utf8"));
|
||||
const scanner = raw?.scanner ?? raw;
|
||||
return Boolean(scanner?.running);
|
||||
} catch {
|
||||
// Older offline runs may not have a status file. A missing status is not
|
||||
// evidence of a running producer, while a present running state blocks.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLocalCrop(runDir: string, imagePath: string) {
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
const candidate = imagePath
|
||||
? path.isAbsolute(imagePath) ? path.resolve(imagePath) : path.resolve(resolvedRunDir, imagePath)
|
||||
: "";
|
||||
if (!candidate) {
|
||||
return { path: "", deleted: false, warning: "No crop image was recorded for this local scan result." };
|
||||
}
|
||||
if (!isPathInside(resolvedRunDir, candidate)) {
|
||||
return { path: candidate, deleted: false, warning: "The recorded crop path is outside the native scan run and was retained." };
|
||||
}
|
||||
if (!/\.png$/i.test(candidate)) {
|
||||
return { path: candidate, deleted: false, warning: "The recorded crop is not a PNG file and was retained." };
|
||||
}
|
||||
try {
|
||||
await fs.unlink(candidate);
|
||||
return { path: candidate, deleted: true };
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return { path: candidate, deleted: false, warning: "The crop image was already absent." };
|
||||
}
|
||||
return { path: candidate, deleted: false, warning: "The scan result was removed, but its crop image could not be removed." };
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
|
||||
}
|
||||
|
||||
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, confirmedRarity?: ArtifactRarity) {
|
||||
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, confirmedRarity === 5 || artifact.level > 16 ? 5 : undefined);
|
||||
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
|
||||
if (errors.length === 0) {
|
||||
const evaluation = evaluateArtifactValue({
|
||||
...artifact,
|
||||
...(confirmedRarity === undefined ? {} : { rarity: confirmedRarity }),
|
||||
});
|
||||
if (evaluation.status !== "evaluated" && evaluation.status !== "excluded") errors.push(evaluation.summary);
|
||||
}
|
||||
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,
|
||||
previous?: ScanResultArtifactIdentity,
|
||||
) {
|
||||
const rarityField = previous?.rarity === undefined
|
||||
? []
|
||||
: [{
|
||||
key: "rarity",
|
||||
label: "Stars",
|
||||
value: `${previous.rarity}★`,
|
||||
confidence: Math.round((previous.rarityConfidence ?? 0) * 100),
|
||||
source: "visual" as const,
|
||||
}];
|
||||
const manualFields = [
|
||||
{ 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 }));
|
||||
return [...manualFields, ...rarityField];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const COMPLETE_RUN_FILES = [
|
||||
"manifest.json",
|
||||
"status.json",
|
||||
"capture-jobs.jsonl",
|
||||
"scan-results.json",
|
||||
"processing-report.json",
|
||||
] as const;
|
||||
|
||||
export interface NativeScannerRunDirectoryOptions {
|
||||
outputRoot: string;
|
||||
requestedRunDir?: string;
|
||||
activeRunDir?: string;
|
||||
}
|
||||
|
||||
export function resolveNativeScannerRunDirectory({
|
||||
outputRoot,
|
||||
requestedRunDir,
|
||||
activeRunDir,
|
||||
}: NativeScannerRunDirectoryOptions) {
|
||||
const root = path.resolve(outputRoot);
|
||||
const requested = requestedRunDir?.trim();
|
||||
if (requested) return resolveContainedDirectory(root, requested);
|
||||
|
||||
const active = activeRunDir?.trim();
|
||||
if (active) return resolveContainedDirectory(root, active);
|
||||
|
||||
return newestCompleteNativeScannerRun(root);
|
||||
}
|
||||
|
||||
export function newestCompleteNativeScannerRun(outputRoot: string) {
|
||||
const root = path.resolve(outputRoot);
|
||||
if (!isDirectory(root)) return "";
|
||||
|
||||
try {
|
||||
const candidates = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => completeRunCandidate(root, path.join(root, entry.name)))
|
||||
.filter((entry): entry is CompleteRunCandidate => Boolean(entry))
|
||||
.sort((left, right) => right.createdAt - left.createdAt || right.name.localeCompare(left.name));
|
||||
return candidates[0]?.runDir ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface CompleteRunCandidate {
|
||||
runDir: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function completeRunCandidate(root: string, candidate: string): CompleteRunCandidate | null {
|
||||
const runDir = resolveContainedDirectory(root, candidate);
|
||||
if (!runDir || COMPLETE_RUN_FILES.some((file) => !existsSync(path.join(runDir, file)))) return null;
|
||||
|
||||
try {
|
||||
const statusPayload = JSON.parse(readFileSync(path.join(runDir, "status.json"), "utf8"));
|
||||
const status = statusPayload?.scanner ?? statusPayload;
|
||||
const target = Number(status?.target ?? 0);
|
||||
const captured = Number(status?.captured ?? 0);
|
||||
if (status?.running !== false || status?.status !== "done" || target < 1 || captured < target) return null;
|
||||
|
||||
const results = JSON.parse(readFileSync(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
if (!Array.isArray(results) || results.length < captured) return null;
|
||||
|
||||
const manifest = JSON.parse(readFileSync(path.join(runDir, "manifest.json"), "utf8"));
|
||||
const manifestCreatedAt = Date.parse(String(manifest?.createdAt ?? ""));
|
||||
return {
|
||||
runDir,
|
||||
name: path.basename(runDir),
|
||||
createdAt: Number.isFinite(manifestCreatedAt) ? manifestCreatedAt : statSync(runDir).mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContainedDirectory(root: string, candidate: string) {
|
||||
try {
|
||||
const resolvedRoot = realpathSync(root);
|
||||
const resolvedCandidate = realpathSync(path.resolve(candidate));
|
||||
if (!isPathInside(resolvedRoot, resolvedCandidate) || !isDirectory(resolvedCandidate)) return "";
|
||||
return resolvedCandidate;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(candidate: string) {
|
||||
try {
|
||||
return statSync(candidate).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { NativeScannerRunTiming, NativeScannerRunTimingPatch } from "../../src/types/global.js";
|
||||
|
||||
export const NATIVE_SCANNER_RUN_TIMING_FILE = "run-timing.json";
|
||||
export const NATIVE_SCANNER_RUN_TIMING_VERSION = "native-scanner-run-timing-v1" as const;
|
||||
|
||||
const timingWriteQueues = new Map<string, Promise<void>>();
|
||||
|
||||
const timestampFields = [
|
||||
"requestStartedAt",
|
||||
"scannerStartedAt",
|
||||
"captureStartedAt",
|
||||
"captureCompletedAt",
|
||||
"processingStartedAt",
|
||||
"processingCompletedAt",
|
||||
"resultsDurableAt",
|
||||
"resultsReconciledAt",
|
||||
] as const satisfies ReadonlyArray<keyof NativeScannerRunTimingPatch>;
|
||||
|
||||
type TimingTimestampField = typeof timestampFields[number];
|
||||
|
||||
export function nativeScannerRunTimingPath(runDir: string) {
|
||||
return path.join(path.resolve(runDir), NATIVE_SCANNER_RUN_TIMING_FILE);
|
||||
}
|
||||
|
||||
export async function readNativeScannerRunTiming(runDir: string): Promise<NativeScannerRunTiming | null> {
|
||||
const timingPath = nativeScannerRunTimingPath(runDir);
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(timingPath, "utf8")) as unknown;
|
||||
return normalizeTiming(raw);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds first-observed lifecycle markers to one run's durable timing record.
|
||||
* A marker is deliberately immutable after the first valid observation so
|
||||
* polling or a later renderer retry cannot rewrite the measured interval.
|
||||
*/
|
||||
export async function recordNativeScannerRunTiming(
|
||||
runDir: string,
|
||||
patch: NativeScannerRunTimingPatch,
|
||||
): Promise<NativeScannerRunTiming> {
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
const previous = timingWriteQueues.get(resolvedRunDir) ?? Promise.resolve();
|
||||
const task = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const current = await readNativeScannerRunTiming(resolvedRunDir);
|
||||
const next = mergeTiming(current, patch);
|
||||
if (!sameTiming(current, next)) {
|
||||
await writeJsonAtomic(nativeScannerRunTimingPath(resolvedRunDir), next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const settled = task.then(() => undefined, () => undefined);
|
||||
timingWriteQueues.set(resolvedRunDir, settled);
|
||||
void settled.finally(() => {
|
||||
if (timingWriteQueues.get(resolvedRunDir) === settled) timingWriteQueues.delete(resolvedRunDir);
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
export function validateNativeScannerRunTiming(timing: NativeScannerRunTiming): string[] {
|
||||
const issues: string[] = [];
|
||||
if (timing.version !== NATIVE_SCANNER_RUN_TIMING_VERSION) {
|
||||
issues.push(`unsupported timing version ${String(timing.version)}`);
|
||||
}
|
||||
|
||||
for (const field of timestampFields) {
|
||||
const value = timing[field];
|
||||
if (value !== undefined && !isValidTimestamp(value)) {
|
||||
issues.push(`${field} is not a valid ISO timestamp`);
|
||||
}
|
||||
}
|
||||
|
||||
validateOrder(issues, timing, "requestStartedAt", "scannerStartedAt");
|
||||
validateOrder(issues, timing, "requestStartedAt", "captureStartedAt");
|
||||
validateOrder(issues, timing, "requestStartedAt", "processingStartedAt");
|
||||
validateOrder(issues, timing, "captureStartedAt", "captureCompletedAt");
|
||||
validateOrder(issues, timing, "captureCompletedAt", "processingCompletedAt");
|
||||
validateOrder(issues, timing, "processingStartedAt", "processingCompletedAt");
|
||||
validateOrder(issues, timing, "processingCompletedAt", "resultsDurableAt");
|
||||
validateOrder(issues, timing, "resultsDurableAt", "resultsReconciledAt");
|
||||
|
||||
validateDuration(issues, timing, "requestToResultsDurableMs", "requestStartedAt", "resultsDurableAt");
|
||||
validateDuration(issues, timing, "requestToResultsReconciledMs", "requestStartedAt", "resultsReconciledAt");
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function hasCompleteNativeScannerRunTiming(timing: NativeScannerRunTiming | null | undefined) {
|
||||
return Boolean(
|
||||
timing?.requestStartedAt
|
||||
&& timing.scannerStartedAt
|
||||
&& timing.captureCompletedAt
|
||||
&& timing.processingStartedAt
|
||||
&& timing.processingCompletedAt
|
||||
&& timing.resultsDurableAt
|
||||
&& timing.resultsReconciledAt,
|
||||
);
|
||||
}
|
||||
|
||||
function mergeTiming(current: NativeScannerRunTiming | null, patch: NativeScannerRunTimingPatch): NativeScannerRunTiming {
|
||||
const next: NativeScannerRunTiming = { version: NATIVE_SCANNER_RUN_TIMING_VERSION };
|
||||
for (const field of timestampFields) {
|
||||
const value = firstValidTimestamp(current?.[field], patch[field]);
|
||||
if (value) next[field] = value;
|
||||
}
|
||||
const durableMs = durationMs(next.requestStartedAt, next.resultsDurableAt);
|
||||
if (durableMs !== null) next.requestToResultsDurableMs = durableMs;
|
||||
const reconciledMs = durationMs(next.requestStartedAt, next.resultsReconciledAt);
|
||||
if (reconciledMs !== null) next.requestToResultsReconciledMs = reconciledMs;
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeTiming(value: unknown): NativeScannerRunTiming {
|
||||
if (!value || typeof value !== "object") throw new Error("Native scanner timing file is not an object.");
|
||||
const candidate = value as Partial<NativeScannerRunTiming>;
|
||||
const raw: NativeScannerRunTiming = {
|
||||
version: candidate.version as NativeScannerRunTiming["version"],
|
||||
};
|
||||
for (const field of timestampFields) {
|
||||
if (candidate[field] !== undefined) raw[field] = candidate[field] as string;
|
||||
}
|
||||
if (candidate.requestToResultsDurableMs !== undefined) {
|
||||
raw.requestToResultsDurableMs = candidate.requestToResultsDurableMs as number;
|
||||
}
|
||||
if (candidate.requestToResultsReconciledMs !== undefined) {
|
||||
raw.requestToResultsReconciledMs = candidate.requestToResultsReconciledMs as number;
|
||||
}
|
||||
const issues = validateNativeScannerRunTiming(raw);
|
||||
if (issues.length > 0) throw new Error(`Invalid native scanner timing file: ${issues.join("; ")}`);
|
||||
return mergeTiming(null, raw);
|
||||
}
|
||||
|
||||
function validateOrder(
|
||||
issues: string[],
|
||||
timing: NativeScannerRunTiming,
|
||||
earlier: TimingTimestampField,
|
||||
later: TimingTimestampField,
|
||||
) {
|
||||
const earlierMs = timestampMs(timing[earlier]);
|
||||
const laterMs = timestampMs(timing[later]);
|
||||
if (earlierMs !== null && laterMs !== null && laterMs < earlierMs) {
|
||||
issues.push(`${later} is before ${earlier}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDuration(
|
||||
issues: string[],
|
||||
timing: NativeScannerRunTiming,
|
||||
field: "requestToResultsDurableMs" | "requestToResultsReconciledMs",
|
||||
startedField: TimingTimestampField,
|
||||
completedField: TimingTimestampField,
|
||||
) {
|
||||
const declared = timing[field];
|
||||
const expected = durationMs(timing[startedField], timing[completedField]);
|
||||
if (declared !== undefined && (!Number.isInteger(declared) || declared < 0)) {
|
||||
issues.push(`${field} is not a non-negative integer`);
|
||||
}
|
||||
if (expected !== null && declared !== expected) {
|
||||
issues.push(`${field} does not match ${startedField} to ${completedField}`);
|
||||
}
|
||||
if (expected === null && declared !== undefined) {
|
||||
issues.push(`${field} exists without both endpoint timestamps`);
|
||||
}
|
||||
}
|
||||
|
||||
function firstValidTimestamp(...values: Array<string | null | undefined>) {
|
||||
return values.find((value): value is string => isValidTimestamp(value));
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown) {
|
||||
return isValidTimestamp(value) ? Date.parse(value) : null;
|
||||
}
|
||||
|
||||
function durationMs(startedAt: string | undefined, completedAt: string | undefined) {
|
||||
const started = timestampMs(startedAt);
|
||||
const completed = timestampMs(completedAt);
|
||||
if (started === null || completed === null || completed < started) return null;
|
||||
return Math.round(completed - started);
|
||||
}
|
||||
|
||||
function sameTiming(left: NativeScannerRunTiming | null, right: NativeScannerRunTiming) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filePath: string, payload: unknown) {
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
||||
await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
await fs.rename(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>InputHelper.Tests</AssemblyName>
|
||||
<RootNamespace>GenshinAssistant.InputHelper.Tests</RootNamespace>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\input-helper\InputHelper.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,375 @@
|
||||
using GenshinAssistant.InputHelper;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GenshinAssistant.InputHelper.Tests;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int failures;
|
||||
|
||||
private sealed record ArtifactStarReportEntry(
|
||||
int? Sequence,
|
||||
string ImagePath,
|
||||
int? StarCount,
|
||||
double Confidence,
|
||||
string? Source);
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
if (TryRunArtifactStarReport(args, out var reportExitCode)) return reportExitCode;
|
||||
|
||||
VerifyReadyOnlyForExpectedForeground();
|
||||
VerifyEveryStopSignalWins();
|
||||
VerifyFocusLossBlocksWithoutRefocus();
|
||||
VerifyKeyDispatchRequiresConfirmedFocus();
|
||||
VerifyTopResetIsBoundedAndCoversTheSupportedRange();
|
||||
VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep();
|
||||
VerifyOnlyTheRealGameProcessCanBecomeTheTarget();
|
||||
VerifyArtifactStarDetectorCountsThreeFourAndFiveStars();
|
||||
VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise();
|
||||
VerifyArtifactStarDetectorRealCropsFromEnvironment();
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Console.WriteLine("InputHelper safety policy and ArtifactStarDetector: all tests passed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"InputHelper safety policy: {failures} test(s) failed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only batch report for already captured native Artifact cards. This
|
||||
/// gives the review-corpus workflow an auditable rarity signal without
|
||||
/// focusing Genshin or changing any saved scan result.
|
||||
/// </summary>
|
||||
private static bool TryRunArtifactStarReport(string[] args, out int exitCode)
|
||||
{
|
||||
exitCode = 0;
|
||||
if (args.Length == 0 || !string.Equals(args[0], "--report-artifact-stars", StringComparison.Ordinal)) return false;
|
||||
|
||||
var directory = args.Skip(1)
|
||||
.FirstOrDefault(argument => argument.StartsWith("--directory=", StringComparison.OrdinalIgnoreCase))?
|
||||
.Split('=', 2)[1]
|
||||
.Trim();
|
||||
var output = args.Skip(1)
|
||||
.FirstOrDefault(argument => argument.StartsWith("--output=", StringComparison.OrdinalIgnoreCase))?
|
||||
.Split('=', 2)[1]
|
||||
.Trim();
|
||||
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
|
||||
{
|
||||
Console.Error.WriteLine("--report-artifact-stars requires --directory=<existing native run directory>.");
|
||||
exitCode = 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entries = Directory
|
||||
.EnumerateFiles(directory, "artifact-*.png", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ReportArtifactStar)
|
||||
.ToArray();
|
||||
var report = new
|
||||
{
|
||||
version = "native-artifact-star-report-v1",
|
||||
createdAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
runDir = Path.GetFullPath(directory),
|
||||
total = entries.Length,
|
||||
counts = entries
|
||||
.GroupBy(entry => entry.StarCount?.ToString() ?? "unknown")
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count()),
|
||||
entries,
|
||||
};
|
||||
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fullOutputPath = Path.GetFullPath(output);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)!);
|
||||
File.WriteAllText(fullOutputPath, json + Environment.NewLine);
|
||||
Console.WriteLine($"Artifact star report: {fullOutputPath}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact star report failed: {exception.Message}");
|
||||
exitCode = 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ArtifactStarReportEntry ReportArtifactStar(string path)
|
||||
{
|
||||
using var bitmap = new Bitmap(path);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
return new ArtifactStarReportEntry(
|
||||
SequenceFromArtifactPath(path),
|
||||
Path.GetFullPath(path),
|
||||
detection.StarCount,
|
||||
detection.Confidence,
|
||||
detection.Source);
|
||||
}
|
||||
|
||||
private static int? SequenceFromArtifactPath(string path)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||
var prefix = "artifact-";
|
||||
return fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(fileName[prefix.Length..], out var sequence)
|
||||
? sequence
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void VerifyReadyOnlyForExpectedForeground()
|
||||
{
|
||||
var decision = Evaluate(expectedHwnd: 42, foregroundHwnd: 42);
|
||||
Check(decision.CanContinue, "expected Genshin foreground should allow input");
|
||||
Check(decision.Outcome == NativeScannerInputGuardOutcome.Ready, "ready decision should use Ready outcome");
|
||||
}
|
||||
|
||||
private static void VerifyEveryStopSignalWins()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
(Name: "stop request", Decision: Evaluate(stopRequested: true, foregroundHwnd: 7)),
|
||||
(Name: "ESC", Decision: Evaluate(escapePressed: true, foregroundHwnd: 7)),
|
||||
(Name: "Enter", Decision: Evaluate(enterPressed: true, foregroundHwnd: 7)),
|
||||
(Name: "F9", Decision: Evaluate(f9Pressed: true, foregroundHwnd: 7)),
|
||||
};
|
||||
|
||||
foreach (var item in cases)
|
||||
{
|
||||
Check(!item.Decision.CanContinue, $"{item.Name} should reject input");
|
||||
Check(item.Decision.Outcome == NativeScannerInputGuardOutcome.Stopped, $"{item.Name} should stop cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyFocusLossBlocksWithoutRefocus()
|
||||
{
|
||||
var mismatch = Evaluate(expectedHwnd: 42, foregroundHwnd: 7, foregroundProcess: "explorer");
|
||||
Check(!mismatch.CanContinue, "foreground mismatch should reject input");
|
||||
Check(mismatch.Outcome == NativeScannerInputGuardOutcome.Blocked, "foreground mismatch should block the run");
|
||||
Check(mismatch.Message.Contains("without refocusing", StringComparison.Ordinal), "focus-loss message should make no-refocus behavior explicit");
|
||||
Check(mismatch.Message.Contains("explorer", StringComparison.Ordinal), "focus-loss message should name the actual foreground process");
|
||||
|
||||
var missingTarget = Evaluate(expectedHwnd: 0, foregroundHwnd: 0);
|
||||
Check(missingTarget.Outcome == NativeScannerInputGuardOutcome.Blocked, "missing target window should block the run");
|
||||
}
|
||||
|
||||
private static void VerifyKeyDispatchRequiresConfirmedFocus()
|
||||
{
|
||||
var sendCalls = 0;
|
||||
var unfocused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(false, () =>
|
||||
{
|
||||
sendCalls++;
|
||||
return 2;
|
||||
});
|
||||
Check(sendCalls == 0, "unfocused key dispatch must not call SendInput");
|
||||
Check(unfocused.EventsSent == 0, "unfocused key dispatch should report zero events");
|
||||
Check(unfocused.InputBlocked, "unfocused key dispatch should report blocked input");
|
||||
|
||||
var focused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () =>
|
||||
{
|
||||
sendCalls++;
|
||||
return 2;
|
||||
});
|
||||
Check(sendCalls == 1, "focused key dispatch should call SendInput exactly once");
|
||||
Check(focused.EventsSent == 2, "focused key dispatch should report both key events");
|
||||
Check(!focused.InputBlocked, "focused complete key dispatch should not report blocked input");
|
||||
|
||||
var partial = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () => 1);
|
||||
Check(partial.EventsSent == 1, "partial key dispatch should preserve the event count");
|
||||
Check(partial.InputBlocked, "partial key dispatch should report blocked input");
|
||||
}
|
||||
|
||||
private static void VerifyTopResetIsBoundedAndCoversTheSupportedRange()
|
||||
{
|
||||
var requiredPages = (int)Math.Ceiling(
|
||||
NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity /
|
||||
(double)NativeScannerInputSafetyPolicy.VisibleArtifactTargetsPerPage);
|
||||
var minimumEvents = requiredPages * NativeScannerInputSafetyPolicy.PageScrollWheelEvents;
|
||||
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents >= minimumEvents,
|
||||
"top reset should cover every supported artifact page");
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents <= 3200,
|
||||
"top reset should remain explicitly bounded");
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs >= 250,
|
||||
"top reset should include a visible stabilization window");
|
||||
}
|
||||
|
||||
private static void VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep()
|
||||
{
|
||||
Check(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents == NativeScannerInputSafetyPolicy.PageScrollWheelEvents,
|
||||
"wheel pacing should yield once per calibrated page-sized batch");
|
||||
Check(NativeScannerInputSafetyPolicy.WheelPacingDelayMs > 0 && NativeScannerInputSafetyPolicy.WheelPacingDelayMs <= 5,
|
||||
"wheel pacing delay should remain short and explicitly bounded");
|
||||
Check(!NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(1),
|
||||
"wheel pacing should not sleep after every single event");
|
||||
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents),
|
||||
"wheel pacing should yield at the batch boundary");
|
||||
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents * 2),
|
||||
"wheel pacing should yield at repeated batch boundaries");
|
||||
}
|
||||
|
||||
private static void VerifyOnlyTheRealGameProcessCanBecomeTheTarget()
|
||||
{
|
||||
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinImpact"),
|
||||
"the global Genshin client process should be allowed");
|
||||
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("yuanshen"),
|
||||
"the Chinese Genshin client process should be allowed case-insensitively");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin Artifact Assistant"),
|
||||
"the assistant must never target itself");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinLauncher"),
|
||||
"a launcher or similarly named process must not become the game target");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin"),
|
||||
"partial process-name matches must not be accepted");
|
||||
}
|
||||
|
||||
private static void VerifyArtifactStarDetectorCountsThreeFourAndFiveStars()
|
||||
{
|
||||
foreach (var count in new[] { 3, 4, 5 })
|
||||
{
|
||||
using var bitmap = CreateSyntheticArtifactCard(count);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
|
||||
Check(detection.StarCount == count, $"synthetic {count}-star card should retain its exact count");
|
||||
Check(detection.Confidence >= 0.80, $"synthetic {count}-star card should be high confidence");
|
||||
Check(detection.Source == ArtifactStarDetector.SourceName, $"synthetic {count}-star card should name the native source");
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise()
|
||||
{
|
||||
using var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||
|
||||
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||
var rowY = roi.Y + roi.Height / 2;
|
||||
// Thin gold marks and scattered pixels resemble a noisy capture but do
|
||||
// not form full star glyphs. The detector must not convert this into a
|
||||
// lower-rarity decision.
|
||||
for (var index = 0; index < 5; index++)
|
||||
{
|
||||
graphics.FillRectangle(gold, roi.X + 22 + index * 34, rowY, 22, 4);
|
||||
}
|
||||
var random = new Random(20260711);
|
||||
for (var index = 0; index < 120; index++)
|
||||
{
|
||||
graphics.FillRectangle(
|
||||
gold,
|
||||
roi.X + random.Next(roi.Width),
|
||||
roi.Y + random.Next(roi.Height),
|
||||
1,
|
||||
1);
|
||||
}
|
||||
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
Check(detection.StarCount is null, "ambiguous gold noise must not produce a star count");
|
||||
Check(detection.Confidence < 0.80, "ambiguous gold noise must remain low confidence");
|
||||
Check(detection.Source is null, "ambiguous gold noise must not claim a native rarity source");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional offline regression check for user-provided, already-captured
|
||||
/// cards. The environment value is intentionally explicit rather than
|
||||
/// probing a user's inventory: <c>3=C:\\sample3.png;4=C:\\sample4.png</c>.
|
||||
/// This keeps the test read-only and makes the expected rarity auditable.
|
||||
/// </summary>
|
||||
private static void VerifyArtifactStarDetectorRealCropsFromEnvironment()
|
||||
{
|
||||
var samples = Environment.GetEnvironmentVariable("ARTIFACT_STAR_REAL_CROPS");
|
||||
if (string.IsNullOrWhiteSpace(samples)) return;
|
||||
|
||||
foreach (var sample in samples.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var separator = sample.IndexOf('=');
|
||||
var expectedText = separator > 0 ? sample[..separator] : string.Empty;
|
||||
var path = separator > 0 ? sample[(separator + 1)..].Trim() : string.Empty;
|
||||
if (!int.TryParse(expectedText, out var expected) || expected is < 1 or > 5 || !File.Exists(path))
|
||||
{
|
||||
Check(false, $"real-card sample must use '<1-5>=<existing png path>': {sample}");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var bitmap = new Bitmap(path);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
Check(detection.StarCount == expected,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should detect {expected} stars, got {detection.StarCount?.ToString() ?? "unknown"}");
|
||||
Check(detection.Confidence >= 0.80,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should meet the accepted confidence threshold");
|
||||
Check(detection.Source == ArtifactStarDetector.SourceName,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should retain the native source");
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap CreateSyntheticArtifactCard(int starCount)
|
||||
{
|
||||
var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||
|
||||
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||
var centerY = roi.Y + roi.Height / 2;
|
||||
var firstCenterX = roi.X + 28;
|
||||
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||
for (var index = 0; index < starCount; index++)
|
||||
{
|
||||
DrawStar(graphics, gold, firstCenterX + index * 34, centerY, 13, 5.5f);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static void DrawStar(Graphics graphics, Brush brush, int centerX, int centerY, float outerRadius, float innerRadius)
|
||||
{
|
||||
var points = new PointF[10];
|
||||
for (var index = 0; index < points.Length; index++)
|
||||
{
|
||||
var radius = index % 2 == 0 ? outerRadius : innerRadius;
|
||||
var angle = -Math.PI / 2 + index * Math.PI / 5;
|
||||
points[index] = new PointF(
|
||||
centerX + radius * (float)Math.Cos(angle),
|
||||
centerY + radius * (float)Math.Sin(angle));
|
||||
}
|
||||
graphics.FillPolygon(brush, points);
|
||||
}
|
||||
|
||||
private static NativeScannerInputGuardDecision Evaluate(
|
||||
bool stopRequested = false,
|
||||
bool escapePressed = false,
|
||||
bool enterPressed = false,
|
||||
bool f9Pressed = false,
|
||||
long expectedHwnd = 42,
|
||||
long foregroundHwnd = 42,
|
||||
string foregroundProcess = "GenshinImpact")
|
||||
=> NativeScannerInputSafetyPolicy.Evaluate(
|
||||
stopRequested,
|
||||
escapePressed,
|
||||
enterPressed,
|
||||
f9Pressed,
|
||||
expectedHwnd,
|
||||
foregroundHwnd,
|
||||
foregroundProcess,
|
||||
"test input");
|
||||
|
||||
private static void Check(bool condition, string message)
|
||||
{
|
||||
if (condition) return;
|
||||
failures++;
|
||||
Console.Error.WriteLine($"FAIL: {message}");
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Buffers;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace GenshinAssistant.InputHelper;
|
||||
|
||||
/// <summary>
|
||||
/// A visual-only rarity signal taken from the row of gold stars on an Artifact
|
||||
/// detail-card crop. It is deliberately conservative: a malformed or
|
||||
/// inconsistent row produces no count instead of guessing a rarity.
|
||||
/// </summary>
|
||||
internal readonly record struct ArtifactStarDetection(
|
||||
int? StarCount,
|
||||
double Confidence,
|
||||
string? Source)
|
||||
{
|
||||
internal static ArtifactStarDetection Unknown() => new(null, 0, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the bright gold Artifact-star glyphs inside the stable, normalized
|
||||
/// star-row region of the native 492x838-style detail crop. This detector only
|
||||
/// reads the captured bitmap; it has no screen, process, or input side effects.
|
||||
/// </summary>
|
||||
internal static class ArtifactStarDetector
|
||||
{
|
||||
internal const string SourceName = "native-star-row-v1";
|
||||
|
||||
private const double MinimumAcceptedConfidence = 0.80;
|
||||
private const double RoiLeft = 0.025;
|
||||
private const double RoiRight = 0.56;
|
||||
private const double RoiTop = 0.245;
|
||||
private const double RoiBottom = 0.345;
|
||||
|
||||
internal static ArtifactStarDetection Detect(Bitmap? bitmap)
|
||||
{
|
||||
if (bitmap is null || bitmap.Width < 120 || bitmap.Height < 200)
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
|
||||
var roi = StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||
if (roi.Width < 24 || roi.Height < 16)
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
|
||||
var bytesPerRow = roi.Width * 4;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(bytesPerRow * roi.Height);
|
||||
BitmapData? data = null;
|
||||
try
|
||||
{
|
||||
// Native capture creates Format32bppArgb cards. Locking explicitly
|
||||
// to that format keeps the detector allocation-bounded and avoids
|
||||
// slow per-pixel GDI calls in the scan hot path.
|
||||
data = bitmap.LockBits(roi, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
for (var y = 0; y < roi.Height; y++)
|
||||
{
|
||||
Marshal.Copy(
|
||||
IntPtr.Add(data.Scan0, y * data.Stride),
|
||||
buffer,
|
||||
y * bytesPerRow,
|
||||
bytesPerRow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (data is not null)
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return DetectFromBgra(buffer, roi.Width, roi.Height, bitmap.Width);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Rectangle StarRowRoiFor(int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) return Rectangle.Empty;
|
||||
var left = Math.Clamp((int)Math.Floor(width * RoiLeft), 0, Math.Max(0, width - 1));
|
||||
var right = Math.Clamp((int)Math.Ceiling(width * RoiRight), left + 1, width);
|
||||
var top = Math.Clamp((int)Math.Floor(height * RoiTop), 0, Math.Max(0, height - 1));
|
||||
var bottom = Math.Clamp((int)Math.Ceiling(height * RoiBottom), top + 1, height);
|
||||
return Rectangle.FromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
private static ArtifactStarDetection DetectFromBgra(byte[] pixels, int width, int height, int cardWidth)
|
||||
{
|
||||
var yellowByColumn = new int[width];
|
||||
var minYByColumn = Enumerable.Repeat(height, width).ToArray();
|
||||
var maxYByColumn = Enumerable.Repeat(-1, width).ToArray();
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var rowOffset = y * width * 4;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var offset = rowOffset + x * 4;
|
||||
var blue = pixels[offset];
|
||||
var green = pixels[offset + 1];
|
||||
var red = pixels[offset + 2];
|
||||
if (!IsStrongGold(red, green, blue)) continue;
|
||||
|
||||
yellowByColumn[x]++;
|
||||
minYByColumn[x] = Math.Min(minYByColumn[x], y);
|
||||
maxYByColumn[x] = Math.Max(maxYByColumn[x], y);
|
||||
}
|
||||
}
|
||||
|
||||
var minimumYellowPixelsPerColumn = Math.Max(3, (int)Math.Ceiling(height * 0.045));
|
||||
var rawSegments = FindColumnSegments(yellowByColumn, minYByColumn, maxYByColumn, minimumYellowPixelsPerColumn);
|
||||
var stars = rawSegments
|
||||
.Where(segment => IsPlausibleStarSegment(segment, cardWidth, height))
|
||||
.ToList();
|
||||
|
||||
if (stars.Count is < 1 or > 5)
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
|
||||
if (!HasPlausibleRowGeometry(stars, cardWidth, height))
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
|
||||
var confidence = CalculateConfidence(stars, cardWidth, height);
|
||||
if (confidence < MinimumAcceptedConfidence)
|
||||
{
|
||||
return ArtifactStarDetection.Unknown();
|
||||
}
|
||||
|
||||
return new ArtifactStarDetection(stars.Count, confidence, SourceName);
|
||||
}
|
||||
|
||||
private static bool IsStrongGold(byte red, byte green, byte blue)
|
||||
{
|
||||
// The card background is warm brown/orange, while a rendered star has a
|
||||
// noticeably brighter yellow core. Favor precision here because a false
|
||||
// five-star classification would make a lower-rarity Artifact appear
|
||||
// ineligible for downstream review instead of preserving uncertainty.
|
||||
return red >= 215
|
||||
&& green >= 165
|
||||
&& blue <= 80
|
||||
&& red >= green + 24
|
||||
&& green >= blue + 72;
|
||||
}
|
||||
|
||||
private static List<StarSegment> FindColumnSegments(
|
||||
int[] yellowByColumn,
|
||||
int[] minYByColumn,
|
||||
int[] maxYByColumn,
|
||||
int minimumYellowPixelsPerColumn)
|
||||
{
|
||||
const int maximumInternalGap = 2;
|
||||
var segments = new List<StarSegment>();
|
||||
var start = -1;
|
||||
var lastActive = -1;
|
||||
|
||||
for (var x = 0; x < yellowByColumn.Length; x++)
|
||||
{
|
||||
var active = yellowByColumn[x] >= minimumYellowPixelsPerColumn;
|
||||
if (active)
|
||||
{
|
||||
if (start < 0) start = x;
|
||||
lastActive = x;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start >= 0 && x - lastActive > maximumInternalGap)
|
||||
{
|
||||
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
|
||||
start = -1;
|
||||
lastActive = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= 0)
|
||||
{
|
||||
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static StarSegment CreateSegment(
|
||||
int start,
|
||||
int end,
|
||||
int[] yellowByColumn,
|
||||
int[] minYByColumn,
|
||||
int[] maxYByColumn)
|
||||
{
|
||||
var pixels = 0;
|
||||
var peakColumnPixels = 0;
|
||||
var minY = int.MaxValue;
|
||||
var maxY = -1;
|
||||
for (var x = start; x <= end; x++)
|
||||
{
|
||||
pixels += yellowByColumn[x];
|
||||
peakColumnPixels = Math.Max(peakColumnPixels, yellowByColumn[x]);
|
||||
minY = Math.Min(minY, minYByColumn[x]);
|
||||
maxY = Math.Max(maxY, maxYByColumn[x]);
|
||||
}
|
||||
|
||||
return new StarSegment(start, end, pixels, peakColumnPixels, minY, maxY);
|
||||
}
|
||||
|
||||
private static bool IsPlausibleStarSegment(StarSegment segment, int cardWidth, int roiHeight)
|
||||
{
|
||||
var minimumWidth = Math.Max(8, (int)Math.Round(cardWidth * 0.020));
|
||||
var maximumWidth = Math.Max(minimumWidth + 1, (int)Math.Round(cardWidth * 0.090));
|
||||
var minimumHeight = Math.Max(8, (int)Math.Round(roiHeight * 0.10));
|
||||
var maximumHeight = Math.Max(minimumHeight + 1, (int)Math.Round(roiHeight * 0.75));
|
||||
var minimumPixels = Math.Max(35, (int)Math.Round(cardWidth * 0.16));
|
||||
var minimumPeakColumnPixels = Math.Max(8, (int)Math.Round(roiHeight * 0.12));
|
||||
var density = segment.PixelCount / (double)Math.Max(1, segment.Width * segment.Height);
|
||||
|
||||
return segment.Width >= minimumWidth
|
||||
&& segment.Width <= maximumWidth
|
||||
&& segment.Height >= minimumHeight
|
||||
&& segment.Height <= maximumHeight
|
||||
&& segment.PixelCount >= minimumPixels
|
||||
&& segment.PeakColumnPixels >= minimumPeakColumnPixels
|
||||
&& density is >= 0.12 and <= 0.78;
|
||||
}
|
||||
|
||||
private static bool HasPlausibleRowGeometry(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
|
||||
{
|
||||
var firstCenter = stars[0].CenterX;
|
||||
var lastCenter = stars[^1].CenterX;
|
||||
if (firstCenter < cardWidth * 0.035 || firstCenter > cardWidth * 0.16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (lastCenter > cardWidth * 0.47)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 1; index < stars.Count; index++)
|
||||
{
|
||||
var spacing = stars[index].CenterX - stars[index - 1].CenterX;
|
||||
if (spacing < cardWidth * 0.045 || spacing > cardWidth * 0.12)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var meanCenterY = stars.Average(star => star.CenterY);
|
||||
return stars.All(star => Math.Abs(star.CenterY - meanCenterY) <= roiHeight * 0.18);
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
|
||||
{
|
||||
var widths = stars.Select(star => (double)star.Width).ToArray();
|
||||
var heights = stars.Select(star => (double)star.Height).ToArray();
|
||||
var densities = stars.Select(star => star.PixelCount / (double)Math.Max(1, star.Width * star.Height)).ToArray();
|
||||
var widthConsistency = Consistency(widths);
|
||||
var heightConsistency = Consistency(heights);
|
||||
var densityQuality = densities.Average(density => Clamp01(1 - Math.Abs(density - 0.45) / 0.45));
|
||||
var evidence = Clamp01(stars.Sum(star => star.PixelCount) / (stars.Count * cardWidth * 0.35));
|
||||
|
||||
var spacingConsistency = 0.82;
|
||||
if (stars.Count > 1)
|
||||
{
|
||||
var spacings = Enumerable.Range(1, stars.Count - 1)
|
||||
.Select(index => stars[index].CenterX - stars[index - 1].CenterX)
|
||||
.ToArray();
|
||||
spacingConsistency = Consistency(spacings);
|
||||
}
|
||||
|
||||
var meanCenterY = stars.Average(star => star.CenterY);
|
||||
var alignment = Clamp01(1 - stars.Average(star => Math.Abs(star.CenterY - meanCenterY)) / Math.Max(1, roiHeight * 0.18));
|
||||
var confidence = 0.20
|
||||
+ 0.20 * widthConsistency
|
||||
+ 0.15 * heightConsistency
|
||||
+ 0.20 * spacingConsistency
|
||||
+ 0.10 * densityQuality
|
||||
+ 0.10 * evidence
|
||||
+ 0.05 * alignment;
|
||||
|
||||
return Math.Round(Clamp01(confidence), 3, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static double Consistency(IReadOnlyList<double> values)
|
||||
{
|
||||
if (values.Count <= 1) return 0.88;
|
||||
var mean = values.Average();
|
||||
if (mean <= 0) return 0;
|
||||
var variance = values.Average(value => Math.Pow(value - mean, 2));
|
||||
return Clamp01(1 - Math.Sqrt(variance) / mean);
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) => Math.Clamp(value, 0, 1);
|
||||
|
||||
private sealed record StarSegment(int StartX, int EndX, int PixelCount, int PeakColumnPixels, int MinY, int MaxY)
|
||||
{
|
||||
public int Width => EndX - StartX + 1;
|
||||
public int Height => MaxY - MinY + 1;
|
||||
public double CenterX => (StartX + EndX) / 2.0;
|
||||
public double CenterY => (MinY + MaxY) / 2.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("InputHelper.Tests")]
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace GenshinAssistant.InputHelper;
|
||||
|
||||
internal enum NativeScannerInputGuardOutcome
|
||||
{
|
||||
Ready,
|
||||
Stopped,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
internal readonly record struct NativeScannerInputGuardDecision(
|
||||
NativeScannerInputGuardOutcome Outcome,
|
||||
string Message)
|
||||
{
|
||||
public bool CanContinue => Outcome == NativeScannerInputGuardOutcome.Ready;
|
||||
|
||||
public static NativeScannerInputGuardDecision Ready()
|
||||
=> new(NativeScannerInputGuardOutcome.Ready, "");
|
||||
|
||||
public static NativeScannerInputGuardDecision Blocked(string message)
|
||||
=> new(NativeScannerInputGuardOutcome.Blocked, message);
|
||||
}
|
||||
|
||||
internal static class NativeScannerInputSafetyPolicy
|
||||
{
|
||||
// The live inventory capacity is 2,400 Artifacts. A full visible page
|
||||
// contains 32 safe targets and the live-calibrated page transition uses 39
|
||||
// wheel events. 3,200 upward events therefore cover the complete inventory
|
||||
// with a deterministic safety margin while remaining explicitly bounded.
|
||||
internal const int MaximumArtifactInventoryCapacity = 2400;
|
||||
internal const int VisibleArtifactTargetsPerPage = 32;
|
||||
internal const int PageScrollWheelEvents = 39;
|
||||
internal const int InventoryTopResetWheelEvents = 3200;
|
||||
internal const int InventoryTopResetStabilizationMs = 300;
|
||||
internal const int PageScrollStabilizationMs = 120;
|
||||
internal const int WheelPacingBatchEvents = PageScrollWheelEvents;
|
||||
internal const int WheelPacingDelayMs = 1;
|
||||
|
||||
internal static bool ShouldPauseAfterWheelEvent(int completedEventCount)
|
||||
=> completedEventCount > 0 && completedEventCount % WheelPacingBatchEvents == 0;
|
||||
|
||||
internal static NativeScannerInputGuardDecision Evaluate(
|
||||
bool stopRequested,
|
||||
bool escapePressed,
|
||||
bool enterPressed,
|
||||
bool f9Pressed,
|
||||
long expectedGenshinHwnd,
|
||||
long foregroundHwnd,
|
||||
string foregroundProcess,
|
||||
string action)
|
||||
{
|
||||
if (stopRequested)
|
||||
{
|
||||
return new NativeScannerInputGuardDecision(
|
||||
NativeScannerInputGuardOutcome.Stopped,
|
||||
$"stop requested before {action}");
|
||||
}
|
||||
if (escapePressed)
|
||||
{
|
||||
return new NativeScannerInputGuardDecision(
|
||||
NativeScannerInputGuardOutcome.Stopped,
|
||||
$"ESC held - native scan stopped before {action}");
|
||||
}
|
||||
if (enterPressed)
|
||||
{
|
||||
return new NativeScannerInputGuardDecision(
|
||||
NativeScannerInputGuardOutcome.Stopped,
|
||||
$"Enter held - native scan stopped before {action}");
|
||||
}
|
||||
if (f9Pressed)
|
||||
{
|
||||
return new NativeScannerInputGuardDecision(
|
||||
NativeScannerInputGuardOutcome.Stopped,
|
||||
$"F9 held - native scan stopped before {action}");
|
||||
}
|
||||
|
||||
if (expectedGenshinHwnd == 0)
|
||||
{
|
||||
return NativeScannerInputGuardDecision.Blocked(
|
||||
$"Genshin target window is unavailable before {action}; native scan stopped without refocusing.");
|
||||
}
|
||||
if (foregroundHwnd != expectedGenshinHwnd)
|
||||
{
|
||||
var actual = string.IsNullOrWhiteSpace(foregroundProcess) ? "unknown" : foregroundProcess;
|
||||
return NativeScannerInputGuardDecision.Blocked(
|
||||
$"Genshin lost foreground before {action}; native scan stopped without refocusing (foreground: {actual}).");
|
||||
}
|
||||
|
||||
return NativeScannerInputGuardDecision.Ready();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Generated
+6704
-4894
File diff suppressed because it is too large
Load Diff
+64
-6
@@ -1,25 +1,62 @@
|
||||
{
|
||||
"name": "genshin-artifact-assistant",
|
||||
"version": "0.1.0",
|
||||
"author": "Bao",
|
||||
"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",
|
||||
"icon:build": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\generate-app-icon.ps1",
|
||||
"package:dir": "npm run icon:build && npm run build && npm run helper:build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --dir --win",
|
||||
"package:verify:static": "node scripts/verify-packaged-app.mjs --static",
|
||||
"package:verify": "node scripts/verify-packaged-app.mjs --input=outputs/dist/win-unpacked",
|
||||
"package:offline-check": "npm run package:dir && npm run package:verify",
|
||||
"package:live:inspect": "node scripts/run-packaged-live-acceptance.mjs --mode=inspect --view=inventory",
|
||||
"package:live:scan5": "node scripts/run-packaged-live-acceptance.mjs --mode=scan5 --view=inventory",
|
||||
"package:live:ui-scan5": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-scan5 --view=scan",
|
||||
"package:live:ui-row1": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-scan-row1 --view=scan",
|
||||
"package:live:ui-stream20": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-stream20 --view=scan",
|
||||
"package:live:ui-full-inventory": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-full-inventory --view=scan",
|
||||
"package:live:builds": "node scripts/run-packaged-builds-acceptance.mjs",
|
||||
"acceptance:offline": "node scripts/run-offline-acceptance.mjs",
|
||||
"dev-cleanup:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 -SelfTest",
|
||||
"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",
|
||||
"scan:native:replay": "vite-node scripts/replay-native-results.ts",
|
||||
"scan:native:analyze-review": "vite-node scripts/analyze-native-review.ts",
|
||||
"scan:native:validate:saved": "vite-node scripts/validate-saved-native-runs.ts",
|
||||
"helper:test": "dotnet run --project native/input-helper-tests/InputHelper.Tests.csproj -c Release",
|
||||
"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": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "33.2.1",
|
||||
"genshin-db": "^5.2.12",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -28,13 +65,17 @@
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "^4.2.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8",
|
||||
"vite-node": "^3.2.4",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
@@ -48,7 +89,24 @@
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "native/input-helper/bin/publish",
|
||||
"to": "input-helper",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "data/ik-inventorylists",
|
||||
"to": "ik-inventorylists",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.png",
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "requireAdministrator"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseArtifactCandidate } from "../src/lib/artifactOcrParser.js";
|
||||
import { artifactReviewReasons } from "../src/lib/scannerLearning.js";
|
||||
import type { CaptureResult } from "../src/types/global.js";
|
||||
|
||||
type ProcessingOcrEntry = NonNullable<CaptureResult["ocr"]>[number];
|
||||
|
||||
type ProcessingResult = {
|
||||
sequence: number;
|
||||
page?: number;
|
||||
capturedAt?: string;
|
||||
parsed?: boolean;
|
||||
needsReview?: boolean;
|
||||
artifactName?: string;
|
||||
slot?: string;
|
||||
setName?: string;
|
||||
ikMatch?: { matched?: boolean; notes?: string[] };
|
||||
notes?: string[];
|
||||
ocr?: ProcessingOcrEntry[];
|
||||
};
|
||||
|
||||
type ProcessingReport = {
|
||||
results?: ProcessingResult[];
|
||||
};
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"genshin-artifact-assistant",
|
||||
"native-scans",
|
||||
);
|
||||
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||
const sourcePath = path.join(runDir, "processing-report.json");
|
||||
const source = JSON.parse(await fs.readFile(sourcePath, "utf8")) as ProcessingReport;
|
||||
const sourceResults = Array.isArray(source.results) ? source.results : [];
|
||||
if (sourceResults.length === 0) throw new Error(`No processing results found in ${sourcePath}`);
|
||||
|
||||
const analyzed = sourceResults.map((sourceResult) => {
|
||||
const parsed = parseArtifactCandidate(captureFromProcessingResult(sourceResult));
|
||||
const reasons = artifactReviewReasons(parsed);
|
||||
if (sourceResult.ikMatch && !sourceResult.ikMatch.matched) reasons.push("ik_mismatch");
|
||||
const uniqueReasons = [...new Set(reasons)];
|
||||
const needsReview = uniqueReasons.length > 0;
|
||||
const oldNeedsReview = Boolean(sourceResult.needsReview);
|
||||
const repairedInitialMainValue = Boolean(
|
||||
parsed
|
||||
&& parsed.level === 0
|
||||
&& parsed.substats.length === 4
|
||||
&& parsed.fields.mainValue.source === "derived"
|
||||
&& parsed.fields.mainValue.confidence >= 90
|
||||
&& sourceResult.notes?.some((note) => /mainValue confidence is low/i.test(note)),
|
||||
);
|
||||
return {
|
||||
sequence: sourceResult.sequence,
|
||||
page: sourceResult.page ?? 0,
|
||||
oldNeedsReview,
|
||||
needsReview,
|
||||
reasons: uniqueReasons,
|
||||
level: parsed?.level ?? null,
|
||||
artifactName: parsed?.name ?? "Unknown artifact",
|
||||
slot: parsed?.slot ?? "Unknown slot",
|
||||
mainStat: parsed?.mainStat ?? "Unknown main stat",
|
||||
mainValue: parsed?.mainValue ?? "?",
|
||||
substatCount: parsed?.substats.length ?? 0,
|
||||
extractionConfidence: parsed?.confidence ?? 0,
|
||||
repairedInitialMainValue,
|
||||
};
|
||||
});
|
||||
|
||||
const review = analyzed.filter((entry) => entry.needsReview);
|
||||
const report = {
|
||||
version: "native-review-analysis-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
runId: path.basename(runDir),
|
||||
runDir,
|
||||
sourcePath,
|
||||
total: analyzed.length,
|
||||
parsed: analyzed.filter((entry) => entry.artifactName !== "Unknown artifact").length,
|
||||
oldReview: analyzed.filter((entry) => entry.oldNeedsReview).length,
|
||||
review: review.length,
|
||||
reviewRate: review.length / analyzed.length,
|
||||
reclassifiedToClean: analyzed.filter((entry) => entry.oldNeedsReview && !entry.needsReview).length,
|
||||
reclassifiedToReview: analyzed.filter((entry) => !entry.oldNeedsReview && entry.needsReview).length,
|
||||
repairedInitialMainValues: analyzed.filter((entry) => entry.repairedInitialMainValue).length,
|
||||
reasons: groupStrings(review.flatMap((entry) => entry.reasons)),
|
||||
levels: groupStrings(review.map((entry) => String(entry.level ?? "missing"))),
|
||||
pageBands: groupStrings(review.map((entry) => pageBand(entry.page))),
|
||||
remainingSamples: review.slice(0, 25),
|
||||
};
|
||||
|
||||
const outputDir = options.outputDir
|
||||
? path.resolve(options.outputDir)
|
||||
: path.resolve("outputs", "native-review-analysis", report.runId);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "native-review-analysis.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`Native review analysis: ${report.runId}`);
|
||||
console.log(`Results: ${report.total}; old review=${report.oldReview}; current review=${report.review} (${(report.reviewRate * 100).toFixed(2)}%)`);
|
||||
console.log(`Reclassified: clean=${report.reclassifiedToClean}; review=${report.reclassifiedToReview}; initial-main repairs=${report.repairedInitialMainValues}`);
|
||||
console.log(`Top reasons: ${report.reasons.slice(0, 8).map((entry) => `${entry.name}=${entry.count}`).join(", ") || "none"}`);
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (options.maxReviewRate !== null && report.reviewRate > options.maxReviewRate) {
|
||||
throw new Error(`Review rate ${(report.reviewRate * 100).toFixed(2)}% exceeds ${(options.maxReviewRate * 100).toFixed(2)}%.`);
|
||||
}
|
||||
|
||||
function captureFromProcessingResult(result: ProcessingResult): CaptureResult {
|
||||
return {
|
||||
id: `native-review-${result.sequence}`,
|
||||
name: result.artifactName || `native-review-${result.sequence}`,
|
||||
width: 492,
|
||||
height: 838,
|
||||
dataUrl: "",
|
||||
capturedAt: result.capturedAt || new Date(0).toISOString(),
|
||||
captureTarget: "genshin-client",
|
||||
ocr: Array.isArray(result.ocr) ? result.ocr : [],
|
||||
};
|
||||
}
|
||||
|
||||
function groupStrings(values: string[]) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function pageBand(page: number) {
|
||||
if (!Number.isFinite(page) || page < 1) return "missing";
|
||||
const start = Math.floor((page - 1) / 5) * 5 + 1;
|
||||
return `${String(start).padStart(2, "0")}-${String(start + 4).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const runDir = valueArg(args, "--run-dir=");
|
||||
const outputDir = valueArg(args, "--output-dir=");
|
||||
const rawMaximum = valueArg(args, "--max-review-rate=");
|
||||
const parsedMaximum = rawMaximum ? Number(rawMaximum) : Number.NaN;
|
||||
const maxReviewRate = Number.isFinite(parsedMaximum)
|
||||
? Math.max(0, Math.min(1, parsedMaximum))
|
||||
: null;
|
||||
return { runDir, outputDir, maxReviewRate };
|
||||
}
|
||||
|
||||
function valueArg(args: string[], prefix: string) {
|
||||
return args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? "";
|
||||
}
|
||||
|
||||
async function latestRunDir(root: string) {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||
for (const candidate of candidates) {
|
||||
const runDir = path.join(root, candidate);
|
||||
try {
|
||||
await fs.access(path.join(runDir, "processing-report.json"));
|
||||
return runDir;
|
||||
} catch {
|
||||
// Continue; this is a read-only saved-run analysis.
|
||||
}
|
||||
}
|
||||
throw new Error(`No processing-report.json found below ${root}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ProcessId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
[int]$ClickX = -1,
|
||||
[int]$ClickY = -1,
|
||||
[int]$WaitAfterClickMs = 700,
|
||||
[switch]$CloseAfterCapture
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
trap {
|
||||
$errorPath = [IO.Path]::GetFullPath("$OutputPath.error.txt")
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $errorPath) | Out-Null
|
||||
[string]$_.Exception.Message | Set-Content -LiteralPath $errorPath -Encoding UTF8
|
||||
exit 1
|
||||
}
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class WindowCaptureNative {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint flags);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint message, UIntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
'@
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$process = Get-Process -Id $ProcessId -ErrorAction Stop
|
||||
$handle = [IntPtr]$process.MainWindowHandle
|
||||
if ($handle -eq [IntPtr]::Zero) { throw "Process $ProcessId has no main window." }
|
||||
|
||||
[WindowCaptureNative]::ShowWindowAsync($handle, 9) | Out-Null
|
||||
[WindowCaptureNative]::SetForegroundWindow($handle) | Out-Null
|
||||
Start-Sleep -Milliseconds 700
|
||||
|
||||
$rect = New-Object WindowCaptureNative+RECT
|
||||
if (-not [WindowCaptureNative]::GetWindowRect($handle, [ref]$rect)) { throw "GetWindowRect failed." }
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -lt 1 -or $height -lt 1) { throw "Window dimensions are invalid: ${width}x${height}." }
|
||||
|
||||
$clicked = $false
|
||||
if ($ClickX -ge 0 -or $ClickY -ge 0) {
|
||||
if ($ClickX -lt 0 -or $ClickY -lt 0 -or $ClickX -ge $width -or $ClickY -ge $height) {
|
||||
throw "Click coordinates must stay inside the target window."
|
||||
}
|
||||
if ([WindowCaptureNative]::GetForegroundWindow() -ne $handle) {
|
||||
throw "Target process is not foreground; no click was sent."
|
||||
}
|
||||
[WindowCaptureNative]::SetCursorPos($rect.Left + $ClickX, $rect.Top + $ClickY) | Out-Null
|
||||
[WindowCaptureNative]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||
[WindowCaptureNative]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||
$clicked = $true
|
||||
Start-Sleep -Milliseconds ([Math]::Max(0, $WaitAfterClickMs))
|
||||
}
|
||||
|
||||
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
$outputDirectory = Split-Path -Parent $resolvedOutput
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
$bitmap = New-Object System.Drawing.Bitmap $width, $height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$printed = $false
|
||||
try {
|
||||
$hdc = $graphics.GetHdc()
|
||||
try {
|
||||
$printed = [WindowCaptureNative]::PrintWindow($handle, $hdc, 2)
|
||||
} finally {
|
||||
$graphics.ReleaseHdc($hdc)
|
||||
}
|
||||
if (-not $printed) {
|
||||
$graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size)
|
||||
}
|
||||
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally {
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
$closeRequested = $false
|
||||
if ($CloseAfterCapture) {
|
||||
$closeRequested = [WindowCaptureNative]::PostMessage($handle, 0x0010, [UIntPtr]::Zero, [IntPtr]::Zero)
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = $true
|
||||
processId = $ProcessId
|
||||
title = $process.MainWindowTitle
|
||||
width = $width
|
||||
height = $height
|
||||
printWindow = $printed
|
||||
clicked = $clicked
|
||||
closeRequested = $closeRequested
|
||||
outputPath = $resolvedOutput
|
||||
} | ConvertTo-Json
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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 safeCaptureReference(value) {
|
||||
const reference = typeof value === "string" ? value.trim() : "";
|
||||
if (!reference || reference.length > 160 || /[\r\n]/.test(reference)) return null;
|
||||
if (path.isAbsolute(reference) || /^[A-Za-z]:[\\/]/.test(reference) || reference.startsWith("\\\\")) return null;
|
||||
return reference;
|
||||
}
|
||||
|
||||
function safeCapturedAt(value) {
|
||||
const capturedAt = typeof value === "string" ? value.trim() : "";
|
||||
if (!capturedAt || capturedAt.length > 80 || /[\r\n]/.test(capturedAt)) return null;
|
||||
return capturedAt;
|
||||
}
|
||||
|
||||
function hasRetrievableLocalPng(value) {
|
||||
if (typeof value !== "string" || path.extname(value).toLowerCase() !== ".png") return false;
|
||||
try {
|
||||
return fs.statSync(value).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function visualEvidenceForCapture(capture) {
|
||||
const captureId = safeCaptureReference(capture?.id);
|
||||
const capturedAt = safeCapturedAt(capture?.capturedAt);
|
||||
const available = hasRetrievableLocalPng(capture?.name);
|
||||
return {
|
||||
captureId,
|
||||
capturedAt,
|
||||
visualEvidence: {
|
||||
status: available ? "available" : "unavailable",
|
||||
source: available ? "local-crop" : "none",
|
||||
reference: captureId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 visualEvidence = visualEvidenceForCapture(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}` : "",
|
||||
captureId: visualEvidence.captureId,
|
||||
capturedAt: visualEvidence.capturedAt,
|
||||
visualEvidence: visualEvidence.visualEvidence,
|
||||
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}.`,
|
||||
visualEvidence: visualEvidence.visualEvidence.status === "available"
|
||||
? "A local PNG was retrievable at export. Inspect it before confirming labels."
|
||||
: "No retrievable local PNG. Do not promote this case to the confirmed corpus.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function candidateComparator(left, right) {
|
||||
const visualEvidencePriority = Number(left.visualEvidence?.status !== "available")
|
||||
- Number(right.visualEvidence?.status !== "available");
|
||||
if (visualEvidencePriority) return visualEvidencePriority;
|
||||
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));
|
||||
}
|
||||
|
||||
async function readCandidates(inputPath, limit) {
|
||||
const candidatesByKey = new Map();
|
||||
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);
|
||||
const existing = candidatesByKey.get(key);
|
||||
if (!existing || candidateComparator(candidate, existing) < 0) {
|
||||
candidatesByKey.set(key, candidate);
|
||||
}
|
||||
}
|
||||
const candidates = [...candidatesByKey.values()];
|
||||
candidates.sort((left, right) => {
|
||||
return candidateComparator(left, right);
|
||||
});
|
||||
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;
|
||||
let visualEvidenceAvailable = 0;
|
||||
let visualEvidenceUnavailable = 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++;
|
||||
if (candidate.visualEvidence?.status === "available") visualEvidenceAvailable++;
|
||||
else visualEvidenceUnavailable++;
|
||||
const missingFields = candidate.missingFastFields || [];
|
||||
if (missingFields.length === 0) completeFastFields++;
|
||||
for (const field of missingFields) increment(missingFastFieldCounts, field);
|
||||
}
|
||||
return {
|
||||
completeFastFields,
|
||||
likelyStaleCaptures,
|
||||
equippedFooterCandidates,
|
||||
lockedTrueCandidates,
|
||||
lockedFalseCandidates,
|
||||
visualEvidenceAvailable,
|
||||
visualEvidenceUnavailable,
|
||||
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}`,
|
||||
`Visual evidence available: ${summary.exportStats.visualEvidenceAvailable}`,
|
||||
`Visual evidence unavailable: ${summary.exportStats.visualEvidenceUnavailable}`,
|
||||
"",
|
||||
"These cases are not ground truth yet. Only manually label candidates with `visualEvidence: available`; then 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(`- captureId: ${candidate.captureId || "unavailable"}`);
|
||||
lines.push(`- capturedAt: ${candidate.capturedAt || "unavailable"}`);
|
||||
lines.push(`- visualEvidence: ${candidate.visualEvidence?.status || "unavailable"} (${candidate.visualEvidence?.source || "none"})`);
|
||||
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,
|
||||
candidateComparator,
|
||||
candidatePriority,
|
||||
defaultReviewSamplesPath,
|
||||
hasRetrievableLocalPng,
|
||||
markdownFor,
|
||||
readCandidates,
|
||||
visualEvidenceForCapture,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
param(
|
||||
[string]$OutputPath = "build\icon.png"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $resolvedOutput) | Out-Null
|
||||
|
||||
$size = 512
|
||||
$bitmap = New-Object System.Drawing.Bitmap $size, $size
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
|
||||
$graphics.Clear([System.Drawing.Color]::Transparent)
|
||||
|
||||
function New-RoundedRectanglePath([System.Drawing.RectangleF]$rectangle, [float]$radius) {
|
||||
$diameter = $radius * 2
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc($rectangle.X, $rectangle.Y, $diameter, $diameter, 180, 90)
|
||||
$path.AddArc($rectangle.Right - $diameter, $rectangle.Y, $diameter, $diameter, 270, 90)
|
||||
$path.AddArc($rectangle.Right - $diameter, $rectangle.Bottom - $diameter, $diameter, $diameter, 0, 90)
|
||||
$path.AddArc($rectangle.X, $rectangle.Bottom - $diameter, $diameter, $diameter, 90, 90)
|
||||
$path.CloseFigure()
|
||||
return $path
|
||||
}
|
||||
|
||||
$bounds = New-Object System.Drawing.RectangleF 22, 22, 468, 468
|
||||
$shape = New-RoundedRectanglePath $bounds 104
|
||||
$background = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||
$bounds,
|
||||
[System.Drawing.Color]::FromArgb(255, 29, 17, 59),
|
||||
[System.Drawing.Color]::FromArgb(255, 33, 91, 125),
|
||||
135
|
||||
)
|
||||
$graphics.FillPath($background, $shape)
|
||||
|
||||
$glow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(42, 157, 112, 255))
|
||||
$graphics.FillEllipse($glow, 66, 44, 330, 330)
|
||||
$cyanGlow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(34, 82, 229, 242))
|
||||
$graphics.FillEllipse($cyanGlow, 206, 196, 262, 262)
|
||||
|
||||
$borderPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(120, 221, 205, 255)), 6
|
||||
$graphics.DrawPath($borderPen, $shape)
|
||||
|
||||
$orbitPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(235, 239, 232, 255)), 19
|
||||
$orbitPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$orbitPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$state = $graphics.Save()
|
||||
$graphics.TranslateTransform(256, 256)
|
||||
$graphics.RotateTransform(-38)
|
||||
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 18, 212)
|
||||
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 248, 92)
|
||||
$graphics.Restore($state)
|
||||
|
||||
$coreBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 151, 235, 242))
|
||||
$coreBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 250, 247, 255)), 12
|
||||
$graphics.FillEllipse($coreBrush, 202, 202, 108, 108)
|
||||
$graphics.DrawEllipse($coreBorder, 202, 202, 108, 108)
|
||||
|
||||
$satelliteBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 241, 211, 142))
|
||||
$satelliteBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 248, 228)), 8
|
||||
$graphics.FillEllipse($satelliteBrush, 342, 88, 66, 66)
|
||||
$graphics.DrawEllipse($satelliteBorder, 342, 88, 66, 66)
|
||||
$graphics.FillEllipse($satelliteBrush, 104, 350, 54, 54)
|
||||
$graphics.DrawEllipse($satelliteBorder, 104, 350, 54, 54)
|
||||
|
||||
$starBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(210, 255, 255, 255))
|
||||
$graphics.FillEllipse($starBrush, 124, 112, 13, 13)
|
||||
$graphics.FillEllipse($starBrush, 394, 282, 10, 10)
|
||||
$graphics.FillEllipse($starBrush, 286, 398, 8, 8)
|
||||
|
||||
try {
|
||||
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally {
|
||||
$starBrush.Dispose()
|
||||
$satelliteBorder.Dispose()
|
||||
$satelliteBrush.Dispose()
|
||||
$coreBorder.Dispose()
|
||||
$coreBrush.Dispose()
|
||||
$orbitPen.Dispose()
|
||||
$borderPen.Dispose()
|
||||
$cyanGlow.Dispose()
|
||||
$glow.Dispose()
|
||||
$background.Dispose()
|
||||
$shape.Dispose()
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = $true
|
||||
width = $size
|
||||
height = $size
|
||||
outputPath = $resolvedOutput
|
||||
} | ConvertTo-Json -Compress
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,28 +13,138 @@
|
||||
# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos
|
||||
# weg, egal wie sie beendet wurden.
|
||||
|
||||
param(
|
||||
[switch]$SelfTest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
||||
$devControlPort = 17317
|
||||
|
||||
function ConvertTo-ComparablePath([string]$Value) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return "" }
|
||||
try {
|
||||
return [System.IO.Path]::GetFullPath($Value).TrimEnd("\").ToLowerInvariant()
|
||||
} catch {
|
||||
return $Value.Trim().TrimEnd("\").ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CommandLineContainsPath([string]$CommandLine, [string]$CandidatePath) {
|
||||
if ([string]::IsNullOrWhiteSpace($CommandLine) -or [string]::IsNullOrWhiteSpace($CandidatePath)) { return $false }
|
||||
$normalizedCommand = $CommandLine.Replace("/", "\").ToLowerInvariant()
|
||||
$normalizedCandidate = (ConvertTo-ComparablePath $CandidatePath).Replace("/", "\")
|
||||
return $normalizedCommand.Contains($normalizedCandidate)
|
||||
}
|
||||
|
||||
function Test-GaaStaleProcess([object]$Process, [string]$ProjectRoot, [string]$ElectronExecutable) {
|
||||
$name = [string]$Process.Name
|
||||
$executable = ConvertTo-ComparablePath ([string]$Process.ExecutablePath)
|
||||
$commandLine = [string]$Process.CommandLine
|
||||
|
||||
if ($name -eq "electron.exe") {
|
||||
return $executable -eq (ConvertTo-ComparablePath $ElectronExecutable)
|
||||
}
|
||||
|
||||
if ($name -eq "InputHelper.exe") {
|
||||
$helperPaths = @(
|
||||
(Join-Path $ProjectRoot "native\input-helper\bin\publish\InputHelper.exe"),
|
||||
(Join-Path $ProjectRoot "outputs\dist\win-unpacked\resources\input-helper\InputHelper.exe")
|
||||
)
|
||||
return @($helperPaths | Where-Object { $executable -eq (ConvertTo-ComparablePath $_) }).Count -gt 0
|
||||
}
|
||||
|
||||
if ($name -eq "powershell.exe" -or $name -eq "pwsh.exe") {
|
||||
$legacyHelper = Join-Path $env:APPDATA "genshin-artifact-assistant\input-helper.ps1"
|
||||
return Test-CommandLineContainsPath $commandLine $legacyHelper
|
||||
}
|
||||
|
||||
if ($name -ne "node.exe") { return $false }
|
||||
|
||||
# Node itself is shared by Codex and many other tools. Only the known dev
|
||||
# entrypoints from this repository are safe to classify as stale GAA work.
|
||||
$knownDevEntrypoints = @(
|
||||
(Join-Path $ProjectRoot "node_modules\vite\bin\vite.js"),
|
||||
(Join-Path $ProjectRoot "node_modules\concurrently\dist\bin\concurrently.js"),
|
||||
(Join-Path $ProjectRoot "node_modules\wait-on\bin\wait-on"),
|
||||
(Join-Path $ProjectRoot "node_modules\cross-env\src\bin\cross-env.js")
|
||||
)
|
||||
return @($knownDevEntrypoints | Where-Object { Test-CommandLineContainsPath $commandLine $_ }).Count -gt 0
|
||||
}
|
||||
|
||||
if ($SelfTest) {
|
||||
$cases = @(
|
||||
@{ Name = "project electron"; Expected = $true; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = $electronPath; CommandLine = "`"$electronPath`" ." } },
|
||||
@{ Name = "other electron"; Expected = $false; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = "C:\Other\electron.exe"; CommandLine = "" } },
|
||||
@{ Name = "project vite"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\vite\bin\vite.js')`" --host 127.0.0.1" } },
|
||||
@{ Name = "project concurrently"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\concurrently\dist\bin\concurrently.js')`" -k" } },
|
||||
@{ Name = "Codex CUA working dir"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Users\vmbao\AppData\Local\OpenAI\Codex\runtimes\cua_node\bin\node.exe"; CommandLine = "node kernel.js --working-dir `"$project`"" } },
|
||||
@{ Name = "unrelated node in repo"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node custom-task.js --cwd `"$project`"" } },
|
||||
@{ Name = "legacy helper"; Expected = $true; Process = [pscustomobject]@{ Name = "powershell.exe"; ExecutablePath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; CommandLine = "powershell -File `"$(Join-Path $env:APPDATA 'genshin-artifact-assistant\input-helper.ps1')`"" } }
|
||||
)
|
||||
$failedCases = @()
|
||||
foreach ($case in $cases) {
|
||||
$actual = Test-GaaStaleProcess $case.Process $project $electronPath
|
||||
if ($actual -ne $case.Expected) { $failedCases += "$($case.Name): expected=$($case.Expected), actual=$actual" }
|
||||
}
|
||||
if ($failedCases.Count -gt 0) {
|
||||
$failedCases | ForEach-Object { Write-Error $_ }
|
||||
throw "$($failedCases.Count) stale-process classifier self-test(s) failed."
|
||||
}
|
||||
Write-Host "Stale-process classifier: $($cases.Count)/$($cases.Count) tests passed."
|
||||
return
|
||||
}
|
||||
|
||||
$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 {
|
||||
($_.Name -eq "electron.exe" -and $_.ExecutablePath -eq $electronPath) -or
|
||||
($_.Name -eq "node.exe" -and $_.CommandLine -like "*$project*") -or
|
||||
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
|
||||
} |
|
||||
Where-Object { Test-GaaStaleProcess $_ $project $electronPath } |
|
||||
ForEach-Object {
|
||||
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))"
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
$killed++
|
||||
$candidatePids[[int]$_.ProcessId] = $_.Name
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
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 {
|
||||
$requestError = $_
|
||||
$errorBody = [string]$requestError.ErrorDetails.Message
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
try {
|
||||
return $errorBody | ConvertFrom-Json
|
||||
} catch {
|
||||
# PowerShell 7 can put a generic message here while the JSON body remains on Response.Content.
|
||||
}
|
||||
}
|
||||
|
||||
$body = ""
|
||||
$response = $requestError.Exception.Response
|
||||
if ($response) {
|
||||
if ($response.PSObject.Methods.Name -contains "GetResponseStream") {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$body = $reader.ReadToEnd()
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
} elseif ($response.Content) {
|
||||
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $errorBody"
|
||||
}
|
||||
throw $requestError
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return [bool](
|
||||
$ProbePayload.ok `
|
||||
-and $ProbePayload.changed `
|
||||
-and $ProbePayload.click `
|
||||
-and $ProbePayload.click.ok `
|
||||
-and $ProbePayload.click.clicked `
|
||||
-and $ProbePayload.click.moved `
|
||||
-and -not $ProbePayload.click.inputBlocked
|
||||
)
|
||||
}
|
||||
|
||||
function Get-NativeScanner([object]$StatusPayload) {
|
||||
if ($StatusPayload.nativeScanner) { return $StatusPayload.nativeScanner }
|
||||
if ($StatusPayload.scanner) { return $StatusPayload.scanner }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Stop-NativeScannerAfterError([int]$WaitSeconds = 10) {
|
||||
$stop = Invoke-DevJson "/scanner/stop"
|
||||
Save-Json "99-native-stop-after-error" $stop | Out-Null
|
||||
$lastPayload = $stop
|
||||
$lastScanner = Get-NativeScanner $stop
|
||||
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||
|
||||
while (($null -eq $lastScanner -or [bool]$lastScanner.running) -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$lastPayload = Invoke-DevJson "/scanner/status"
|
||||
$lastScanner = Get-NativeScanner $lastPayload
|
||||
}
|
||||
|
||||
Save-Json "99-native-stop-final-status" $lastPayload | Out-Null
|
||||
$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running
|
||||
return [pscustomobject]@{
|
||||
requested = $true
|
||||
endpointOk = [bool]$stop.ok
|
||||
stopped = [bool]$stopped
|
||||
ok = [bool]($stop.ok -and $stopped)
|
||||
status = if ($lastScanner) { [string]$lastScanner.status } else { "missing" }
|
||||
runId = if ($lastScanner) { [string]$lastScanner.runId } else { "" }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
timing = $null
|
||||
contract = $null
|
||||
cleanupStop = $null
|
||||
errors = @()
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $false
|
||||
$scannerFinished = $false
|
||||
$startedRunId = ""
|
||||
|
||||
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.ok -or -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 -or -not $data.status -or -not [bool]$data.status.valid) {
|
||||
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 `
|
||||
-or -not $preflight.status `
|
||||
-or -not [bool]$preflight.status.ready `
|
||||
-or -not [bool]$preflight.status.categoryReady `
|
||||
-or [string]$preflight.status.category -ne $Category `
|
||||
-or -not [bool]$preflight.status.genshinFound `
|
||||
-or -not [bool]$preflight.status.isSixteenNine
|
||||
) {
|
||||
$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."
|
||||
}
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $true
|
||||
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
||||
Save-Json "05-native-start" $start | Out-Null
|
||||
$startedScanner = Get-NativeScanner $start
|
||||
if (-not $start.ok -or $null -eq $startedScanner) {
|
||||
throw "Native scanner start returned no scanner payload."
|
||||
}
|
||||
$startedRunId = [string]$startedScanner.runId
|
||||
if ([string]::IsNullOrWhiteSpace($startedRunId)) {
|
||||
throw "Native scanner start returned no runId."
|
||||
}
|
||||
if (-not [bool]$startedScanner.running) {
|
||||
$scannerFinished = $true
|
||||
throw "Native scanner did not enter running state: status='$($startedScanner.status)', message='$($startedScanner.message)'."
|
||||
}
|
||||
if ([int]$startedScanner.target -ne $safeLimit -or [string]$startedScanner.category -ne $Category) {
|
||||
throw "Native scanner start contract mismatch: target=$($startedScanner.target), category='$($startedScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
|
||||
$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 = @{
|
||||
runId = [string]$finalScanner.runId
|
||||
category = [string]$finalScanner.category
|
||||
target = [int]$finalScanner.target
|
||||
status = [string]$finalScanner.status
|
||||
running = [bool]$finalScanner.running
|
||||
runDir = [string]$finalScanner.runDir
|
||||
captured = [int]$finalScanner.captured
|
||||
queued = [int]$finalScanner.queued
|
||||
clicked = [int]$finalScanner.clicked
|
||||
pages = [int]$finalScanner.pages
|
||||
initialTopResetMs = [int]$finalScanner.initialTopResetMs
|
||||
initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted
|
||||
activeMs = [int]$finalScanner.activeMs
|
||||
totalMs = [int]$finalScanner.totalMs
|
||||
message = [string]$finalScanner.message
|
||||
}
|
||||
|
||||
if ($finalScanner.running) {
|
||||
throw "Native scanner did not finish before timeout."
|
||||
}
|
||||
if ([string]$finalScanner.runId -ne $startedRunId) {
|
||||
throw "Native scanner final status belongs to run '$($finalScanner.runId)', expected '$startedRunId'."
|
||||
}
|
||||
$scannerFinished = $true
|
||||
if ([string]$finalScanner.status -ne "done") {
|
||||
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
||||
}
|
||||
if ([int]$finalScanner.target -ne $safeLimit -or [string]$finalScanner.category -ne $Category) {
|
||||
throw "Native scanner final contract mismatch: target=$($finalScanner.target), category='$($finalScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
if ([int]$finalScanner.captured -ne $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.queued -ne $safeLimit) {
|
||||
throw "Native scanner queued $($finalScanner.queued), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.clicked -ne $safeLimit) {
|
||||
throw "Native scanner clicked $($finalScanner.clicked), expected exactly $safeLimit."
|
||||
}
|
||||
if (-not [bool]$finalScanner.initialTopResetCompleted) {
|
||||
throw "Native scanner did not confirm the bounded initial inventory top reset."
|
||||
}
|
||||
if ([int]$finalScanner.initialTopResetMs -le 0) {
|
||||
throw "Native scanner reported invalid initialTopResetMs=$($finalScanner.initialTopResetMs)."
|
||||
}
|
||||
if ([int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs -or [int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs) {
|
||||
throw "Native scanner timing contract is invalid: reset=$($finalScanner.initialTopResetMs), active=$($finalScanner.activeMs), total=$($finalScanner.totalMs)."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$finalScanner.runDir) -or -not (Test-Path -LiteralPath ([string]$finalScanner.runDir) -PathType Container)) {
|
||||
throw "Native scanner returned an invalid run directory '$($finalScanner.runDir)'."
|
||||
}
|
||||
$resolvedNativeRunDir = (Resolve-Path -LiteralPath ([string]$finalScanner.runDir)).Path
|
||||
|
||||
$runDirParam = UriEscape($resolvedNativeRunDir)
|
||||
$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."
|
||||
}
|
||||
if ([int]$process.status.processed -ne $safeLimit) {
|
||||
throw "Native processing handled $($process.status.processed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.parsed -ne $safeLimit) {
|
||||
throw "Native processing parsed $($process.status.parsed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.errors -ne 0) {
|
||||
throw "Native processing reported $($process.status.errors) errors."
|
||||
}
|
||||
$processResults = @($process.status.results)
|
||||
$actualParsed = @($processResults | Where-Object { [bool]$_.parsed }).Count
|
||||
$actualReview = @($processResults | Where-Object { [bool]$_.needsReview }).Count
|
||||
$actualErrors = @($processResults | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.error) }).Count
|
||||
if ($processResults.Count -ne $safeLimit) {
|
||||
throw "Native processing returned $($processResults.Count) result rows, expected exactly $safeLimit."
|
||||
}
|
||||
if ($actualParsed -ne [int]$process.status.parsed -or $actualReview -ne [int]$process.status.review -or $actualErrors -ne [int]$process.status.errors) {
|
||||
throw "Native processing summary does not match result rows (parsed=$($process.status.parsed)/$actualParsed, review=$($process.status.review)/$actualReview, errors=$($process.status.errors)/$actualErrors)."
|
||||
}
|
||||
if ([int]$process.status.review -lt 0 -or [int]$process.status.review -gt $safeLimit) {
|
||||
throw "Native processing returned invalid review count $($process.status.review) for limit $safeLimit."
|
||||
}
|
||||
$reviewRate = if ($safeLimit -gt 0) { [double]$process.status.review / $safeLimit } else { 0 }
|
||||
if ($reviewRate -gt 0.15) {
|
||||
throw "Native processing review rate $([Math]::Round($reviewRate * 100, 1))% exceeds 15%."
|
||||
}
|
||||
$persistedProcessRows = @($processResults | Where-Object { [bool]$_.persisted })
|
||||
if (-not $Persist -and ([bool]$process.status.persisted -or [int]$process.status.stored -ne 0 -or $persistedProcessRows.Count -ne 0)) {
|
||||
throw "Dry native smoke wrote to the store (stored=$($process.status.stored), persisted=$($process.status.persisted), persistedRows=$($persistedProcessRows.Count))."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
$loadedResults = @($results.status.results)
|
||||
if ([int]$results.status.total -ne $safeLimit -or $loadedResults.Count -ne $safeLimit) {
|
||||
throw "Native result contract mismatch: total=$($results.status.total), loaded=$($loadedResults.Count), expected=$safeLimit."
|
||||
}
|
||||
$timing = $results.status.timing
|
||||
$summary.timing = @{
|
||||
requestStartedAt = if ($timing) { [string]$timing.requestStartedAt } else { "" }
|
||||
captureCompletedAt = if ($timing) { [string]$timing.captureCompletedAt } else { "" }
|
||||
processingCompletedAt = if ($timing) { [string]$timing.processingCompletedAt } else { "" }
|
||||
resultsDurableAt = if ($timing) { [string]$timing.resultsDurableAt } else { "" }
|
||||
resultsReconciledAt = if ($timing) { [string]$timing.resultsReconciledAt } else { "" }
|
||||
requestToResultsDurableMs = if ($timing) { [int64]$timing.requestToResultsDurableMs } else { -1 }
|
||||
requestToResultsReconciledMs = if ($timing) { [int64]$timing.requestToResultsReconciledMs } else { -1 }
|
||||
}
|
||||
$requiredTimingFields = @(
|
||||
"requestStartedAt",
|
||||
"captureCompletedAt",
|
||||
"processingCompletedAt",
|
||||
"resultsDurableAt",
|
||||
"resultsReconciledAt"
|
||||
)
|
||||
$missingTimingFields = @($requiredTimingFields | Where-Object { [string]::IsNullOrWhiteSpace([string]$timing.$_) })
|
||||
if ($null -eq $timing -or $missingTimingFields.Count -ne 0) {
|
||||
throw "Native run timing contract is incomplete: missing=$($missingTimingFields -join ', ')."
|
||||
}
|
||||
if ([int64]$timing.requestToResultsDurableMs -lt 0 -or [int64]$timing.requestToResultsReconciledMs -lt [int64]$timing.requestToResultsDurableMs) {
|
||||
throw "Native run timing contract has invalid derived durations: durable=$($timing.requestToResultsDurableMs), reconciled=$($timing.requestToResultsReconciledMs)."
|
||||
}
|
||||
$persistedLoadedRows = @($loadedResults | Where-Object {
|
||||
[bool]$_.persistedArtifact -or -not [string]::IsNullOrWhiteSpace([string]$_.artifactRecordId)
|
||||
})
|
||||
if (-not $Persist -and $persistedLoadedRows.Count -ne 0) {
|
||||
throw "Dry native smoke loaded $($persistedLoadedRows.Count) result rows marked as persisted."
|
||||
}
|
||||
|
||||
$jobsPath = Join-Path $resolvedNativeRunDir "capture-jobs.jsonl"
|
||||
$pngFiles = @(Get-ChildItem -LiteralPath $resolvedNativeRunDir -Filter "artifact-*.png" -File)
|
||||
$jobs = @(
|
||||
Get-Content -LiteralPath $jobsPath |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object { $_ | ConvertFrom-Json }
|
||||
)
|
||||
$expectedPngNames = @(1..$safeLimit | ForEach-Object { "artifact-{0:D4}.png" -f $_ })
|
||||
$actualPngNames = @($pngFiles.Name | Sort-Object)
|
||||
$pngNameMismatches = @(Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames)
|
||||
$badSequenceJobs = @($jobs | Where-Object { [int]$_.sequence -lt 1 -or [int]$_.sequence -gt $safeLimit })
|
||||
$duplicateSequenceJobs = @($jobs | Group-Object sequence | Where-Object { $_.Count -ne 1 })
|
||||
$badClickJobs = @($jobs | Where-Object { [int]$_.clickEventsSent -ne 2 })
|
||||
$badPathJobs = @($jobs | Where-Object {
|
||||
$expectedName = "artifact-{0:D4}.png" -f [int]$_.sequence
|
||||
$expectedAbsolutePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedNativeRunDir $expectedName))
|
||||
[System.IO.Path]::GetFileName([string]$_.relativePath) -ne $expectedName `
|
||||
-or [System.IO.Path]::GetFullPath([string]$_.absolutePath) -ne $expectedAbsolutePath
|
||||
})
|
||||
$emptyPngFiles = @($pngFiles | Where-Object { $_.Length -le 0 })
|
||||
$summary.contract = @{
|
||||
jobs = $jobs.Count
|
||||
pngs = $pngFiles.Count
|
||||
pngNameMismatches = $pngNameMismatches.Count
|
||||
badSequenceJobs = $badSequenceJobs.Count
|
||||
duplicateSequenceJobs = $duplicateSequenceJobs.Count
|
||||
badClickJobs = $badClickJobs.Count
|
||||
badPathJobs = $badPathJobs.Count
|
||||
emptyPngs = $emptyPngFiles.Count
|
||||
persistedProcessRows = $persistedProcessRows.Count
|
||||
persistedLoadedRows = $persistedLoadedRows.Count
|
||||
reviewRatePct = [Math]::Round($reviewRate * 100, 1)
|
||||
}
|
||||
if ($jobs.Count -ne $safeLimit -or $pngFiles.Count -ne $safeLimit) {
|
||||
throw "Native file contract mismatch: jobs=$($jobs.Count), pngs=$($pngFiles.Count), expected=$safeLimit."
|
||||
}
|
||||
if ($pngNameMismatches.Count -ne 0 -or $emptyPngFiles.Count -ne 0) {
|
||||
throw "Native PNG contract mismatch: names=$($pngNameMismatches.Count), empty=$($emptyPngFiles.Count)."
|
||||
}
|
||||
if ($badSequenceJobs.Count -ne 0 -or $duplicateSequenceJobs.Count -ne 0 -or $badPathJobs.Count -ne 0) {
|
||||
throw "Native job contract mismatch: invalidSequences=$($badSequenceJobs.Count), duplicateSequences=$($duplicateSequenceJobs.Count), invalidPaths=$($badPathJobs.Count)."
|
||||
}
|
||||
if ($badClickJobs.Count -ne 0) {
|
||||
throw "Native run contains $($badClickJobs.Count) jobs without exactly two click events."
|
||||
}
|
||||
|
||||
$summary.ok = $true
|
||||
} catch {
|
||||
$summary.errors += [string]$_.Exception.Message
|
||||
if ($scannerStartAttempted -and -not $scannerFinished) {
|
||||
try {
|
||||
$summary.cleanupStop = Stop-NativeScannerAfterError
|
||||
if (-not $summary.cleanupStop.ok) {
|
||||
$summary.errors += "Native scanner cleanup stop did not reach a confirmed stopped state."
|
||||
}
|
||||
} catch {
|
||||
$summary.cleanupStop = @{
|
||||
requested = $true
|
||||
ok = $false
|
||||
error = [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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 safeProvenanceValue(value, fallback = "unknown") {
|
||||
const text = typeof value === "string" ? value.trim() : "";
|
||||
if (!text || text.length > 200 || /[\r\n]/.test(text)) return fallback;
|
||||
if (path.isAbsolute(text) || /[A-Za-z]:[\\/]/.test(text) || text.startsWith("\\\\")) return fallback;
|
||||
return text;
|
||||
}
|
||||
|
||||
function visualEvidenceNote(candidate) {
|
||||
const status = candidate?.visualEvidence?.status === "available" ? "available" : "unavailable";
|
||||
const parts = [`visualEvidence=${status}`];
|
||||
const captureId = safeProvenanceValue(candidate?.captureId, "");
|
||||
const capturedAt = safeProvenanceValue(candidate?.capturedAt, "");
|
||||
if (captureId) parts.push(`captureId=${captureId}`);
|
||||
if (capturedAt) parts.push(`capturedAt=${capturedAt}`);
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
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 reason = safeProvenanceValue(candidate.reason, "review-sample");
|
||||
const savedAt = safeProvenanceValue(candidate.savedAt, "unknown");
|
||||
const resolution = safeProvenanceValue(candidate.resolution, "");
|
||||
const sourceCandidate = stableId(candidate.id);
|
||||
const entry = {
|
||||
id,
|
||||
confirmed: true,
|
||||
ocr: candidate.ocr,
|
||||
expect,
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: resolution || undefined,
|
||||
note: `${reason} | savedAt=${savedAt} | sourceCandidate=${sourceCandidate} | ${visualEvidenceNote(candidate)}`,
|
||||
},
|
||||
};
|
||||
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);
|
||||
if (candidate?.visualEvidence?.status !== "available") {
|
||||
throw new Error("Candidate has no retrievable visual evidence. Re-export after retaining a local PNG before preparing a confirmed corpus case.");
|
||||
}
|
||||
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,
|
||||
visualEvidenceNote,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { loadNativeScannerDeletedResultIds } from "../electron/services/nativeScannerResultTombstones.js";
|
||||
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||
import type { StoredScanResultEntry } from "../src/types/storage.js";
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "genshin-artifact-assistant", "native-scans");
|
||||
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||
const sourcePath = path.join(runDir, "scan-results.json");
|
||||
const source = await fs.readFile(sourcePath, "utf8");
|
||||
const parsed = JSON.parse(source);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error(`No scan results found in ${sourcePath}`);
|
||||
const rawEntries = parsed.filter(isStoredScanResultEntry);
|
||||
if (rawEntries.length !== parsed.length) throw new Error(`Invalid scan-result entries: ${parsed.length - rawEntries.length}`);
|
||||
const deletedResultIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||
const entries = rawEntries.filter((entry) => !deletedResultIds.has(entry.id));
|
||||
if (entries.length === 0) throw new Error(`No visible scan results found in ${sourcePath}`);
|
||||
|
||||
const report = replayNativeScanResults({
|
||||
entries,
|
||||
repeats: options.repeats,
|
||||
runId: path.basename(runDir),
|
||||
sourcePath,
|
||||
sourceSha256: createHash("sha256").update(source).digest("hex"),
|
||||
});
|
||||
const outputDir = path.resolve("outputs", "native-replay", report.runId);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "native-replay-report.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`Native offline replay: ${report.runId}`);
|
||||
if (deletedResultIds.size > 0) console.log(`Locally removed results excluded: ${deletedResultIds.size}`);
|
||||
console.log(
|
||||
`Results: ${report.total}; evaluated=${report.values.evaluated}; excluded=${report.values.excluded}; `
|
||||
+ `review=${report.values.review}; unknown=${report.values.unknown}`,
|
||||
);
|
||||
console.log(`Projection: available=${report.projections.available}; complete=${report.projections.complete}; unavailable=${report.projections.unavailable}`);
|
||||
console.log(`Score: min=${report.score.min}; avg=${report.score.average}; max=${report.score.max}`);
|
||||
console.log(`Deterministic: ${report.deterministic} across ${report.repeats} repeats`);
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (!report.deterministic) throw new Error("Offline replay produced different evaluation hashes.");
|
||||
if (report.cleanUnevaluated.length > 0) {
|
||||
console.error(`Clean but unevaluated results: ${report.cleanUnevaluated.map((entry) => `#${entry.sequence} ${entry.summary}`).join(" | ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const runDir = args.find((argument) => argument.startsWith("--run-dir="))?.slice("--run-dir=".length) ?? "";
|
||||
const repeatValue = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 3);
|
||||
return { runDir, repeats: Number.isFinite(repeatValue) ? repeatValue : 3 };
|
||||
}
|
||||
|
||||
async function latestRunDir(root: string) {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||
for (const candidate of candidates) {
|
||||
const runDir = path.join(root, candidate);
|
||||
try {
|
||||
await fs.access(path.join(runDir, "scan-results.json"));
|
||||
return runDir;
|
||||
} catch {
|
||||
// Continue to the next saved run; no live capture is started.
|
||||
}
|
||||
}
|
||||
throw new Error(`No saved native scan-results.json found below ${root}`);
|
||||
}
|
||||
|
||||
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.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const steps = [
|
||||
{ id: "dev-cleanup-safety", command: npmCommand, args: ["run", "dev-cleanup:test"] },
|
||||
{ id: "native-helper-safety", command: npmCommand, args: ["run", "helper:test"] },
|
||||
{ id: "lint", command: npmCommand, args: ["run", "lint"] },
|
||||
{ id: "tests", command: npmCommand, args: ["test"] },
|
||||
{ id: "ocr-eval", command: npmCommand, args: ["run", "eval"] },
|
||||
{ id: "assessment-self-test", command: npmCommand, args: ["run", "scan:assessment:test"] },
|
||||
{ id: "saved-native-validation", command: npmCommand, args: ["run", "scan:native:validate:saved"] },
|
||||
{ id: "package-offline-check", command: npmCommand, args: ["run", "package:offline-check"] },
|
||||
{ id: "dependency-audit", command: npmCommand, args: ["audit"] },
|
||||
{ id: "production-dependency-audit", command: npmCommand, args: ["audit", "--omit=dev"] },
|
||||
{ id: "git-diff-check", command: "git", args: ["diff", "--check"] },
|
||||
];
|
||||
|
||||
const startedAt = new Date();
|
||||
const results = [];
|
||||
for (const step of steps) {
|
||||
const stepStartedAt = Date.now();
|
||||
console.log(`\n=== Offline acceptance: ${step.id} ===`);
|
||||
const invocation = commandInvocation(step);
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
results.push({
|
||||
id: step.id,
|
||||
ok: result.status === 0 && !result.error,
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error?.message ?? null,
|
||||
elapsedMs: Date.now() - stepStartedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const outputDir = path.join(projectRoot, "outputs", "offline-acceptance");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "offline-acceptance-report.json");
|
||||
const report = {
|
||||
version: "offline-acceptance-v2",
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
ok: results.every((result) => result.ok),
|
||||
projectRoot,
|
||||
results,
|
||||
};
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`\nOffline acceptance: ${report.ok ? "PASS" : "FAIL"}`);
|
||||
for (const result of results) {
|
||||
console.log(`${result.ok ? "PASS" : "FAIL"} ${result.id} (${result.elapsedMs} ms)`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
|
||||
function commandInvocation(step) {
|
||||
if (process.platform !== "win32" || !step.command.endsWith(".cmd")) return step;
|
||||
const tokens = [step.command, ...step.args];
|
||||
if (tokens.some((token) => !/^[A-Za-z0-9:._=@/-]+$/.test(token))) {
|
||||
throw new Error(`Unsafe Windows command token in offline acceptance step ${step.id}.`);
|
||||
}
|
||||
return {
|
||||
command: process.env.ComSpec || "cmd.exe",
|
||||
args: ["/d", "/s", "/c", tokens.join(" ")],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const args = new Map(process.argv.slice(2).map((entry) => {
|
||||
const [key, ...value] = entry.replace(/^--/, "").split("=");
|
||||
return [key, value.join("=") || "true"];
|
||||
}));
|
||||
|
||||
const port = Number(args.get("port") ?? 9223);
|
||||
const outputPath = path.resolve(args.get("output") ?? "outputs/packaged-live/builds-functional-acceptance.json");
|
||||
const screenshotPath = path.resolve(args.get("screenshot") ?? "outputs/packaged-live/builds-functional-acceptance.png");
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("--port must be an integer between 1 and 65535.");
|
||||
}
|
||||
|
||||
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json());
|
||||
const target = targets.find((candidate) => (
|
||||
candidate.type === "page"
|
||||
&& String(candidate.title).includes("Genshin Artifact Assistant")
|
||||
&& !String(candidate.url).includes("overlay=1")
|
||||
));
|
||||
if (!target?.webSocketDebuggerUrl || !String(target.url).startsWith("file:")) {
|
||||
throw new Error("No packaged Genshin Artifact Assistant renderer with CDP was found.");
|
||||
}
|
||||
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Timed out connecting to packaged Electron CDP.")), 10_000);
|
||||
socket.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Packaged Electron CDP connection failed."));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
const diagnostics = { exceptions: [], consoleErrors: [], logErrors: [] };
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Runtime.exceptionThrown") {
|
||||
diagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
|
||||
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
|
||||
diagnostics.consoleErrors.push((message.params.args ?? []).map((entry) => entry.value ?? entry.description ?? entry.type).join(" "));
|
||||
} else if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") {
|
||||
diagnostics.logErrors.push(message.params.entry.text ?? "Unknown renderer log error");
|
||||
}
|
||||
if (!message.id || !pending.has(message.id)) return;
|
||||
const request = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
clearTimeout(request.timer);
|
||||
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
|
||||
function call(method, params = {}, timeoutMs = 30_000) {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP ${method} timed out after ${timeoutMs} ms.`));
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(expression, timeoutMs = 30_000) {
|
||||
const response = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, timeoutMs);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Renderer evaluation failed.");
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
await call("Page.bringToFront", {}, 10_000);
|
||||
await call("Runtime.enable", {}, 10_000);
|
||||
await call("Log.enable", {}, 10_000);
|
||||
await call("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1304,
|
||||
height: 821,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, 10_000);
|
||||
await call("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
|
||||
}, 10_000);
|
||||
await evaluate("localStorage.removeItem('gaa-ui-locale'); 'locale-cleared'", 10_000);
|
||||
await call("Page.reload", { ignoreCache: true }, 10_000);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const acceptance = await evaluate(`
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const waitFor = async (selector, timeout = 10_000) => {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) return element;
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error('Timed out waiting for ' + selector);
|
||||
};
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error('Packaged preload bridge is unavailable.');
|
||||
const initialLocale = document.documentElement.lang;
|
||||
if (initialLocale !== 'en' || document.documentElement.dataset.appLocale !== 'en') {
|
||||
throw new Error('Fresh packaged UI did not default to English.');
|
||||
}
|
||||
|
||||
const settingsTrigger = await waitFor('[data-app-settings-trigger]');
|
||||
settingsTrigger.click();
|
||||
const languageSelect = await waitFor('[data-app-language-select]');
|
||||
languageSelect.value = 'de';
|
||||
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(100);
|
||||
const germanLocaleApplied = document.documentElement.lang === 'de'
|
||||
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artefakte';
|
||||
languageSelect.value = 'en';
|
||||
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(100);
|
||||
const englishLocaleRestored = document.documentElement.lang === 'en'
|
||||
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artifacts';
|
||||
document.querySelector('[data-app-settings-backdrop]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
const buildsNavigation = await waitFor('[data-navigation-id="builds"]');
|
||||
buildsNavigation.focus();
|
||||
const navigationFocusBeforeOpen = document.activeElement === buildsNavigation;
|
||||
buildsNavigation.click();
|
||||
const view = await waitFor('.build-preview-page');
|
||||
await sleep(250);
|
||||
const headingFocusAfterOpen = document.activeElement?.id === 'app-view-heading';
|
||||
const results = await api.nativeScannerLoadResults({ limit: 2400 });
|
||||
const contextInput = document.querySelector('.build-context-number input');
|
||||
const contextCheckbox = document.querySelector('.build-context-confirm input[type="checkbox"]');
|
||||
let contextInteraction = { available: false, accepted: false };
|
||||
if (contextInput instanceof HTMLInputElement && contextCheckbox instanceof HTMLInputElement) {
|
||||
// React tracks controlled input values on the instance. Assigning the
|
||||
// property directly makes the browser value change, but React can
|
||||
// restore the old draft on the next checkbox render. Use the native
|
||||
// prototype setter, then dispatch a real input event so this CDP probe
|
||||
// exercises the same state transition as a typed value.
|
||||
const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
nativeValueSetter?.call(contextInput, '106.6');
|
||||
contextInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(0);
|
||||
contextCheckbox.click();
|
||||
await sleep(100);
|
||||
contextInteraction = { available: true, accepted: contextInput.value === '106.6' && contextCheckbox.checked };
|
||||
}
|
||||
const viewStyle = getComputedStyle(view);
|
||||
const reducedMotionRespected = viewStyle.animationDuration === '0s'
|
||||
|| viewStyle.animationDuration === '0ms'
|
||||
|| matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const noDocumentOverflow = document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight <= document.documentElement.clientHeight;
|
||||
return {
|
||||
ok: Boolean(
|
||||
results.ok
|
||||
&& germanLocaleApplied
|
||||
&& englishLocaleRestored
|
||||
&& navigationFocusBeforeOpen
|
||||
&& headingFocusAfterOpen
|
||||
&& contextInteraction.accepted
|
||||
&& reducedMotionRespected
|
||||
&& noDocumentOverflow
|
||||
),
|
||||
locale: { initial: initialLocale, germanLocaleApplied, englishLocaleRestored },
|
||||
focus: { navigationFocusBeforeOpen, headingFocusAfterOpen },
|
||||
contextInteraction,
|
||||
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length, error: results.error ?? '' },
|
||||
rendered: {
|
||||
bodyText: view.textContent?.slice(0, 6_000) ?? '',
|
||||
hasSuggestions: Boolean(document.querySelector('.build-suggestion-card')),
|
||||
deferredProfiles: document.querySelectorAll('.build-deferred-list li').length,
|
||||
overflow: { width: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, height: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight },
|
||||
reducedMotionRespected,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`);
|
||||
|
||||
const screenshot = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }, 30_000);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
||||
const report = {
|
||||
version: "packaged-builds-functional-acceptance-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
target: { title: target.title, url: target.url },
|
||||
acceptance,
|
||||
diagnostics,
|
||||
screenshotPath,
|
||||
ok: Boolean(acceptance?.ok) && diagnostics.exceptions.length === 0 && diagnostics.consoleErrors.length === 0 && diagnostics.logErrors.length === 0,
|
||||
};
|
||||
await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
socket.close();
|
||||
|
||||
console.log(`Packaged Builds acceptance: ${report.ok ? "PASS" : "FAIL"}`);
|
||||
console.log(`Report: ${outputPath}`);
|
||||
console.log(`Screenshot: ${screenshotPath}`);
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
@@ -0,0 +1,941 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const args = new Map(process.argv.slice(2).map((entry) => {
|
||||
const [key, ...value] = entry.replace(/^--/, "").split("=");
|
||||
return [key, value.join("=") || "true"];
|
||||
}));
|
||||
const port = Number(args.get("port") ?? 9223);
|
||||
const mode = args.get("mode") ?? "inspect";
|
||||
const view = args.get("view") ?? "scan";
|
||||
const state = args.get("state") ?? "default";
|
||||
const closeAfter = args.get("close") === "true";
|
||||
const consoleCheck = args.get("console-check") === "true";
|
||||
const viewportWidth = args.has("viewport-width") ? Number(args.get("viewport-width")) : null;
|
||||
const viewportHeight = args.has("viewport-height") ? Number(args.get("viewport-height")) : null;
|
||||
const reprocessRunDir = args.has("run-dir") ? path.resolve(args.get("run-dir")) : "";
|
||||
const reprocessTarget = args.has("expected-target") ? Number(args.get("expected-target")) : null;
|
||||
const reprocessMaxReviewRate = Number(args.get("max-review-rate") ?? 0.15);
|
||||
const viewIds = new Set(["scan", "inventory", "triage", "builds", "overlay", "diagnose"]);
|
||||
const output = path.resolve(args.get("output") ?? `outputs/packaged-live/packaged-${mode}-${view}.json`);
|
||||
const screenshotPath = path.resolve(args.get("screenshot") ?? `outputs/packaged-live/packaged-${mode}-${view}.png`);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid CDP port: ${port}`);
|
||||
if ((viewportWidth === null) !== (viewportHeight === null)) throw new Error("Both --viewport-width and --viewport-height are required together.");
|
||||
if (viewportWidth !== null && (!Number.isInteger(viewportWidth) || !Number.isInteger(viewportHeight) || viewportWidth < 480 || viewportHeight < 320)) {
|
||||
throw new Error("Viewport dimensions must be integer CSS pixels of at least 480x320.");
|
||||
}
|
||||
if (!new Set(["inspect", "scan5", "ui-scan5", "ui-scan-row1", "ui-stream20", "ui-full-inventory", "reprocess-run"]).has(mode)) throw new Error(`Unsupported mode: ${mode}`);
|
||||
if (!viewIds.has(view)) throw new Error(`Unsupported view: ${view}`);
|
||||
if (mode === "reprocess-run" && (!reprocessRunDir || !Number.isInteger(reprocessTarget) || reprocessTarget < 1 || reprocessTarget > 2400)) {
|
||||
throw new Error("reprocess-run requires --run-dir and --expected-target in 1..2400.");
|
||||
}
|
||||
if (mode === "reprocess-run" && (!Number.isFinite(reprocessMaxReviewRate) || reprocessMaxReviewRate < 0 || reprocessMaxReviewRate > 1)) {
|
||||
throw new Error("--max-review-rate must be in 0..1.");
|
||||
}
|
||||
if (!new Set(["default", "scan-settings", "artifact-open", "artifact-probe", "post-scan-results", "review-deeplink", "keyboard-focus", "overlay-open", "inventory-confirm", "inventory-delete-confirm", "inventory-store-delete-confirm", "inventory-roving", "inventory-technical"]).has(state)) throw new Error(`Unsupported state: ${state}`);
|
||||
if (state === "scan-settings" && view !== "scan") throw new Error("scan-settings state requires --view=scan.");
|
||||
if (state === "artifact-open" && view !== "scan") throw new Error("artifact-open state requires --view=scan.");
|
||||
if (state === "artifact-probe" && view !== "scan") throw new Error("artifact-probe state requires --view=scan.");
|
||||
if (state === "post-scan-results" && view !== "scan") throw new Error("post-scan-results state requires --view=scan.");
|
||||
if (state === "review-deeplink" && view !== "triage") throw new Error("review-deeplink state requires --view=triage.");
|
||||
if (state === "overlay-open" && view !== "overlay") throw new Error("overlay-open state requires --view=overlay.");
|
||||
if (state === "inventory-confirm" && view !== "inventory") throw new Error("inventory-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-delete-confirm" && view !== "inventory") throw new Error("inventory-delete-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-store-delete-confirm" && view !== "inventory") throw new Error("inventory-store-delete-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-roving" && view !== "inventory") throw new Error("inventory-roving state requires --view=inventory.");
|
||||
if (state === "inventory-technical" && view !== "inventory") throw new Error("inventory-technical state requires --view=inventory.");
|
||||
|
||||
const uiScanConfig = mode === "ui-full-inventory"
|
||||
? {
|
||||
scopeMode: "all",
|
||||
expectedTarget: null,
|
||||
deadlineMs: 900000,
|
||||
}
|
||||
: mode === "ui-scan-row1"
|
||||
? {
|
||||
scopeMode: "limit",
|
||||
limit: 1,
|
||||
expectedTarget: 8,
|
||||
unit: "rows",
|
||||
deadlineMs: 240000,
|
||||
}
|
||||
: mode === "ui-stream20"
|
||||
? {
|
||||
scopeMode: "limit",
|
||||
limit: 20,
|
||||
expectedTarget: 20,
|
||||
unit: "artifacts",
|
||||
deadlineMs: 240000,
|
||||
}
|
||||
: {
|
||||
scopeMode: "limit",
|
||||
limit: 5,
|
||||
expectedTarget: 5,
|
||||
unit: "artifacts",
|
||||
deadlineMs: 240000,
|
||||
};
|
||||
|
||||
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json());
|
||||
const target = targets.find((candidate) => (
|
||||
candidate.type === "page"
|
||||
&& String(candidate.title).includes("Genshin Artifact Assistant")
|
||||
&& !String(candidate.url).includes("overlay=1")
|
||||
));
|
||||
if (!target?.webSocketDebuggerUrl || !String(target.url).startsWith("file:")) {
|
||||
throw new Error("No packaged Genshin Artifact Assistant file:// renderer target found.");
|
||||
}
|
||||
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Timed out connecting to packaged Electron CDP.")), 10_000);
|
||||
socket.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Packaged Electron CDP connection failed."));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
const rendererDiagnostics = {
|
||||
exceptions: [],
|
||||
consoleErrors: [],
|
||||
logErrors: [],
|
||||
};
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Runtime.exceptionThrown") {
|
||||
rendererDiagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
|
||||
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
|
||||
rendererDiagnostics.consoleErrors.push((message.params.args ?? []).map((entry) => entry.value ?? entry.description ?? entry.type).join(" "));
|
||||
} else if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") {
|
||||
rendererDiagnostics.logErrors.push(message.params.entry.text ?? "Unknown renderer log error");
|
||||
}
|
||||
if (!message.id || !pending.has(message.id)) return;
|
||||
const request = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
clearTimeout(request.timer);
|
||||
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
|
||||
function call(method, params = {}, timeoutMs = 30_000) {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP ${method} timed out after ${timeoutMs} ms.`));
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(expression, timeoutMs = 30_000) {
|
||||
const response = await call("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
}, timeoutMs);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Renderer evaluation failed.");
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
await call("Page.bringToFront", {}, 10_000);
|
||||
if (consoleCheck) {
|
||||
await call("Runtime.enable", {}, 10_000);
|
||||
await call("Log.enable", {}, 10_000);
|
||||
}
|
||||
if (viewportWidth !== null && viewportHeight !== null) {
|
||||
await call("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, 10_000);
|
||||
}
|
||||
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||
|
||||
const inspectExpression = `
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const runtime = await api.getRuntimeInfo();
|
||||
const guard = await api.getAutomationGuard();
|
||||
const data = await api.nativeScannerDataStatus();
|
||||
const results = await api.nativeScannerLoadResults({ limit: 3 });
|
||||
const artifacts = await api.loadArtifacts();
|
||||
const sources = await api.listCaptureSources();
|
||||
const genshinSource = sources.find((source) => source.isGenshinCandidate) ?? null;
|
||||
const readinessCapture = genshinSource
|
||||
? await api.captureSource(genshinSource.id, 0, false, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
bridgeKeys: Object.keys(api).sort(),
|
||||
runtime,
|
||||
guard,
|
||||
data,
|
||||
latestResults: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||
artifactStore: { ok: artifacts.ok, total: artifacts.total },
|
||||
captureReadiness: readinessCapture ? {
|
||||
source: { id: genshinSource.id, name: genshinSource.name },
|
||||
captureTarget: readinessCapture.captureTarget,
|
||||
layout: readinessCapture.layout,
|
||||
artifactDetail: readinessCapture.artifactDetail,
|
||||
inventoryGrid: readinessCapture.inventoryGrid ? {
|
||||
rows: readinessCapture.inventoryGrid.rows,
|
||||
cols: readinessCapture.inventoryGrid.cols,
|
||||
confidence: readinessCapture.inventoryGrid.confidence,
|
||||
source: readinessCapture.inventoryGrid.source,
|
||||
} : null,
|
||||
} : { source: null },
|
||||
navigation: [...document.querySelectorAll("button,a")].map((element) => element.textContent.trim()).filter(Boolean).slice(0, 100),
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const scanExpression = `
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const preflight = await api.nativeScannerPreflight({ category: "artifacts" });
|
||||
if (!preflight.ready) return { ok: false, phase: "preflight", preflight };
|
||||
let status = await api.nativeScannerStart({ limit: 5, category: "artifacts" });
|
||||
const deadline = Date.now() + 120000;
|
||||
while (status.running && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
status = await api.nativeScannerStatus();
|
||||
}
|
||||
if (status.running) {
|
||||
status = await api.nativeScannerStop();
|
||||
return { ok: false, phase: "timeout", status };
|
||||
}
|
||||
if (status.status !== "done" || status.captured !== 5 || !status.initialTopResetCompleted) {
|
||||
return { ok: false, phase: "capture", status };
|
||||
}
|
||||
const processing = await api.nativeScannerProcessRun({ runDir: status.runDir, limit: 5, persist: false });
|
||||
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: 5 });
|
||||
return {
|
||||
ok: Boolean(
|
||||
processing.ok && processing.processed === 5 && processing.parsed === 5
|
||||
&& processing.errors === 0 && processing.stored === 0 && !processing.persisted
|
||||
&& results.ok && results.total === 5 && results.results.length === 5
|
||||
&& results.results.every((result) => !result.persistedArtifact && !result.artifactRecordId)
|
||||
),
|
||||
phase: "complete",
|
||||
preflight,
|
||||
status,
|
||||
processing: {
|
||||
ok: processing.ok,
|
||||
processed: processing.processed,
|
||||
parsed: processing.parsed,
|
||||
review: processing.review,
|
||||
errors: processing.errors,
|
||||
stored: processing.stored,
|
||||
persisted: processing.persisted,
|
||||
elapsedMs: processing.elapsedMs,
|
||||
queueConcurrency: processing.queueConcurrency,
|
||||
},
|
||||
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||
};
|
||||
})()`;
|
||||
|
||||
const uiScanExpression = `
|
||||
(async () => {
|
||||
const config = ${JSON.stringify(uiScanConfig)};
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||
priorSummaryClose?.click();
|
||||
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const existingStatus = await api.nativeScannerStatus();
|
||||
if (existingStatus.running || document.querySelector('.scan-progress-surface.is-busy')) {
|
||||
return { ok: false, phase: 'precondition', error: 'A scanner run is already active.', existingStatus };
|
||||
}
|
||||
let dialog = document.querySelector('.scanner-settings-modal');
|
||||
if (!dialog) {
|
||||
const settingsButton = document.querySelector('[data-scan-action="open-settings"]');
|
||||
if (!(settingsButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Scan settings action was not found.' };
|
||||
settingsButton.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
dialog = document.querySelector('.scanner-settings-modal');
|
||||
}
|
||||
let configuredLimit = null;
|
||||
let configuredScopeValid = false;
|
||||
if (config.scopeMode === 'all') {
|
||||
const fullScope = dialog?.querySelector('input[data-scan-scope="all"]');
|
||||
if (!(fullScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Full inventory scope option was not found.' };
|
||||
if (!fullScope.checked) fullScope.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
configuredScopeValid = fullScope.checked;
|
||||
} else {
|
||||
const limitedScope = dialog?.querySelector('input[data-scan-scope="limit"]');
|
||||
if (!(limitedScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limited scope option was not found.' };
|
||||
if (!limitedScope.checked) limitedScope.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const unitButton = dialog?.querySelector('[data-scan-limit-unit="' + config.unit + '"]');
|
||||
if (!(unitButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Limit unit action was not found.', unit: config.unit };
|
||||
unitButton.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const inputSelector = 'input[data-scan-limit-input]';
|
||||
const limitInput = document.querySelector(inputSelector);
|
||||
if (!(limitInput instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limit input was not found.' };
|
||||
for (let attempt = 0; attempt < 30 && Number(document.querySelector(inputSelector)?.value) !== config.limit; attempt += 1) {
|
||||
const currentInput = document.querySelector(inputSelector);
|
||||
const current = Number(currentInput?.value);
|
||||
const action = current > config.limit ? 'decrease-limit' : 'increase-limit';
|
||||
const stepper = dialog?.querySelector('[data-scan-action="' + action + '"]');
|
||||
if (!(stepper instanceof HTMLButtonElement)) break;
|
||||
stepper.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
configuredLimit = Number(document.querySelector(inputSelector)?.value);
|
||||
if (configuredLimit !== config.limit) return { ok: false, phase: 'settings', error: 'Scan limit could not be configured.', configuredLimit, config };
|
||||
configuredScopeValid = limitedScope.checked
|
||||
&& unitButton.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
const configuredText = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
if (!configuredScopeValid) return { ok: false, phase: 'settings', error: 'Scan scope was not configured.', configuredLimit, configuredText, config };
|
||||
document.querySelector('[data-scan-action="close-settings"]')?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const startButton = document.querySelector('[data-scan-action="start-auto"]');
|
||||
if (!(startButton instanceof HTMLButtonElement) || startButton.disabled) return { ok: false, phase: 'start', error: 'Auto-Scan action is unavailable.', configuredText };
|
||||
startButton.click();
|
||||
|
||||
const deadline = Date.now() + config.deadlineMs;
|
||||
let sawBusy = false;
|
||||
let sawProcessing = false;
|
||||
let sawCaptureRunning = false;
|
||||
let sawLiveResultDuringCapture = false;
|
||||
let sawEvaluationAdvanceDuringCapture = false;
|
||||
let sawResultSlideIn = false;
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
let maxResultRowsDuringCapture = 0;
|
||||
let maxEvaluatedDuringCapture = 0;
|
||||
const resultObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
const resultNode = node.matches('.result-rail-row') ? node : node.querySelector('.result-rail-row');
|
||||
if (!(resultNode instanceof HTMLElement)) continue;
|
||||
requestAnimationFrame(() => {
|
||||
if (getComputedStyle(resultNode).animationName.includes('scanner-result-slide-in')) {
|
||||
sawResultSlideIn = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
resultObserver.observe(document.body, { childList: true, subtree: true });
|
||||
let lastProgressText = '';
|
||||
while (Date.now() < deadline) {
|
||||
const progress = document.querySelector('.scan-progress-surface');
|
||||
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||
const progressText = [progress?.textContent?.trim(), evaluation?.textContent?.trim()].filter(Boolean).join(' ');
|
||||
if (progress?.classList.contains('is-busy')) sawBusy = true;
|
||||
const liveCaptureStatus = await api.nativeScannerStatus();
|
||||
const liveProcessingStatus = await api.nativeScannerProcessingStatus();
|
||||
if (evaluation && (liveProcessingStatus.running || liveProcessingStatus.processed > 0)) sawProcessing = true;
|
||||
if (progressText) lastProgressText = progressText;
|
||||
const liveResultRows = document.querySelectorAll('.result-rail-row').length;
|
||||
if (liveCaptureStatus.running) {
|
||||
sawCaptureRunning = true;
|
||||
maxResultRowsDuringCapture = Math.max(maxResultRowsDuringCapture, liveResultRows);
|
||||
maxEvaluatedDuringCapture = Math.max(maxEvaluatedDuringCapture, liveProcessingStatus.processed);
|
||||
if (liveResultRows > 0) sawLiveResultDuringCapture = true;
|
||||
if (liveProcessingStatus.processed > 0) sawEvaluationAdvanceDuringCapture = true;
|
||||
}
|
||||
const summary = document.querySelector('.scan-summary-modal');
|
||||
if (sawBusy && summary && !progress?.classList.contains('is-busy')) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
resultObserver.disconnect();
|
||||
|
||||
const status = await api.nativeScannerStatus();
|
||||
const processing = await api.nativeScannerProcessingStatus();
|
||||
const resolvedTarget = config.expectedTarget ?? status.target;
|
||||
const targetValid = Number.isInteger(resolvedTarget) && resolvedTarget > 0 && resolvedTarget <= 2400;
|
||||
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: targetValid ? resolvedTarget : 2400 });
|
||||
const loadedEntries = Array.isArray(results.results) ? results.results : [];
|
||||
const summaryText = document.querySelector('.scan-summary-modal')?.textContent?.trim() ?? '';
|
||||
const resultRows = document.querySelectorAll('.result-rail-row').length;
|
||||
const expectedPages = targetValid ? Math.ceil(resolvedTarget / 32) : 0;
|
||||
const reviewRate = targetValid ? processing.review / resolvedTarget : 1;
|
||||
const persistenceDisabled = processing.stored === 0
|
||||
&& loadedEntries.every((result) => !result.persistedArtifact && !result.artifactRecordId);
|
||||
const documentFits = document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight;
|
||||
return {
|
||||
ok: Boolean(
|
||||
sawBusy && sawProcessing && sawCaptureRunning
|
||||
&& sawLiveResultDuringCapture && sawEvaluationAdvanceDuringCapture && (sawResultSlideIn || reducedMotion)
|
||||
&& configuredScopeValid
|
||||
&& targetValid && status.status === 'done' && status.target === resolvedTarget && status.captured === resolvedTarget
|
||||
&& status.pages === expectedPages && status.initialTopResetCompleted
|
||||
&& !processing.running && processing.total === resolvedTarget && processing.processed === resolvedTarget
|
||||
&& processing.parsed === resolvedTarget && processing.errors === 0 && reviewRate <= 0.15
|
||||
&& results.ok && results.total === resolvedTarget && loadedEntries.length === resolvedTarget
|
||||
&& resultRows === resolvedTarget && persistenceDisabled && documentFits
|
||||
),
|
||||
phase: 'complete',
|
||||
configuredText,
|
||||
configuredLimit,
|
||||
configuredScopeValid,
|
||||
config,
|
||||
resolvedTarget,
|
||||
expectedPages,
|
||||
reviewRate,
|
||||
persistenceDisabled,
|
||||
documentFits,
|
||||
sawBusy,
|
||||
sawProcessing,
|
||||
sawCaptureRunning,
|
||||
sawLiveResultDuringCapture,
|
||||
sawEvaluationAdvanceDuringCapture,
|
||||
sawResultSlideIn,
|
||||
reducedMotion,
|
||||
maxResultRowsDuringCapture,
|
||||
maxEvaluatedDuringCapture,
|
||||
lastProgressText,
|
||||
summaryText,
|
||||
resultRows,
|
||||
status,
|
||||
processing,
|
||||
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: loadedEntries.length },
|
||||
};
|
||||
})()`;
|
||||
|
||||
const report = {
|
||||
version: "packaged-live-acceptance-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
mode,
|
||||
view,
|
||||
state,
|
||||
target: { title: target.title, url: target.url },
|
||||
inspection: await evaluate(inspectExpression, 60_000),
|
||||
};
|
||||
|
||||
if (mode === "scan5") {
|
||||
report.scan = await evaluate(scanExpression, 180_000);
|
||||
if (!report.scan?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
report.navigation = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-navigation-id=${JSON.stringify(view)}]');
|
||||
if (!(target instanceof HTMLElement)) return { ok: false, error: ${JSON.stringify(`${view} navigation was not found.`)} };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||
return {
|
||||
ok: activeNavigation?.getAttribute('data-navigation-id') === ${JSON.stringify(view)},
|
||||
clickedNavigation: target.textContent.trim(),
|
||||
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
document: {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.navigation?.ok) process.exitCode = 1;
|
||||
|
||||
if (mode === "ui-scan5" || mode === "ui-scan-row1" || mode === "ui-stream20" || mode === "ui-full-inventory") {
|
||||
report.scan = await evaluate(uiScanExpression, mode === "ui-full-inventory" ? 1_020_000 : 300_000);
|
||||
if (!report.scan?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (mode === "reprocess-run") {
|
||||
report.reprocess = await evaluate(`
|
||||
(async () => {
|
||||
const runDir = ${JSON.stringify(reprocessRunDir)};
|
||||
const expectedTarget = ${JSON.stringify(reprocessTarget)};
|
||||
const maxReviewRate = ${JSON.stringify(reprocessMaxReviewRate)};
|
||||
const processing = await window.assistantApi.nativeScannerProcessRun({
|
||||
runDir,
|
||||
persist: false,
|
||||
limit: expectedTarget,
|
||||
stream: false,
|
||||
expectedTotal: expectedTarget,
|
||||
});
|
||||
const resultCount = Array.isArray(processing?.results) ? processing.results.length : 0;
|
||||
const reviewRate = processing?.processed > 0 ? processing.review / processing.processed : 1;
|
||||
return {
|
||||
ok: Boolean(
|
||||
processing?.ok
|
||||
&& processing.processed === expectedTarget
|
||||
&& processing.parsed === expectedTarget
|
||||
&& resultCount === expectedTarget
|
||||
&& processing.errors === 0
|
||||
&& processing.stored === 0
|
||||
&& processing.persisted === false
|
||||
&& reviewRate <= maxReviewRate
|
||||
),
|
||||
runDir: processing?.runDir ?? runDir,
|
||||
processed: processing?.processed ?? 0,
|
||||
parsed: processing?.parsed ?? 0,
|
||||
review: processing?.review ?? 0,
|
||||
reviewRate,
|
||||
stored: processing?.stored ?? 0,
|
||||
errors: processing?.errors ?? 0,
|
||||
elapsedMs: processing?.elapsedMs ?? 0,
|
||||
resultCount,
|
||||
persistenceDisabled: processing?.persisted === false,
|
||||
maxReviewRate,
|
||||
};
|
||||
})()
|
||||
`, 1_020_000);
|
||||
if (!report.reprocess?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "scan-settings") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-scan-action="open-settings"]');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Scan settings action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const dialog = document.querySelector('[role="dialog"], .modal');
|
||||
const visibleFocusable = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||
const firstFocusable = visibleFocusable[0];
|
||||
const lastFocusable = visibleFocusable.at(-1);
|
||||
lastFocusable?.focus();
|
||||
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
ok: Boolean(dialog && firstFocusable && document.activeElement === firstFocusable),
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
dialogText: dialog?.textContent?.trim() ?? null,
|
||||
focusLooped: Boolean(dialog?.contains(document.activeElement) && document.activeElement === firstFocusable),
|
||||
focusedLabel: document.activeElement?.getAttribute?.('aria-label') ?? document.activeElement?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "artifact-open") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||
priorSummaryClose?.click();
|
||||
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const sources = await api.listCaptureSources();
|
||||
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||
if (!source) return { ok: false, inputSent: false, error: "Genshin source was not found." };
|
||||
const options = {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
};
|
||||
const before = await api.captureSource(source.id, 0, false, options);
|
||||
const grid = before?.inventoryGrid;
|
||||
const gridReady = before?.captureTarget === "genshin-client"
|
||||
&& before?.width === 1920 && before?.height === 1080
|
||||
&& grid?.source === "detected" && grid.confidence >= 60
|
||||
&& grid.rows === 4 && grid.cols === 8 && grid.centers?.length >= 32;
|
||||
if (!gridReady) {
|
||||
return { ok: false, inputSent: false, error: "Accepted 1920x1080 4x8 Artifact grid was not detected.", before };
|
||||
}
|
||||
if (before.artifactDetail?.present && before.artifactDetail.confidence >= 45) {
|
||||
return { ok: true, inputSent: false, alreadyOpen: true, artifactDetail: before.artifactDetail };
|
||||
}
|
||||
const target = grid.centers[0];
|
||||
const focus = await api.focusGenshinForScanStart();
|
||||
if (!focus.focused) return { ok: false, inputSent: false, error: "Genshin focus failed.", focus };
|
||||
const click = await api.clickScreen(target.x, target.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const after = await api.captureSource(source.id, 0, false, options);
|
||||
await api.focusMainWindow();
|
||||
return {
|
||||
ok: Boolean(click.clicked && !click.inputBlocked && after?.artifactDetail?.present && after.artifactDetail.confidence >= 45),
|
||||
inputSent: Boolean(click.clicked),
|
||||
target,
|
||||
click,
|
||||
artifactDetail: after?.artifactDetail ?? null,
|
||||
grid: after?.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, confidence: after.inventoryGrid.confidence } : null,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "artifact-probe") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
const preflight = await api.nativeScannerPreflight({ category: 'artifacts' });
|
||||
if (!preflight.ready || preflight.bounds?.width !== 1920 || preflight.bounds?.height !== 1080) {
|
||||
return { ok: false, error: 'Artifact probe requires the accepted 1920x1080 preflight.', preflight };
|
||||
}
|
||||
const focus = await api.focusGenshinForScanStart();
|
||||
if (!focus.focused) return { ok: false, error: 'Genshin focus failed.', focus, preflight };
|
||||
const click = await api.clickScreen(179, 254);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
const sources = await api.listCaptureSources();
|
||||
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||
const capture = source
|
||||
? await api.captureSource(source.id, 0, false, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
})
|
||||
: null;
|
||||
await api.focusMainWindow();
|
||||
return {
|
||||
ok: Boolean(click.clicked && !click.inputBlocked && capture?.artifactDetail?.present),
|
||||
focus,
|
||||
click,
|
||||
artifactDetail: capture?.artifactDetail ?? null,
|
||||
inventoryGrid: capture?.inventoryGrid ?? null,
|
||||
};
|
||||
})()
|
||||
`, 60_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "post-scan-results") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const summary = document.querySelector('.scan-summary-modal');
|
||||
const close = summary?.querySelector('[data-scan-action="summary-close"]');
|
||||
close?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
const panel = document.querySelector('.scanner-result-panel');
|
||||
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||
const rail = document.querySelector('.result-rail-list');
|
||||
const rows = [...document.querySelectorAll('.result-rail-row')];
|
||||
const panelRect = panel?.getBoundingClientRect();
|
||||
return {
|
||||
ok: Boolean(
|
||||
!document.querySelector('.scan-summary-modal')
|
||||
&& panel && evaluation && rail && rows.length > 0
|
||||
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||
),
|
||||
resultRows: rows.length,
|
||||
firstResult: rows[0]?.textContent?.trim() ?? null,
|
||||
lastResult: rows.at(-1)?.textContent?.trim() ?? null,
|
||||
evaluationText: evaluation?.textContent?.trim() ?? null,
|
||||
panelRect: panelRect ? { x: panelRect.x, y: panelRect.y, width: panelRect.width, height: panelRect.height } : null,
|
||||
rail: rail ? { clientHeight: rail.clientHeight, scrollHeight: rail.scrollHeight, scrollTop: rail.scrollTop } : null,
|
||||
document: {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "review-deeplink") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.triage-primary-action');
|
||||
if (!target) return { ok: false, error: "Review deep-link action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||
const activeFilter = document.querySelector('.inventory-filter-control button.active')?.textContent?.trim() ?? null;
|
||||
return {
|
||||
ok: activeNavigation?.getAttribute('data-navigation-id') === 'inventory' && Boolean(activeFilter),
|
||||
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||
activeFilter,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "keyboard-focus") {
|
||||
report.interaction = await evaluate(`
|
||||
(() => {
|
||||
const target = document.querySelector('.nav-item:not([aria-current="page"]):not(:disabled)');
|
||||
if (!(target instanceof HTMLElement)) return { ok: false, error: "Focusable navigation item was not found." };
|
||||
target.focus();
|
||||
const styles = getComputedStyle(target);
|
||||
return {
|
||||
ok: document.activeElement === target && styles.outlineStyle !== "none" && Number.parseFloat(styles.outlineWidth) >= 2,
|
||||
focusedText: target.textContent?.trim() ?? null,
|
||||
outline: styles.outline,
|
||||
boxShadow: styles.boxShadow,
|
||||
};
|
||||
})()
|
||||
`, 10_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "overlay-open") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.overlay-open-button');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Overlay action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const toast = document.querySelector('.app-toast');
|
||||
return {
|
||||
ok: Boolean(toast),
|
||||
toastText: toast?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
let target = document.querySelector('[data-inventory-action="promote"]:not(:disabled), [data-inventory-action="review"]:not(:disabled)');
|
||||
if (!target) {
|
||||
const reviewFilter = document.querySelector('[data-inventory-filter="review"]');
|
||||
reviewFilter?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const rows = [...document.querySelectorAll('.inventory-row')];
|
||||
for (const row of rows) {
|
||||
row.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
target = document.querySelector('[data-inventory-action="review"]:not(:disabled)');
|
||||
if (target) break;
|
||||
}
|
||||
}
|
||||
if (!target) return { ok: false, skipped: true, error: "No safe confirmation/editor action is available for the current corpus." };
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-promotion-confirm, .inventory-review-editor');
|
||||
return {
|
||||
ok: Boolean(confirmation),
|
||||
action: target.textContent.trim(),
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
focusedText: document.activeElement?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok && !report.interaction?.skipped) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-delete-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||
if (!(target instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "No local-only artifact deletion action is available for the selected row." };
|
||||
}
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||
const prompt = confirmation?.querySelector('strong');
|
||||
const description = confirmation?.querySelector('small');
|
||||
const visibleInViewport = (element) => {
|
||||
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||
};
|
||||
return {
|
||||
ok: Boolean(
|
||||
confirmation
|
||||
&& visibleInViewport(prompt)
|
||||
&& visibleInViewport(description)
|
||||
&& confirmButton instanceof HTMLButtonElement
|
||||
&& cancelButton instanceof HTMLButtonElement
|
||||
&& document.activeElement === confirmButton
|
||||
),
|
||||
action: target.textContent?.trim() ?? null,
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
promptVisible: visibleInViewport(prompt),
|
||||
descriptionVisible: visibleInViewport(description),
|
||||
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||
confirmDisabled: confirmButton instanceof HTMLButtonElement ? confirmButton.disabled : null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-store-delete-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const storeFilter = document.querySelector('[data-inventory-filter="stored"]');
|
||||
if (!(storeFilter instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "The local Store filter is unavailable." };
|
||||
}
|
||||
storeFilter.click();
|
||||
await sleep(160);
|
||||
const storeRow = document.querySelector('.inventory-list [role="option"]');
|
||||
if (!(storeRow instanceof HTMLElement)) {
|
||||
return { ok: false, error: "No local Store row is available for a non-mutating confirmation probe." };
|
||||
}
|
||||
storeRow.click();
|
||||
await sleep(120);
|
||||
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||
if (!(target instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "The selected local Store row did not expose its delete action." };
|
||||
}
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||
const prompt = confirmation?.querySelector('strong');
|
||||
const description = confirmation?.querySelector('small');
|
||||
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||
const linkedStoreToggle = document.querySelector('[data-inventory-action="toggle-linked-store"]');
|
||||
const visibleInViewport = (element) => {
|
||||
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||
};
|
||||
return {
|
||||
ok: Boolean(
|
||||
confirmation
|
||||
&& visibleInViewport(prompt)
|
||||
&& visibleInViewport(description)
|
||||
&& confirmButton instanceof HTMLButtonElement
|
||||
&& cancelButton instanceof HTMLButtonElement
|
||||
&& document.activeElement === confirmButton
|
||||
&& !linkedStoreToggle
|
||||
),
|
||||
action: target.textContent?.trim() ?? null,
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
promptVisible: visibleInViewport(prompt),
|
||||
descriptionVisible: visibleInViewport(description),
|
||||
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||
hasLinkedStoreToggle: Boolean(linkedStoreToggle),
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-roving") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const listbox = document.querySelector('.inventory-list[role="listbox"]');
|
||||
const before = listbox?.querySelector('[role="option"][aria-selected="true"]');
|
||||
if (!(before instanceof HTMLElement)) return { ok: false, error: "Selected inventory row was not found." };
|
||||
before.focus();
|
||||
before.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const after = listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||
return {
|
||||
ok: Boolean(after && after !== before && document.activeElement === after && after.getAttribute('tabindex') === '0'),
|
||||
before: before.textContent.trim(),
|
||||
after: after?.textContent?.trim() ?? null,
|
||||
activeMatchesSelection: document.activeElement === after,
|
||||
tabbableRows: listbox.querySelectorAll('[role="option"][tabindex="0"]').length,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-technical") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.inventory-technical-button');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Technical details action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const dialog = document.querySelector('.inventory-technical-modal[role="dialog"]');
|
||||
const focusables = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||
const firstFocusable = focusables[0];
|
||||
const lastFocusable = focusables.at(-1);
|
||||
lastFocusable?.focus();
|
||||
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rect = dialog?.getBoundingClientRect();
|
||||
return {
|
||||
ok: Boolean(
|
||||
dialog && firstFocusable && document.activeElement === firstFocusable
|
||||
&& rect && rect.top >= 0 && rect.bottom <= innerHeight
|
||||
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||
),
|
||||
dialogText: dialog?.textContent?.trim() ?? null,
|
||||
focusLooped: document.activeElement === firstFocusable,
|
||||
dialogRect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await call("Page.bringToFront", {}, 10_000);
|
||||
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||
const screenshot = await call("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
}, 30_000);
|
||||
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
||||
fs.writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
||||
report.screenshotPath = screenshotPath;
|
||||
report.rendererDiagnostics = { checked: consoleCheck, ...rendererDiagnostics };
|
||||
if (consoleCheck && (rendererDiagnostics.exceptions.length > 0 || rendererDiagnostics.consoleErrors.length > 0 || rendererDiagnostics.logErrors.length > 0)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2), "utf8");
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
console.log(`Report: ${output}`);
|
||||
console.log(`Screenshot: ${screenshotPath}`);
|
||||
if (state === "scan-settings") await evaluate(`document.querySelector('[data-scan-action="close-settings"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "inventory-delete-confirm") await evaluate(`document.querySelector('[data-inventory-action="cancel-delete"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "inventory-store-delete-confirm") {
|
||||
await evaluate(`
|
||||
document.querySelector('[data-inventory-action="cancel-delete"]')?.click();
|
||||
document.querySelector('[data-inventory-filter="all"]')?.click();
|
||||
true
|
||||
`, 5_000).catch(() => undefined);
|
||||
}
|
||||
if (state === "inventory-technical") await evaluate(`document.querySelector('.inventory-technical-modal .icon-button')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "overlay-open") await evaluate("window.assistantApi.hideOverlay(); true", 5_000).catch(() => undefined);
|
||||
if (closeAfter) {
|
||||
await evaluate("window.close(); true", 5_000).catch(() => undefined);
|
||||
}
|
||||
if (viewportWidth !== null && viewportHeight !== null) {
|
||||
await call("Emulation.clearDeviceMetricsOverride", {}, 5_000).catch(() => undefined);
|
||||
}
|
||||
socket.close();
|
||||
@@ -0,0 +1,411 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { loadNativeScannerResultTombstones } from "../electron/services/nativeScannerResultTombstones.js";
|
||||
import { evaluateStoredScanResult, projectArtifactUpgrade } from "../src/lib/artifactEvaluation.js";
|
||||
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||
import type { ArtifactUpgradeProjection, StoredScanResultEntry } from "../src/types/storage.js";
|
||||
|
||||
type NativeRunManifest = {
|
||||
runId?: string;
|
||||
category?: string;
|
||||
target?: number;
|
||||
};
|
||||
|
||||
type NativeRunStatus = {
|
||||
scanner?: {
|
||||
status?: string;
|
||||
runId?: string;
|
||||
target?: number;
|
||||
captured?: number;
|
||||
queued?: number;
|
||||
clicked?: number;
|
||||
pages?: number;
|
||||
activeMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type NativeProcessingReport = {
|
||||
ok?: boolean;
|
||||
processed?: number;
|
||||
review?: number;
|
||||
stored?: number;
|
||||
errors?: number;
|
||||
elapsedMs?: number;
|
||||
queueConcurrency?: number;
|
||||
persisted?: boolean;
|
||||
};
|
||||
|
||||
type NativeCaptureJob = {
|
||||
sequence?: number;
|
||||
page?: number;
|
||||
category?: string;
|
||||
absolutePath?: string;
|
||||
relativePath?: string;
|
||||
downstream?: string;
|
||||
};
|
||||
|
||||
type NativeReviewLogEntry = {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
resultId?: string;
|
||||
};
|
||||
|
||||
type RunValidationSummary = {
|
||||
runId: string;
|
||||
runDir: string;
|
||||
target: number;
|
||||
issues: string[];
|
||||
evidence: {
|
||||
jobs: number;
|
||||
pngs: number;
|
||||
results: number;
|
||||
captured: number;
|
||||
pages: number;
|
||||
processingErrors: number;
|
||||
persisted: boolean;
|
||||
originalReview: number;
|
||||
currentReview: number;
|
||||
approvedReviewLogs: number;
|
||||
duplicatePngHashGroups: number;
|
||||
repeatedPagePairs: string[];
|
||||
identicalBoundaryPairs: string[];
|
||||
};
|
||||
performance: {
|
||||
captureMs: number;
|
||||
capturePerSecond: number | null;
|
||||
processingMs: number;
|
||||
processingPerSecond: number | null;
|
||||
};
|
||||
replay: ReturnType<typeof replayNativeScanResults>;
|
||||
};
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = options.scanRoot || path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"genshin-artifact-assistant",
|
||||
"native-scans",
|
||||
);
|
||||
const selectedRuns = await selectLatestRuns(scanRoot, options.targets);
|
||||
const runs: RunValidationSummary[] = [];
|
||||
|
||||
for (const target of options.targets) {
|
||||
const runDir = selectedRuns.get(target);
|
||||
if (!runDir) throw new Error(`No saved native run with target ${target} found below ${scanRoot}`);
|
||||
runs.push(await validateRun(runDir, target, options.repeats));
|
||||
}
|
||||
|
||||
const result = {
|
||||
version: "saved-native-run-validation-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
scanRoot,
|
||||
targets: options.targets,
|
||||
repeats: options.repeats,
|
||||
ok: runs.every((run) => run.issues.length === 0),
|
||||
runs,
|
||||
};
|
||||
const outputDir = path.resolve("outputs", "native-replay");
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "saved-native-validation-report.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||
|
||||
console.log(`Saved native validation: ${result.ok ? "PASS" : "FAIL"}`);
|
||||
for (const run of runs) {
|
||||
const replay = run.replay;
|
||||
console.log(
|
||||
`${run.target}: ${run.runId}; capture=${formatRate(run.performance.capturePerSecond)}/s; `
|
||||
+ `processing=${formatRate(run.performance.processingPerSecond)}/s; `
|
||||
+ `evaluated=${replay.values.evaluated}; excluded=${replay.values.excluded}; `
|
||||
+ `review=${replay.values.review}; unknown=${replay.values.unknown}; `
|
||||
+ `projection=${replay.projections.available}/${replay.projections.complete}; issues=${run.issues.length}`,
|
||||
);
|
||||
for (const issue of run.issues) console.error(` - ${issue}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
|
||||
async function validateRun(runDir: string, target: number, repeats: number): Promise<RunValidationSummary> {
|
||||
const issues: string[] = [];
|
||||
const manifestPath = path.join(runDir, "manifest.json");
|
||||
const statusPath = path.join(runDir, "status.json");
|
||||
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
|
||||
const resultsPath = path.join(runDir, "scan-results.json");
|
||||
const processingPath = path.join(runDir, "processing-report.json");
|
||||
const [manifest, statusPayload, jobs, rawResults, processing, reviewLogs] = await Promise.all([
|
||||
readJson<NativeRunManifest>(manifestPath),
|
||||
readJson<NativeRunStatus>(statusPath),
|
||||
readJsonLines<NativeCaptureJob>(jobsPath),
|
||||
readJson<unknown[]>(resultsPath),
|
||||
readJson<NativeProcessingReport>(processingPath),
|
||||
readOptionalJsonLines<NativeReviewLogEntry>(path.join(runDir, "review-log.jsonl")),
|
||||
]);
|
||||
const status = statusPayload.scanner ?? {};
|
||||
const results = Array.isArray(rawResults) ? rawResults.filter(isStoredScanResultEntry) : [];
|
||||
const runId = manifest.runId || path.basename(runDir);
|
||||
|
||||
expectEqual(issues, "manifest target", manifest.target, target);
|
||||
expectEqual(issues, "manifest category", manifest.category, "artifacts");
|
||||
expectEqual(issues, "status run id", status.runId, runId);
|
||||
if (!new Set(["done", "completed"]).has(status.status ?? "")) issues.push(`scanner status is ${status.status || "missing"}`);
|
||||
expectEqual(issues, "status target", status.target, target);
|
||||
expectEqual(issues, "captured count", status.captured, target);
|
||||
expectEqual(issues, "queued count", status.queued, target);
|
||||
expectEqual(issues, "job count", jobs.length, target);
|
||||
expectEqual(issues, "raw result count", rawResults.length, target);
|
||||
expectEqual(issues, "valid result count", results.length, target);
|
||||
expectEqual(issues, "processed count", processing.processed, target);
|
||||
expectEqual(issues, "processing errors", processing.errors, 0);
|
||||
expectEqual(issues, "processing persisted", processing.persisted, false);
|
||||
if (processing.ok !== true) issues.push("processing report is not ok");
|
||||
if (!Number.isFinite(processing.queueConcurrency) || (processing.queueConcurrency ?? 0) < 1) {
|
||||
issues.push("processing queue concurrency is missing or invalid");
|
||||
}
|
||||
|
||||
validateSequences(issues, "capture job", jobs.map((job) => job.sequence), target);
|
||||
validateSequences(issues, "scan result", results.map((entry) => entry.sequence), target);
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
let pngs = 0;
|
||||
const pngHashes: Array<{ sequence: number; page: number; hash: string }> = [];
|
||||
for (const job of jobs) {
|
||||
if (job.category !== "artifacts") issues.push(`job #${job.sequence ?? "?"} has category ${job.category ?? "missing"}`);
|
||||
if (job.downstream !== "ocr-parse-store") issues.push(`job #${job.sequence ?? "?"} has unexpected downstream contract`);
|
||||
const absolutePath = typeof job.absolutePath === "string" ? path.resolve(job.absolutePath) : "";
|
||||
if (!absolutePath || !isPathInside(resolvedRunDir, absolutePath)) {
|
||||
issues.push(`job #${job.sequence ?? "?"} image escapes or misses the run directory`);
|
||||
continue;
|
||||
}
|
||||
if (path.extname(absolutePath).toLowerCase() !== ".png") {
|
||||
issues.push(`job #${job.sequence ?? "?"} image is not PNG`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath);
|
||||
if (!stat.isFile() || stat.size === 0) issues.push(`job #${job.sequence ?? "?"} PNG is empty`);
|
||||
else {
|
||||
pngs += 1;
|
||||
const bytes = await fs.readFile(absolutePath);
|
||||
pngHashes.push({
|
||||
sequence: finiteNumber(job.sequence),
|
||||
page: finiteNumber(job.page),
|
||||
hash: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push(`job #${job.sequence ?? "?"} PNG is missing`);
|
||||
}
|
||||
}
|
||||
expectEqual(issues, "PNG count", pngs, target);
|
||||
const duplicatePngHashGroups = duplicateHashGroupCount(pngHashes);
|
||||
const repeatedPagePairs = identicalPagePairs(pngHashes);
|
||||
const identicalBoundaryPairs = identicalScrollBoundaryPairs(pngHashes);
|
||||
if (repeatedPagePairs.length > 0) {
|
||||
issues.push(`entire captured pages repeat exactly: ${repeatedPagePairs.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const entry of results) {
|
||||
expectEqual(issues, `result #${entry.sequence} run id`, entry.runId, runId);
|
||||
expectEqual(issues, `result #${entry.sequence} category`, entry.category, "artifact");
|
||||
const evaluated = evaluateStoredScanResult(entry);
|
||||
if (evaluated.valueScore !== null && (evaluated.valueScore < 0 || evaluated.valueScore > 100)) {
|
||||
issues.push(`result #${entry.sequence} has score outside 0-100`);
|
||||
}
|
||||
validateProjection(issues, entry.sequence, evaluated);
|
||||
}
|
||||
|
||||
const replay = replayNativeScanResults({ entries: results, repeats, runId, sourcePath: resultsPath });
|
||||
if (!replay.deterministic) issues.push("replay payload is not deterministic");
|
||||
if (replay.cleanUnevaluated.length > 0) issues.push(`${replay.cleanUnevaluated.length} clean results remain unevaluated`);
|
||||
const currentReview = results.filter((entry) => entry.extractionStatus === "review" || entry.needsReview).length;
|
||||
const originalReview = Number(processing.review ?? 0);
|
||||
const approvedReviewLogs = reviewLogs.filter((entry) => entry.ok && entry.action === "approve" && entry.resultId).length;
|
||||
const resolvedReview = Math.max(0, originalReview - currentReview);
|
||||
if (resolvedReview > approvedReviewLogs) {
|
||||
issues.push(`${resolvedReview} processing reviews disappeared but only ${approvedReviewLogs} approvals are logged`);
|
||||
}
|
||||
|
||||
const captureMs = finiteNumber(status.activeMs);
|
||||
const processingMs = finiteNumber(processing.elapsedMs);
|
||||
return {
|
||||
runId,
|
||||
runDir,
|
||||
target,
|
||||
issues: [...new Set(issues)],
|
||||
evidence: {
|
||||
jobs: jobs.length,
|
||||
pngs,
|
||||
results: results.length,
|
||||
captured: finiteNumber(status.captured),
|
||||
pages: finiteNumber(status.pages),
|
||||
processingErrors: finiteNumber(processing.errors),
|
||||
persisted: processing.persisted === true,
|
||||
originalReview,
|
||||
currentReview,
|
||||
approvedReviewLogs,
|
||||
duplicatePngHashGroups,
|
||||
repeatedPagePairs,
|
||||
identicalBoundaryPairs,
|
||||
},
|
||||
performance: {
|
||||
captureMs,
|
||||
capturePerSecond: rate(target, captureMs),
|
||||
processingMs,
|
||||
processingPerSecond: rate(target, processingMs),
|
||||
},
|
||||
replay,
|
||||
};
|
||||
}
|
||||
|
||||
function validateProjection(issues: string[], sequence: number, entry: StoredScanResultEntry) {
|
||||
const evaluation = entry.valueEvaluation;
|
||||
if (!evaluation) {
|
||||
issues.push(`result #${sequence} has no derived evaluation`);
|
||||
return;
|
||||
}
|
||||
const projection = projectArtifactUpgrade(entry.artifact, evaluation);
|
||||
if (projection.status === "available") {
|
||||
const scores = [projection.worstScore, projection.middleScore, projection.bestScore];
|
||||
if (scores.some((score) => typeof score !== "number")) issues.push(`result #${sequence} projection misses a score`);
|
||||
else if (!(scores[0]! <= scores[1]! && scores[1]! <= scores[2]!)) issues.push(`result #${sequence} projection order is invalid`);
|
||||
if (evaluation.rarity !== 5 || (entry.artifact?.level ?? 20) >= 20 || (entry.artifact?.substats.length ?? 0) < 4) {
|
||||
issues.push(`result #${sequence} projection violates its 5-star under-level boundary`);
|
||||
}
|
||||
}
|
||||
validateProjectionScores(issues, sequence, projection);
|
||||
}
|
||||
|
||||
function validateProjectionScores(issues: string[], sequence: number, projection: ArtifactUpgradeProjection) {
|
||||
for (const score of [projection.worstScore, projection.middleScore, projection.bestScore]) {
|
||||
if (score !== null && (score < 0 || score > 100)) issues.push(`result #${sequence} projection score is outside 0-100`);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLatestRuns(scanRoot: string, targets: number[]) {
|
||||
const selected = new Map<number, string>();
|
||||
const directories = (await fs.readdir(scanRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const directory of directories) {
|
||||
const runDir = path.join(scanRoot, directory);
|
||||
try {
|
||||
const manifest = await readJson<NativeRunManifest>(path.join(runDir, "manifest.json"));
|
||||
const target = Number(manifest.target);
|
||||
// A user may intentionally remove a bad local row and its crop. Such a
|
||||
// run is still valid for the app UI but no longer immutable acceptance
|
||||
// evidence, so select an untouched run for validation instead.
|
||||
if ((await loadNativeScannerResultTombstones(runDir)).length > 0) continue;
|
||||
if (targets.includes(target) && !selected.has(target)) selected.set(target, runDir);
|
||||
} catch {
|
||||
// Ignore unrelated or incomplete directories; selected evidence is validated strictly below.
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function validateSequences(issues: string[], label: string, values: Array<number | undefined>, target: number) {
|
||||
const sequences = values.filter((value): value is number => Number.isInteger(value)).sort((left, right) => left - right);
|
||||
const expected = Array.from({ length: target }, (_, index) => index + 1);
|
||||
if (sequences.length !== expected.length || sequences.some((value, index) => value !== expected[index])) {
|
||||
issues.push(`${label} sequence is not exactly 1-${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateHashGroupCount(entries: Array<{ hash: string }>) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of entries) counts.set(entry.hash, (counts.get(entry.hash) ?? 0) + 1);
|
||||
return [...counts.values()].filter((count) => count > 1).length;
|
||||
}
|
||||
|
||||
function identicalPagePairs(entries: Array<{ page: number; sequence: number; hash: string }>) {
|
||||
const pages = new Map<number, Array<{ sequence: number; hash: string }>>();
|
||||
for (const entry of entries) {
|
||||
const page = pages.get(entry.page) ?? [];
|
||||
page.push({ sequence: entry.sequence, hash: entry.hash });
|
||||
pages.set(entry.page, page);
|
||||
}
|
||||
const ordered = [...pages.entries()]
|
||||
.map(([page, values]) => ({ page, hashes: values.sort((left, right) => left.sequence - right.sequence).map((value) => value.hash) }))
|
||||
.sort((left, right) => left.page - right.page);
|
||||
const repeated: string[] = [];
|
||||
for (let leftIndex = 0; leftIndex < ordered.length; leftIndex += 1) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < ordered.length; rightIndex += 1) {
|
||||
const left = ordered[leftIndex];
|
||||
const right = ordered[rightIndex];
|
||||
if (left.hashes.length > 0 && left.hashes.length === right.hashes.length && left.hashes.every((hash, index) => hash === right.hashes[index])) {
|
||||
repeated.push(`${left.page}/${right.page}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return repeated;
|
||||
}
|
||||
|
||||
function identicalScrollBoundaryPairs(entries: Array<{ sequence: number; hash: string }>) {
|
||||
const hashes = new Map(entries.map((entry) => [entry.sequence, entry.hash]));
|
||||
return [[32, 33], [64, 65], [96, 97]]
|
||||
.filter(([left, right]) => hashes.has(left) && hashes.get(left) === hashes.get(right))
|
||||
.map(([left, right]) => `${left}/${right}`);
|
||||
}
|
||||
|
||||
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.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function expectEqual(issues: string[], label: string, actual: unknown, expected: unknown) {
|
||||
if (actual !== expected) issues.push(`${label}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function rate(count: number, milliseconds: number) {
|
||||
return milliseconds > 0 ? Math.round((count * 100_000 / milliseconds)) / 100 : null;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? "n/a" : value.toFixed(2);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function readJsonLines<T>(filePath: string): Promise<T[]> {
|
||||
return (await fs.readFile(filePath, "utf8"))
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line.replace(/^\uFEFF/, "")) as T);
|
||||
}
|
||||
|
||||
async function readOptionalJsonLines<T>(filePath: string): Promise<T[]> {
|
||||
try {
|
||||
return await readJsonLines<T>(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const targetArg = args.find((argument) => argument.startsWith("--targets="))?.slice("--targets=".length) ?? "20,50,100";
|
||||
const targets = [...new Set(targetArg.split(",").map(Number).filter((value) => Number.isInteger(value) && value > 0))];
|
||||
if (targets.length === 0) throw new Error("--targets must contain at least one positive integer.");
|
||||
const repeatArg = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 5);
|
||||
const repeats = Number.isFinite(repeatArg) ? Math.max(2, Math.min(10, Math.round(repeatArg))) : 5;
|
||||
const scanRoot = args.find((argument) => argument.startsWith("--scan-root="))?.slice("--scan-root=".length) ?? "";
|
||||
return { targets, repeats, scanRoot: scanRoot ? path.resolve(scanRoot) : "" };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { extractFile, listPackage } from "@electron/asar";
|
||||
|
||||
export function rendererAssetReferences(html) {
|
||||
return [...String(html).matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)]
|
||||
.map((match) => match[1].trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function absoluteRendererAssetReferences(html) {
|
||||
return rendererAssetReferences(html).filter((reference) => reference.startsWith("/"));
|
||||
}
|
||||
|
||||
export function runtimeSignature(source) {
|
||||
return String(source).match(/APP_RUNTIME_SIGNATURE\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function verifyProjectPackaging(projectRoot = process.cwd()) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||
const checks = [];
|
||||
const viteConfigPath = path.join(projectRoot, "vite.config.ts");
|
||||
const viteConfig = readText(viteConfigPath);
|
||||
const rendererIndexPath = path.join(projectRoot, "dist", "index.html");
|
||||
const rendererIndex = readText(rendererIndexPath);
|
||||
const mainSource = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceRuntimeSignature = runtimeSignature(mainSource);
|
||||
check(checks, packageJson.main === "dist-electron/electron/main.js", "main-entry", packageJson.main);
|
||||
check(checks, packageJson.build?.win?.requestedExecutionLevel === "requireAdministrator", "windows-elevation", packageJson.build?.win?.requestedExecutionLevel);
|
||||
check(checks, /\bbase\s*:\s*["']\.\/["']/.test(viteConfig), "vite-relative-base", "Vite base must be ./ for Electron loadFile");
|
||||
check(checks, resourceMapping(packageJson, "native/input-helper/bin/publish", "input-helper"), "helper-resource-mapping", "native helper -> resources/input-helper");
|
||||
check(checks, resourceMapping(packageJson, "data/ik-inventorylists", "ik-inventorylists"), "ik-resource-mapping", "IK lists -> resources/ik-inventorylists");
|
||||
checkFile(checks, path.join(projectRoot, "native", "input-helper", "bin", "publish", "InputHelper.exe"), "compiled-helper", 1_000_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "artifacts.json"), "ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "ik-version", 1);
|
||||
checkFile(checks, rendererIndexPath, "compiled-renderer-index", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "preload.cjs"), "compiled-preload", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "runtimePaths.js"), "compiled-runtime-path-policy", 100);
|
||||
if (rendererIndex) checkRendererAssetPolicy(checks, rendererIndex, "project-renderer");
|
||||
check(checks, sourceRuntimeSignature.startsWith("2026-07-10-"), "current-runtime-signature", sourceRuntimeSignature || "missing");
|
||||
return packagingReport("project", projectRoot, checks);
|
||||
}
|
||||
|
||||
export function verifyPackagedDirectory(packageDir, projectRoot = process.cwd()) {
|
||||
const resolved = path.resolve(packageDir);
|
||||
const resources = path.join(resolved, "resources");
|
||||
const asarPath = path.join(resources, "app.asar");
|
||||
const checks = [];
|
||||
checkFile(checks, path.join(resolved, "Genshin Artifact Assistant.exe"), "packaged-executable", 1_000_000);
|
||||
checkFile(checks, asarPath, "app-asar", 1_000);
|
||||
checkFile(checks, path.join(resources, "input-helper", "InputHelper.exe"), "packaged-helper", 1_000_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "artifacts.json"), "packaged-ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "version.txt"), "packaged-ik-version", 1);
|
||||
if (fs.existsSync(asarPath)) {
|
||||
const files = new Set(listPackage(asarPath).map((entry) => entry.replaceAll("\\", "/").replace(/^\//, "")));
|
||||
for (const expected of ["dist/index.html", "dist-electron/electron/main.js", "dist-electron/electron/preload.cjs", "dist-electron/electron/runtimePaths.js", "package.json"]) {
|
||||
check(checks, files.has(expected), `asar:${expected}`, expected);
|
||||
}
|
||||
if (files.has("dist/index.html")) {
|
||||
const rendererIndex = extractFile(asarPath, asarExtractPath("dist/index.html")).toString("utf8");
|
||||
checkRendererAssetPolicy(checks, rendererIndex, "packaged-renderer");
|
||||
const missingAssets = rendererAssetReferences(rendererIndex)
|
||||
.filter(isLocalRendererReference)
|
||||
.map((reference) => rendererReferenceAsarPath(reference))
|
||||
.filter((reference) => !files.has(reference));
|
||||
check(checks, missingAssets.length === 0, "packaged-renderer-assets-present", missingAssets.join(", ") || "all referenced assets present");
|
||||
}
|
||||
if (files.has("dist-electron/electron/main.js")) {
|
||||
const packagedMain = extractFile(asarPath, asarExtractPath("dist-electron/electron/main.js")).toString("utf8");
|
||||
const sourceMain = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceSignature = runtimeSignature(sourceMain);
|
||||
const packagedSignature = runtimeSignature(packagedMain);
|
||||
check(
|
||||
checks,
|
||||
Boolean(sourceSignature) && packagedSignature === sourceSignature,
|
||||
"runtime-signature-match",
|
||||
`${sourceSignature || "missing source"} == ${packagedSignature || "missing package"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const sourceVersion = fs.readFileSync(path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "utf8").trim();
|
||||
const packagedVersionPath = path.join(resources, "ik-inventorylists", "version.txt");
|
||||
const packagedVersion = fs.existsSync(packagedVersionPath) ? fs.readFileSync(packagedVersionPath, "utf8").trim() : "";
|
||||
check(checks, sourceVersion === packagedVersion, "ik-version-match", `${sourceVersion} == ${packagedVersion || "missing"}`);
|
||||
return packagingReport("packaged", resolved, checks);
|
||||
}
|
||||
|
||||
function resourceMapping(packageJson, from, to) {
|
||||
return (packageJson.build?.extraResources ?? []).some((entry) => entry.from === from && entry.to === to);
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
}
|
||||
|
||||
function checkRendererAssetPolicy(checks, html, idPrefix) {
|
||||
const absoluteReferences = absoluteRendererAssetReferences(html);
|
||||
check(
|
||||
checks,
|
||||
absoluteReferences.length === 0,
|
||||
`${idPrefix}-assets-relative`,
|
||||
absoluteReferences.join(", ") || "all src/href references are relative",
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalRendererReference(reference) {
|
||||
return !reference.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(reference);
|
||||
}
|
||||
|
||||
function rendererReferenceAsarPath(reference) {
|
||||
const withoutQuery = reference.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
return path.posix.normalize(path.posix.join("dist", withoutQuery));
|
||||
}
|
||||
|
||||
function asarExtractPath(reference) {
|
||||
return reference.replaceAll("/", path.sep);
|
||||
}
|
||||
|
||||
function checkFile(checks, filePath, id, minimumBytes) {
|
||||
const exists = fs.existsSync(filePath);
|
||||
const size = exists ? fs.statSync(filePath).size : 0;
|
||||
check(checks, exists && size >= minimumBytes, id, `${filePath} (${size} bytes)`);
|
||||
}
|
||||
|
||||
function check(checks, ok, id, detail) {
|
||||
checks.push({ id, ok: Boolean(ok), detail: String(detail ?? "") });
|
||||
}
|
||||
|
||||
function packagingReport(mode, input, checks) {
|
||||
return {
|
||||
version: "packaging-offline-v2",
|
||||
mode,
|
||||
input,
|
||||
ok: checks.every((entry) => entry.ok),
|
||||
passed: checks.filter((entry) => entry.ok).length,
|
||||
failed: checks.filter((entry) => !entry.ok).length,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const staticOnly = process.argv.includes("--static");
|
||||
const inputArg = process.argv.find((argument) => argument.startsWith("--input="));
|
||||
const reports = [verifyProjectPackaging()];
|
||||
if (!staticOnly) {
|
||||
const input = inputArg?.slice("--input=".length) || path.resolve("outputs", "dist", "win-unpacked");
|
||||
reports.push(verifyPackagedDirectory(input));
|
||||
}
|
||||
const result = { version: "packaging-offline-suite-v2", ok: reports.every((report) => report.ok), reports };
|
||||
const outputDir = path.resolve("outputs", "packaging");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "offline-package-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||
for (const report of reports) {
|
||||
console.log(`${report.mode}: ${report.passed}/${report.checks.length} checks passed`);
|
||||
for (const failed of report.checks.filter((entry) => !entry.ok)) console.error(`FAIL ${failed.id}: ${failed.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||||
@@ -0,0 +1,268 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILD_FIT_PROFILE_CORPUS_VERSION,
|
||||
curatedBuildFitProfiles,
|
||||
findCuratedBuildFitProfile,
|
||||
} from "./buildFitProfiles";
|
||||
import {
|
||||
BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||
BUILD_FIT_INPUT_VERSION,
|
||||
type BuildFitArtifactInputV1,
|
||||
} from "../types/buildFit";
|
||||
import {
|
||||
BUILD_FIT_REQUIRED_FIELD_KEYS,
|
||||
assessBuildFitCombination,
|
||||
validateBuildFitContract,
|
||||
} from "../lib/buildFitContract";
|
||||
import type { ArtifactSlot } from "../types/domain";
|
||||
import type { ScanResultFieldConfidence } from "../types/storage";
|
||||
|
||||
describe("curated Build-Fit profile corpus", () => {
|
||||
it("contains unique, valid, explicitly sourced contracts", () => {
|
||||
expect(BUILD_FIT_PROFILE_CORPUS_VERSION).toBe("build-fit-profile-corpus-v2");
|
||||
expect(curatedBuildFitProfiles.length).toBeGreaterThanOrEqual(4);
|
||||
expect(new Set(curatedBuildFitProfiles.map((profile) => profile.id)).size).toBe(curatedBuildFitProfiles.length);
|
||||
|
||||
for (const profile of curatedBuildFitProfiles) {
|
||||
expect(validateBuildFitContract(profile)).toEqual({ valid: true, issues: [] });
|
||||
expect(Date.parse(profile.provenance.reviewedAt)).toBeLessThanOrEqual(Date.now());
|
||||
expect(Date.parse(profile.provenance.validUntil)).toBeGreaterThan(Date.now());
|
||||
expect(profile.target.assumptions.length).toBeGreaterThan(0);
|
||||
expect(profile.provenance.sources.some((source) => source.kind === "curated" && source.reference.startsWith("https://"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses only character and Set keys present in the vendored IK 6.7.0 catalogs", () => {
|
||||
const artifactCatalog = readJson<Record<string, unknown>>("data/ik-inventorylists/artifacts.json");
|
||||
const characterCatalog = readJson<Record<string, unknown>>("data/ik-inventorylists/characters.json");
|
||||
|
||||
for (const profile of curatedBuildFitProfiles) {
|
||||
expect(characterCatalog).toHaveProperty(profile.character.id);
|
||||
for (const plan of profile.setPlans) {
|
||||
for (const requirement of plan.requirements) {
|
||||
expect(artifactCatalog).toHaveProperty(requirement.setKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies the Furina Solo-Hydro golden shape and keeps ranking deferred", () => {
|
||||
const profile = findCuratedBuildFitProfile("furina-off-field-solo-hydro-c0-c1");
|
||||
expect(profile).not.toBeNull();
|
||||
if (!profile) return;
|
||||
|
||||
const assessment = assessBuildFitCombination(profile, {
|
||||
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||
id: "furina-golden-fixture",
|
||||
artifacts: furinaArtifacts(),
|
||||
aggregateContext: [{
|
||||
id: "furina-golden-er-context",
|
||||
stat: "Energy Recharge",
|
||||
unit: "percent",
|
||||
nonArtifactValue: 106.6,
|
||||
sourceIds: ["local-build-context-v1"],
|
||||
reason: "Explicit fixture total from character and weapon context.",
|
||||
}],
|
||||
});
|
||||
|
||||
expect(assessment.status).toBe("eligible");
|
||||
expect(assessment.score).toBeNull();
|
||||
expect(assessment.recommendationStatus).toBe("deferred");
|
||||
expect(assessment.facts.matchedSetPlanIds).toEqual(["golden-troupe-4pc"]);
|
||||
expect(assessment.facts.aggregateTargets).toEqual([{
|
||||
stat: "Energy Recharge",
|
||||
unit: "percent",
|
||||
priority: "required",
|
||||
minimum: 200,
|
||||
artifactContribution: 93.4,
|
||||
nonArtifactValue: 106.6,
|
||||
totalValue: 200,
|
||||
status: "met",
|
||||
sourceIds: ["local-build-context-v1"],
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not turn the Furina ER requirement into a guessed total without build context", () => {
|
||||
const profile = findCuratedBuildFitProfile("furina-off-field-solo-hydro-c0-c1");
|
||||
expect(profile).not.toBeNull();
|
||||
if (!profile) return;
|
||||
|
||||
const assessment = assessBuildFitCombination(profile, {
|
||||
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||
id: "furina-pending-context-fixture",
|
||||
artifacts: furinaArtifacts(),
|
||||
});
|
||||
|
||||
expect(assessment.status).toBe("eligible");
|
||||
expect(assessment.facts.aggregateTargets[0]).toMatchObject({
|
||||
artifactContribution: 93.4,
|
||||
nonArtifactValue: null,
|
||||
totalValue: null,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["neuvillette-on-field-charged-attack", "marechaussee-hunter-4pc"],
|
||||
["raiden-off-field-hyperbloom-trigger", "flower-of-paradise-lost-4pc"],
|
||||
["nahida-off-field-deepwood-support", "deepwood-memories-4pc"],
|
||||
] as const)("validates the sourced %s golden combination", (profileId, planId) => {
|
||||
const profile = findCuratedBuildFitProfile(profileId);
|
||||
expect(profile).not.toBeNull();
|
||||
if (!profile) return;
|
||||
|
||||
const assessment = assessBuildFitCombination(profile, {
|
||||
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||
id: `${profileId}-golden-fixture`,
|
||||
artifacts: profileArtifacts(profileId),
|
||||
});
|
||||
|
||||
expect(assessment.status).toBe("eligible");
|
||||
expect(assessment.score).toBeNull();
|
||||
expect(assessment.recommendationStatus).toBe("deferred");
|
||||
expect(assessment.facts.matchedSetPlanIds).toContain(planId);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"neuvillette-on-field-charged-attack",
|
||||
"raiden-off-field-hyperbloom-trigger",
|
||||
"nahida-off-field-deepwood-support",
|
||||
] as const)("blocks a Review artifact for %s instead of treating its profile evidence as safe", (profileId) => {
|
||||
const profile = findCuratedBuildFitProfile(profileId);
|
||||
expect(profile).not.toBeNull();
|
||||
if (!profile) return;
|
||||
const artifacts = profileArtifacts(profileId);
|
||||
artifacts[0] = { ...artifacts[0], extractionStatus: "review", needsReview: true };
|
||||
|
||||
const assessment = assessBuildFitCombination(profile, {
|
||||
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||
id: `${profileId}-review-fixture`,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
expect(assessment.status).toBe("blocked");
|
||||
expect(assessment.blockers.map((blocker) => blocker.code)).toContain(`artifact_ineligible:${artifacts[0].artifactId}`);
|
||||
});
|
||||
});
|
||||
|
||||
function furinaArtifacts(): BuildFitArtifactInputV1[] {
|
||||
return [
|
||||
artifact("furina-flower", "flower", "goldentroupe", "HP", "4,780", [
|
||||
"Energy Recharge+10.4%",
|
||||
"CRIT Rate+7.0%",
|
||||
"CRIT DMG+14.0%",
|
||||
"HP%+9.9%",
|
||||
]),
|
||||
artifact("furina-plume", "plume", "goldentroupe", "ATK", "311", [
|
||||
"Energy Recharge+10.4%",
|
||||
"CRIT Rate+7.0%",
|
||||
"CRIT DMG+14.0%",
|
||||
"HP%+9.9%",
|
||||
]),
|
||||
artifact("furina-sands", "sands", "goldentroupe", "Energy Recharge", "51.8%", [
|
||||
"CRIT Rate+10.5%",
|
||||
"CRIT DMG+14.0%",
|
||||
"HP%+9.9%",
|
||||
"HP+239",
|
||||
]),
|
||||
artifact("furina-goblet", "goblet", "goldentroupe", "HP%", "46.6%", [
|
||||
"Energy Recharge+10.4%",
|
||||
"CRIT Rate+7.0%",
|
||||
"CRIT DMG+14.0%",
|
||||
"HP+239",
|
||||
]),
|
||||
artifact("furina-circlet", "circlet", "tenacityofthemillelith", "CRIT Rate", "31.1%", [
|
||||
"Energy Recharge+10.4%",
|
||||
"CRIT DMG+21.0%",
|
||||
"HP%+9.9%",
|
||||
"HP+239",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function profileArtifacts(profileId: string): BuildFitArtifactInputV1[] {
|
||||
if (profileId === "neuvillette-on-field-charged-attack") {
|
||||
return completeSet("neuvillette", "marechausseehunter", [
|
||||
["sands", "HP%", "46.6%"],
|
||||
["goblet", "Hydro DMG Bonus", "46.6%"],
|
||||
["circlet", "CRIT Rate", "31.1%"],
|
||||
], ["Energy Recharge+10.4%", "CRIT Rate+7.0%", "CRIT DMG+14.0%", "HP%+9.9%"]);
|
||||
}
|
||||
if (profileId === "raiden-off-field-hyperbloom-trigger") {
|
||||
return completeSet("raiden", "flowerofparadiselost", [
|
||||
["sands", "Elemental Mastery", "187"],
|
||||
["goblet", "Elemental Mastery", "187"],
|
||||
["circlet", "Elemental Mastery", "187"],
|
||||
], ["Elemental Mastery+40", "HP%+9.9%", "ATK+19", "DEF+21"]);
|
||||
}
|
||||
if (profileId === "nahida-off-field-deepwood-support") {
|
||||
return completeSet("nahida", "deepwoodmemories", [
|
||||
["sands", "Elemental Mastery", "187"],
|
||||
["goblet", "Dendro DMG Bonus", "46.6%"],
|
||||
["circlet", "CRIT Rate", "31.1%"],
|
||||
], ["Energy Recharge+10.4%", "Elemental Mastery+40", "CRIT Rate+7.0%", "CRIT DMG+14.0%"]);
|
||||
}
|
||||
throw new Error(`Missing fixture for ${profileId}.`);
|
||||
}
|
||||
|
||||
function completeSet(
|
||||
prefix: string,
|
||||
setKey: string,
|
||||
variableMainStats: Array<[ArtifactSlot, string, string]>,
|
||||
substats: string[],
|
||||
): BuildFitArtifactInputV1[] {
|
||||
const mainStats = new Map(variableMainStats.map(([slot, stat, value]) => [slot, { stat, value }] as const));
|
||||
return [
|
||||
artifact(`${prefix}-flower`, "flower", setKey, "HP", "4,780", substats),
|
||||
artifact(`${prefix}-plume`, "plume", setKey, "ATK", "311", substats),
|
||||
artifact(`${prefix}-sands`, "sands", setKey, mainStats.get("sands")?.stat ?? "ATK%", mainStats.get("sands")?.value ?? "46.6%", substats),
|
||||
artifact(`${prefix}-goblet`, "goblet", setKey, mainStats.get("goblet")?.stat ?? "ATK%", mainStats.get("goblet")?.value ?? "46.6%", substats),
|
||||
artifact(`${prefix}-circlet`, "circlet", "offset", mainStats.get("circlet")?.stat ?? "CRIT Rate", mainStats.get("circlet")?.value ?? "31.1%", substats),
|
||||
];
|
||||
}
|
||||
|
||||
function artifact(
|
||||
artifactId: string,
|
||||
slot: ArtifactSlot,
|
||||
setKey: string,
|
||||
mainStat: string,
|
||||
mainValue: string,
|
||||
substats: string[],
|
||||
): BuildFitArtifactInputV1 {
|
||||
return {
|
||||
version: BUILD_FIT_INPUT_VERSION,
|
||||
artifactId,
|
||||
canonicalSetKey: setKey,
|
||||
artifact: {
|
||||
name: artifactId,
|
||||
slot,
|
||||
level: 20,
|
||||
setName: setKey,
|
||||
mainStat,
|
||||
mainValue,
|
||||
substats,
|
||||
equipped: "Not detected",
|
||||
locked: false,
|
||||
},
|
||||
extractionStatus: "parsed",
|
||||
extractionConfidence: 96,
|
||||
needsReview: false,
|
||||
fieldConfidences: fieldConfidences(),
|
||||
};
|
||||
}
|
||||
|
||||
function fieldConfidences(): ScanResultFieldConfidence[] {
|
||||
return BUILD_FIT_REQUIRED_FIELD_KEYS.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
value: "fixture",
|
||||
confidence: 95,
|
||||
source: "ocr",
|
||||
}));
|
||||
}
|
||||
|
||||
function readJson<T>(relativePath: string): T {
|
||||
return JSON.parse(readFileSync(resolve(process.cwd(), relativePath), "utf8")) as T;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
BUILD_FIT_CONTRACT_VERSION,
|
||||
BUILD_FIT_REQUIRED_FIELD_KEYS,
|
||||
type BuildFitContractV1,
|
||||
} from "../types/buildFit";
|
||||
|
||||
export const BUILD_FIT_PROFILE_CORPUS_VERSION = "build-fit-profile-corpus-v2" as const;
|
||||
|
||||
/**
|
||||
* Curated production candidates. Each profile has a bounded source-review
|
||||
* lifetime, Golden/adversarial coverage, and no authority outside its explicit
|
||||
* assumptions. The separate evidence ranker remains read-only and never uses
|
||||
* Roll-Efficiency as a Build-Fit input.
|
||||
*/
|
||||
export const curatedBuildFitProfiles: readonly BuildFitContractV1[] = [
|
||||
{
|
||||
version: BUILD_FIT_CONTRACT_VERSION,
|
||||
id: "furina-off-field-solo-hydro-c0-c1",
|
||||
revision: 1,
|
||||
character: {
|
||||
id: "furina",
|
||||
name: "Furina",
|
||||
},
|
||||
target: {
|
||||
id: "off-field-solo-hydro-c0-c1",
|
||||
label: "Off-field DPS + Support · Solo Hydro · C0-C1",
|
||||
role: "sub_dps",
|
||||
description: "Ousia off-field personal damage and Burst support in a team where Furina is the only Hydro character.",
|
||||
assumptions: [
|
||||
"Furina is constellation 0 or 1; constellation 2 changes her HP and damage balance, while constellation 4 materially lowers her Energy Recharge requirement.",
|
||||
"Furina is the only Hydro character and uses her Burst once per rotation.",
|
||||
"Weapon, Favonius procs, rotation, and team Energy contributions must be supplied as explicit aggregate context.",
|
||||
"The profile targets Ousia off-field damage and support, not constellation 2+ behavior, on-field attacks, or a no-Burst team.",
|
||||
],
|
||||
},
|
||||
provenance: {
|
||||
gameVersion: "Luna II",
|
||||
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||
validUntil: "2026-10-08T20:00:00.000Z",
|
||||
sources: [
|
||||
{
|
||||
id: "kqm-furina-quick-guide-luna-ii",
|
||||
label: "KQM Furina Quick Guide",
|
||||
kind: "curated",
|
||||
version: "Luna II",
|
||||
reference: "https://keqingmains.com/q/furina-quickguide/",
|
||||
},
|
||||
{
|
||||
id: "local-build-context-v1",
|
||||
label: "Explicit local build context",
|
||||
kind: "user",
|
||||
version: "1",
|
||||
reference: "local://build-fit/aggregate-context",
|
||||
},
|
||||
],
|
||||
},
|
||||
setPlans: [
|
||||
{
|
||||
id: "golden-troupe-4pc",
|
||||
label: "Golden Troupe · 4pc",
|
||||
tier: "preferred",
|
||||
requirements: [{ setKey: "goldentroupe", pieces: 4 }],
|
||||
reason: "The sourced guide identifies 4pc Golden Troupe as the personal-damage set for Furina's off-field Ousia Skill.",
|
||||
},
|
||||
],
|
||||
mainStats: {
|
||||
sands: [
|
||||
{
|
||||
stat: "Energy Recharge",
|
||||
priority: 1,
|
||||
reason: "Use when the explicit team and weapon context cannot otherwise reach the required Burst uptime.",
|
||||
},
|
||||
{
|
||||
stat: "HP%",
|
||||
priority: 1,
|
||||
reason: "Use when the sourced Energy Recharge target is already met from the complete build context.",
|
||||
},
|
||||
],
|
||||
goblet: [
|
||||
{
|
||||
stat: "HP%",
|
||||
priority: 1,
|
||||
reason: "Preferred for this pre-C2 Golden Troupe target according to the sourced guide.",
|
||||
},
|
||||
{
|
||||
stat: "Hydro DMG Bonus",
|
||||
priority: 2,
|
||||
reason: "A sourced alternative that remains dependent on the surrounding build and team buffs.",
|
||||
},
|
||||
],
|
||||
circlet: [
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
priority: 1,
|
||||
reason: "Choose the CRIT main stat needed to balance the complete build.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
priority: 1,
|
||||
reason: "Choose the CRIT main stat needed to balance the complete build.",
|
||||
},
|
||||
],
|
||||
},
|
||||
substats: [
|
||||
{
|
||||
stat: "Energy Recharge",
|
||||
weight: 1,
|
||||
reason: "Ordinal source priority: first until the explicit total-build requirement is met; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
weight: 0.8,
|
||||
reason: "Ordinal source priority shared with CRIT DMG after Energy Recharge; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
weight: 0.8,
|
||||
reason: "Ordinal source priority shared with CRIT Rate after Energy Recharge; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "HP%",
|
||||
weight: 0.6,
|
||||
reason: "Ordinal source priority after Energy Recharge and CRIT; not a damage multiplier.",
|
||||
},
|
||||
],
|
||||
aggregateTargets: [
|
||||
{
|
||||
stat: "Energy Recharge",
|
||||
unit: "percent",
|
||||
minimum: 200,
|
||||
priority: "required",
|
||||
reason: "The sourced guide lists 200%+ for Solo Hydro; exact weapon, Favonius, rotation, and team context remains explicit.",
|
||||
},
|
||||
],
|
||||
evidenceRules: {
|
||||
minimumExtractionConfidence: 88,
|
||||
minimumFieldConfidence: 78,
|
||||
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||
},
|
||||
conflictPolicy: {
|
||||
equippedByOtherCharacter: "warn",
|
||||
assignedToOtherBuild: "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
version: BUILD_FIT_CONTRACT_VERSION,
|
||||
id: "neuvillette-on-field-charged-attack",
|
||||
revision: 1,
|
||||
character: {
|
||||
id: "neuvillette",
|
||||
name: "Neuvillette",
|
||||
},
|
||||
target: {
|
||||
id: "on-field-charged-attack",
|
||||
label: "On-field Charged Attack DPS",
|
||||
role: "main_dps",
|
||||
description: "On-field Hydro damage focused on enhanced Charged Attacks with a complete Marechaussee Hunter set.",
|
||||
assumptions: [
|
||||
"Neuvillette is played as an on-field Charged Attack damage dealer rather than only as a driver for teammates.",
|
||||
"His Charged Attack HP drain is available to maintain the Marechaussee Hunter four-piece effect.",
|
||||
"Weapon, team, Favonius procs, Energy Recharge requirement, constellation, and rotation are external context and are not inferred from Artifacts.",
|
||||
"This profile does not select a team or claim that a profile candidate proves character ownership.",
|
||||
],
|
||||
},
|
||||
provenance: {
|
||||
gameVersion: "Luna IV",
|
||||
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||
validUntil: "2026-10-08T20:00:00.000Z",
|
||||
sources: [{
|
||||
id: "kqm-neuvillette-quick-guide-luna-iv",
|
||||
label: "KQM Neuvillette Quick Guide",
|
||||
kind: "curated",
|
||||
version: "Luna IV",
|
||||
reference: "https://keqingmains.com/q/neuvillette-quickguide/",
|
||||
}],
|
||||
},
|
||||
setPlans: [{
|
||||
id: "marechaussee-hunter-4pc",
|
||||
label: "Marechaussee Hunter · 4pc",
|
||||
tier: "preferred",
|
||||
requirements: [{ setKey: "marechausseehunter", pieces: 4 }],
|
||||
reason: "The sourced guide identifies four-piece Marechaussee Hunter as Neuvillette's strongest general Charged Attack set, with stacks enabled by his HP drain.",
|
||||
}],
|
||||
mainStats: {
|
||||
sands: [{
|
||||
stat: "HP%",
|
||||
priority: 1,
|
||||
reason: "The sourced Charged Attack profile scales from HP.",
|
||||
}],
|
||||
goblet: [
|
||||
{
|
||||
stat: "Hydro DMG Bonus",
|
||||
priority: 1,
|
||||
reason: "The sourced default damage-facing Goblet choice for this profile.",
|
||||
},
|
||||
{
|
||||
stat: "HP%",
|
||||
priority: 2,
|
||||
reason: "A sourced viable alternative whose exact value depends on weapon and team buffs.",
|
||||
},
|
||||
],
|
||||
circlet: [
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
priority: 1,
|
||||
reason: "Use the CRIT main stat that balances the complete build.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
priority: 1,
|
||||
reason: "Use the CRIT main stat that balances the complete build.",
|
||||
},
|
||||
{
|
||||
stat: "HP%",
|
||||
priority: 2,
|
||||
reason: "A sourced viable alternative with appropriately strong supporting substats.",
|
||||
},
|
||||
],
|
||||
},
|
||||
substats: [
|
||||
{
|
||||
stat: "Energy Recharge",
|
||||
weight: 1,
|
||||
reason: "Ordinal source priority until the explicit team and weapon requirement is met; this contract does not invent a universal threshold.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
weight: 0.8,
|
||||
reason: "Ordinal source priority shared with CRIT DMG; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
weight: 0.8,
|
||||
reason: "Ordinal source priority shared with CRIT Rate; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "HP%",
|
||||
weight: 0.8,
|
||||
reason: "Sourced HP scaling priority; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "HP",
|
||||
weight: 0.6,
|
||||
reason: "Flat HP is a lower sourced priority than HP% for this profile.",
|
||||
},
|
||||
],
|
||||
aggregateTargets: [],
|
||||
evidenceRules: {
|
||||
minimumExtractionConfidence: 88,
|
||||
minimumFieldConfidence: 78,
|
||||
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||
},
|
||||
conflictPolicy: {
|
||||
equippedByOtherCharacter: "warn",
|
||||
assignedToOtherBuild: "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
version: BUILD_FIT_CONTRACT_VERSION,
|
||||
id: "raiden-off-field-hyperbloom-trigger",
|
||||
revision: 1,
|
||||
character: {
|
||||
id: "raidenshogun",
|
||||
name: "Raiden Shogun",
|
||||
},
|
||||
target: {
|
||||
id: "off-field-hyperbloom-trigger",
|
||||
label: "Off-field Hyperbloom Trigger",
|
||||
role: "reaction",
|
||||
description: "Off-field Electro application focused only on triggering Hyperbloom with Elemental Mastery Artifact rules.",
|
||||
assumptions: [
|
||||
"Raiden remains off-field after activating her Skill and is the intended Hyperbloom trigger.",
|
||||
"This excludes Burst DPS, Aggravate, Quickbloom, Overloaded, and personal-damage comparisons.",
|
||||
"Another team member is the intended four-piece Deepwood Memories holder; Raiden-held Deepwood is a separate fallback, not this target.",
|
||||
"Character level, team composition, weapon, and reaction ownership remain external context and are not inferred from Artifacts.",
|
||||
],
|
||||
},
|
||||
provenance: {
|
||||
gameVersion: "5.7",
|
||||
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||
validUntil: "2026-10-08T20:00:00.000Z",
|
||||
sources: [{
|
||||
id: "kqm-raiden-quick-guide-5-7",
|
||||
label: "KQM Raiden Shogun Quick Guide",
|
||||
kind: "curated",
|
||||
version: "5.7",
|
||||
reference: "https://keqingmains.com/q/raiden-quickguide/",
|
||||
}],
|
||||
},
|
||||
setPlans: [
|
||||
{
|
||||
id: "flower-of-paradise-lost-4pc",
|
||||
label: "Flower of Paradise Lost · 4pc",
|
||||
tier: "preferred",
|
||||
requirements: [{ setKey: "flowerofparadiselost", pieces: 4 }],
|
||||
reason: "The sourced Hyperbloom section lists four-piece Flower of Paradise Lost as the highest-damage set for this narrow trigger target.",
|
||||
},
|
||||
{
|
||||
id: "gilded-dreams-4pc",
|
||||
label: "Gilded Dreams · 4pc",
|
||||
tier: "alternative",
|
||||
requirements: [{ setKey: "gildeddreams", pieces: 4 }],
|
||||
reason: "The sourced guide lists four-piece Gilded Dreams as an EM-focused and resin-efficient alternative.",
|
||||
},
|
||||
],
|
||||
mainStats: {
|
||||
sands: [{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||
}],
|
||||
goblet: [{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||
}],
|
||||
circlet: [{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||
}],
|
||||
},
|
||||
substats: [{
|
||||
stat: "Elemental Mastery",
|
||||
weight: 1,
|
||||
reason: "The sourced narrow trigger target treats Elemental Mastery as the only relevant Artifact substat; this is not a general Raiden profile.",
|
||||
}],
|
||||
aggregateTargets: [],
|
||||
evidenceRules: {
|
||||
minimumExtractionConfidence: 88,
|
||||
minimumFieldConfidence: 78,
|
||||
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||
},
|
||||
conflictPolicy: {
|
||||
equippedByOtherCharacter: "warn",
|
||||
assignedToOtherBuild: "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
version: BUILD_FIT_CONTRACT_VERSION,
|
||||
id: "nahida-off-field-deepwood-support",
|
||||
revision: 1,
|
||||
character: {
|
||||
id: "nahida",
|
||||
name: "Nahida",
|
||||
},
|
||||
target: {
|
||||
id: "off-field-deepwood-support",
|
||||
label: "Off-field Deepwood Support",
|
||||
role: "support",
|
||||
description: "Off-field Skill Dendro application and team support while Nahida is the intended Deepwood Memories holder.",
|
||||
assumptions: [
|
||||
"Nahida is used primarily off-field for Skill-based Dendro application and support.",
|
||||
"Nahida is the intended team holder of four-piece Deepwood Memories; only one team member should supply that effect.",
|
||||
"Weapon, team, rotation, Favonius, exact Energy Recharge requirement, and which teammate owns the highest Elemental Mastery remain external context.",
|
||||
"The profile does not turn the conditional 900-1000 Elemental Mastery discussion into a hard universal target.",
|
||||
],
|
||||
},
|
||||
provenance: {
|
||||
gameVersion: "5.6",
|
||||
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||
validUntil: "2026-10-08T20:00:00.000Z",
|
||||
sources: [{
|
||||
id: "kqm-nahida-quick-guide-5-6",
|
||||
label: "KQM Nahida Quick Guide",
|
||||
kind: "curated",
|
||||
version: "5.6",
|
||||
reference: "https://keqingmains.com/q/nahida-quickguide/",
|
||||
}],
|
||||
},
|
||||
setPlans: [{
|
||||
id: "deepwood-memories-4pc",
|
||||
label: "Deepwood Memories · 4pc",
|
||||
tier: "preferred",
|
||||
requirements: [{ setKey: "deepwoodmemories", pieces: 4 }],
|
||||
reason: "The sourced guide describes four-piece Deepwood Memories as a useful generalist team set that Nahida can maintain reliably.",
|
||||
}],
|
||||
mainStats: {
|
||||
sands: [{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts Elemental Mastery Sands.",
|
||||
}],
|
||||
goblet: [
|
||||
{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts Elemental Mastery Goblet.",
|
||||
},
|
||||
{
|
||||
stat: "Dendro DMG Bonus",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts Dendro DMG Bonus Goblet.",
|
||||
},
|
||||
],
|
||||
circlet: [
|
||||
{
|
||||
stat: "Elemental Mastery",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts Elemental Mastery Circlet.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts CRIT Circlet according to complete build context.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
priority: 1,
|
||||
reason: "The sourced off-field table accepts CRIT Circlet according to complete build context.",
|
||||
},
|
||||
],
|
||||
},
|
||||
substats: [
|
||||
{
|
||||
stat: "Energy Recharge",
|
||||
weight: 1,
|
||||
reason: "Ordinal source priority until the explicit team requirement is met; this contract does not invent a universal threshold.",
|
||||
},
|
||||
{
|
||||
stat: "Elemental Mastery",
|
||||
weight: 0.8,
|
||||
reason: "Sourced off-field priority; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT Rate",
|
||||
weight: 0.6,
|
||||
reason: "Sourced lower ordinal priority after Energy Recharge and Elemental Mastery; not a damage multiplier.",
|
||||
},
|
||||
{
|
||||
stat: "CRIT DMG",
|
||||
weight: 0.6,
|
||||
reason: "Sourced lower ordinal priority after Energy Recharge and Elemental Mastery; not a damage multiplier.",
|
||||
},
|
||||
],
|
||||
aggregateTargets: [],
|
||||
evidenceRules: {
|
||||
minimumExtractionConfidence: 88,
|
||||
minimumFieldConfidence: 78,
|
||||
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||
},
|
||||
conflictPolicy: {
|
||||
equippedByOtherCharacter: "warn",
|
||||
assignedToOtherBuild: "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findCuratedBuildFitProfile(id: string) {
|
||||
return curatedBuildFitProfiles.find((profile) => profile.id === id) ?? null;
|
||||
}
|
||||
+1433
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { JsonArtifactStoreRepository } from "../../electron/repositories/artifactStoreRepository";
|
||||
import type { StoredArtifactRecord } from "../types/storage";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("artifact store repository local removal", () => {
|
||||
it("removes only the explicitly requested local record", async () => {
|
||||
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
|
||||
tempDirectories.push(userDataPath);
|
||||
const repository = new JsonArtifactStoreRepository(userDataPath);
|
||||
await repository.saveMany([record("remove-me"), record("keep-me", "Gladiator's Destiny")]);
|
||||
|
||||
const removal = await repository.removeByIds(["remove-me", "missing-record"]);
|
||||
const loaded = await repository.loadAll();
|
||||
|
||||
expect(removal).toMatchObject({ ok: true, removed: 1, total: 1 });
|
||||
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
|
||||
});
|
||||
|
||||
it("does not remove a record for blank local ids", async () => {
|
||||
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
|
||||
tempDirectories.push(userDataPath);
|
||||
const repository = new JsonArtifactStoreRepository(userDataPath);
|
||||
await repository.saveMany([record("keep-me")]);
|
||||
|
||||
const removal = await repository.removeByIds(["", " "]);
|
||||
const loaded = await repository.loadAll();
|
||||
|
||||
expect(removal).toMatchObject({ ok: true, removed: 0, total: 1 });
|
||||
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
|
||||
});
|
||||
});
|
||||
|
||||
function record(id: string, name = "Gladiator's Nostalgia"): StoredArtifactRecord {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slot: "Flower of Life",
|
||||
level: 20,
|
||||
setName: "Gladiator's Finale",
|
||||
mainStat: "HP",
|
||||
mainValue: "4780",
|
||||
substats: ["CRIT Rate+7.8%", "CRIT DMG+14.0%", "ATK%+5.8%", "Energy Recharge+11.7%"],
|
||||
equipped: "Not detected",
|
||||
confidence: 96,
|
||||
needsReview: false,
|
||||
source: "native-ik-scan",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLikelyGenshinSourceName } from "../../electron/services/captureSourceClassifier";
|
||||
|
||||
describe("capture source classifier", () => {
|
||||
it.each([
|
||||
"Genshin Impact",
|
||||
"GenshinImpact",
|
||||
"Genshin",
|
||||
"YuanShen",
|
||||
"\u539f\u795e",
|
||||
])("accepts the real game title %s", (title) => {
|
||||
expect(isLikelyGenshinSourceName(title)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Genshin Artifact Assistant",
|
||||
"Genshin Artifact Assistant Codex | Working",
|
||||
"docs - Genshin scanner",
|
||||
"Screen 1",
|
||||
"",
|
||||
])("rejects non-game windows containing Genshin text: %s", (title) => {
|
||||
expect(isLikelyGenshinSourceName(title)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-minnesang-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Minnesang of Love and Lament",
|
||||
"artifact-slot": "Circlet of Logos",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-main-stat-value": "7.0%",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "CRIT Rate+2.7%\n- HP+4..1%\n- ATK+18\n- DEF+5.1%",
|
||||
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n© 4-Piece Set: After a Normal\nAttack, Charged Attack,\nElemental Skill, or Elemental",
|
||||
"artifact-footer": "",
|
||||
},
|
||||
expect: {
|
||||
name: "Minnesang of Love and Lament",
|
||||
slot: "Circlet of Logos",
|
||||
level: 0,
|
||||
mainStat: "ATK%",
|
||||
mainValue: "7.0%",
|
||||
setName: "A Day Carved From Rising Winds",
|
||||
substats: ["CRIT Rate+2.7%", "HP%+4.1%", "ATK+18", "DEF%+5.1%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=309 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-holy-crown-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Holy Crown of the Believer",
|
||||
"artifact-slot": "Circlet of Logos",
|
||||
"artifact-main-stat-label": "Elemental Mastery",
|
||||
"artifact-main-stat-value": "28",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "ATK+4.1%\n+ Energy Recharge+4.5%\n+ ATK+19\n+ HP+4..1%",
|
||||
"artifact-set-effects": "¢2-Piece Set: Energy Recharge\n+20%.\n2 4-Piece Set: When dealing\nElemental DMG, gain the",
|
||||
"artifact-footer": "Equipped: Illuga",
|
||||
},
|
||||
expect: {
|
||||
name: "Holy Crown of the Believer",
|
||||
slot: "Circlet of Logos",
|
||||
level: 0,
|
||||
mainStat: "Elemental Mastery",
|
||||
mainValue: "28",
|
||||
setName: "Silken Moon's Serenade",
|
||||
equipped: "Illuga",
|
||||
substats: ["ATK%+4.1%", "Energy Recharge+4.5%", "ATK+19", "HP%+4.1%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=870 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-conductor-top-hat-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Conductor's Top Hat",
|
||||
"artifact-slot": "Circlet of Logos",
|
||||
"artifact-main-stat-label": "DEF",
|
||||
"artifact-main-stat-value": "8.1%",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ HP+209\n- HP+4..1%\n- CRIT DMG+5.4%\n- ATK+16 unactivated",
|
||||
"artifact-set-effects": "«:2-Piece Set: Increases Elementa\nMastery by 80.\n2 4-Piece Set: Increases Charged\nAttack DMG by 35% if the",
|
||||
"artifact-footer": "",
|
||||
},
|
||||
expect: {
|
||||
name: "Conductor's Top Hat",
|
||||
slot: "Circlet of Logos",
|
||||
level: 0,
|
||||
mainStat: "DEF%",
|
||||
mainValue: "8.7%",
|
||||
setName: "Wanderer's Troupe",
|
||||
substats: ["HP+209", "HP%+4.1%", "CRIT DMG+5.4%", "ATK+16"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1691 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-viridescent-arrow-feather-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Viridescent Arrow Feather",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-main-stat-value": "",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ CRIT DMG+5.4%\n- DEF+19\n+ CRIT Rate+2.7%\n+ HP+4..1%",
|
||||
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
|
||||
"artifact-footer": "",
|
||||
},
|
||||
expect: {
|
||||
name: "Viridescent Arrow Feather",
|
||||
slot: "Plume of Death",
|
||||
level: 0,
|
||||
mainStat: "ATK",
|
||||
mainValue: "47",
|
||||
setName: "Viridescent Venerer",
|
||||
substats: ["CRIT DMG+5.4%", "DEF+19", "CRIT Rate+2.7%", "HP%+4.1%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1723 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-gladiator-destiny-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Gladiator's Destiny",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-main-stat-value": "",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "- DEF+6.6%\n- HP+4..1%\n- Elemental Mastery+19\n- Energy Recharge+5.8%",
|
||||
"artifact-set-effects": "Gladiator's Finale:\n© 2-Piece Set: ATK +18%.\n@4-Piece Set: If the wielder of\nthis artifact set uses a Sword,",
|
||||
"artifact-footer": "",
|
||||
},
|
||||
expect: {
|
||||
name: "Gladiator's Destiny",
|
||||
slot: "Plume of Death",
|
||||
level: 0,
|
||||
mainStat: "ATK",
|
||||
mainValue: "47",
|
||||
setName: "Gladiator's Finale",
|
||||
substats: ["DEF%+6.6%", "HP%+4.1%", "Elemental Mastery+19", "Energy Recharge+5.8%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1844 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-maidens-distant-love-duplicated-decimal",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Maiden's Distant Love",
|
||||
"artifact-slot": "Flower of Life",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-main-stat-value": "",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "- DEF+23\n+ CRIT Rate+2.7%\n+ ATK+5.3%\n+ HP+4..1%",
|
||||
"artifact-set-effects": "¢2-Piece Set: Character Healing\nEffectiveness +15%\n4-Piece Set: Using an Elemental\nSkill or Burst increases healing",
|
||||
"artifact-footer": "",
|
||||
},
|
||||
expect: {
|
||||
name: "Maiden's Distant Love",
|
||||
slot: "Flower of Life",
|
||||
level: 0,
|
||||
mainStat: "HP",
|
||||
mainValue: "717",
|
||||
setName: "Maiden Beloved",
|
||||
substats: ["DEF+23", "CRIT Rate+2.7%", "ATK%+5.3%", "HP%+4.1%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=2005 | duplicated decimal in HP percent substat",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-ovations-slot-safe-name",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-footer": "",
|
||||
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n2 4-Piece Set: Increases\nSuperconduct Reaction DMG by\n80% and Stellar-Conduct",
|
||||
"artifact-main-stat-value": "8.1%",
|
||||
"artifact-level": "+0",
|
||||
"artifact-main-stat-label": "DEF",
|
||||
"artifact-substats": "- DEF+21\n+ Energy Recharge+4.5%\n- CRIT Rate+3.1%\n- ATK+14 unactivated",
|
||||
"artifact-name": "Postintty That Ceased Upon",
|
||||
},
|
||||
expect: {
|
||||
name: "Ovations That Ceased Upon Festivity",
|
||||
slot: "Goblet of Eonothem",
|
||||
level: 0,
|
||||
mainStat: "DEF%",
|
||||
mainValue: "8.7%",
|
||||
setName: "Disenchantment in Deep Shadow",
|
||||
substats: ["DEF+21", "Energy Recharge+4.5%", "CRIT Rate+3.1%", "ATK+14"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=141 | direct goblet-slot evidence disambiguates the fuzzy piece name",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { automationGuardFromHelperResponse } from "../../electron/services/inputHelper";
|
||||
|
||||
const fallbackScript = readFileSync(
|
||||
path.join(process.cwd(), "electron", "services", "inputHelperPowerShellFallback.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("input helper guard contract", () => {
|
||||
it("preserves the helper elevation signal together with stop keys", () => {
|
||||
expect(automationGuardFromHelperResponse({
|
||||
ok: true,
|
||||
isElevated: true,
|
||||
cursorX: 691,
|
||||
cursorY: 540,
|
||||
escapePressed: false,
|
||||
enterPressed: true,
|
||||
f9Pressed: false,
|
||||
})).toEqual({
|
||||
ok: true,
|
||||
isElevated: true,
|
||||
cursorX: 691,
|
||||
cursorY: 540,
|
||||
escapePressed: false,
|
||||
enterPressed: true,
|
||||
f9Pressed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves elevation unknown instead of inventing a value for legacy responses", () => {
|
||||
expect(automationGuardFromHelperResponse({ ok: true }).isElevated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the PowerShell fallback on the same exact game-process whitelist", () => {
|
||||
expect(fallbackScript).toContain("$cachedName -in @('GenshinImpact', 'YuanShen')");
|
||||
expect(fallbackScript).toContain("$_.ProcessName -in @('GenshinImpact', 'YuanShen')");
|
||||
expect(fallbackScript).not.toContain("-match 'GenshinImpact|YuanShen|Genshin'");
|
||||
});
|
||||
});
|
||||
@@ -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,80 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "scripts", "native-live-smoke.ps1");
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
describe("native live smoke script contract", () => {
|
||||
it("accepts the click probe only when input delivery and the detail change are both proven", () => {
|
||||
expect(script).toContain("$ProbePayload.ok");
|
||||
expect(script).toContain("$ProbePayload.changed");
|
||||
expect(script).toContain("$ProbePayload.click.ok");
|
||||
expect(script).toContain("$ProbePayload.click.clicked");
|
||||
expect(script).toContain("$ProbePayload.click.moved");
|
||||
expect(script).toContain("-and -not $ProbePayload.click.inputBlocked");
|
||||
});
|
||||
|
||||
it("requires exact capture, queue, click, process, and result counts", () => {
|
||||
expect(script).toContain("[int]$finalScanner.target -ne $safeLimit");
|
||||
expect(script).toContain("[int]$finalScanner.captured -ne $safeLimit");
|
||||
expect(script).toContain("[int]$finalScanner.queued -ne $safeLimit");
|
||||
expect(script).toContain("[int]$finalScanner.clicked -ne $safeLimit");
|
||||
expect(script).toContain("[int]$process.status.processed -ne $safeLimit");
|
||||
expect(script).toContain("[int]$process.status.parsed -ne $safeLimit");
|
||||
expect(script).toContain("$processResults.Count -ne $safeLimit");
|
||||
expect(script).toContain("[int]$results.status.total -ne $safeLimit");
|
||||
expect(script).toContain("$loadedResults.Count -ne $safeLimit");
|
||||
});
|
||||
|
||||
it("requires a completed bounded inventory-top reset and coherent timings", () => {
|
||||
expect(script).toContain("initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted");
|
||||
expect(script).toContain("-not [bool]$finalScanner.initialTopResetCompleted");
|
||||
expect(script).toContain("[int]$finalScanner.initialTopResetMs -le 0");
|
||||
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs");
|
||||
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs");
|
||||
expect(script).toContain("$results.status.timing");
|
||||
expect(script).toContain("$requiredTimingFields");
|
||||
expect(script).toContain("requestToResultsReconciledMs");
|
||||
expect(script).toContain("Native run timing contract is incomplete");
|
||||
});
|
||||
|
||||
it("reconciles review and error counts and enforces the fifteen-percent review gate", () => {
|
||||
expect(script).toContain("$actualReview -ne [int]$process.status.review");
|
||||
expect(script).toContain("$actualErrors -ne [int]$process.status.errors");
|
||||
expect(script).toContain("[int]$process.status.review -lt 0");
|
||||
expect(script).toContain("[int]$process.status.review -gt $safeLimit");
|
||||
expect(script).toContain("if ($reviewRate -gt 0.15)");
|
||||
});
|
||||
|
||||
it("rejects dry-run persistence in both processing and loaded result rows", () => {
|
||||
expect(script).toContain("$persistedProcessRows");
|
||||
expect(script).toContain("[bool]$process.status.persisted");
|
||||
expect(script).toContain("[int]$process.status.stored -ne 0");
|
||||
expect(script).toContain("[bool]$_.persistedArtifact");
|
||||
expect(script).toContain("[string]$_.artifactRecordId");
|
||||
expect(script).toContain("-not $Persist -and $persistedLoadedRows.Count -ne 0");
|
||||
});
|
||||
|
||||
it("binds each job to one expected non-empty PNG and exactly two click events", () => {
|
||||
expect(script).toContain('"artifact-{0:D4}.png" -f $_');
|
||||
expect(script).toContain("Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames");
|
||||
expect(script).toContain("$duplicateSequenceJobs");
|
||||
expect(script).toContain("[int]$_.clickEventsSent -ne 2");
|
||||
expect(script).toContain("$badPathJobs");
|
||||
expect(script).toContain("$emptyPngFiles");
|
||||
});
|
||||
|
||||
it("requests cleanup after every start attempt and waits for a confirmed stopped state", () => {
|
||||
const attemptIndex = script.indexOf("$scannerStartAttempted = $true");
|
||||
const startIndex = script.indexOf('$start = Invoke-DevJson "/scanner/start');
|
||||
expect(attemptIndex).toBeGreaterThan(-1);
|
||||
expect(startIndex).toBeGreaterThan(attemptIndex);
|
||||
expect(script).toContain("function Stop-NativeScannerAfterError");
|
||||
expect(script).toContain('Invoke-DevJson "/scanner/stop"');
|
||||
expect(script).toContain("while (($null -eq $lastScanner -or [bool]$lastScanner.running)");
|
||||
expect(script).toContain("$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running");
|
||||
expect(script).toContain("if ($scannerStartAttempted -and -not $scannerFinished)");
|
||||
expect(script).toContain("Native scanner cleanup stop did not reach a confirmed stopped state.");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user