Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 639b0b7f59 | |||
| 28d60eb915 | |||
| eb666febb6 | |||
| c10f17b4ef | |||
| 13fd46c104 | |||
| c025daa4f1 | |||
| 8b73c01e46 | |||
| f791d1464c |
@@ -5,6 +5,11 @@ node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
outputs/dist/
|
||||
outputs/admin-start/
|
||||
outputs/live-capture/
|
||||
outputs/live-soak/
|
||||
outputs/native-live-smoke/
|
||||
outputs/review-eval-candidates/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# Genshin Artifact Assistant
|
||||
|
||||
Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on Inventory Kamera or Genshin Optimizer as the main workflow.
|
||||
Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on external scanner or optimizer tools as the main workflow.
|
||||
|
||||
## Current MVP
|
||||
|
||||
- Electron + React + TypeScript app shell.
|
||||
- Dark purple fintech/glassmorphism UI direction.
|
||||
- Genshin capture source discovery.
|
||||
- Smart Capture that focuses Genshin, hides the app, captures the primary screen, and restores the app.
|
||||
- Artifact detail-panel crop detection with focused OCR regions.
|
||||
- OCR parser for artifact name, slot, main stat, substats, set, equipped character, confidence, and review notes.
|
||||
- Demo triage and build suggestion views.
|
||||
- Read-only overlay preview shell.
|
||||
- Electron + React + TypeScript app shell with a C# sidecar for fast read-only scan input/capture.
|
||||
- Artifact-first scanner scope. Weapons, materials, and character details are intentionally not active scanner features yet.
|
||||
- Smart Capture for the currently visible artifact detail view with focused OCR crops, parser confidence, and review notes.
|
||||
- Visible-inventory auto-scan baseline with read-only tile selection, detail verification, OCR, parse, store/review, scroll, and summary.
|
||||
- Native IK-style artifact capture path that writes card crops and run artifacts for post-capture processing.
|
||||
- Vendored Inventory Kamera `inventorylists` under `data/ik-inventorylists` as artifact reference data.
|
||||
- Scan result rail plus Inventory view for native results, stored artifacts, crop previews, IK/GOOD match state, explicit promotion, single-result review/edit/approve, and Artifact-only pipeline state.
|
||||
- GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and read-only overlay preview shell.
|
||||
|
||||
## Documentation
|
||||
|
||||
This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app:
|
||||
|
||||
- [AGENTS.md](AGENTS.md)
|
||||
- [docs/CURRENT_STATUS.md](docs/CURRENT_STATUS.md)
|
||||
- [docs/PROJECT.md](docs/PROJECT.md)
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+181
-7
@@ -6,18 +6,27 @@ This document describes the structure, boundaries, flows, and technical rules of
|
||||
|
||||
Genshin Artifact Assistant is a local desktop application. Electron owns OS integration, screen capture, IPC, and overlay windows. React owns the interactive UI. Domain logic for OCR parsing, scoring, scanner state, and recommendations lives in TypeScript modules under `src/lib`.
|
||||
|
||||
The scanner performance direction is now native-first: the C# input helper owns
|
||||
the fast capture/click/scroll loop and uses vendored Inventory Kamera
|
||||
`inventorylists` as the scanner dictionary source. Electron is the process,
|
||||
IPC, packaging, hotkey, and dev-control shell. React is only the visual control
|
||||
and status surface for this path.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User["User"]
|
||||
Genshin["Genshin Impact Window"]
|
||||
Electron["Electron Main Process"]
|
||||
Native["C# Input Helper / Native Scanner"]
|
||||
React["React Renderer"]
|
||||
Parser["OCR Parser and Scoring"]
|
||||
LocalData["Local Snapshot / Future SQLite"]
|
||||
|
||||
User --> React
|
||||
React --> Electron
|
||||
Electron --> Genshin
|
||||
Electron --> Native
|
||||
Native --> Genshin
|
||||
Native --> Electron
|
||||
Electron --> React
|
||||
React --> Parser
|
||||
Parser --> React
|
||||
@@ -49,17 +58,44 @@ flowchart LR
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, input/capture sidecar orchestration, JSON artifact store, dev-only scanner control endpoints |
|
||||
| `electron/main.ts` | Electron app composition, dependency wiring, app lifecycle, hotkeys, and IPC/dev-control registration |
|
||||
| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery |
|
||||
| `electron/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup |
|
||||
| `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable |
|
||||
| `electron/services/nativeScannerProcessingService.ts` | Post-capture processor for native IK runs; reads crop jobs, OCRs/parses card crops, matches IK metadata, writes processing outputs, and safely loads native crop previews |
|
||||
| `electron/services/nativeScannerResultWorkflowService.ts` | Authoritative promotion and review workflow for native results; reloads run state, validates edits, updates durable results/logs, and only writes the artifact store after explicit confirmation |
|
||||
| `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling |
|
||||
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
||||
| `native/input-helper/IkInventoryLists.cs` | Loads and validates the vendored IK `inventorylists` feature catalog |
|
||||
| `native/input-helper/NativeScannerFiles.cs` | Writes native scanner manifest, status, and JSONL crop-job files |
|
||||
| `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store |
|
||||
| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview |
|
||||
| `src/lib/ikArtifactMatcher.ts` | Pure IK inventorylist matcher for native artifact set/piece/slot validation and GOOD key metadata |
|
||||
| `src/lib/ikCatalogMatcher.ts` | Pure IK inventorylist matcher for simple weapon, character, and material names plus compact GOOD-key samples |
|
||||
| `src/lib/ikScanCapabilities.ts` | Pure capability summary that separates IK catalog coverage from implemented native capture support |
|
||||
| `src/lib/scanResultEntry.ts` | Pure durable scan-result entry/status helpers that keep extraction confidence separate from deferred artifact value evaluation |
|
||||
| `src/App.tsx` | Thin React entry that renders the app page |
|
||||
| `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing |
|
||||
| `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly |
|
||||
| `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration |
|
||||
| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and ESC/B fallback paths |
|
||||
| `src/features/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports |
|
||||
| `src/features/inventory/*` | Scanned-artifact inventory browser for native result entries, stored artifacts, filters, sorting, compact detail state, native crop preview display, and IK catalog status |
|
||||
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
|
||||
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
|
||||
| `src/lib/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
|
||||
| `src/lib/autoScanEntry.ts` | Pure entry-mode planning and auto-scan preflight validation |
|
||||
| `src/lib/cardReadyGate.ts` | Detail/page fingerprint readiness gate for scan timing |
|
||||
| `src/lib/artifactEvaluation.ts` | Planned deterministic artifact value evaluation with score reasons and review-safe output |
|
||||
| `src/lib/upgradeProjection.ts` | Planned best/middle/worst upgrade projection for under-leveled artifacts |
|
||||
| `src/lib/scoring.ts` | Recommendation and build scoring logic |
|
||||
| `src/lib/demoData.ts` | Temporary local demo snapshot |
|
||||
| `data/ik-inventorylists/*` | 1:1 vendored Inventory Kamera inventory lists used by the native scanner data preflight and future matching |
|
||||
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
|
||||
| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
|
||||
| `src/types/*` | Shared app, capture, and domain contracts |
|
||||
| `src/styles/global.css` | Stylesheet entrypoint importing split style modules |
|
||||
| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay styles |
|
||||
| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles |
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
@@ -99,8 +135,16 @@ sequenceDiagram
|
||||
| Capture sources | Electron desktopCapturer | Electron main | Scan UI |
|
||||
| Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview |
|
||||
| OCR crops | Electron main | Electron main | Details modal, parser |
|
||||
| Native card crops | C# input helper | Native scanner run directory under app userData | Electron status, inventory preview, later OCR/parse queue |
|
||||
| IK inventorylists | `data/ik-inventorylists` copied from Inventory Kamera 1.4.4 | Source data package | Native scanner data preflight and future matcher |
|
||||
| IK simple catalog match | IK weapon/character/material inventorylist catalog | `IkCatalogItemMatch` | Future category scanners, inventory catalog evidence |
|
||||
| Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser |
|
||||
| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, future local DB |
|
||||
| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, post-capture report, future local DB |
|
||||
| IK artifact match | IK artifact inventorylist catalog | `ScanResultIkMatch` | Native post-capture review gate, scan result entry, inventory detail |
|
||||
| Parser field confidence | Native post-capture parser | `ScanResultFieldConfidence` | Scan result entry, inventory detail review signal |
|
||||
| Scan result entry | Scan loop or native post-capture processor | `StoredScanResultEntry` / native run `scan-results.json` | Live scan rail, artifact inventory, summary |
|
||||
| Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
|
||||
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only |
|
||||
| Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training |
|
||||
| Stored artifacts | Manual/automatic scans | Electron userData `artifact-store.json` (dedupe by content signature) | Future triage, recommendations, SQLite migration |
|
||||
| Recommendations | Scoring module | Renderer domain logic | Triage and builds views |
|
||||
@@ -126,15 +170,141 @@ sequenceDiagram
|
||||
- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing.
|
||||
- Dev-only probes under `http://127.0.0.1:17317` are used for live validation:
|
||||
`/automation/probe-click?index=N` tests one read-only tile selection, and
|
||||
`/scanner/start?limit=N` starts an auto-scan with a temporary limit payload.
|
||||
`/scanner/start?limit=N` starts the native capture scan with a temporary
|
||||
limit payload from an already visible artifact detail view.
|
||||
The live known-good result on 2026-07-07 is documented in
|
||||
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
|
||||
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
|
||||
- Artifact grid automation uses 32 safe click targets per full page
|
||||
(`8 x 4`). The apparent lower fifth row sits in the bottom control band on
|
||||
16:9 captures and is not clicked automatically.
|
||||
- Guided auto-entry is state gated. The normal scan button first performs a
|
||||
lightweight no-OCR preflight; OCR/store/review work starts only after the
|
||||
artifact inventory grid and artifact detail card are visually confirmed.
|
||||
- The normal scan button does not navigate into inventory when that preflight
|
||||
fails; it blocks and asks the operator to open the Artifact inventory with a
|
||||
visible detail card. Explicit Dev-Control entry modes can still test direct
|
||||
inventory or Paimon-menu choreography, but they are not the merge-ready
|
||||
default path.
|
||||
- Item verification uses the artifact OCR capture's own detail fingerprint, so
|
||||
the loop no longer performs a separate card-ready capture before OCR. Page
|
||||
waits remain fingerprint based and can proceed as soon as the inventory pane
|
||||
changes and stabilizes.
|
||||
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
||||
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them.
|
||||
|
||||
Parsed artifacts from both modes are persisted into `artifact-store.json` keyed by a content signature that excludes the equipped character, so re-equipping updates a record instead of duplicating it. Leveling an artifact currently creates a new record (documented limitation until rescan-merge exists).
|
||||
**Native IK capture scan** is the new high-speed path. It runs inside the C#
|
||||
sidecar, computes a fixed 16:9 8x4 visible-inventory grid, clicks and scrolls
|
||||
natively, captures the artifact detail card as PNG crops, and reports progress
|
||||
through IPC/dev-control. The renderer only starts, stops, and displays status.
|
||||
OCR, parsing, GOOD persistence, and artifact value evaluation are deliberately
|
||||
separate follow-up stages so capture speed is not blocked by UI work.
|
||||
|
||||
Each native run writes a self-contained run directory under app userData:
|
||||
|
||||
- `manifest.json`: run schema, IK data version/categories, Genshin bounds,
|
||||
grid, detail crop rectangle, explicit scan category, and downstream queue
|
||||
contract.
|
||||
- `capture-jobs.jsonl`: one job per captured card crop with page/row/column,
|
||||
client/screen coordinates, click event count, image path, and downstream
|
||||
`ocr-parse-store` marker. Jobs include the scan category; today only
|
||||
`artifacts` produces jobs. Native preflight and post-capture processing also
|
||||
guard this category boundary so catalog-only weapon, character, and material
|
||||
entries cannot be accidentally parsed as artifacts.
|
||||
- `status.json`: latest scanner status snapshot for recovery and dev tooling.
|
||||
Its `supportedCategories` block separates IK catalog availability from native
|
||||
capture support: artifacts are the only native-capture category today, while
|
||||
weapons, characters, and materials are catalog-only until their own capture
|
||||
flows have evidence.
|
||||
- `scan-results.json`: durable per-artifact result entries with capture
|
||||
metadata, parsed artifact identity when available, extraction status, and
|
||||
value status. Native capture currently writes `deferred` value status for
|
||||
clean extraction and `review` for uncertain extraction.
|
||||
- `processing-report.json`: optional post-capture OCR/parse report generated
|
||||
from `capture-jobs.jsonl`. It records `queueConcurrency` for the bounded
|
||||
post-capture OCR/parse worker and is non-persisting by default until native
|
||||
crop OCR has live validation evidence.
|
||||
|
||||
Parsed artifacts from manual and renderer auto-scan modes are persisted into
|
||||
`artifact-store.json` keyed by a content signature that excludes the equipped
|
||||
character, so re-equipping updates a record instead of duplicating it. Leveling
|
||||
an artifact currently creates a new record (documented limitation until
|
||||
rescan-merge exists). Native IK capture currently writes card crops first; a
|
||||
downstream OCR/parse processor can report parsed artifacts from those crops
|
||||
without slowing capture. Store promotion remains opt-in until native crop OCR is
|
||||
validated.
|
||||
The Inventory surface derives a dry-run promotion summary from native
|
||||
`scan-results.json` entries and the local artifact store. It can show which
|
||||
native artifacts are ready for explicit promotion, already stored, review-only,
|
||||
or blocked, but it does not write store records by itself.
|
||||
During auto-scan, artifact store writes can be batched and flushed after the
|
||||
click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path.
|
||||
Auto-scan artifact captures also bypass Electron source-list enumeration and use
|
||||
the GDI capture helper directly once the selected source/Genshin state has been
|
||||
preflighted. Manual captures and source refresh still use `desktopCapturer`.
|
||||
|
||||
## Scan Results And Inventory UX
|
||||
|
||||
The next product surface is documented in
|
||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
||||
Architecture rules:
|
||||
|
||||
- The active scan view is an operator surface, not the full evaluator. It should
|
||||
show the screenshot/preview, the right-side live result rail, Stop/status
|
||||
controls, and review access.
|
||||
- The live result rail receives completed artifact results only. Intermediate
|
||||
OCR/debug stats stay in diagnostics or detail.
|
||||
- A scan result row preserves extraction status and artifact value status as
|
||||
separate data even when the UI shows one compact pill.
|
||||
- The artifact inventory view owns browsing, filtering, sorting, opening detail,
|
||||
and showing the current artifact-only native pipeline state.
|
||||
- Weapons, materials, and character details stay out of the active Inventory UI
|
||||
until their values are actually scanned; loaded IK catalog data alone is not a
|
||||
user-facing scanner capability.
|
||||
- The artifact detail view owns screenshot/crop inspection, OCR confidence,
|
||||
parser notes, value score reasons, and upgrade projection.
|
||||
- Native crop previews are served through the Electron bridge only for PNG paths
|
||||
inside the selected native scanner run directory.
|
||||
- Native artifact post-processing uses IK artifact set/piece/slot matching as a
|
||||
review gate. A conflict is extraction uncertainty, not a weak artifact value.
|
||||
- Native artifact post-processing stores per-field parser confidence so detail
|
||||
review can explain which OCR/parser fields are trustworthy.
|
||||
- Upgrade projection is a local deterministic/probabilistic helper, never a
|
||||
claim that an artifact will roll a specific way.
|
||||
|
||||
Planned result flow:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ScanLoop["Scan loop"]
|
||||
Parsed["Parsed artifact"]
|
||||
ReviewGate["Extraction confidence / review gate"]
|
||||
Value["Artifact value evaluator"]
|
||||
Store["Artifact store"]
|
||||
Rail["Live result rail"]
|
||||
Inventory["Artifact inventory"]
|
||||
Detail["Artifact detail"]
|
||||
|
||||
ScanLoop --> Parsed
|
||||
Parsed --> ReviewGate
|
||||
ReviewGate --> Value
|
||||
Value --> Store
|
||||
Value --> Rail
|
||||
Store --> Inventory
|
||||
Rail --> Detail
|
||||
Inventory --> Detail
|
||||
```
|
||||
|
||||
Future queue refactor:
|
||||
|
||||
- One capture/game-control worker may click, scroll, focus, and poll failsafes.
|
||||
- OCR/parse/evaluation may process bounded queued screenshot/crop jobs.
|
||||
- Queueing must preserve stop behavior, duplicate handling, review decisions,
|
||||
and the existing read-only safety boundary.
|
||||
- The native post-capture processor already consumes crop jobs with bounded
|
||||
parallelism while preserving report order. Further queue work is secondary to
|
||||
live throughput evidence and result quality.
|
||||
|
||||
## Security And Safety
|
||||
|
||||
@@ -156,4 +326,8 @@ Parsed artifacts from both modes are persisted into `artifact-store.json` keyed
|
||||
|
||||
## Performance
|
||||
|
||||
Current OCR is prototype-grade and may be slower than the target scanner. Two batch-scan bottlenecks were removed: input/capture no longer spawn a PowerShell process (and recompile Win32 interop) per action, and the Tesseract worker is created once and reused across captures. The eventual batch scanner should still move expensive capture/OCR/build work into workers or a native sidecar.
|
||||
Current OCR is measured through the eval harness and live scan assessments
|
||||
rather than assumed good. The app keeps a Tesseract.js worker pool and reports
|
||||
capture, OCR, card-ready, scroll-ready, active-scan, and projected-100 timings.
|
||||
For the next product phase, performance work should not displace extraction
|
||||
quality, result clarity, or review safety unless evidence shows a regression.
|
||||
|
||||
+168
-49
@@ -5,23 +5,35 @@ movement, click input, elevation, and live validation status.
|
||||
|
||||
## Current Known-Good State
|
||||
|
||||
Validated live on 2026-07-07 with Genshin open in the artifact inventory at
|
||||
1920x1080, English UI:
|
||||
Validated live with Genshin open in the artifact inventory at 1920x1080,
|
||||
English UI:
|
||||
|
||||
- `npm run dev:admin` starts the app elevated after the user confirms UAC.
|
||||
- Runtime status reported `isElevated: true`, `genshinFound: true`, and
|
||||
- Runtime status reports `isElevated: true`, `genshinFound: true`, and
|
||||
`targetProcess: "GenshinImpact"`.
|
||||
- The safe probe endpoint `/automation/probe-click?index=1` focused Genshin,
|
||||
moved the cursor to the second visible inventory tile, clicked it, and changed
|
||||
the artifact detail panel fingerprint.
|
||||
- Probe result: `clicked: true`, `inputBlocked: false`,
|
||||
`foregroundProcess: "GenshinImpact"`, and `changed: true`.
|
||||
- A bounded live auto-scan via `/scanner/start?limit=2` completed with:
|
||||
`clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`,
|
||||
`review: 2`, `misses: 0`, `status: "done"`.
|
||||
- `/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).
|
||||
|
||||
This proves that the current elevated app plus helper path can deliver mouse
|
||||
movement and click input to the focused Genshin client in this environment.
|
||||
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
|
||||
|
||||
@@ -40,8 +52,7 @@ outputs/admin-start/admin-dev.log
|
||||
```
|
||||
|
||||
The user must confirm the Windows UAC prompt. The app cannot and must not click
|
||||
the Secure Desktop UAC prompt for itself. After confirmation, the app can verify
|
||||
its own runtime through the dev status endpoint.
|
||||
the Secure Desktop UAC prompt for itself.
|
||||
|
||||
Useful checks:
|
||||
|
||||
@@ -55,11 +66,12 @@ Expected runtime facts before automatic scan:
|
||||
- `isElevated: true`
|
||||
- `genshinFound: true`
|
||||
- `targetProcess: "GenshinImpact"`
|
||||
- hotkeys registered
|
||||
- `/health.appBuild.signature` matches `APP_RUNTIME_SIGNATURE` in
|
||||
`electron/main.ts`
|
||||
|
||||
## Mouse And Click Validation
|
||||
|
||||
Use the probe before broad auto-scan work:
|
||||
Use a probe before broad auto-scan work:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" |
|
||||
@@ -71,8 +83,8 @@ feed, enhance, lock, unlock, spend, or modify game resources.
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `click.ok: true`, `clicked: true`, `inputBlocked: false` means Windows did not
|
||||
block SendInput/UIPI in the current configuration.
|
||||
- `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.
|
||||
@@ -80,22 +92,12 @@ Interpretation:
|
||||
neighboring artifacts render identically; retry with another `index`, `row`,
|
||||
or `col`.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
# Second visible tile
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1"
|
||||
|
||||
# Specific grid cell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?row=0&col=3"
|
||||
```
|
||||
|
||||
## Bounded Live Auto-Scan
|
||||
|
||||
For live validation, prefer a bounded scan first:
|
||||
Start with a tiny bounded run:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2"
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2"
|
||||
```
|
||||
|
||||
Then poll:
|
||||
@@ -105,24 +107,141 @@ Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
|
||||
ConvertTo-Json -Depth 12
|
||||
```
|
||||
|
||||
The `/scanner/start?limit=N` endpoint sends a renderer command payload with a
|
||||
temporary scan limit. It does not change the normal UI setting. The normal
|
||||
hotkeys and buttons still use the UI's configured scan limit.
|
||||
The scan starts only after a valid lookup package, supported 16:9 layout,
|
||||
detected artifact grid, Genshin-client capture, and visual artifact-detail
|
||||
markers are all present. If any preflight check fails, keep using the
|
||||
visible-inventory path and fix the blocking evidence before trying broader
|
||||
runs.
|
||||
|
||||
The loop verifies detail fingerprints after clicks, retries one unchanged detail
|
||||
read, stores parsed artifacts in a batch, and flushes pending writes before the
|
||||
final summary. Stats separate clicked, attempted, verified, parsed, stored,
|
||||
review, duplicates, and misses so progress is not confused with successful
|
||||
reads.
|
||||
|
||||
For the native IK-style path, prefer the dedicated smoke runner:
|
||||
|
||||
```powershell
|
||||
npm run scan:native:smoke
|
||||
npm run scan:native:smoke:5
|
||||
```
|
||||
|
||||
It checks `/health`, `/scanner/native/data`,
|
||||
`/scanner/native/preflight?category=artifacts` including the native visual
|
||||
blank-capture guard, runs the guarded
|
||||
`/automation/probe-click?index=1`, starts `/scanner/start?limit=N&category=artifacts`,
|
||||
waits for the native helper to finish, runs `/scanner/native/process` with
|
||||
`persist=0`, and loads `/scanner/native/results`. Evidence is written to:
|
||||
|
||||
```text
|
||||
outputs/native-live-smoke/<timestamp>/
|
||||
```
|
||||
|
||||
Use this native smoke path before enabling any artifact-store promotion from
|
||||
native `scan-results.json`.
|
||||
|
||||
The native preflight and start endpoints both accept an explicit category:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/native/preflight?category=artifacts"
|
||||
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2&category=artifacts"
|
||||
```
|
||||
|
||||
Only `artifacts` is implemented as native capture today. `weapons`,
|
||||
`characters`, and `materials` are expected to block with a catalog-only message
|
||||
at preflight/start time until their category-specific capture flows are
|
||||
implemented and validated. They are not part of the active UI scope while those
|
||||
values are not scanned.
|
||||
Artifact preflight also blocks when the Genshin client capture is blank,
|
||||
almost entirely white/black, or too visually uniform to be trusted.
|
||||
The probe-click endpoint additionally requires a detected Genshin-client
|
||||
artifact inventory grid and visible artifact detail card before sending input.
|
||||
|
||||
## Evidence Commands
|
||||
|
||||
After the elevated app is running and Genshin is open on the artifact inventory,
|
||||
the non-elevated terminal can drive the local dev-control endpoints and save a
|
||||
full evidence bundle:
|
||||
|
||||
```powershell
|
||||
npm run scan:soak
|
||||
```
|
||||
|
||||
The helper writes timestamped JSON snapshots and a transcript to:
|
||||
|
||||
```text
|
||||
outputs/live-soak/<timestamp>/
|
||||
```
|
||||
|
||||
For short iteration:
|
||||
|
||||
```powershell
|
||||
npm run scan:iterate:validated
|
||||
npm run scan:iterate:validated:wait
|
||||
```
|
||||
|
||||
For a full 2, 5, 20, 45, 100 current-run evidence chain:
|
||||
|
||||
```powershell
|
||||
npm run scan:goal:validated
|
||||
npm run scan:goal:validated:wait
|
||||
```
|
||||
|
||||
For later-session repeatability without changing OCR engines:
|
||||
|
||||
```powershell
|
||||
npm run scan:repeatability:wait
|
||||
```
|
||||
|
||||
Validate a saved assessment before using it as final evidence:
|
||||
|
||||
```powershell
|
||||
npm run scan:assessment:validate -- --latest --summary
|
||||
npm run scan:assessment:validate -- --input=<run-dir>\scan-performance-assessment.json --summary
|
||||
```
|
||||
|
||||
Optional budget flags:
|
||||
|
||||
```powershell
|
||||
npm run scan:assessment:validate -- --latest --summary --limit=20 --max-active-average-ms=333 --max-capture-roundtrip-overhead-ms=120
|
||||
```
|
||||
|
||||
`--max-active-average-ms=333` is the strict 3 artifacts/second check. A passing
|
||||
100-artifact current run is valid scanner evidence; it is not evidence that the
|
||||
3 artifacts/second budget was met unless this budget check also passes.
|
||||
|
||||
The assessment self-test does not need Genshin:
|
||||
|
||||
```powershell
|
||||
npm run scan:assessment:test
|
||||
```
|
||||
|
||||
## Review-To-Eval Quality Loop
|
||||
|
||||
After any live scan that creates review samples, export candidates before adding
|
||||
anything to the permanent eval corpus:
|
||||
|
||||
```powershell
|
||||
npm run eval:review-candidates -- --limit=80
|
||||
```
|
||||
|
||||
Read `outputs/review-eval-candidates/review-eval-candidates.md`. It is a review
|
||||
worklist, not ground truth. Only after expected fields are confirmed or
|
||||
corrected against the real artifact should a case move into
|
||||
`src/eval/corpus/confirmedReviewCorpus.ts`.
|
||||
|
||||
For a manually checked candidate, generate a paste-ready confirmed-case snippet:
|
||||
|
||||
```powershell
|
||||
npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json
|
||||
```
|
||||
|
||||
## Anti-Cheat And Safety Boundary
|
||||
|
||||
Do not describe the current implementation as bypassing anti-cheat. The app
|
||||
does not read memory, hook the process, inject code, modify game files, inspect
|
||||
packets, or interact with kernel drivers. It uses normal Windows screen capture,
|
||||
focus, cursor movement, wheel, and click input.
|
||||
|
||||
The practical finding is narrower:
|
||||
|
||||
- A non-elevated app can be blocked by Windows integrity/UIPI when the target
|
||||
process is elevated or protected.
|
||||
- Running the app elevated fixed input delivery in the tested environment.
|
||||
- Genshin's anti-cheat may still affect behavior on other machines, game modes,
|
||||
overlays, or future versions. Re-run the probe before trusting broad scans.
|
||||
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.
|
||||
@@ -133,10 +252,9 @@ The current 16:9 layout profile is calibrated from a 1920x1080 English
|
||||
artifact-inventory capture:
|
||||
|
||||
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
|
||||
- inventory grid: `8 x 5`
|
||||
- inventory grid: `8 x 4` safe automated targets
|
||||
- first tile center: `x=179`, `y=254`, `row=0`, `col=0`
|
||||
- second tile center: `x=325`, `y=254`, `row=0`, `col=1`
|
||||
- inventory count crop successfully read `2059/2400` in the live session
|
||||
|
||||
The profile is resolution-scaled for 16:9. Off-profile setups should be treated
|
||||
as higher risk and validated with Smart Capture plus the probe.
|
||||
@@ -150,5 +268,6 @@ Before marking an automation change done:
|
||||
3. Run `npm test`.
|
||||
4. Run `npm run build`.
|
||||
5. If Genshin is available, run `/automation/probe-click?index=1`.
|
||||
6. For scan-loop changes, run `/scanner/start?limit=2` before any broader scan.
|
||||
6. For scan-loop changes, run
|
||||
`/scanner/start?entry=visible-inventory&limit=2` before any broader scan.
|
||||
7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- [ ] The expected crop or capture behavior is clear.
|
||||
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
|
||||
- [ ] Auto-scan starts only from confirmed artifact inventory plus visible detail card, or blocks with a reason.
|
||||
- [ ] Capture failures are shown to the user.
|
||||
- [ ] OCR uncertainty remains inspectable in Details.
|
||||
- [ ] Parser output does not silently trust low-confidence text.
|
||||
@@ -12,6 +13,25 @@
|
||||
- [ ] `npm run build` passes.
|
||||
- [ ] Manual Smart Capture is tested when possible.
|
||||
|
||||
## Live Scan Timing Claim
|
||||
|
||||
- [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`.
|
||||
- [ ] `npm run scan:live:preflight` passes, or use the validated command that runs it first.
|
||||
- [ ] During manual UAC startup, `npm run scan:live:preflight:wait` may be used before the scan chain.
|
||||
- [ ] `npm run scan:assessment:test` passes.
|
||||
- [ ] Use `npm run scan:iterate:validated` or `npm run scan:iterate:validated:wait` for short 20-artifact tuning loops.
|
||||
- [ ] Prefer `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` for the final live run because it runs preflight, scan, and assessment validation in sequence.
|
||||
- [ ] The run includes `scan-performance-assessment.json`.
|
||||
- [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=<path>\scan-performance-assessment.json` passes.
|
||||
- [ ] If claiming the current scanner path, the validator is run with `--expect-winner=current`.
|
||||
- [ ] Final report cites the validator JSON or `--summary` output, including the assessment path.
|
||||
- [ ] For iteration claims, the 20-artifact run finishes cleanly and is validated with `--limit=20`.
|
||||
- [ ] For final claims, the 100-artifact run finishes cleanly.
|
||||
- [ ] Parsed count is at least the requested count.
|
||||
- [ ] Miss rate is at or below 2%.
|
||||
- [ ] Review rate is at or below 15%.
|
||||
- [ ] Speed reporting uses active scan timing plus quality, not click count alone.
|
||||
|
||||
## UI Change
|
||||
|
||||
- [ ] The main workflow remains visible without unnecessary scrolling.
|
||||
@@ -21,6 +41,34 @@
|
||||
- [ ] Desktop viewport is checked manually.
|
||||
- [ ] `npm run build` passes.
|
||||
|
||||
## Scan Results And Inventory UX
|
||||
|
||||
- [ ] The scan page keeps preview, Stop/status, and the live result rail visible without page-level scrolling.
|
||||
- [ ] The live rail shows finished artifact rows only, not intermediate OCR/debug state.
|
||||
- [ ] Each live row has scan number, artifact name or compact fallback, value score, and a short status pill.
|
||||
- [ ] Extraction confidence and artifact value remain separate in the data model.
|
||||
- [ ] Native post-capture runs write `scan-results.json` with extraction status and deferred/review value status.
|
||||
- [ ] Native post-capture reports include bounded queue evidence such as `queueConcurrency` when OCR/parse workers run.
|
||||
- [ ] Native artifact post-processing records IK/GOOD match metadata and sends IK set/piece/slot conflicts to review.
|
||||
- [ ] Native scan result details preserve parser field confidence for review instead of hiding uncertainty.
|
||||
- [ ] Native inventory promotion is previewed as a dry-run decision before any artifact-store write path is enabled.
|
||||
- [ ] Weapon, character, and material IK matching claims are backed by category-specific capture evidence, not just catalog availability.
|
||||
- [ ] Native `supportedCategories` distinguishes `catalogAvailable` from `nativeCaptureSupported`.
|
||||
- [ ] Native scan start, run manifest, status, and capture jobs carry an explicit scan category.
|
||||
- [ ] Native preflight and post-capture processing block catalog-only categories before OCR/parsing.
|
||||
- [ ] Native preflight blocks blank or too-uniform Genshin captures before any click is sent.
|
||||
- [ ] Native crop previews are loaded only through the Electron bridge and stay constrained to PNG files inside the selected run directory.
|
||||
- [ ] Native live smoke uses `npm run scan:native:smoke` before broad native timing or store-promotion claims.
|
||||
- [ ] Artifact-only scope is visible in the UI; weapons, materials, and character details are not presented as active scanner features while they are not scanned.
|
||||
- [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision.
|
||||
- [ ] Duplicate state is represented separately from artifact quality.
|
||||
- [ ] The artifact inventory view can browse stored scan results without opening diagnostics.
|
||||
- [ ] Inventory filters/sorting cover review, score, set, slot, equipped, locked, and newest scan where data exists.
|
||||
- [ ] Artifact detail shows screenshot/crop, parsed fields, OCR confidence, parser notes, value reasons, and review state.
|
||||
- [ ] Upgrade projection, if shown, is labeled as a projection and includes worst/middle/best cases.
|
||||
- [ ] Unit tests cover score/status derivation and projection edge cases when those modules change.
|
||||
- [ ] Scanner safety rules and existing quality gates are unchanged.
|
||||
|
||||
## Parser Or Scoring Change
|
||||
|
||||
- [ ] Known-good OCR samples still parse correctly.
|
||||
@@ -35,3 +83,21 @@
|
||||
- [ ] App starts outside browser preview.
|
||||
- [ ] Smart Capture bridge is available.
|
||||
- [ ] No generated debug artifacts are included accidentally.
|
||||
|
||||
## Git Merge And Gitea Push
|
||||
|
||||
- [ ] `git status --short --branch` is checked before switching branches,
|
||||
committing, merging, or pushing.
|
||||
- [ ] Work is committed on the intended feature branch before merging into
|
||||
`main`.
|
||||
- [ ] Merge into `main` uses fast-forward when possible, or a deliberate merge
|
||||
commit when the history requires it.
|
||||
- [ ] `main` is clean before push except for explicitly ignored or deliberately
|
||||
untracked local files.
|
||||
- [ ] `git remote -v` points to the intended Gitea remote before pushing.
|
||||
- [ ] Gitea HTTPS credentials are stored through Git Credential Manager, not in
|
||||
repository files or remote URLs.
|
||||
- [ ] If authentication fails, refresh the credential using
|
||||
[GITEA_AUTH.md](GITEA_AUTH.md), then rerun the same `git push`.
|
||||
- [ ] After push, `git status --short --branch` shows `main...origin/main`
|
||||
without ahead/behind drift.
|
||||
|
||||
@@ -11,16 +11,22 @@ This document defines project engineering standards.
|
||||
## File Organization
|
||||
|
||||
- Keep Electron OS integration in `electron/`.
|
||||
- Keep Electron `main.ts` as composition/wiring. Move durable window, file, helper, capture, OCR, or dev-control responsibilities into named modules under `electron/`.
|
||||
- Keep React components in `src/`, with extraction when `App.tsx` becomes hard to review.
|
||||
- Keep feature controller hooks small enough to review. If a hook owns persistence, import/export, entry choreography, scan-loop orchestration, and UI state at once, split those concerns into feature-local hooks or services.
|
||||
- Keep pure domain logic in `src/lib/`.
|
||||
- Keep shared contracts in `src/types/`.
|
||||
- Keep generated outputs in `dist/`, `dist-electron/`, and `outputs/`.
|
||||
- Keep `src/styles/global.css` as the stylesheet entrypoint. Put broad app styles in `base.css` and dev/diagnostic-only styling in `diagnostics.css` unless a more specific style module is introduced.
|
||||
|
||||
## UI Rules
|
||||
|
||||
- The scan page should prioritize the capture workspace over secondary status content.
|
||||
- The active scan view should read as preview plus compact result rail, not as a dashboard of live diagnostics.
|
||||
- Details and debug information belong in modals or secondary panels.
|
||||
- Avoid long, overfilled cards on scanner pages.
|
||||
- Do not collapse OCR/extraction confidence and artifact value into one ambiguous UI state; uncertain reads should be visibly `Review`.
|
||||
- Artifact value pills should be short, stable labels backed by deterministic data, with detailed reasons behind click-through detail.
|
||||
- The design direction is dark purple fintech glassmorphism with premium, focused controls.
|
||||
- Disable buttons when their required data does not exist.
|
||||
|
||||
@@ -30,6 +36,7 @@ This document defines project engineering standards.
|
||||
- Confidence and raw OCR details must remain inspectable.
|
||||
- Heuristics should fail safely into unknown fields or review notes.
|
||||
- Do not add irreversible game actions.
|
||||
- Keep auto-entry choreography separate from scan-loop execution. Entry code may navigate to a readable artifact detail state; loop code should process verified grid targets.
|
||||
|
||||
## TypeScript Rules
|
||||
|
||||
@@ -48,3 +55,8 @@ This document defines project engineering standards.
|
||||
- Update `docs/PROJECT.md` when product scope changes.
|
||||
- Update `docs/ARCHITECTURE.md` when module boundaries or flows change.
|
||||
- Add an ADR to `docs/DECISIONS.md` for durable technical trade-offs.
|
||||
- Keep workflow/runbook docs linked from `docs/PROJECT.md` so future sessions
|
||||
do not depend on chat history.
|
||||
- Do not store tokens, passwords, cookies, or generated credentials in project
|
||||
files. Use Git Credential Manager for Gitea HTTPS credentials; document only
|
||||
the setup path in `docs/GITEA_AUTH.md`.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Current App Status
|
||||
|
||||
Updated: 2026-07-09
|
||||
|
||||
This document is the short current-state entry point. For deeper architecture
|
||||
details see [ARCHITECTURE.md](ARCHITECTURE.md). For the scanner and inventory
|
||||
roadmap see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
||||
For live scanner runbooks see [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
|
||||
## Summary
|
||||
|
||||
The app is now an Artifact-first local scanner with a native high-speed capture
|
||||
path validated at 20/50/100-item scale in the current live session. The current product scope is intentionally limited to
|
||||
artifacts. Weapons, materials, and character details may exist as vendored IK
|
||||
catalog data, but they are not active scanner features while their values are
|
||||
not scanned.
|
||||
|
||||
Electron/React should act as the visual control and status surface. The C#
|
||||
helper owns fast Genshin focus, click, scroll, and card-crop capture for the
|
||||
native path. OCR, parsing, review, promotion, and value evaluation remain
|
||||
separate downstream stages so capture speed is not blocked by UI work.
|
||||
|
||||
## What Works Now
|
||||
|
||||
- Smart Capture can read the currently opened artifact detail view through
|
||||
focused crops, OCR preprocessing, deterministic parsing, confidence, and
|
||||
notes.
|
||||
- The visible-inventory auto-scan path is the current stable production
|
||||
baseline: preflight, grid detection, read-only tile selection, detail
|
||||
verification, OCR, parse, store/review, scroll, and summary.
|
||||
- The native IK-style artifact capture path is wired through the C# helper.
|
||||
It captures visible 16:9 Artifact inventory detail card crops and writes a
|
||||
self-contained run directory.
|
||||
- Native runs write `manifest.json`, `capture-jobs.jsonl`, and `status.json`.
|
||||
Post-capture processing can consume the job file and write
|
||||
`scan-results.json` plus `processing-report.json`.
|
||||
- Vendored Inventory Kamera `inventorylists` are copied under
|
||||
`data/ik-inventorylists` and packaged as scanner reference data. Current
|
||||
version is `6.7.0`.
|
||||
- Native artifact post-processing matches parsed artifacts against IK artifact
|
||||
set/piece/slot data and sends conflicts to review.
|
||||
- The scan page has a compact latest-results rail. Native parsed results are
|
||||
labeled as `Geparst`, persisted results as stored, uncertain results as
|
||||
review, and value evaluation as `Wert offen`.
|
||||
- The Inventory view can browse native scan results, stored artifacts, and
|
||||
snapshot fallback rows. It shows native crop previews, field confidence,
|
||||
IK/GOOD metadata, dry-run promotion status, and a per-result
|
||||
`Naechster Schritt`.
|
||||
- The Inventory view now shows an Artifact-only pipeline strip for scope,
|
||||
native capture, OCR queue, review gate, promotion, and evidence.
|
||||
- GOOD import/export, review samples, local text replacements, scanner
|
||||
diagnostics, OCR eval, and local artifact storage exist.
|
||||
|
||||
## What Was Just Done
|
||||
|
||||
- The active UI scope was narrowed to Artifact scanning only.
|
||||
- Weapon, material, and character-detail IK catalog coverage was removed from
|
||||
the primary Inventory feature surface so it cannot look like implemented scan
|
||||
support.
|
||||
- Native result labels were made more honest:
|
||||
`Geparst` means extracted, `Stored` means persisted, `Review` means unsafe,
|
||||
and `Wert offen` means artifact value evaluation has not run yet.
|
||||
- The Inventory detail panel gained a `Naechster Schritt` card:
|
||||
ready for promotion, review first, already in store, blocked, or value later.
|
||||
- New filters were added for native rows and promotable rows.
|
||||
- Native 20/50/100-item dry runs were completed. A live OCR decimal-loss bug
|
||||
and missing native result timestamp were fixed and regression-tested.
|
||||
- Native Inventory results now support explicit single-result promotion with a
|
||||
second confirmation, authoritative main-process revalidation, duplicate
|
||||
detection, store write, scan-result update, and `promotion-log.jsonl`.
|
||||
- Review-required native results now have an inline editor for identity,
|
||||
main stat/value, level, substats, equipped state, lock state, and notes.
|
||||
Approve requires IK identity validation, canonical main-value validation,
|
||||
and legal substat rolls; Reject keeps the result blocked. Approved cases
|
||||
write `review-log.jsonl` and reuse `review-samples.jsonl` for OCR eval export.
|
||||
- The three real Review rows from the 100-item native run were manually
|
||||
corrected and approved through that workflow. The run now has zero remaining
|
||||
Review rows, all three corrections reached the eval candidate pipeline, and
|
||||
the confirmed cases are permanent OCR regressions.
|
||||
- The parser can now repair the confirmed dropped/extra-digit substat cases at
|
||||
+20 only when one legal 5-star roll-count combination exists. Ambiguous OCR
|
||||
stays reviewable.
|
||||
- Project docs and roadmap were updated to state Artifact-only scope and the
|
||||
next scanner/product priorities.
|
||||
|
||||
## Current Evidence
|
||||
|
||||
- `npm run lint` passed.
|
||||
- `npm test` passed with `252` tests.
|
||||
- `npm run build` passed.
|
||||
- `git diff --check` passed; only existing CRLF warnings were reported for
|
||||
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
|
||||
- Native IK dry runs on 2026-07-09 completed at 20, 50, and 100 items with
|
||||
complete crop/job/result counts, `0` processing errors, and `persisted: false`.
|
||||
The 100-item run captured `100/100` across 4 pages in `24,673 ms`, parsed
|
||||
`100/100`, and safely routed 3 OCR value losses into Review. See
|
||||
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
|
||||
- The older visible-inventory path has broader live evidence, including a
|
||||
100-artifact run with `100/100` verified and parsed, `98` stored,
|
||||
`2` duplicates, `0` review samples, `0` misses, and `393 ms/artifact`.
|
||||
|
||||
## Known Limits
|
||||
|
||||
- Native IK-style capture has current-session scale evidence, but later-session
|
||||
repeatability and packaged-app evidence are still open.
|
||||
- The native path is limited to visible 16:9 Artifact inventory with a visible
|
||||
detail card.
|
||||
- Native post-capture results do not persist by default. Clean selected results
|
||||
can now be promoted explicitly after a second UI confirmation.
|
||||
- Artifact value scoring and upgrade projection are not implemented for native
|
||||
results yet.
|
||||
- Review/edit/approve is implemented for one selected native result at a time.
|
||||
Batch review is intentionally not available.
|
||||
- The `333 ms/artifact` target for 3 artifacts/second is not proven.
|
||||
- The native capture worker exceeded 4 artifacts/second, but the smoke runner
|
||||
still captures and processes sequentially; 3 artifacts/second end to end is
|
||||
therefore not proven.
|
||||
- Weapons, materials, and character details are intentionally ignored until
|
||||
their values are actually scanned.
|
||||
|
||||
## What Is Next
|
||||
|
||||
1. Add Artifact value evaluation after extraction is trustworthy. Keep value
|
||||
reasons separate from OCR confidence.
|
||||
2. Add detail-level value reasons and optional upgrade projection. Projection
|
||||
must stay labeled as probabilistic.
|
||||
3. Confirm packaged-app behavior for the C# helper, IK lists, preload bridge,
|
||||
native crop previews, and smoke commands.
|
||||
4. Repeat the native 20/50/100 evidence in a later session to prove repeatability.
|
||||
5. Return to recommendations only after native artifact ingestion, promotion,
|
||||
review, value scoring, and repeatability are strong enough.
|
||||
|
||||
## Not Next
|
||||
|
||||
- Do not prioritize weapons, materials, or character details yet.
|
||||
- Do not auto-persist native scan results before review/promotion is safe.
|
||||
- Do not claim Inventory Kamera parity until broad native Artifact runs prove
|
||||
repeatable speed and quality.
|
||||
- Do not merge extraction confidence and artifact value into one UI score.
|
||||
+157
-8
@@ -16,6 +16,9 @@ This document contains Architecture Decision Records.
|
||||
| ADR-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
|
||||
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
|
||||
| ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
|
||||
| ADR-011 | Retire alternate OCR comparison paths after current scanner baseline | Superseded | 2026-07-09 |
|
||||
| ADR-012 | Separate live scan results, artifact inventory, and value evaluation | Accepted | 2026-07-09 |
|
||||
| ADR-013 | Move high-speed scanning into the native helper and vendor IK inventorylists | Accepted | 2026-07-09 |
|
||||
|
||||
## ADR-001: Build A Local Electron App First
|
||||
|
||||
@@ -45,7 +48,8 @@ Accepted
|
||||
|
||||
### Context
|
||||
|
||||
The user wants an app that works without Inventory Kamera, Genshin Optimizer, Enka, or HoYoLAB as core dependencies.
|
||||
The user wants an app that works without optimizer imports, external scanner
|
||||
tools, Enka, or HoYoLAB as core dependencies.
|
||||
|
||||
### Decision
|
||||
|
||||
@@ -182,9 +186,8 @@ Accepted
|
||||
The persistent PowerShell helper (ADR-006) still carries Windows PowerShell 5.1
|
||||
quirks (the `Marshal::SizeOf` interop bug), compiles Win32 interop at startup,
|
||||
and captures each frame by writing a PNG to the temp directory and reading it
|
||||
back. Inventory Kamera - the proven reference for automated Genshin scanning -
|
||||
uses a C#/.NET stack with InputSimulator (SendInput) and direct GDI/BitBlt
|
||||
capture.
|
||||
back. A C#/.NET sidecar with SendInput and direct GDI/BitBlt capture is a
|
||||
cleaner fit for the validated Windows automation path.
|
||||
|
||||
### Decision
|
||||
|
||||
@@ -213,10 +216,9 @@ Accepted
|
||||
The current pipeline finds the artifact detail panel with hardcoded orange/green
|
||||
color thresholds (`inferDetailRect`) and then crops fixed percentages of that
|
||||
guessed rectangle. This is brittle against HDR, color profiles, UI scale, aspect
|
||||
ratio, and game UI updates. Inventory Kamera instead requires borderless 16:9 and
|
||||
scales fixed crop coordinates from a reference resolution, then feeds Tesseract
|
||||
preprocessed (grayscale, upscaled, thresholded) crops - which is why general
|
||||
Tesseract is accurate enough for them.
|
||||
ratio, and game UI updates. A stricter borderless 16:9 profile with fixed crop
|
||||
coordinates from a reference resolution is easier to validate and reproduce than
|
||||
per-frame color hunting.
|
||||
|
||||
### Decision
|
||||
|
||||
@@ -286,3 +288,150 @@ Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
- The implementation remains inside the allowed safety boundary: no memory
|
||||
reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
|
||||
locking/unlocking, or spending resources.
|
||||
|
||||
## ADR-011: Retire Alternate OCR Comparison Paths After Current Scanner Baseline
|
||||
|
||||
### Status
|
||||
|
||||
Superseded
|
||||
|
||||
### Context
|
||||
|
||||
The project previously carried alternate OCR-engine and comparison paths to
|
||||
decide whether the current scanner should change OCR defaults. The current
|
||||
visible-inventory path has now produced clean 100-artifact evidence, and the
|
||||
next product phase is result clarity, artifact inventory, detail evaluation, and
|
||||
corpus growth. Keeping an alternate engine path increases app size and
|
||||
maintenance surface without serving the current user workflow.
|
||||
|
||||
### Decision
|
||||
|
||||
Retire the alternate OCR-engine and current-vs-reference comparison paths. Keep a
|
||||
single current OCR/capture path and preserve the quality-gated live soak
|
||||
assessment. A qualified scan result must:
|
||||
|
||||
- finish cleanly,
|
||||
- parse at least the requested count,
|
||||
- keep miss rate at or below 2%,
|
||||
- keep review rate at or below 15%,
|
||||
- report active scan timing and bottlenecks,
|
||||
- come from a runtime whose `/health.appBuild.signature` matches the current
|
||||
source.
|
||||
|
||||
`scripts/live-soak.ps1` writes the evidence bundle and
|
||||
`scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that
|
||||
the ranking logic rejects fast but low-quality synthetic runs without needing
|
||||
Genshin. `npm run scan:assessment:validate -- --summary` prints the assessment
|
||||
path and `createdAt` timestamp so reports can cite the exact evidence file.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Speed claims cannot be based on click count or elapsed time alone.
|
||||
- A new OCR engine is out of scope until scanner UX, inventory, detail
|
||||
evaluation, and corpus growth justify reopening that work.
|
||||
- Stale elevated Electron instances are treated as invalid evidence, not as a
|
||||
harmless warning.
|
||||
- The validated `:wait` scan scripts are acceptable for manual post-UAC startup;
|
||||
non-waiting scripts remain useful when automation should fail fast.
|
||||
|
||||
## ADR-012: Separate Live Scan Results, Artifact Inventory, And Value Evaluation
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
The visible-inventory scanner baseline is fast enough for the current product
|
||||
phase. The next user value comes from better artifact content extraction and a
|
||||
clearer surface for scanned artifacts, not from another broad speed pass.
|
||||
|
||||
The scan page currently has more diagnostic/result detail than the operator
|
||||
needs during a live scan. Showing every stat, confidence value, and evaluation
|
||||
while the scanner is running makes the main workspace noisy and increases the
|
||||
risk that a user treats uncertain OCR as a final artifact judgment.
|
||||
|
||||
The desired product flow is:
|
||||
|
||||
- left side: screenshot/preview,
|
||||
- right side: compact live list of finished artifact results,
|
||||
- later menu item: browsable scanned artifact inventory,
|
||||
- click-through detail: screenshot, parsed stats, OCR confidence, evaluation
|
||||
reasons, and upgrade projection.
|
||||
|
||||
### Decision
|
||||
|
||||
Adopt a split scan/result/inventory model:
|
||||
|
||||
- The active scan page shows the latest screenshot/preview and a compact live
|
||||
result rail.
|
||||
- The live rail shows only scan number, artifact name or compact fallback,
|
||||
artifact value score `0-100`, and a result pill after evaluation has finished.
|
||||
- Extraction confidence and artifact value are separate data concepts. A low
|
||||
confidence read becomes `Review`; it must not be silently displayed as a
|
||||
normal weak artifact.
|
||||
- Debug stats, raw OCR, confidence breakdowns, and detailed evaluation belong in
|
||||
diagnostics, scan summary, or artifact detail.
|
||||
- Add a scanned artifact inventory view for browsing, filtering, sorting, and
|
||||
opening details.
|
||||
- Add artifact detail evaluation before promoting broad recommendations.
|
||||
- Add upgrade projection only as a detail-level feature, with worst/middle/best
|
||||
projected value scores and clear uncertainty labeling.
|
||||
- Keep the producer/consumer screenshot queue as a later implementation phase:
|
||||
one game-control worker may click/scroll/capture, while OCR/parse/evaluation
|
||||
workers can process bounded queued jobs once the UI/data contracts are stable.
|
||||
|
||||
### Consequences
|
||||
|
||||
- The scan page becomes calmer and more task-focused.
|
||||
- The app can surface useful artifact outcomes without hiding OCR uncertainty.
|
||||
- Inventory and detail views become the natural place for richer analysis.
|
||||
- Recommendation work has a cleaner dependency chain: trusted scans, compact
|
||||
results, artifact inventory, detail evaluation, then build recommendations.
|
||||
- Renderer-loop speed work remains secondary unless measured timings show a real
|
||||
regression; native capture speed work is handled by ADR-013.
|
||||
|
||||
## ADR-013: Move High-Speed Scanning Into The Native Helper And Vendor IK Inventorylists
|
||||
|
||||
### Status
|
||||
|
||||
Accepted
|
||||
|
||||
### Context
|
||||
|
||||
Inventory Kamera is fast because the game-control loop is native and the UI does
|
||||
not perform per-artifact work. The Electron renderer path paid for focus,
|
||||
capture, OCR, parse, persistence, status updates, and UI state inside one loop.
|
||||
The user also has Inventory Kamera 1.4.4 locally, including `inventorylists`
|
||||
that can be used 1:1 as scanner reference data.
|
||||
|
||||
### Decision
|
||||
|
||||
Use the C# input helper as the high-speed scanner service. Electron starts,
|
||||
stops, packages, and reports status. React remains a visual control surface. The
|
||||
IK `inventorylists` are copied into `data/ik-inventorylists` and packaged as
|
||||
extra resources. The first native milestone captures artifact detail card crops
|
||||
quickly; OCR, parsing, GOOD persistence, and evaluation are separate downstream
|
||||
steps. Each native run writes `manifest.json`, `capture-jobs.jsonl`, and
|
||||
`status.json`; downstream workers must consume those files instead of asking the
|
||||
renderer to do per-artifact scanner work.
|
||||
|
||||
The first downstream worker is a post-capture processor that reads
|
||||
`capture-jobs.jsonl` and writes both `scan-results.json` and
|
||||
`processing-report.json`. `scan-results.json` is the durable result contract:
|
||||
one entry per captured artifact card with extraction status, artifact identity
|
||||
when parsed, review state, and deferred value status. `processing-report.json`
|
||||
remains the diagnostic OCR/parse report. The worker OCRs/parses after capture
|
||||
and does not persist by default; DB writes require explicit opt-in after native
|
||||
crop OCR is validated with live evidence.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Capture throughput can be optimized without renderer roundtrips per artifact.
|
||||
- The scanner data source now matches IK's artifact names and set/piece keys.
|
||||
Other vendored IK lists are data only until those values are actually scanned.
|
||||
- Evaluation can remain deferred without blocking capture speed.
|
||||
- Live safety stays read-only: no memory reads, hooks, injection, game-file
|
||||
modification, deleting, feeding, enhancing, or spending resources.
|
||||
- Non-16:9 layouts currently block in native preflight until layout support is
|
||||
expanded.
|
||||
|
||||
@@ -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,61 @@
|
||||
# Scanner Merge Evidence
|
||||
|
||||
Merged branch: scanner baseline branch
|
||||
Target branch: `main`
|
||||
Merge commit: `c025daa`
|
||||
Merged on: 2026-07-09
|
||||
|
||||
This document records the evidence used to merge the scanner branch into
|
||||
`main`. It separates the merge-relevant proof from optional follow-up work.
|
||||
|
||||
## Merge-Relevant Evidence
|
||||
|
||||
| Area | Status | Evidence |
|
||||
| --- | --- | --- |
|
||||
| TypeScript/lint gate | Passed | `npm run lint` |
|
||||
| Unit/regression suite | Passed | `npm test` with 209 tests, including equipped footer, lock detection, and PNG lock-crop regression coverage |
|
||||
| Production build | Passed | `npm run build` |
|
||||
| Whitespace check | Passed | `git diff --check` |
|
||||
| OCR eval gate | Passed | `npm run eval` with `23/23` exact-match cases and `100%` critical fields |
|
||||
| Scan assessment self-test | Passed | `npm run scan:assessment:test`; the fixture rejects fast but low-quality synthetic runs |
|
||||
| Live runtime preflight | Passed | `npm run scan:live:preflight`; signature `2026-07-08-direct-gdi-reviewfix`, elevated yes, Genshin found |
|
||||
| Safe visible-inventory scan path | Passed | 2026-07-09 live run: `/scanner/start?entry=visible-inventory&limit=20` completed `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, `0` misses, `8047 ms` elapsed |
|
||||
| Equipped character OCR/persist path | Passed for live smoke | 2026-07-09 live captures read equipped footers for `Citlali` and `Linnea`; parser regression covers `Equipped: Linnea l` -> `Linnea` |
|
||||
| Unlocked lock state | Passed for live smoke | 2026-07-09 Smart Capture reported `locked: false` on an unlocked artifact; after restart, `lockSignal.ratio: 0` with threshold `0.06` |
|
||||
| Positive locked lock state | Passed | 2026-07-09 Smart Capture on a visibly locked artifact reported `locked: true`, `lockSignal.ratio: 0.14797913950456323`, threshold `0.06` |
|
||||
| Lock-state persistence | Passed | 2026-07-09 `/scanner/start?entry=visible-inventory&limit=1` stored `A Note in Spring's Leich` with `equipped: "Citlali"` and `locked: true` |
|
||||
| Lock-state diagnostics | Passed | Capture results include `lockSignal.ratio`, `lockSignal.threshold`, and the lock crop rect. Detection uses decoded PNG crop pixels to avoid native bitmap channel-order ambiguity |
|
||||
| Unsafe auto-entry default | Mitigated | Normal guided Auto-Scan now blocks when no artifact detail card is visible instead of falling back to `auto-entry` |
|
||||
| Review-to-eval export | Passed by tests | `npm run eval:review-candidates` script is covered by `src/eval/reviewEvalCandidatesScript.test.ts` and output is Git-ignored |
|
||||
|
||||
## Final Gate
|
||||
|
||||
```powershell
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Latest run after the cleanup: passed with `209` tests.
|
||||
`npm run eval` and `npm run scan:assessment:test` also passed after the
|
||||
lock-state fix.
|
||||
|
||||
## Explicitly Not Merge-Blocking
|
||||
|
||||
- `auto-entry`, `direct-inventory`, and `paimon-menu` are still experimental
|
||||
Dev-Control entry modes. They can be tested later with low limits, but they
|
||||
are no longer the normal merge path.
|
||||
- The optional `3 artifacts/second` target is not proven. The stable current
|
||||
path is closer to `2.5` to `2.75 artifacts/second` on clean 20-artifact live
|
||||
runs.
|
||||
- Alternate OCR-engine comparison paths have been retired. The current scanner
|
||||
path is the maintained baseline; future engine work should be reopened only
|
||||
with a new explicit product reason.
|
||||
|
||||
## Merge Result
|
||||
|
||||
Merged into `main` and pushed to `origin/main` on 2026-07-09. The previously
|
||||
open `locked: true` proof passed for both Smart Capture and auto-scan
|
||||
persistence before merge. The feature branch was deleted locally and remotely
|
||||
after the merge.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Native Artifact Scanner Validation - 2026-07-09
|
||||
|
||||
This report records the first bounded 20/50/100-item validation of the native
|
||||
Artifact capture and post-capture processing path. All runs used the visible
|
||||
1920x1080 16:9 Artifact inventory, category `artifacts`, and `persist=0`.
|
||||
No Artifact result was written to the local store.
|
||||
|
||||
## Result Summary
|
||||
|
||||
| Limit | Capture | Pages | Capture time | Capture rate | Parsed | Review | Errors | Processing time | Processing rate |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 20 | 20/20 | 1 | 4,544 ms | 4.40/s | 20/20 | 0 | 0 | 7,349 ms | 2.72/s |
|
||||
| 50 | 50/50 | 2 | 11,925 ms | 4.19/s | 50/50 | 1 | 0 | 15,398 ms after fix | 3.25/s |
|
||||
| 100 | 100/100 | 4 | 24,673 ms | 4.05/s | 100/100 | 3 | 0 | 38,343 ms | 2.61/s |
|
||||
|
||||
The 100-item run produced exactly 100 PNG crops, 100 JSONL capture jobs, and
|
||||
100 durable scan results. All 100 results preserved a real native capture
|
||||
timestamp; none fell back to the Unix epoch.
|
||||
|
||||
The native capture worker stayed above 4 artifacts/second. The current smoke
|
||||
runner performs capture and post-capture OCR sequentially, so these numbers do
|
||||
not prove a 3 artifacts/second end-to-end pipeline. That target remains open.
|
||||
|
||||
## Quality Findings
|
||||
|
||||
The 50-item run exposed two correctness gaps in the downstream result path:
|
||||
|
||||
- OCR read `31.1%` as `311%`, while the parser still assigned high confidence.
|
||||
- Native jobs carried the correct `capturedAt`, but durable scan results used
|
||||
the Unix epoch because the processor did not forward the timestamp.
|
||||
|
||||
The parser now reconciles percent main values against the canonical slot,
|
||||
stat, and level reference. It repairs an unambiguous missing decimal and lowers
|
||||
confidence for unresolved <= +16 conflicts instead of silently replacing a
|
||||
possible 4-star value. The processor now forwards native timestamps. The
|
||||
existing 50-item run was reprocessed and confirmed `31.1%`, the original
|
||||
capture time, one review, zero errors, and no persistence.
|
||||
|
||||
The final 100-item run placed three real OCR losses into Review:
|
||||
|
||||
- `DEF%+11.7%` was read as `DEF%+1.7%`.
|
||||
- `ATK%+15.7%` was read as `ATK%+156.7%`.
|
||||
- `HP+1,165` was read as `HP+1`.
|
||||
|
||||
All three values failed the legal substat-roll check and were prevented from
|
||||
becoming clean, promotable results. They were then manually verified and
|
||||
approved through the real single-result review workflow. The corrected results
|
||||
are `DEF%+11.7%`, `ATK%+15.7%`, and `HP+1,165`; the run now has zero remaining
|
||||
Review results. Approval wrote three durable review-log entries and three eval
|
||||
samples. The confirmed cases were also added to the permanent OCR regression
|
||||
corpus.
|
||||
|
||||
The parser now applies a conservative repair for this exact class of error: on
|
||||
a +20 Artifact with four substats, dropped or extra percentage digits are only
|
||||
repaired when exactly one combination satisfies the legal 5-star total roll
|
||||
count. Thousands separators in flat substats are preserved. Ambiguous cases
|
||||
continue to Review instead of being silently changed.
|
||||
|
||||
## Evidence Paths
|
||||
|
||||
- 20-item bundle: `outputs/native-live-smoke/20260709-223003/`
|
||||
- 20-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223006/`
|
||||
- 50-item bundle: `outputs/native-live-smoke/20260709-223038/`
|
||||
- 50-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223041/`
|
||||
- 100-item bundle: `outputs/native-live-smoke/20260709-223438/`
|
||||
- 100-item native run: `%APPDATA%/genshin-artifact-assistant/native-scans/20260709-223441/`
|
||||
|
||||
## Validation
|
||||
|
||||
- `npm run lint`: passed
|
||||
- `npm test`: passed, 246 tests at the time of the live validation
|
||||
- Manual review follow-up: 3/3 corrected and approved, 0 remaining Review rows
|
||||
- OCR regression eval after follow-up: 26/26 exact cases, 77/77 fields
|
||||
- `npm run build`: passed
|
||||
- Native 20/50/100 dry runs: passed capture and processing completeness
|
||||
- Artifact-store writes: zero
|
||||
|
||||
## Decision
|
||||
|
||||
The native Artifact path has enough same-session scale evidence to move the
|
||||
next implementation focus from capture expansion, promotion, and single-result
|
||||
review to deterministic Artifact value evaluation. The visible-inventory path
|
||||
remains the production baseline until packaged behavior and later-session
|
||||
native repeatability are also proven.
|
||||
+193
-30
@@ -3,13 +3,18 @@
|
||||
This document is the source of truth for project intent, scope, runtime facts, and operational expectations.
|
||||
|
||||
For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md).
|
||||
For the current app status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||
For the next scan-result and artifact-inventory product phase, see
|
||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
||||
For the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md).
|
||||
For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md).
|
||||
|
||||
## Project Identity
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Project name | Genshin Artifact Assistant |
|
||||
| Status | Scanner rebuild in progress |
|
||||
| Status | Artifact-first scanner baseline with native IK-style 20/50/100 scale evidence, explicit selected promotion, review/edit/approve, scan result rail, and Artifact-only Inventory pipeline; value scoring and later-session repeatability are next |
|
||||
| Platform | Windows desktop |
|
||||
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
|
||||
| Runtime | Electron app with React UI and TypeScript |
|
||||
@@ -26,7 +31,11 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
- Build one local canonical Genshin data package for artifact sets, pieces, slots, stats, and characters.
|
||||
- Parse artifact name, slot, main stat, substats, set, equipped state, and confidence deterministically against that package.
|
||||
- Save weak or failed reads automatically as review samples and turn corrections into reusable local fixes.
|
||||
- Keep the app offline-first and usable without Genshin Optimizer, Inventory Kamera, Enka, or HoYoLAB.
|
||||
- Present finished scan results as a compact artifact list instead of a debug-heavy live stats surface.
|
||||
- Keep extraction confidence separate from artifact value so uncertain OCR becomes review, not a misleading low score.
|
||||
- Provide a browsable local artifact inventory with detail views before promoting broader recommendations.
|
||||
- Keep the app offline-first and usable without optimizer imports, Enka, HoYoLAB,
|
||||
or any external scanner as a core dependency.
|
||||
- Re-introduce recommendations only after the scanner base is trustworthy.
|
||||
|
||||
## Non-Goals
|
||||
@@ -40,17 +49,20 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
|
||||
| ID | Requirement | Priority | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| FR-001 | List capture sources and automatically prefer the detected Genshin window when available. | Must | Prototype |
|
||||
| FR-002 | Read one currently opened artifact reliably from the local screen and show its parsed result. | Must | Prototype |
|
||||
| FR-003 | Generate and maintain a local canonical Genshin data package for sets, pieces, slots, stats, characters, aliases, and UI profiles. | Must | In progress |
|
||||
| FR-004 | Parse artifact fields only through deterministic matching, validation, and derivation against the canonical package. | Must | In progress |
|
||||
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Prototype |
|
||||
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Prototype |
|
||||
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Prototype |
|
||||
| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | In progress |
|
||||
| FR-001 | List capture sources and automatically prefer the detected Genshin window when available. | Must | Implemented |
|
||||
| FR-002 | Read one currently opened artifact reliably from the local screen and show its parsed result. | Must | Implemented |
|
||||
| FR-003 | Generate and maintain a local canonical Genshin data package for sets, pieces, slots, stats, characters, aliases, and UI profiles. | Must | Implemented baseline |
|
||||
| FR-004 | Parse artifact fields only through deterministic matching, validation, and derivation against the canonical package. | Must | Implemented baseline |
|
||||
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Implemented for visible-inventory baseline |
|
||||
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Implemented baseline |
|
||||
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Implemented baseline |
|
||||
| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | Implemented baseline |
|
||||
| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending |
|
||||
| FR-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending |
|
||||
| FR-011 | Farming overlay for reward scans. | Later | Prototype shell |
|
||||
| FR-012 | Show active scan results as a minimal right-side rail with artifact number, name or compact fallback, value score, and status pill. | Should | Partial foundation |
|
||||
| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | Should | Partial foundation |
|
||||
| FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Planned |
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
@@ -58,8 +70,10 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
| --- | --- | --- |
|
||||
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
|
||||
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
|
||||
| Performance | A 100-artifact visible-inventory run should finish cleanly with low review/miss rates and report timing evidence. | `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` quality-gated report |
|
||||
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
|
||||
| Reliability | Uncertain OCR must be visible to the user. | Confidence and details view |
|
||||
| Score integrity | Extraction confidence and artifact value are separate concepts. | Review state can block or qualify a value score |
|
||||
| Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` |
|
||||
| Maintainability | Scanner heuristics must be isolated and documented. | Parser tests, scan-loop tests, data generator, review sample pipeline |
|
||||
|
||||
@@ -94,32 +108,99 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
||||
- A local canonical data package already exists in `src/data/genshinGameData.json`, generated from `genshin-db`.
|
||||
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
||||
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
||||
- The scan surface has an initial recent-results rail backed by the newest
|
||||
stored artifacts.
|
||||
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
||||
- The scanner now has a validated visible-inventory path: 32 safe artifact
|
||||
targets per page, lookup-derived fields, fast OCR crop profile, and a
|
||||
quality-gated live soak runner.
|
||||
- Elevated live automation is validated in the current dev environment:
|
||||
`/automation/probe-click?index=1` changed the selected artifact and
|
||||
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
|
||||
- A 2026-07-08 visible-inventory 50-artifact run completed cleanly with
|
||||
50/50 parsed and stored, 0 review, 0 duplicates, and 0 misses. It is stable
|
||||
but still too slow for the 2-3 artifacts/second target.
|
||||
- Later 2026-07-08 direct-GDI hot-path runs completed 20/20 parsed with
|
||||
0 misses and 0 review. The best clean 20-artifact iteration reached
|
||||
7285 ms, or roughly 2.75 artifacts/second; the final stable
|
||||
`2026-07-08-direct-gdi-reviewfix` run completed in 7973 ms. The
|
||||
3 artifacts/second target remains unproven.
|
||||
- The same direct-GDI path completed a 100-artifact run with 100/100 parsed,
|
||||
0 review, 0 misses, and 42064 ms elapsed across 4 pages.
|
||||
- Review samples can now be exported with `npm run eval:review-candidates` into
|
||||
a Git-ignored human-labeling worklist. This is the next quality phase before
|
||||
adding more OCR corpus cases or trusting review queue data as labels.
|
||||
- Native IK-style Artifact capture is wired through the C# helper. It writes
|
||||
card crops and run artifacts for downstream OCR/parse processing instead of
|
||||
making React do per-artifact work in the hot capture loop.
|
||||
- The native post-capture processor can write `scan-results.json` and
|
||||
`processing-report.json`, preserve parser field confidence, match Artifact
|
||||
results against IK artifact set/piece/slot data, and keep store persistence
|
||||
opt-in.
|
||||
- The Inventory view now has an Artifact-only pipeline surface for scope,
|
||||
native capture, OCR queue, review gate, promotion, and evidence. Native rows
|
||||
expose crop previews, IK/GOOD metadata, dry-run promotion state, and a
|
||||
`Naechster Schritt` card.
|
||||
- The active UI intentionally hides weapon, material, and character-detail IK
|
||||
catalog coverage until those values are actually scanned.
|
||||
|
||||
### What is still structurally weak
|
||||
|
||||
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
|
||||
- Broader scan soak testing still needs to increase the live limit gradually and
|
||||
validate scroll/page transitions beyond the first visible row.
|
||||
- Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs
|
||||
with 0 misses on the current engine. The current 2026-07-09 100-artifact run
|
||||
completed `100/100` verified and parsed with `0` review and `0` misses.
|
||||
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
|
||||
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
||||
- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow.
|
||||
- Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus.
|
||||
- The scan page now has a minimalist recent-results rail, and the Inventory view
|
||||
can inspect native Artifact results, crop previews, IK match state, promotion
|
||||
dry-runs, and pipeline risk/status. Value scoring remains incomplete;
|
||||
single-result review/edit/approve is implemented and batch review is
|
||||
intentionally unavailable.
|
||||
- The new native IK-style path has same-session 20/50/100 live scale evidence
|
||||
with complete capture/result counts and safe Review gating. Later-session
|
||||
repeatability and packaged behavior remain open.
|
||||
- Store promotion from native `scan-results.json` now supports one selected,
|
||||
confirmed result at a time with main-process revalidation and a durable log.
|
||||
Batch promotion intentionally remains unavailable.
|
||||
- Native Review results can be corrected and approved or rejected one at a
|
||||
time. Approval revalidates IK identity, canonical main values, and legal
|
||||
substat rolls, then feeds the existing review-to-eval candidate pipeline.
|
||||
- Recommendations and build logic exist, but artifact inventory, detail review,
|
||||
and value scoring should land first so recommendations have trustworthy inputs.
|
||||
- Repeatability and 3 artifacts/second are still open; speed work should not
|
||||
outrank result clarity, inventory UX, or corpus growth while the current path
|
||||
is stable.
|
||||
|
||||
### Current product conclusion
|
||||
|
||||
The app should stop behaving like an OCR demo with extra features around it. The next phase is a scanner product rebuild: canonical data first, scan engine second, learning loop third, recommendations later.
|
||||
The app has crossed from OCR-demo/prototype into an Artifact-first scanner app.
|
||||
The broadest proven live path is still the visible-inventory scan flow: the
|
||||
operator opens Artifact inventory with a visible detail card, the app verifies
|
||||
the state, scans read-only, persists parsed artifacts, and keeps uncertain data
|
||||
reviewable. The newer native IK-style path is the intended high-speed direction:
|
||||
the helper captures Artifact card crops quickly, while OCR, parsing, review,
|
||||
promotion, and value evaluation run downstream.
|
||||
|
||||
The next product phase is not broad category expansion. Weapons, materials, and
|
||||
character details stay out of active scope. The priority is later-session
|
||||
native repeatability, value
|
||||
evaluation, and detail explanations before recommendations become the core
|
||||
product surface.
|
||||
|
||||
## Product Direction
|
||||
|
||||
- Artifact scanning is the first-class feature.
|
||||
- Character optimization returns only after scan quality is trustworthy.
|
||||
- Team building stays out of the critical path until artifact ingestion is stable.
|
||||
- Inventory Kamera remains a reference for scan choreography and page movement, not a runtime dependency.
|
||||
- External scanners are not runtime dependencies. The app owns its scan
|
||||
choreography, OCR, and quality gates.
|
||||
- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion.
|
||||
- The scan workspace should be an operator surface: preview, live result rail,
|
||||
Stop, status, and review access. Detailed stats and debug evidence belong in
|
||||
diagnostics, summaries, or artifact detail.
|
||||
- Artifact value scoring must not hide OCR uncertainty. `Review` is a distinct
|
||||
outcome, not just a weak artifact score.
|
||||
|
||||
## Execution Plan
|
||||
|
||||
@@ -131,7 +212,9 @@ Outcome:
|
||||
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
|
||||
|
||||
Status:
|
||||
- In progress
|
||||
- Mostly done for the scanner baseline. The Diagnose/dev surface is separated
|
||||
and scrollable, scan summaries are compact, and the normal Auto-Scan path is
|
||||
guarded. Further UI polish remains useful but no longer blocks scanner merge.
|
||||
|
||||
### Phase 1 - Canonical game data package
|
||||
|
||||
@@ -149,7 +232,9 @@ Outcome:
|
||||
- Parser stops "free guessing" outside the canonical package.
|
||||
|
||||
Status:
|
||||
- In progress
|
||||
- Implemented as a generated lookup package in `src/data/genshinGameData.json`
|
||||
with validation and parser integration. Continue regenerating and expanding
|
||||
aliases deliberately when Genshin data or OCR samples require it.
|
||||
|
||||
### Phase 2 - Deterministic parser hardening
|
||||
|
||||
@@ -162,9 +247,15 @@ Outcome:
|
||||
5. safe derivation from piece/slot/value references
|
||||
- Main stat/value inference is tightened with slot constraints and reference tables.
|
||||
- Bad parses automatically generate structured review reasons.
|
||||
- Equipped-character parsing is canonical-data constrained: noisy known names can
|
||||
match through aliases/fuzzy lookup, but unknown footer fragments stay
|
||||
`Not detected` instead of being stored as invented character names.
|
||||
|
||||
Status:
|
||||
- In progress
|
||||
- Implemented for the merge baseline. Parser tests cover canonical set/slot/stat
|
||||
matching, equipped-character footer noise, known aliases, and unsafe one-letter
|
||||
fragments. Continue growing the confirmed review corpus before tightening
|
||||
thresholds further.
|
||||
|
||||
### Phase 3 - Scanner core rebuild
|
||||
|
||||
@@ -182,9 +273,15 @@ Outcome:
|
||||
- resume or stop
|
||||
- Progress counts only when a new verified artifact or duplicate signature is confirmed.
|
||||
- Repeated pages, unchanged detail cards, blocked cursor movement, and scroll failures stop the scan with diagnosis instead of producing fake progress.
|
||||
- Fast artifact-read captures include the equipped footer when an equipped
|
||||
marker is visible; preflight and polling captures still skip expensive OCR.
|
||||
|
||||
Status:
|
||||
- In progress
|
||||
- Implemented and merged for the visible-inventory path. Live validation on
|
||||
2026-07-09 covered `20/20` verified/parsed with `0` review and `0` misses,
|
||||
equipped-character persistence, unlocked lock state, positive locked state,
|
||||
and locked persistence. Explicit entry-mode experiments remain separate from
|
||||
the normal merge-ready path.
|
||||
|
||||
### Phase 4 - Input automation replacement
|
||||
|
||||
@@ -200,7 +297,10 @@ Outcome:
|
||||
- Auto-scan never starts on a session that cannot prove one successful detail-card change.
|
||||
|
||||
Status:
|
||||
- First live path validated; broader soak testing still needed
|
||||
- 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
|
||||
|
||||
@@ -216,25 +316,86 @@ Outcome:
|
||||
- Review samples become both parser regression fixtures and learning inputs.
|
||||
|
||||
Status:
|
||||
- Planned
|
||||
- Prepared in code for text replacements, field aliases, constrained fixes,
|
||||
crop adjustment proposals, and UI-profile adjustment proposals. Crop/profile
|
||||
changes still require live review before being auto-applied.
|
||||
|
||||
### Phase 6 - Recommendations come back on top of a trusted scanner
|
||||
### Phase 6 - Minimal scan result rail and artifact inventory
|
||||
|
||||
Outcome:
|
||||
- The scan page shows a large screenshot/preview and a compact right-side rail
|
||||
of finished artifact evaluations.
|
||||
- Each row shows scan number, artifact name or compact fallback, value score,
|
||||
and a colored status pill.
|
||||
- Debug stats, confidence breakdowns, and OCR internals move out of the main
|
||||
scan surface.
|
||||
- A new artifact inventory menu provides compact browsing, filters, sorting,
|
||||
and click-through details.
|
||||
|
||||
Status:
|
||||
- Started with a scan result rail in the scan surface and an `Inventory` view.
|
||||
The rail can show native `scan-results.json` entries after post-processing
|
||||
and falls back to recent stored artifacts. The inventory browser can filter,
|
||||
sort, inspect native/store/snapshot rows, and preview native card crops from
|
||||
the selected run directory. It also exposes the current IK inventorylist
|
||||
version, active Artifact-only scope, and pipeline status for capture,
|
||||
post-processing, review, promotion, and evidence. Native Artifact rows carry
|
||||
IK/GOOD match status from the post-capture processor and a dry-run promotion
|
||||
decision that shows whether a native result is speicherbar, already stored,
|
||||
review-only, or blocked without writing to the store. Detail review exists;
|
||||
deterministic value score and value reasons are still pending. See
|
||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
|
||||
for the implementation phases and acceptance criteria.
|
||||
|
||||
### Phase 7 - Artifact detail evaluation and upgrade projection
|
||||
|
||||
Outcome:
|
||||
- Artifact detail explains parsed fields, OCR confidence, scoring reasons, and
|
||||
review needs.
|
||||
- Upgrade projection is available only when enough data is known and is labeled
|
||||
as probabilistic, with worst/middle/best projected value scores.
|
||||
- Low-confidence OCR disables or qualifies value conclusions instead of showing
|
||||
false certainty.
|
||||
|
||||
Status:
|
||||
- Planned after the scan result rail and inventory data contracts.
|
||||
|
||||
### Phase 8 - Recommendations come back on top of a trusted scanner
|
||||
|
||||
Outcome:
|
||||
- Account snapshot and build suggestions are only promoted once scan quality is high enough to trust owned artifacts.
|
||||
- Recommendations explain uncertainty and surface conflicts instead of pretending perfect certainty.
|
||||
|
||||
Status:
|
||||
- Deferred until scanner trust is acceptable
|
||||
- Next major product area after inventory, detail evaluation, repeatability, and
|
||||
corpus work. Do not promote recommendation UX until stored artifact quality is
|
||||
backed by more confirmed review samples and repeat live scan runs.
|
||||
|
||||
## Immediate Next Implementation Order
|
||||
|
||||
1. Finish scan-page cleanup so the main operator view is no longer noisy.
|
||||
2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples.
|
||||
3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules.
|
||||
4. Soak-test the elevated C# helper automation path with gradually larger scan limits and page scroll transitions.
|
||||
5. Extend the learning system from text-only fixes into crop/UI profile tuning.
|
||||
6. Resume recommendation work only when scan accuracy is consistently trustworthy.
|
||||
1. Add deterministic Artifact value evaluation with explainable reasons.
|
||||
2. Keep the visible-inventory scanner path as the production baseline and avoid
|
||||
promoting `auto-entry`, `direct-inventory`, or `paimon-menu` until they pass
|
||||
their own low-limit live validations.
|
||||
3. Keep the active native scope artifact-only until weapons, materials, and
|
||||
character details are actually scanned.
|
||||
4. Finish the scan result and inventory detail contracts from
|
||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md),
|
||||
preserving separate extraction confidence and artifact value.
|
||||
5. Finish value reasons in the existing Artifact inventory detail view before
|
||||
expanding broad build recommendations.
|
||||
6. Keep the existing scan preview/result rail and Inventory workflow compact;
|
||||
move new diagnostics behind the dedicated diagnostics surface.
|
||||
7. Continue growing the confirmed OCR corpus from review samples exported by
|
||||
`npm run eval:review-candidates` and prepared through
|
||||
`npm run eval:prepare-confirmed`.
|
||||
8. Repeat live scanner runs in later sessions to prove repeatability across
|
||||
pages, locked/unlocked artifacts, equipped footers, and duplicate handling.
|
||||
9. Continue the optional `3 artifacts/second` work only if the next change can
|
||||
reduce OCR/capture transport time without weakening quality gates.
|
||||
10. Start recommendation/product UX work only after inventory/detail evaluation,
|
||||
repeat scan quality, and confirmed corpus coverage are strong enough to trust
|
||||
stored artifacts.
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -243,4 +404,6 @@ Status:
|
||||
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
|
||||
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
||||
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
||||
| What exact first-pass value formula should drive the `0-100` artifact score before build-aware recommendations exist? | Open |
|
||||
| Which upgrade projection model is honest enough for early UX: deterministic roll buckets, probability-weighted outcomes, or a deliberately simple best/middle/worst estimate? | Open |
|
||||
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||
|
||||
+54
-3
@@ -2,18 +2,31 @@
|
||||
|
||||
Field-level accuracy measurement for the artifact OCR parser. This is the gate
|
||||
every OCR, crop, layout, or parser change runs against (see ADR-007).
|
||||
It is necessary but not sufficient for live scanner proof: scan speed and
|
||||
review/miss rates are measured by `npm run scan:iterate:validated` for short
|
||||
iteration and `npm run scan:goal:validated` for the final 100-artifact proof.
|
||||
Use the `:wait` variants directly after UAC startup.
|
||||
|
||||
## Run it
|
||||
|
||||
```powershell
|
||||
npm run eval # full accuracy report for the seed corpus
|
||||
npm run eval:review-candidates # export unconfirmed review samples for human labeling
|
||||
npm test # runs the eval gate alongside the rest of the suite
|
||||
npm run scan:assessment:test # verifies quality-first scan ranking logic
|
||||
npm run scan:iterate:validated:wait # 20-artifact live scan
|
||||
npm run scan:repeatability:wait # 20/45/100 current-engine repeatability
|
||||
npm run scan:goal:validated:wait # final 100-artifact live scan
|
||||
```
|
||||
|
||||
The report prints exact-match rate, overall field accuracy, a per-field
|
||||
breakdown (critical fields marked with `*`), and every failing case with an
|
||||
`expected "..." got "..."` diff.
|
||||
|
||||
Repeatability runs prove whether the visible-inventory scanner stays stable
|
||||
across later sessions. They are current-path evidence and should be reported
|
||||
with review/miss rates plus timing, not just click count.
|
||||
|
||||
## How it works
|
||||
|
||||
- `src/eval/ocrEvalHarness.ts` - pure metric functions. `runOcrEval(cases)`
|
||||
@@ -28,6 +41,35 @@ breakdown (critical fields marked with `*`), and every failing case with an
|
||||
|
||||
The review queue is the corpus source. A saved review sample carries the OCR
|
||||
text plus the parser's *guess* - `reviewSampleToEvalCase` extracts both.
|
||||
For the local Electron queue, run:
|
||||
|
||||
```powershell
|
||||
npm run eval:review-candidates -- --limit=80
|
||||
```
|
||||
|
||||
This writes:
|
||||
|
||||
- `outputs/review-eval-candidates/review-eval-candidates.json`
|
||||
- `outputs/review-eval-candidates/review-eval-candidates.md`
|
||||
|
||||
The exporter deduplicates samples, puts complete modern OCR captures first,
|
||||
marks missing fast-profile fields so stale/partial captures do not crowd out
|
||||
useful cases, and surfaces ownership/lock evidence (`artifact-footer`,
|
||||
`equipped`, and `locked=true/false`) for the next validation pass.
|
||||
|
||||
After manually checking one candidate against the real artifact, create a
|
||||
confirmed corpus snippet with explicit expected labels:
|
||||
|
||||
```powershell
|
||||
npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json
|
||||
```
|
||||
|
||||
The script reads the latest
|
||||
`outputs/review-eval-candidates/review-eval-candidates.json` by default and
|
||||
writes a `.confirmed.ts` snippet under `outputs/review-eval-candidates/`.
|
||||
It refuses to run without explicit labels, so parser guesses are not silently
|
||||
promoted to ground truth. Review that snippet, then paste the object into
|
||||
`src/eval/corpus/confirmedReviewCorpus.ts`.
|
||||
|
||||
The parser's guess is a label **candidate, not ground truth** (using it directly
|
||||
would be the parser grading itself). To add a real case:
|
||||
@@ -35,11 +77,20 @@ would be the parser grading itself). To add a real case:
|
||||
1. Convert review samples with `reviewSamplesToEvalCases(records)`.
|
||||
2. Open each produced case and confirm or correct the `expect` values against
|
||||
what the artifact actually is in-game. Set `confirmed: true`.
|
||||
3. Move the corrected case into a file under `src/eval/corpus/` and add it to the
|
||||
corpus array.
|
||||
3. Move the corrected case into
|
||||
`src/eval/corpus/confirmedReviewCorpus.ts`. The main eval gate imports
|
||||
`src/eval/corpus/index.ts`, which combines the seed corpus with confirmed
|
||||
review cases.
|
||||
|
||||
`src/eval/corpus/confirmedReviewCorpus.test.ts` rejects common corpus mistakes:
|
||||
duplicate case ids, missing OCR text, empty labels, missing source notes, or a
|
||||
case that was copied in without `confirmed: true`.
|
||||
|
||||
Prefer cases that cover new failure modes: unseen resolutions, new sets or
|
||||
characters, and OCR noise the current corpus does not exercise.
|
||||
characters, equipped footer noise, and OCR noise the current corpus does not
|
||||
exercise. `locked` is a capture-side visual flag rather than a text parser field;
|
||||
validate it from review-export metadata and live screenshots instead of adding
|
||||
it to the OCR eval labels.
|
||||
|
||||
## When a change moves a number
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# Scanner Results And Artifact Inventory Roadmap
|
||||
|
||||
This document defines the next product phase after the validated visible-inventory
|
||||
scanner baseline and the new native IK-style capture direction. The next capture
|
||||
milestone optimizes speed by moving game-control and card-crop capture into the
|
||||
C# helper. Artifact extraction, review safety, and inventory UX stay separate
|
||||
downstream work so the Electron app remains a visual/status surface instead of
|
||||
the worker.
|
||||
|
||||
For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||
|
||||
## Product Stance
|
||||
|
||||
- Keep the scan workspace focused on operation, not analysis.
|
||||
- Keep the live preview as the dominant surface.
|
||||
- Keep Electron/React out of per-artifact scanner work in the fast path.
|
||||
- Use IK `inventorylists` 1:1 as the scanner dictionary source for artifacts.
|
||||
Other IK lists may stay vendored, but weapons, materials, and character
|
||||
details are not active product scope until their values are actually scanned.
|
||||
- Move debug metrics, OCR internals, and detailed evaluation behind details,
|
||||
diagnostics, or the inventory view.
|
||||
- Do not merge scan confidence and artifact value into one ambiguous score.
|
||||
- Treat uncertain OCR as review, not as a low-quality artifact.
|
||||
- Prioritize extracting correct artifact content over another speed pass unless
|
||||
live timings regress materially.
|
||||
|
||||
## Target User Flow
|
||||
|
||||
1. The user opens Artifact inventory in Genshin with a visible detail card.
|
||||
2. The scanner runs the existing read-only visible-inventory flow.
|
||||
3. The scan view shows the latest screenshot/preview on the left.
|
||||
4. A compact live result rail on the right receives one row per finished
|
||||
artifact evaluation.
|
||||
5. Each row shows only:
|
||||
- scan number,
|
||||
- artifact name or compact slot/set fallback,
|
||||
- artifact value score from `0` to `100`,
|
||||
- a colored result pill.
|
||||
6. After the scan, the user opens the inventory menu to browse all scanned
|
||||
artifacts.
|
||||
7. Clicking an artifact opens a detail view with screenshot, parsed fields,
|
||||
OCR confidence, scoring reasons, and optional upgrade projection.
|
||||
|
||||
## Score Contract
|
||||
|
||||
The UI must keep two concepts separate:
|
||||
|
||||
| Concept | Meaning | UI behavior |
|
||||
| --- | --- | --- |
|
||||
| Extraction confidence | How reliable the scan/OCR/parser result is. | Drives `Review`, warnings, and detail confidence rows. |
|
||||
| Artifact value score | How useful the artifact appears for builds. | Drives the `0-100` value and good/mid/weak pill. |
|
||||
|
||||
Rules:
|
||||
|
||||
- If extraction confidence is too low, show `Review` instead of a normal value
|
||||
decision, even when a tentative value score exists.
|
||||
- If the artifact is a duplicate, show duplicate state separately from value.
|
||||
- The live rail may show one compact pill, but the data model should preserve
|
||||
separate `extractionStatus` and `valueStatus` fields.
|
||||
- Score labels should be stable and simple:
|
||||
|
||||
| Value score | Label |
|
||||
| --- | --- |
|
||||
| `90-100` | Strong |
|
||||
| `70-89` | Good |
|
||||
| `45-69` | Mid |
|
||||
| `0-44` | Weak |
|
||||
| unknown or unsafe | Review |
|
||||
|
||||
The exact formula can start simple and deterministic. It should explain its
|
||||
reasons in the detail view before it becomes a recommendation source.
|
||||
|
||||
## Planned Pipeline Shape
|
||||
|
||||
The current TypeScript scan loop can keep shipping as fallback while the native
|
||||
pipeline takes over high-speed capture. The target producer/consumer pipeline is:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Native["C# native click, scroll, capture worker"]
|
||||
Queue["Bounded card-crop queue"]
|
||||
OCR["OCR and parse workers"]
|
||||
Match["IK inventorylists matching"]
|
||||
Eval["Artifact evaluation (deferred)"]
|
||||
Aggregate["Aggregator and store"]
|
||||
UI["Live rail and inventory"]
|
||||
|
||||
Native --> Queue
|
||||
Queue --> OCR
|
||||
OCR --> Match
|
||||
Match --> Eval
|
||||
Eval --> Aggregate
|
||||
Aggregate --> UI
|
||||
```
|
||||
|
||||
Constraints:
|
||||
|
||||
- Only the native helper may control Genshin input, focus, click, scroll, card
|
||||
capture, or failsafe polling in the fast path.
|
||||
- OCR/parse/evaluation workers may run concurrently on already captured
|
||||
screenshot/crop jobs.
|
||||
- The queue must be bounded, initially around `4-8` jobs, so the scanner does
|
||||
not outrun retries, review decisions, or stop requests.
|
||||
- The pipeline must preserve current safety rules: no memory reads, hooks,
|
||||
injection, game-file changes, deleting, feeding, enhancing, locking/unlocking,
|
||||
or spending resources.
|
||||
- Evaluation may be omitted until after capture speed and crop quality are
|
||||
validated.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Documentation and contracts
|
||||
|
||||
Status: prepared by this document and ADR-013.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Product direction is documented.
|
||||
- Main docs point to this roadmap.
|
||||
- Acceptance criteria and checklists exist before code changes.
|
||||
- Native run artifacts are part of the scanner contract:
|
||||
`manifest.json`, `capture-jobs.jsonl`, `status.json`,
|
||||
`scan-results.json`, and `processing-report.json`.
|
||||
|
||||
### Phase 1 - Result data model
|
||||
|
||||
Status: foundation implemented for native post-capture processing. The native
|
||||
scanner reports run status and writes crop jobs; the post-capture processor now
|
||||
writes durable per-artifact `scan-results.json` entries next to the diagnostic
|
||||
`processing-report.json`. Native artifact results can now carry IK inventorylist
|
||||
match metadata, and IK set/piece/slot conflicts force review instead of clean
|
||||
extraction. The post-capture OCR/parse worker runs as a bounded queue while
|
||||
preserving result order. `scan-results.json` also stores parser field confidence
|
||||
metadata for native artifact details. Evaluation remains explicitly deferred.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Add a durable scan result entry model with sequence number, capture metadata,
|
||||
parsed artifact identity, extraction status, artifact value score, value
|
||||
status, duplicate/review flags, and timestamps. Initial native entries use
|
||||
`valueStatus: "deferred"` for clean parses and `valueStatus: "review"` for
|
||||
uncertain extraction.
|
||||
- Keep existing stored artifact records compatible.
|
||||
- Add tests for status derivation so low-confidence OCR cannot become a normal
|
||||
`Good` or `Strong` result.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/types/domain.ts`
|
||||
- `src/types/storage.ts`
|
||||
- `src/lib/scannerSession.ts`
|
||||
- `src/lib/storedArtifactAdapter.ts`
|
||||
- `src/lib/scanReviewUtils.ts`
|
||||
|
||||
### Phase 2 - Minimal live result rail
|
||||
|
||||
Status: foundation implemented for native post-capture results. The scan main
|
||||
section shows newest stored artifacts as fallback and can display the latest
|
||||
native `scan-results.json` entries after post-processing. Result rail rows can
|
||||
open the Inventory surface for crop/IK/detail inspection. Value scores are still
|
||||
pending.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Rework the scan main section into preview plus right-side result rail.
|
||||
- Remove live evaluation cards and noisy stats from the primary scan area.
|
||||
- Append rows only after an artifact has finished parse/evaluation.
|
||||
- Keep Stop, scan status, and review access available.
|
||||
- Keep debug stats in diagnostics or summary modals.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/features/scan/components/ScanMainSection.tsx`
|
||||
- `src/features/scan/components/ScanResultCards.tsx`
|
||||
- `src/features/scan/components/hooks/useScanMainSectionModel.ts`
|
||||
- `src/features/scan/components/hooks/useScanResultCardsModel.ts`
|
||||
- `src/styles/base.css`
|
||||
|
||||
### Phase 3 - Artifact inventory view
|
||||
|
||||
Status: foundation implemented and now scoped to active Artifact scanning only.
|
||||
The app has an `Inventory` navigation item with
|
||||
a compact browser for native `scan-results.json` entries, stored artifacts, and
|
||||
snapshot fallback rows. Filtering, sorting, a detail panel, and secure native
|
||||
crop preview loading are present. The view now surfaces the vendored IK
|
||||
Artifact version/counts, active Artifact-only scope, compact pipeline state for
|
||||
native capture, OCR queue, review, promotion, and evidence, plus per-result
|
||||
IK/GOOD match status for native artifacts. The inventory view also computes a
|
||||
dry-run promotion summary from `scan-results.json` plus the local artifact
|
||||
store, separating `speicherbar`, already stored, review, and blocked native
|
||||
results. One selected clean result can now be promoted after a second UI
|
||||
confirmation; the main process revalidates the run result, writes the store,
|
||||
updates `scan-results.json`, and appends `promotion-log.jsonl`. Weapons,
|
||||
materials, and character details
|
||||
remain hidden from the active feature UI while they are not scanned. The native
|
||||
helper still reports the category distinction in
|
||||
`supportedCategories` via `catalogAvailable`, `nativeCaptureSupported`, and
|
||||
`scanStatus`, so dev-control evidence cannot accidentally claim that every IK
|
||||
catalog has an implemented scanner. Native scan start, status, manifest, and
|
||||
capture jobs now carry an explicit scan category; only `artifacts` can currently
|
||||
produce native capture jobs. Value scoring is still pending; single-result
|
||||
review/edit/approve is implemented in the detail phase below.
|
||||
|
||||
Support code for simple IK weapon, character, and material name/GOOD-key
|
||||
matching exists, but those categories still need their own capture flows before
|
||||
they can be claimed as scanned inventory.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Add a menu item for scanned artifact inventory.
|
||||
- Show a compact, minimal list or dense grid of stored artifacts.
|
||||
- Each entry shows the same score/pill language as the live rail.
|
||||
- Provide filters and sorting for review, score, set, slot, equipped, locked,
|
||||
and newest scan.
|
||||
- Avoid a marketing/landing layout; the first screen is the actual inventory.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/features/inventory/*`
|
||||
- `src/features/layout/navigation.ts`
|
||||
- `src/pages/app/*`
|
||||
- `src/lib/artifactStore.ts`
|
||||
- repository bridge/storage files as needed
|
||||
|
||||
### Phase 4 - Artifact detail view
|
||||
|
||||
Status: started for native scan results. Inventory detail can show the native
|
||||
card crop from the run directory through the Electron bridge, constrained to
|
||||
PNG files inside the active native run folder. It also shows stored parser
|
||||
field-confidence rows from native `scan-results.json`, IK/GOOD metadata, dry-run
|
||||
promotion state, and a `Naechster Schritt` card. Native Review results now have
|
||||
an inline field editor with approve/reject, authoritative validation, run logs,
|
||||
and review-to-eval export. Value reasons are still pending.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Clicking a live row or inventory item opens detail.
|
||||
- Detail shows screenshot or detail crop when available.
|
||||
- Detail lists parsed fields, OCR confidence, parser notes, extraction status,
|
||||
value score, and scoring reasons.
|
||||
- Review-required items make the uncertainty explicit and do not present their
|
||||
score as final.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/features/inventory/components/*`
|
||||
- `src/features/scan/components/modals/*`
|
||||
- `src/lib/artifactOcrParser.ts`
|
||||
- `src/lib/scoring.ts`
|
||||
|
||||
### Phase 5 - Artifact value evaluation
|
||||
|
||||
Status: next product feature after the now-completed same-session native scale,
|
||||
selected promotion, and review/edit/approve workflow.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Add a deterministic artifact value evaluator before promoting build
|
||||
recommendations.
|
||||
- Explain the score through factors such as set, slot, main stat, substat
|
||||
quality, level, locked/equipped state, and available character/build context.
|
||||
- The evaluator must accept incomplete data and return review/unknown instead
|
||||
of confident nonsense.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/lib/artifactEvaluation.ts`
|
||||
- `src/lib/scoring.ts`
|
||||
- `src/lib/substatRolls.ts`
|
||||
- `src/lib/genshinLookup.ts`
|
||||
- targeted unit tests under `src/lib/*.test.ts`
|
||||
|
||||
### Phase 6 - Upgrade projection
|
||||
|
||||
Status: later detail-level feature after artifact value evaluation.
|
||||
|
||||
Outcome:
|
||||
|
||||
- For artifacts below max level, show optional projection only in detail.
|
||||
- Provide `worst`, `middle`, and `best` projected value scores.
|
||||
- Label projection as probabilistic and not a guaranteed result.
|
||||
- Use known Genshin upgrade constraints and current substats; unknown or
|
||||
partially read data must disable or soften the projection.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/lib/upgradeProjection.ts`
|
||||
- `src/lib/substatRolls.ts`
|
||||
- `src/features/inventory/components/*`
|
||||
- parser/scoring tests
|
||||
|
||||
### Phase 7 - Queue-based analysis pipeline
|
||||
|
||||
Outcome:
|
||||
|
||||
- Introduce a bounded screenshot/crop job queue only after the UI/data contract
|
||||
is stable.
|
||||
- Keep one game-control worker.
|
||||
- Allow OCR/parse/evaluation workers to process queued jobs.
|
||||
- Preserve stop/failsafe behavior and review decisions.
|
||||
- Compare throughput against the current baseline without weakening accuracy.
|
||||
|
||||
Status: started for post-capture processing. Native capture already writes
|
||||
crop jobs without waiting for OCR. The downstream processor now consumes those
|
||||
jobs with bounded parallelism and writes stable ordered reports. Live throughput
|
||||
comparison remains a final validation item.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `src/lib/autoScanLoop.ts`
|
||||
- `src/lib/scannerSession.ts`
|
||||
- Electron capture/OCR boundary in `electron/main.ts` or extracted services
|
||||
- scan-loop tests and live soak scripts
|
||||
|
||||
### Phase 8 - Recommendation promotion
|
||||
|
||||
Status: intentionally delayed until native artifact ingestion, review,
|
||||
promotion, value scoring, and repeatability are trustworthy.
|
||||
|
||||
Outcome:
|
||||
|
||||
- Promote account-level recommendations only after scan result quality,
|
||||
inventory browsing, detail review, and value scoring are trustworthy.
|
||||
- Recommendations must reference stored artifact quality and uncertainty.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The scan page still fits the primary workflow without page-level scrolling.
|
||||
- The preview remains visible during active scan.
|
||||
- The right rail shows finished artifact evaluations, not noisy intermediate
|
||||
parser/debug state.
|
||||
- Review items are visibly different from weak artifacts.
|
||||
- Artifact value score and extraction confidence remain separate in data.
|
||||
- Inventory view can browse stored scan results without opening diagnostics.
|
||||
- Detail view explains why an artifact received its score.
|
||||
- Upgrade projection never implies a guaranteed future roll.
|
||||
- Existing safety constraints and scan quality gates remain intact.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
- `npm run lint`
|
||||
- `npm test`
|
||||
- `npm run build`
|
||||
- `git diff --check`
|
||||
- `npm run scan:native:smoke` before claiming native capture plus
|
||||
post-capture processing on live Genshin data.
|
||||
- Broader native Artifact runs with 20/50/100 items before claiming IK-style
|
||||
speed or stability.
|
||||
- Add unit tests for score/status derivation and upgrade projection.
|
||||
- For scanner-facing changes, run a low-limit visible-inventory live scan before
|
||||
wider validation.
|
||||
- Keep `npm run scan:repeatability:wait` for later regression checks, not for
|
||||
every UI-only pass.
|
||||
+164
-52
@@ -1,61 +1,173 @@
|
||||
# Scanner rework status
|
||||
# Scanner Rework Status
|
||||
|
||||
Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in
|
||||
[DECISIONS.md](DECISIONS.md) for the decisions behind these. For the current
|
||||
live automation runbook, see
|
||||
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
||||
Updated: 2026-07-09
|
||||
|
||||
## Done (implemented, unit-tested, build green)
|
||||
Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||
|
||||
- **OCR eval harness** — `src/eval/`, `npm run eval`, gate in `npm test`. See
|
||||
[ocr-eval.md](ocr-eval.md).
|
||||
- **C# input/capture sidecar** — `native/input-helper/`, `npm run helper:build`.
|
||||
Replaces the PowerShell helper on the same JSON protocol; PowerShell remains a
|
||||
fallback. Verified end-to-end (spawn, runtime, base64 capture).
|
||||
- **Layout profiles + OCR preprocessing** — `src/lib/layoutProfile.ts` (pure
|
||||
geometry, 16:9 detection), `src/lib/ocrPreprocess.ts` (grayscale + Otsu
|
||||
binarize). main.ts now uses calibrated 16:9 detail/count/grid coordinates
|
||||
first and OCRs an upscaled + binarized copy.
|
||||
- **Card-ready gating** — `src/lib/cardReadyGate.ts` replaces the fixed 280 ms
|
||||
settle with change+stability polling; robust to animation.
|
||||
- **GOOD interop** — `src/lib/goodInterop.ts` (export + best-effort import for
|
||||
scanned records), Electron file-picker import/export, and store merge.
|
||||
- **Rescan-merge** — `src/lib/artifactMerge.ts` collapses leveled re-scan
|
||||
duplicates by a level-independent identity.
|
||||
- **Data staleness warning** — `src/lib/dataPackageStatus.ts`, surfaced in the
|
||||
Scanner Diagnose data-package line.
|
||||
- **Lock detection (experimental)** — `src/lib/lockDetection.ts`, wired into
|
||||
live capture as a read-only `locked` flag and persisted with scanned records.
|
||||
- **Elevated live automation path** — `npm run dev:admin` now starts through
|
||||
`scripts/dev-admin.ps1` and logs to `outputs/admin-start/admin-dev.log`.
|
||||
Live status confirmed `isElevated: true`, `genshinFound: true`, and
|
||||
`targetProcess: "GenshinImpact"`.
|
||||
- **Read-only click probe** — `/automation/probe-click?index=1` verified that
|
||||
the app can focus Genshin, move to a visible inventory tile, click it, and
|
||||
observe a changed detail panel fingerprint (`clicked: true`,
|
||||
`inputBlocked: false`, `changed: true`).
|
||||
- **Bounded auto-scan validation** — `/scanner/start?limit=2` completed live
|
||||
with 2 clicks, 2 verified detail views, 2 parsed artifacts, 2 stored records,
|
||||
2 review samples, and 0 misses.
|
||||
## Was Es Kann
|
||||
|
||||
## Remaining — needs the live environment or a UI pass
|
||||
- Native IK-Erfassung ist als neuer schneller Pfad verdrahtet: Electron/React
|
||||
starten und zeigen Status, der C# Helper prueft IK-Listen, fokussiert Genshin,
|
||||
klickt das 16:9 8x4 Grid, scrollt und schreibt Detailkarten-Crops.
|
||||
- IK `inventorylists` aus Inventory Kamera 1.4.4 sind 1:1 unter
|
||||
`data/ik-inventorylists` uebernommen und werden in Builds als Resource
|
||||
mitgeliefert. Der Helper meldet Version `6.7.0` mit `61` Artifact-Sets,
|
||||
`289` Artifact-Pieces, `247` Waffen, `119` Charakteren und `715` Materialien.
|
||||
- Jeder native Run legt `manifest.json`, `capture-jobs.jsonl` und `status.json`
|
||||
im Run-Ordner an. Damit kann OCR/Parsing als nachgelagerte Queue laufen,
|
||||
ohne den Capture-Loop wieder in React/Electron zu ziehen.
|
||||
- Nach einem nativen Run kann die App `capture-jobs.jsonl` nachgelagert
|
||||
verarbeiten und `scan-results.json` plus `processing-report.json` schreiben.
|
||||
`scan-results.json` enthaelt dauerhafte Ergebnis-Eintraege mit
|
||||
Extraction-Status, Review-Zustand und bewusst deferierter Value-Auswertung.
|
||||
Diese Stufe OCRt/parst nach der Erfassung, matched native Artifact-Ergebnisse
|
||||
gegen IK `inventorylists`, zwingt IK-Konflikte in Review, verarbeitet Crops
|
||||
ueber eine bounded Queue, schreibt Parser-Feldconfidence in die Resultate und
|
||||
persistiert standardmaessig noch nicht in die DB.
|
||||
- Smart Capture reads the current artifact detail view through focused 16:9
|
||||
crops, OCR preprocessing, deterministic parser matching, and local lookup data.
|
||||
- Visible-inventory auto-scan is the production baseline: preflight, grid
|
||||
detection, focus, click, detail verification, OCR, parse, store/review, scroll,
|
||||
and summary.
|
||||
- The current live path has completed a 100-artifact run with `100/100` verified
|
||||
and parsed, `98` stored, `2` duplicates, `0` review samples, `0` misses, and
|
||||
`393 ms/artifact`.
|
||||
- Native IK live smoke passed on 2026-07-09 with runtime signature
|
||||
`2026-07-09-native-ik-visual-probe`: visual preflight ready, guarded probe
|
||||
changed the detail panel, `2/2` native artifact card crops captured,
|
||||
post-capture processing parsed `2/2`, `0` review, `0` errors, and
|
||||
`queueConcurrency: 2` without persisting to the artifact store.
|
||||
- Native 20/50/100 dry validation also passed in the same live session. The
|
||||
100-item run captured and parsed `100/100` across 4 pages with `0` errors,
|
||||
`3` correctly gated Review results, and `0` store writes. See
|
||||
`docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md`.
|
||||
- Stored artifacts, review samples, local text replacements, GOOD-compatible
|
||||
import/export, scanner diagnostics, and OCR eval are implemented.
|
||||
- The scan page now has a compact `Letzte Ergebnisse` rail. It uses the latest
|
||||
native `scan-results.json` entries after post-processing and falls back to
|
||||
newest stored artifacts when no native run result is loaded. Rail rows open
|
||||
the Inventory surface for detail inspection.
|
||||
- The app now has an `Inventory` navigation view for browsing native scan
|
||||
result entries, stored artifacts, and snapshot fallback rows with filters,
|
||||
sorting, a detail panel, and native crop previews loaded from the scan run
|
||||
directory. The same view is now scoped to Artifact scanning: it shows IK
|
||||
Artifact set/piece coverage, a Native Artifact pipeline strip for capture,
|
||||
post-processing, review, promotion, and evidence, plus a per-result
|
||||
`Naechster Schritt` panel. Weapons, materials, and character details remain
|
||||
loaded data only and are intentionally hidden from the active feature UI while
|
||||
they are not scanned.
|
||||
- The live-soak runner writes JSON/CSV evidence bundles and validates quality via
|
||||
`scan-performance-assessment.json`.
|
||||
|
||||
These cannot be finished/validated without Genshin running at the user's
|
||||
resolution or without UI work best tested live:
|
||||
## Was Zuletzt Gemacht Wurde
|
||||
|
||||
1. **Validate/tune OCR preprocessing** on more real captures — confirm invert +
|
||||
threshold + upscale factor help (not hurt) actual Tesseract reads. The
|
||||
text-level eval harness cannot measure image preprocessing.
|
||||
2. **Validate locked=true** against a known locked artifact — unlocked/grey lock
|
||||
was live-checked; a gold locked icon still needs a positive sample.
|
||||
- Der aktive UI-Scope wurde auf Artifact scanning begrenzt.
|
||||
- Waffen, Materialien und Charakterdetails bleiben als vendored IK-Daten
|
||||
vorhanden, werden aber nicht mehr als aktive Scanner-Features in der
|
||||
Inventory UI dargestellt.
|
||||
- Die Inventory UI zeigt jetzt eine Native Artifact Pipeline:
|
||||
Scope, Native Capture, OCR Queue, Review Gate, Promotion und Evidenz.
|
||||
- Native Ergebnislabels wurden geschaerft:
|
||||
`Geparst` bedeutet extrahiert, `Stored` bedeutet persistiert, `Review`
|
||||
bedeutet unsicher und `Wert offen` bedeutet bewusst noch nicht bewertet.
|
||||
- Native Artifact Details zeigen jetzt einen `Naechster Schritt`:
|
||||
Promotion bereit, Review zuerst, bereits im Store, blockiert oder Wert spaeter.
|
||||
- Inventory Filter wurden um native Ergebnisse und speicherbare Ergebnisse
|
||||
erweitert.
|
||||
- Ein ausgewaehltes, sauberes natives Ergebnis kann jetzt nach einer zweiten
|
||||
UI-Bestaetigung promotet werden. Der Main-Prozess validiert die autoritative
|
||||
Run-Datei erneut, prueft Store-Duplikate, schreibt den Store, aktualisiert
|
||||
`scan-results.json` und protokolliert in `promotion-log.jsonl`.
|
||||
- Native Review-Ergebnisse koennen direkt im Inventory anhand des Crops editiert,
|
||||
gegen IK, kanonische Main-Werte und legale Substat-Rolls validiert sowie
|
||||
freigegeben oder abgelehnt werden. Freigaben aktualisieren `scan-results.json`,
|
||||
schreiben `review-log.jsonl` und erzeugen einen normalen Eval-Review-Sample.
|
||||
- Projekt-, Roadmap-, Architektur-, Runbook- und Checklist-Doku wurden auf
|
||||
Artifact-only Scope nachgezogen.
|
||||
|
||||
3. **Broader scan soak test** — after the bounded two-item live scan passed,
|
||||
the next automation validation should increase the limit gradually and watch
|
||||
for repeated pages, scroll behavior, duplicate handling, and OCR review rate.
|
||||
## Was Noch Nicht
|
||||
|
||||
## Grow the eval corpus
|
||||
- Native IK-Erfassung captured aktuell Karten-Crops; OCR/Parser laufen erst
|
||||
nachgelagert ueber den Processor. GOOD-Speichern und Evaluierung laufen noch
|
||||
nicht automatisch im nativen Pipeline-Nachgang.
|
||||
- Native IK-Erfassung ist aktuell auf das sichtbare 16:9 Artifact-Inventar
|
||||
begrenzt.
|
||||
- The result rail is still a foundation: it can show native `scan-results.json`
|
||||
entries after post-processing, but does not yet calculate or show real value
|
||||
scores.
|
||||
- The inventory browser is still a foundation: value scoring and richer
|
||||
review/edit flows are not complete.
|
||||
- Upgrade projection and build recommendations should wait until inventory and
|
||||
detail evaluation have trustworthy stored artifacts.
|
||||
- Repeatability across later sessions, more accounts, more locked/equipped
|
||||
combinations, and more confirmed OCR corpus cases still needs growth.
|
||||
- The strict `333 ms/artifact` budget for 3 artifacts/second is not proven.
|
||||
- Weapons, materials, and character details are intentionally out of active
|
||||
scope until their values are actually scanned.
|
||||
|
||||
Every low-confidence review sample already stores its crops + OCR. Confirm/correct
|
||||
those via `reviewSampleToEvalCase` and commit them into `src/eval/corpus/` so the
|
||||
harness keeps measuring real-world accuracy across patches. See
|
||||
[ocr-eval.md](ocr-eval.md).
|
||||
## Wo Es Noch Probleme Macht
|
||||
|
||||
- Die native Pipeline hat jetzt 20/50/100-Skalenevidenz aus einer Live-Session.
|
||||
Spaetere Session-Repeatability und Packaged-App-Evidenz stehen noch aus.
|
||||
- Capture roundtrip and OCR remain the main timing costs in the 100-artifact
|
||||
path.
|
||||
- Auto-scan still requires a visible artifact inventory detail card for the
|
||||
production path; broader auto-entry modes remain dev experiments.
|
||||
- OCR/parser quality is good on the confirmed corpus, but review samples must be
|
||||
manually labeled before they can become permanent eval ground truth.
|
||||
- Non-16:9 or unusual game layouts are intentionally higher risk and should
|
||||
block or fall into review instead of silently scanning.
|
||||
- The UI now labels these risks in the Artifact Inventory pipeline. Same-session
|
||||
scale is proven, but external-tool parity and later-session repeatability are
|
||||
intentionally not claimed.
|
||||
|
||||
## Was Noch Verbesserungsfaehig Ist
|
||||
|
||||
- Repeat the bounded 20/50/100 native runs in a later session and in a packaged
|
||||
app to verify that the current post-capture queue remains repeatable.
|
||||
- Keep native persistence explicit: clean selected results can be promoted
|
||||
after confirmation, while uncertain results must pass single-item review.
|
||||
- Finish value scoring and value-reason contracts in the existing detail view from
|
||||
`docs/scanner-results-inventory-roadmap.md`.
|
||||
- Keep diagnostics available but out of the primary scan surface.
|
||||
- Continue expanding confirmed OCR eval cases from real review samples. The
|
||||
first three native Review cases are corrected, approved, and permanent
|
||||
regression cases.
|
||||
- Add artifact value/detail evaluation without collapsing extraction confidence
|
||||
and artifact value into one ambiguous status.
|
||||
- Continue performance work only when it reduces capture/OCR overhead without
|
||||
weakening review, miss, duplicate, or safety gates.
|
||||
|
||||
## Was Als Naechstes Ansteht
|
||||
|
||||
1. Artifact Value Evaluation ergaenzen, aber erst nach sauberer Extraktion und
|
||||
weiterhin getrennt von OCR/Parser-Confidence.
|
||||
2. Detail-Ansicht um Value-Gruende und optionale Upgrade-Projektion erweitern.
|
||||
3. Packaged-App-Verhalten fuer Helper, IK-Listen, Preload-Bridge,
|
||||
Crop-Preview und Smoke-Kommandos pruefen.
|
||||
4. Empfehlungen und Build-UX erst danach wieder nach vorne ziehen.
|
||||
|
||||
## Current Validation Commands
|
||||
|
||||
```powershell
|
||||
npm run scan:live:preflight
|
||||
npm run scan:iterate:validated
|
||||
npm run scan:goal:validated
|
||||
npm run scan:repeatability:wait
|
||||
npm run scan:assessment:validate -- --latest --summary
|
||||
npm run eval
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Latest static validation after the Artifact-only Inventory UI update:
|
||||
|
||||
```powershell
|
||||
npm run lint # passed
|
||||
npm test # passed, 252 tests
|
||||
npm run build # passed
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`git diff --check` passed with existing CRLF warnings for
|
||||
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BrowserWindow, Menu, screen } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
export interface AppWindowManagerOptions {
|
||||
preloadPath: string;
|
||||
rendererUrl?: string;
|
||||
rendererFilePath: string;
|
||||
onMainReadyToShow?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AppWindowManager {
|
||||
createMainWindow: () => void;
|
||||
focusMainWindow: () => { ok: boolean };
|
||||
hasMainWindow: () => boolean;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
sendScannerCommand: (command: unknown) => void;
|
||||
createOverlayWindow: () => void;
|
||||
hideOverlayWindow: () => { ok: boolean };
|
||||
}
|
||||
|
||||
export function createAppWindowManager({
|
||||
preloadPath,
|
||||
rendererUrl,
|
||||
rendererFilePath,
|
||||
onMainReadyToShow,
|
||||
}: AppWindowManagerOptions): AppWindowManager {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
|
||||
function createMainWindow() {
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
backgroundColor: "#090711",
|
||||
title: "Genshin Artifact Assistant",
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
void onMainReadyToShow?.();
|
||||
focusMainWindow();
|
||||
});
|
||||
mainWindow.webContents.once("did-finish-load", () => {
|
||||
setTimeout(() => focusMainWindow(), 350);
|
||||
});
|
||||
|
||||
if (rendererUrl) {
|
||||
mainWindow.loadURL(rendererUrl);
|
||||
} else {
|
||||
mainWindow.loadFile(rendererFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
function focusMainWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return { ok: false };
|
||||
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
// Genshin often keeps foreground focus after a scan click. Toggling
|
||||
// always-on-top for one tick nudges Windows to surface the dashboard again
|
||||
// without leaving it pinned above other apps.
|
||||
mainWindow.setAlwaysOnTop(true, "screen-saver");
|
||||
mainWindow.focus();
|
||||
setTimeout(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
mainWindow.focus();
|
||||
}, 250);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function sendScannerCommand(command: unknown) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("scanner:command", command);
|
||||
}
|
||||
|
||||
function createOverlayWindow() {
|
||||
if (overlayWindow) {
|
||||
overlayWindow.show();
|
||||
return;
|
||||
}
|
||||
|
||||
const display = screen.getPrimaryDisplay();
|
||||
overlayWindow = new BrowserWindow({
|
||||
x: display.workArea.x,
|
||||
y: display.workArea.y,
|
||||
width: display.workArea.width,
|
||||
height: display.workArea.height,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
focusable: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
|
||||
if (rendererUrl) {
|
||||
overlayWindow.loadURL(`${rendererUrl}?overlay=1`);
|
||||
} else {
|
||||
overlayWindow.loadFile(rendererFilePath, {
|
||||
query: { overlay: "1" },
|
||||
});
|
||||
}
|
||||
|
||||
overlayWindow.on("closed", () => {
|
||||
overlayWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function hideOverlayWindow() {
|
||||
overlayWindow?.close();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
createMainWindow,
|
||||
focusMainWindow,
|
||||
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
getMainWindow: () => mainWindow,
|
||||
sendScannerCommand,
|
||||
createOverlayWindow,
|
||||
hideOverlayWindow,
|
||||
};
|
||||
}
|
||||
@@ -14,10 +14,22 @@ import type {
|
||||
ClickResult,
|
||||
AutomationGuard,
|
||||
ScrollResult,
|
||||
KeyPressResult,
|
||||
SaveResultWithPath,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
ScannerStatusPayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type {
|
||||
@@ -32,6 +44,17 @@ interface AppHandlersDependencies {
|
||||
moveMainWindowOffGenshin: () => Promise<void>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -60,6 +83,7 @@ interface CaptureHandlersDependencies {
|
||||
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
keyPress: (key: string) => Promise<KeyPressResult>;
|
||||
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||
}
|
||||
|
||||
@@ -71,6 +95,17 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
|
||||
focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
|
||||
publishScannerStatus: dependencies.publishScannerStatus,
|
||||
nativeScannerDataStatus: dependencies.nativeScannerDataStatus,
|
||||
nativeScannerCatalog: dependencies.nativeScannerCatalog,
|
||||
nativeScannerPreflight: dependencies.nativeScannerPreflight,
|
||||
nativeScannerStart: dependencies.nativeScannerStart,
|
||||
nativeScannerStop: dependencies.nativeScannerStop,
|
||||
nativeScannerStatus: dependencies.nativeScannerStatus,
|
||||
nativeScannerProcessRun: dependencies.nativeScannerProcessRun,
|
||||
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
|
||||
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
|
||||
getRuntimeInfo: dependencies.readRuntimeInfo,
|
||||
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
||||
saveSnapshot: dependencies.saveSnapshotToDisk,
|
||||
@@ -96,6 +131,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
captureSource: dependencies.captureSource,
|
||||
clickScreen: dependencies.clickScreen,
|
||||
scrollScreen: dependencies.scrollScreen,
|
||||
keyPress: dependencies.keyPress,
|
||||
getAutomationGuard: dependencies.getAutomationGuard,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
AppRuntimeInfo,
|
||||
} from "../src/types/global.js";
|
||||
import { validateLookupPackage } from "../src/lib/genshinLookup.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface DevControlServerDependencies {
|
||||
registeredHotkeys: Record<string, boolean>;
|
||||
appBuild: AppRuntimeInfo;
|
||||
hasMainWindow: () => boolean;
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
warmOcr: (engine: "current") => Promise<unknown>;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
@@ -25,6 +49,7 @@ interface DevControlServerDependencies {
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
requestShutdown?: (reason: string) => void;
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
@@ -104,6 +129,7 @@ async function writeDevCaptureSnapshot(capture: CaptureResult) {
|
||||
: null,
|
||||
inventoryCount: capture.inventoryCount ?? null,
|
||||
locked: capture.locked,
|
||||
lockSignal: capture.lockSignal,
|
||||
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
|
||||
ocr: capture.ocr ?? [],
|
||||
files,
|
||||
@@ -136,21 +162,32 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/dev/shutdown") {
|
||||
if (!deps.requestShutdown) {
|
||||
writeDevJson(res, 501, { ok: false, error: "shutdown not supported" });
|
||||
return;
|
||||
}
|
||||
const reason = url.searchParams.get("reason") || "dev-control shutdown requested";
|
||||
writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason });
|
||||
setTimeout(() => deps.requestShutdown?.(reason), 50);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||
? { type: "start-auto", scanLimit: limit }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
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.sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "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") {
|
||||
@@ -171,7 +208,204 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
deps.nativeScannerStatus()
|
||||
.then((nativeScanner) => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus(), nativeScanner }))
|
||||
.catch(() => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/data") {
|
||||
deps.nativeScannerDataStatus()
|
||||
.then((status) => writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/catalog") {
|
||||
deps.nativeScannerCatalog()
|
||||
.then((catalog) => writeDevJson(res, catalog.data.valid ? 200 : 409, { ok: catalog.data.valid, catalog }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/preflight") {
|
||||
const category = url.searchParams.get("category") ?? undefined;
|
||||
deps.nativeScannerPreflight(category ? { category } : undefined)
|
||||
.then((status) => writeDevJson(res, status.ready ? 200 : 409, { ok: status.ready, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/process") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const persist = url.searchParams.get("persist") === "1";
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
deps.nativeScannerProcessRun({
|
||||
runDir,
|
||||
persist,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
})
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/results") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
deps.nativeScannerLoadResults({
|
||||
runDir,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||
})
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/native/image") {
|
||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||
const imagePath = url.searchParams.get("imagePath");
|
||||
if (!imagePath) {
|
||||
writeDevJson(res, 400, { ok: false, error: "imagePath query param is required" });
|
||||
return;
|
||||
}
|
||||
deps.nativeScannerLoadImage({ runDir, imagePath })
|
||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/ocr/warmup") {
|
||||
deps.warmOcr("current")
|
||||
.then((status) => writeDevJson(res, 200, { ok: true, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/status") {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/regenerate") {
|
||||
execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 })
|
||||
.then(({ stdout, stderr }) => {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr });
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/benchmark-ocr") {
|
||||
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const profileParam = url.searchParams.get("profile");
|
||||
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
const benchmarkSource = source;
|
||||
|
||||
async function runCurrentBenchmark() {
|
||||
const startedAt = Date.now();
|
||||
const captures: Array<{
|
||||
index: number;
|
||||
elapsedMs: number;
|
||||
ocrFields: number;
|
||||
timedOut: boolean;
|
||||
ocrSkipped: boolean;
|
||||
artifactDetailConfidence: number;
|
||||
sanctified: boolean;
|
||||
prepareMs: number;
|
||||
ocrMs: number;
|
||||
totalMs: number;
|
||||
ocrProfile?: "full" | "fast";
|
||||
ocrWorkerPoolSize?: number;
|
||||
ocrFieldMs?: Record<string, number>;
|
||||
}> = [];
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const captureStartedAt = Date.now();
|
||||
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile,
|
||||
ocrEngine: "current",
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
captures.push({
|
||||
index,
|
||||
elapsedMs: Date.now() - captureStartedAt,
|
||||
ocrFields: capture.ocr?.length ?? 0,
|
||||
timedOut: Boolean(capture.ocrTimedOut),
|
||||
ocrSkipped: Boolean(capture.ocrSkipped),
|
||||
artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0,
|
||||
sanctified: Boolean(capture.sanctified),
|
||||
prepareMs: capture.timings?.prepareMs ?? 0,
|
||||
ocrMs: capture.timings?.ocrMs ?? 0,
|
||||
totalMs: capture.timings?.totalMs ?? 0,
|
||||
ocrProfile: capture.timings?.ocrProfile,
|
||||
ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize,
|
||||
ocrFieldMs: capture.timings?.ocrFieldMs,
|
||||
});
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right);
|
||||
const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right);
|
||||
const averageMs = Math.round(elapsedMs / limit);
|
||||
const averageOcrMs = ocrTimings.length > 0
|
||||
? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length)
|
||||
: 0;
|
||||
const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0;
|
||||
const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0;
|
||||
const ocrFieldTotals = captures.reduce<Record<string, { totalMs: number; count: number; maxMs: number }>>((fields, capture) => {
|
||||
for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) {
|
||||
const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 };
|
||||
current.totalMs += elapsed;
|
||||
current.count += 1;
|
||||
current.maxMs = Math.max(current.maxMs, elapsed);
|
||||
fields[field] = current;
|
||||
}
|
||||
return fields;
|
||||
}, {});
|
||||
const ocrFieldAverages = Object.fromEntries(
|
||||
Object.entries(ocrFieldTotals).map(([field, timing]) => [
|
||||
field,
|
||||
{
|
||||
averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)),
|
||||
maxMs: timing.maxMs,
|
||||
count: timing.count,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
engine: "current",
|
||||
nativeTesseract: "not-enabled",
|
||||
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
|
||||
ocrProfile,
|
||||
limit,
|
||||
elapsedMs,
|
||||
averageMs,
|
||||
averageOcrMs,
|
||||
minMs: timings[0] ?? 0,
|
||||
p50Ms: percentile(0.5),
|
||||
p90Ms: percentile(0.9),
|
||||
maxMs: timings[timings.length - 1] ?? 0,
|
||||
ocrP50Ms: ocrPercentile(0.5),
|
||||
ocrP90Ms: ocrPercentile(0.9),
|
||||
ocrFieldAverages,
|
||||
projectedMs: {
|
||||
artifacts20: averageMs * 20,
|
||||
artifacts45: averageMs * 45,
|
||||
artifacts100: averageMs * 100,
|
||||
},
|
||||
skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length,
|
||||
captures,
|
||||
};
|
||||
}
|
||||
const summary = await runCurrentBenchmark();
|
||||
writeDevJson(res, 200, {
|
||||
ok: true,
|
||||
summary,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
@@ -212,6 +446,22 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,17 @@ import { ipcMain } from "electron";
|
||||
import type {
|
||||
BooleanResult,
|
||||
FocusGenshinResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
RuntimeInfo,
|
||||
SaveSnapshotResult,
|
||||
ScannerStatusPayload,
|
||||
@@ -13,6 +24,17 @@ interface AppCommandDependencies {
|
||||
moveMainWindowOffGenshin: () => Promise<void>;
|
||||
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
|
||||
nativeScannerDataStatus: () => Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog: () => Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||
loadSnapshot: () => Promise<AppSnapshot | null>;
|
||||
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
|
||||
@@ -26,6 +48,17 @@ export function registerAppHandlers({
|
||||
moveMainWindowOffGenshin,
|
||||
focusGenshinForScanStart,
|
||||
publishScannerStatus,
|
||||
nativeScannerDataStatus,
|
||||
nativeScannerCatalog,
|
||||
nativeScannerPreflight,
|
||||
nativeScannerStart,
|
||||
nativeScannerStop,
|
||||
nativeScannerStatus,
|
||||
nativeScannerProcessRun,
|
||||
nativeScannerLoadResults,
|
||||
nativeScannerPromoteResults,
|
||||
nativeScannerReviewResult,
|
||||
nativeScannerLoadImage,
|
||||
getRuntimeInfo,
|
||||
loadSnapshot,
|
||||
saveSnapshot,
|
||||
@@ -42,6 +75,17 @@ export function registerAppHandlers({
|
||||
await publishScannerStatus(status);
|
||||
return { ok: true };
|
||||
});
|
||||
ipcMain.handle("scanner:nativeDataStatus", async () => nativeScannerDataStatus());
|
||||
ipcMain.handle("scanner:nativeCatalog", async () => nativeScannerCatalog());
|
||||
ipcMain.handle("scanner:nativePreflight", async (_event, options?: { category?: NativeScannerStartCategory | string }) => nativeScannerPreflight(options));
|
||||
ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options));
|
||||
ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop());
|
||||
ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus());
|
||||
ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options));
|
||||
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options));
|
||||
ipcMain.handle("scanner:nativePromoteResults", async (_event, options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options));
|
||||
ipcMain.handle("scanner:nativeReviewResult", async (_event, options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => nativeScannerReviewResult(options));
|
||||
ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options));
|
||||
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
|
||||
ipcMain.handle("snapshot:load", async () => loadSnapshot());
|
||||
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+1031
-231
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||
clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||
scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||
keyPress: (key) => ipcRenderer.invoke("automation:keyPress", key),
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
@@ -22,6 +23,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
|
||||
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
|
||||
nativeScannerPreflight: (options) => ipcRenderer.invoke("scanner:nativePreflight", options),
|
||||
nativeScannerStart: (options) => ipcRenderer.invoke("scanner:nativeStart", options),
|
||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||
nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback) => {
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { CaptureOptions, GoodDatabase, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
|
||||
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||
clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||
keyPress: (key: string) => ipcRenderer.invoke("automation:keyPress", key),
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
@@ -25,6 +26,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"),
|
||||
nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"),
|
||||
nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativePreflight", options),
|
||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options),
|
||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -7,408 +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 bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT { public int X; public int Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT { public int type; public MOUSEINPUT mi; }
|
||||
"@
|
||||
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
|
||||
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
|
||||
# same symptom): the older SetProcessDPIAware() only applies a single,
|
||||
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
|
||||
# on one display, this app's window on a differently-scaled second display),
|
||||
# that single scale factor is wrong for whichever monitor didn't set it,
|
||||
# silently shifting every SetCursorPos/click coordinate off-target even
|
||||
# though cursor readback still matches what we asked for (both go through the
|
||||
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
|
||||
try {
|
||||
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
|
||||
} catch {
|
||||
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# SizeOf must receive a struct instance: passing the type object throws in
|
||||
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
|
||||
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
|
||||
$genshinHwnd = [IntPtr]::Zero
|
||||
|
||||
function Send-MouseInput {
|
||||
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
|
||||
$mouseInput = New-Object Native.InputHelper+INPUT
|
||||
$mouseInput.type = 0
|
||||
$mouseInput.mi.dx = $dx
|
||||
$mouseInput.mi.dy = $dy
|
||||
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
|
||||
$mouseInput.mi.dwFlags = $flags
|
||||
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
|
||||
}
|
||||
|
||||
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves
|
||||
# with bare SetCursorPos, then clicks via the InputSimulator library's
|
||||
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE
|
||||
# SendInput call (two INPUT structs in the same array) - back-to-back with no
|
||||
# artificial delay between them, unlike two separate SendInput calls with a
|
||||
# Start-Sleep in between. Returns the number of injected events (2 = ok).
|
||||
function Send-MouseClickBatch {
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 0
|
||||
$down.mi.dwFlags = 0x0002
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 0
|
||||
$up.mi.dwFlags = 0x0004
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Get-CursorPoint {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
return $pt
|
||||
}
|
||||
|
||||
function Get-ProcessNameFromHwnd {
|
||||
param([IntPtr]$hwnd)
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return "" }
|
||||
$pidValue = [uint32]0
|
||||
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
|
||||
if ($pidValue -eq 0) { return "" }
|
||||
try {
|
||||
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentProcessElevation {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-ForegroundInfo {
|
||||
$hwnd = [Native.InputHelper]::GetForegroundWindow()
|
||||
return @{
|
||||
foregroundHwnd = $hwnd.ToInt64()
|
||||
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorState {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
|
||||
# call" bit is unreliable and fires for ESC presses that happened long
|
||||
# before the scan (ESC is used constantly to navigate Genshin menus).
|
||||
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
|
||||
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
|
||||
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
|
||||
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
|
||||
}
|
||||
|
||||
function Get-GenshinClientBounds {
|
||||
$hwnd = Find-GenshinWindow
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $null }
|
||||
|
||||
$rect = New-Object Native.InputHelper+RECT
|
||||
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
|
||||
|
||||
$topLeft = New-Object Native.InputHelper+POINT
|
||||
$topLeft.X = 0
|
||||
$topLeft.Y = 0
|
||||
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) { return $null }
|
||||
|
||||
return @{
|
||||
Left = $topLeft.X
|
||||
Top = $topLeft.Y
|
||||
Width = $width
|
||||
Height = $height
|
||||
}
|
||||
}
|
||||
|
||||
function Find-GenshinWindow {
|
||||
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
||||
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||
return $script:genshinHwnd
|
||||
}
|
||||
|
||||
# Plain SetForegroundWindow from this background helper process is silently
|
||||
# refused by Windows' foreground lock. Attach our thread's input queue to the
|
||||
# target (and current foreground) window thread and clear the lock timeout, so
|
||||
# the foreground change is honored - the same technique Inventory Kamera uses.
|
||||
function Force-Foreground {
|
||||
param([IntPtr]$hwnd)
|
||||
$current = [Native.InputHelper]::GetCurrentThreadId()
|
||||
$targetPid = [uint32]0
|
||||
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
|
||||
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
|
||||
$foreground = [uint32]0
|
||||
if ($fgWindow -ne [IntPtr]::Zero) {
|
||||
$fgPid = [uint32]0
|
||||
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
|
||||
}
|
||||
|
||||
$attachedTarget = $false
|
||||
$attachedForeground = $false
|
||||
$oldTimeout = [uint32]0
|
||||
$timeoutRead = $false
|
||||
try {
|
||||
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
|
||||
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
|
||||
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
|
||||
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
|
||||
# Inject a no-op input (0,0 mouse move) so this process is the last input
|
||||
# source, which Windows requires before it will honor a foreground change.
|
||||
Send-MouseInput -flags 0x0001 | Out-Null
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
|
||||
return [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
} finally {
|
||||
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
|
||||
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
|
||||
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Focus-GenshinWindow {
|
||||
$hwnd = Find-GenshinWindow
|
||||
$info = @{
|
||||
hwnd = $hwnd.ToInt64()
|
||||
focused = $false
|
||||
alreadyForeground = $false
|
||||
foregroundProcess = ""
|
||||
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $info }
|
||||
|
||||
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||
if (-not $info.alreadyForeground) {
|
||||
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
|
||||
Start-Sleep -Milliseconds 140
|
||||
}
|
||||
|
||||
$foreground = [Native.InputHelper]::GetForegroundWindow()
|
||||
$info.focused = ($foreground -eq $hwnd)
|
||||
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
|
||||
return $info
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ($line.Trim().Length -eq 0) { continue }
|
||||
$response = @{ id = ""; ok = $true }
|
||||
try {
|
||||
$cmd = $line | ConvertFrom-Json
|
||||
$response.id = "$($cmd.id)"
|
||||
switch ("$($cmd.op)") {
|
||||
"ping" {
|
||||
$response.pong = $true
|
||||
}
|
||||
"cursor" {
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
}
|
||||
"runtime" {
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$hwnd = Find-GenshinWindow
|
||||
$foregroundInfo = Get-ForegroundInfo
|
||||
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
|
||||
$response.genshinHwnd = $hwnd.ToInt64()
|
||||
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
$response.foregroundProcess = $foregroundInfo.foregroundProcess
|
||||
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
|
||||
$response.helperPid = $PID
|
||||
}
|
||||
"focus" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.genshinFound = ($focusInfo.hwnd -ne 0)
|
||||
$response.setForegroundResult = $focusInfo.setForegroundResult
|
||||
}
|
||||
"click" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$targetX = [int]$cmd.x
|
||||
$targetY = [int]$cmd.y
|
||||
# Matches Inventory Kamera's verified-working sequence exactly: bare
|
||||
# SetCursorPos immediately followed by a click, with NO extra move
|
||||
# event and NO artificial delay between moving and clicking - IK's
|
||||
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back,
|
||||
# zero gap. Settling delays only happen after the click, in the scan
|
||||
# loop. Down+up are sent as one SendInput call (see
|
||||
# Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick().
|
||||
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
|
||||
$point = Get-CursorPoint
|
||||
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
|
||||
$clickEventsSent = 0
|
||||
if ($onTarget) {
|
||||
$clickEventsSent = Send-MouseClickBatch
|
||||
}
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
$response.moved = $onTarget
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
# Never report a click unless the cursor is verifiably on the target.
|
||||
# Real acceptance is proven later by the detail-panel fingerprint.
|
||||
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
|
||||
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
|
||||
}
|
||||
"scroll" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
|
||||
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
|
||||
Start-Sleep -Milliseconds 30
|
||||
}
|
||||
$point = Get-CursorPoint
|
||||
$response.cursorX = $point.X
|
||||
$response.cursorY = $point.Y
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$notches = [int]$cmd.notches
|
||||
$stepDelta = 120
|
||||
if ($notches -lt 0) { $stepDelta = -120 }
|
||||
$count = [Math]::Abs($notches)
|
||||
if ($count -gt 60) { $count = 60 }
|
||||
$sentTotal = 0
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
|
||||
Start-Sleep -Milliseconds 45
|
||||
}
|
||||
$response.notchesSent = $sentTotal
|
||||
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
|
||||
}
|
||||
"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;
|
||||
@@ -545,8 +154,15 @@ 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;
|
||||
}
|
||||
|
||||
@@ -641,6 +257,20 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
};
|
||||
}
|
||||
|
||||
async function keyPress(key: string) {
|
||||
const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
|
||||
return {
|
||||
ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
|
||||
key,
|
||||
focused: Boolean(result.focused),
|
||||
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||
inputBlocked: Boolean(result.inputBlocked),
|
||||
eventsSent: Number(result.eventsSent ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAutomationGuard() {
|
||||
const result = await request("cursor", {}, 4000);
|
||||
return {
|
||||
@@ -672,7 +302,9 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
: "primary-screen";
|
||||
|
||||
return {
|
||||
dataUrl: `data:image/png;base64,${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),
|
||||
@@ -681,6 +313,39 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -688,8 +353,15 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
getGenshinWindowBounds,
|
||||
clickScreen,
|
||||
scrollScreen,
|
||||
keyPress,
|
||||
getAutomationGuard,
|
||||
capturePrimaryScreenViaGdi,
|
||||
nativeScannerDataStatus,
|
||||
nativeScannerCatalog,
|
||||
nativeScannerPreflight,
|
||||
nativeScannerStart,
|
||||
nativeScannerStop,
|
||||
nativeScannerStatus,
|
||||
dispose: () => inputHelper.dispose(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
// PowerShell fallback for environments where the compiled C# sidecar is unavailable. Keep the JSON protocol aligned with native/input-helper/Program.cs.
|
||||
|
||||
export const INPUT_HELPER_SCRIPT = String.raw`
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$signature = @"
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetProcessDPIAware();
|
||||
[DllImport("shcore.dll")]
|
||||
public static extern int SetProcessDpiAwareness(int value);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT { public int X; public int Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT { public int type; public MOUSEINPUT mi; }
|
||||
"@
|
||||
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
|
||||
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
|
||||
# same symptom): the older SetProcessDPIAware() only applies a single,
|
||||
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
|
||||
# on one display, this app's window on a differently-scaled second display),
|
||||
# that single scale factor is wrong for whichever monitor didn't set it,
|
||||
# silently shifting every SetCursorPos/click coordinate off-target even
|
||||
# though cursor readback still matches what we asked for (both go through the
|
||||
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
|
||||
try {
|
||||
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
|
||||
} catch {
|
||||
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# SizeOf must receive a struct instance: passing the type object throws in
|
||||
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
|
||||
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
|
||||
$genshinHwnd = [IntPtr]::Zero
|
||||
|
||||
function Send-MouseInput {
|
||||
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
|
||||
$mouseInput = New-Object Native.InputHelper+INPUT
|
||||
$mouseInput.type = 0
|
||||
$mouseInput.mi.dx = $dx
|
||||
$mouseInput.mi.dy = $dy
|
||||
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
|
||||
$mouseInput.mi.dwFlags = $flags
|
||||
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
|
||||
}
|
||||
|
||||
# Uses bare SetCursorPos, then sends button-down and button-up as ONE SendInput
|
||||
# call (two INPUT structs in the same array) - back-to-back with no artificial
|
||||
# delay between them, unlike two separate SendInput calls with a
|
||||
# Start-Sleep in between. Returns the number of injected events (2 = ok).
|
||||
function Send-MouseClickBatch {
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 0
|
||||
$down.mi.dwFlags = 0x0002
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 0
|
||||
$up.mi.dwFlags = 0x0004
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Send-KeyPressBatch {
|
||||
param([int]$virtualKey)
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 1
|
||||
$down.mi.dx = $virtualKey
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 1
|
||||
$up.mi.dx = $virtualKey
|
||||
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
|
||||
$up.mi.dy = 0x0002
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Resolve-VirtualKey {
|
||||
param([string]$key)
|
||||
switch ($key.ToUpperInvariant()) {
|
||||
"ESC" { return 27 }
|
||||
"ESCAPE" { return 27 }
|
||||
"ENTER" { return 13 }
|
||||
"B" { return 66 }
|
||||
"C" { return 67 }
|
||||
"1" { return 49 }
|
||||
default { throw "unsupported key: $key" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorPoint {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
return $pt
|
||||
}
|
||||
|
||||
function Get-ProcessNameFromHwnd {
|
||||
param([IntPtr]$hwnd)
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return "" }
|
||||
$pidValue = [uint32]0
|
||||
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
|
||||
if ($pidValue -eq 0) { return "" }
|
||||
try {
|
||||
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentProcessElevation {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-ForegroundInfo {
|
||||
$hwnd = [Native.InputHelper]::GetForegroundWindow()
|
||||
return @{
|
||||
foregroundHwnd = $hwnd.ToInt64()
|
||||
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorState {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
|
||||
# call" bit is unreliable and fires for ESC presses that happened long
|
||||
# before the scan (ESC is used constantly to navigate Genshin menus).
|
||||
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
|
||||
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
|
||||
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
|
||||
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
|
||||
}
|
||||
|
||||
function Get-GenshinClientBounds {
|
||||
$hwnd = Find-GenshinWindow
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $null }
|
||||
|
||||
$rect = New-Object Native.InputHelper+RECT
|
||||
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
|
||||
|
||||
$topLeft = New-Object Native.InputHelper+POINT
|
||||
$topLeft.X = 0
|
||||
$topLeft.Y = 0
|
||||
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) { return $null }
|
||||
|
||||
return @{
|
||||
Left = $topLeft.X
|
||||
Top = $topLeft.Y
|
||||
Width = $width
|
||||
Height = $height
|
||||
}
|
||||
}
|
||||
|
||||
function Find-GenshinWindow {
|
||||
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
||||
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||
return $script:genshinHwnd
|
||||
}
|
||||
|
||||
# Plain SetForegroundWindow from this background helper process is silently
|
||||
# refused by Windows' foreground lock. Attach our thread's input queue to the
|
||||
# target (and current foreground) window thread and clear the lock timeout, so
|
||||
# the foreground change is honored.
|
||||
function Force-Foreground {
|
||||
param([IntPtr]$hwnd)
|
||||
$current = [Native.InputHelper]::GetCurrentThreadId()
|
||||
$targetPid = [uint32]0
|
||||
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
|
||||
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
|
||||
$foreground = [uint32]0
|
||||
if ($fgWindow -ne [IntPtr]::Zero) {
|
||||
$fgPid = [uint32]0
|
||||
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
|
||||
}
|
||||
|
||||
$attachedTarget = $false
|
||||
$attachedForeground = $false
|
||||
$oldTimeout = [uint32]0
|
||||
$timeoutRead = $false
|
||||
try {
|
||||
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
|
||||
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
|
||||
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
|
||||
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
|
||||
# Inject a no-op input (0,0 mouse move) so this process is the last input
|
||||
# source, which Windows requires before it will honor a foreground change.
|
||||
Send-MouseInput -flags 0x0001 | Out-Null
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
|
||||
return [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
} finally {
|
||||
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
|
||||
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
|
||||
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Focus-GenshinWindow {
|
||||
$hwnd = Find-GenshinWindow
|
||||
$info = @{
|
||||
hwnd = $hwnd.ToInt64()
|
||||
focused = $false
|
||||
alreadyForeground = $false
|
||||
foregroundProcess = ""
|
||||
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $info }
|
||||
|
||||
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||
if (-not $info.alreadyForeground) {
|
||||
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
|
||||
Start-Sleep -Milliseconds 140
|
||||
}
|
||||
|
||||
$foreground = [Native.InputHelper]::GetForegroundWindow()
|
||||
$info.focused = ($foreground -eq $hwnd)
|
||||
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
|
||||
return $info
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ($line.Trim().Length -eq 0) { continue }
|
||||
$response = @{ id = ""; ok = $true }
|
||||
try {
|
||||
$cmd = $line | ConvertFrom-Json
|
||||
$response.id = "$($cmd.id)"
|
||||
switch ("$($cmd.op)") {
|
||||
"ping" {
|
||||
$response.pong = $true
|
||||
}
|
||||
"cursor" {
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
}
|
||||
"runtime" {
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$hwnd = Find-GenshinWindow
|
||||
$foregroundInfo = Get-ForegroundInfo
|
||||
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
|
||||
$response.genshinHwnd = $hwnd.ToInt64()
|
||||
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
$response.foregroundProcess = $foregroundInfo.foregroundProcess
|
||||
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
|
||||
$response.helperPid = $PID
|
||||
}
|
||||
"focus" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.genshinFound = ($focusInfo.hwnd -ne 0)
|
||||
$response.setForegroundResult = $focusInfo.setForegroundResult
|
||||
}
|
||||
"click" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$targetX = [int]$cmd.x
|
||||
$targetY = [int]$cmd.y
|
||||
# Bare SetCursorPos immediately followed by a click, with NO extra move
|
||||
# event and NO artificial delay between moving and clicking. Settling
|
||||
# delays only happen after the click, in the scan loop. Down+up are sent
|
||||
# as one SendInput call (see Send-MouseClickBatch).
|
||||
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
|
||||
$point = Get-CursorPoint
|
||||
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
|
||||
$clickEventsSent = 0
|
||||
if ($onTarget) {
|
||||
$clickEventsSent = Send-MouseClickBatch
|
||||
}
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
$response.moved = $onTarget
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
# Never report a click unless the cursor is verifiably on the target.
|
||||
# Real acceptance is proven later by the detail-panel fingerprint.
|
||||
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
|
||||
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
|
||||
}
|
||||
"scroll" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
|
||||
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
|
||||
Start-Sleep -Milliseconds 30
|
||||
}
|
||||
$point = Get-CursorPoint
|
||||
$response.cursorX = $point.X
|
||||
$response.cursorY = $point.Y
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$notches = [int]$cmd.notches
|
||||
$stepDelta = 120
|
||||
if ($notches -lt 0) { $stepDelta = -120 }
|
||||
$count = [Math]::Abs($notches)
|
||||
if ($count -gt 60) { $count = 60 }
|
||||
$sentTotal = 0
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
|
||||
Start-Sleep -Milliseconds 45
|
||||
}
|
||||
$response.notchesSent = $sentTotal
|
||||
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
|
||||
}
|
||||
"key" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$vk = Resolve-VirtualKey -key "$($cmd.key)"
|
||||
$sent = Send-KeyPressBatch -virtualKey $vk
|
||||
$response.key = "$($cmd.key)"
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$response.eventsSent = $sent
|
||||
$response.inputBlocked = ($sent -lt 2)
|
||||
}
|
||||
"bounds" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$response.found = $false
|
||||
} else {
|
||||
$response.found = $true
|
||||
$response.left = $clientBounds.Left
|
||||
$response.top = $clientBounds.Top
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
}
|
||||
}
|
||||
"capture" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$clientBounds = @{
|
||||
Left = $screenBounds.Left
|
||||
Top = $screenBounds.Top
|
||||
Width = $screenBounds.Width
|
||||
Height = $screenBounds.Height
|
||||
}
|
||||
$response.captureTarget = "primary-screen"
|
||||
} else {
|
||||
$response.captureTarget = "genshin-client"
|
||||
}
|
||||
$bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size)
|
||||
$capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png")
|
||||
$bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$response.path = $capturePath
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
$response.originX = $clientBounds.Left
|
||||
$response.originY = $clientBounds.Top
|
||||
}
|
||||
default {
|
||||
$response.ok = $false
|
||||
$response.error = "unknown op"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$response.ok = $false
|
||||
$response.error = $_.Exception.Message
|
||||
}
|
||||
Write-Output (ConvertTo-Json $response -Compress)
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,496 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pngBufferToBitmap } from "./pngBitmap.js";
|
||||
import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js";
|
||||
import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
||||
import { toStoredArtifact } from "../../src/lib/artifactStore.js";
|
||||
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
||||
import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js";
|
||||
import type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreSaveResult,
|
||||
CaptureResult,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
ReviewSamplePayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type { ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
||||
|
||||
export type NativeCaptureJobPayload = {
|
||||
sequence: number;
|
||||
category?: string;
|
||||
page?: number;
|
||||
row?: number;
|
||||
col?: number;
|
||||
capturedAt?: string;
|
||||
relativePath?: string;
|
||||
absolutePath?: string;
|
||||
};
|
||||
|
||||
export interface NativeScannerProcessingService {
|
||||
processRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise<NativeScannerProcessStatus>;
|
||||
loadResults(options?: { runDir?: string; limit?: number }): Promise<NativeScannerResultsLoadStatus>;
|
||||
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
|
||||
reviewResult(options: {
|
||||
runDir?: string;
|
||||
resultId: string;
|
||||
action: "approve" | "reject";
|
||||
artifact?: NativeScannerReviewArtifactInput;
|
||||
note?: string;
|
||||
}): Promise<NativeScannerReviewStatus>;
|
||||
loadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
|
||||
}
|
||||
|
||||
interface NativeScannerProcessingServiceDependencies {
|
||||
resolveRunDir(runDir?: string): string;
|
||||
buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise<CaptureResult>;
|
||||
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
||||
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
||||
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
||||
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
||||
}
|
||||
|
||||
type ProcessedNativeJob = {
|
||||
result: NativeScannerProcessStatus["results"][number];
|
||||
scanResult: StoredScanResultEntry;
|
||||
storedRecord: StoredArtifactRecord | null;
|
||||
};
|
||||
|
||||
const POST_CAPTURE_QUEUE_CONCURRENCY = 4;
|
||||
|
||||
export function createNativeScannerProcessingService(
|
||||
deps: NativeScannerProcessingServiceDependencies,
|
||||
): NativeScannerProcessingService {
|
||||
const resultWorkflows = createNativeScannerResultWorkflowService(deps);
|
||||
return {
|
||||
async processRun(options = {}) {
|
||||
const started = Date.now();
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
if (!runDir) {
|
||||
return emptyProcessStatus("No native scanner runDir available.");
|
||||
}
|
||||
|
||||
const { jobsPath, jobs } = await readNativeCaptureJobs(runDir);
|
||||
const limit = Math.max(1, Math.min(jobs.length, Math.round(options.limit ?? jobs.length)));
|
||||
const selectedJobs = jobs.slice(0, limit);
|
||||
const runId = path.basename(runDir);
|
||||
const ikCatalog = deps.loadIkArtifactCatalog
|
||||
? await deps.loadIkArtifactCatalog().catch(() => null)
|
||||
: null;
|
||||
|
||||
const processedJobs = await mapWithConcurrency(
|
||||
selectedJobs,
|
||||
POST_CAPTURE_QUEUE_CONCURRENCY,
|
||||
(job) => processNativeCaptureJob({
|
||||
deps,
|
||||
ikCatalog,
|
||||
job,
|
||||
persist: Boolean(options.persist),
|
||||
runDir,
|
||||
runId,
|
||||
}),
|
||||
);
|
||||
const results = processedJobs.map((entry) => entry.result);
|
||||
const scanResults = processedJobs.map((entry) => entry.scanResult);
|
||||
const recordsToPersist = processedJobs
|
||||
.map((entry) => entry.storedRecord)
|
||||
.filter((record): record is StoredArtifactRecord => Boolean(record));
|
||||
|
||||
let stored = 0;
|
||||
if (options.persist && recordsToPersist.length > 0) {
|
||||
const saved = await deps.saveArtifacts(recordsToPersist);
|
||||
stored = saved.added + saved.updated;
|
||||
}
|
||||
|
||||
const status: NativeScannerProcessStatus = {
|
||||
ok: true,
|
||||
runDir,
|
||||
jobsPath,
|
||||
reportPath: path.join(runDir, "processing-report.json"),
|
||||
scanResultsPath: path.join(runDir, "scan-results.json"),
|
||||
processed: results.length,
|
||||
parsed: results.filter((result) => result.parsed).length,
|
||||
review: results.filter((result) => result.needsReview).length,
|
||||
stored,
|
||||
errors: results.filter((result) => result.error).length,
|
||||
elapsedMs: Date.now() - started,
|
||||
queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, selectedJobs.length),
|
||||
persisted: Boolean(options.persist),
|
||||
results,
|
||||
};
|
||||
await fs.writeFile(status.scanResultsPath, JSON.stringify(scanResults, null, 2), "utf8");
|
||||
await fs.writeFile(status.reportPath, JSON.stringify(status, null, 2), "utf8");
|
||||
return status;
|
||||
},
|
||||
|
||||
async loadResults(options = {}) {
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
if (!runDir) {
|
||||
return emptyResultsStatus("No native scanner runDir available.");
|
||||
}
|
||||
|
||||
const resultsPath = path.join(runDir, "scan-results.json");
|
||||
try {
|
||||
const raw = await fs.readFile(resultsPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const results = Array.isArray(parsed)
|
||||
? parsed.filter(isStoredScanResultEntry)
|
||||
: [];
|
||||
const limit = Number.isFinite(options.limit)
|
||||
? Math.max(1, Math.min(results.length, Math.round(options.limit ?? results.length)))
|
||||
: results.length;
|
||||
return {
|
||||
ok: true,
|
||||
runDir,
|
||||
path: resultsPath,
|
||||
total: results.length,
|
||||
results: results.slice(-limit),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...emptyResultsStatus(error instanceof Error ? error.message : String(error)),
|
||||
runDir,
|
||||
path: resultsPath,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
promoteResults: resultWorkflows.promoteResults,
|
||||
reviewResult: resultWorkflows.reviewResult,
|
||||
|
||||
async loadImage(options) {
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
if (!runDir) {
|
||||
return emptyImageStatus("No native scanner runDir available.");
|
||||
}
|
||||
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
const imagePath = path.isAbsolute(options.imagePath)
|
||||
? path.resolve(options.imagePath)
|
||||
: path.resolve(resolvedRunDir, options.imagePath);
|
||||
if (!isPathInside(resolvedRunDir, imagePath)) {
|
||||
return { ...emptyImageStatus("Image path is outside the native scanner run directory."), runDir: resolvedRunDir, path: imagePath };
|
||||
}
|
||||
if (!/\.png$/i.test(imagePath)) {
|
||||
return { ...emptyImageStatus("Native scanner previews must be PNG files."), runDir: resolvedRunDir, path: imagePath };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(imagePath);
|
||||
if (stat.size > 20 * 1024 * 1024) {
|
||||
return { ...emptyImageStatus("Native scanner preview is too large."), runDir: resolvedRunDir, path: imagePath };
|
||||
}
|
||||
const buffer = await fs.readFile(imagePath);
|
||||
const bitmap = pngBufferToBitmap(buffer);
|
||||
return {
|
||||
ok: true,
|
||||
runDir: resolvedRunDir,
|
||||
path: imagePath,
|
||||
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...emptyImageStatus(error instanceof Error ? error.message : String(error)),
|
||||
runDir: resolvedRunDir,
|
||||
path: imagePath,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record<string, number> {
|
||||
return {
|
||||
processed: status.processed,
|
||||
parsed: status.parsed,
|
||||
review: status.review,
|
||||
stored: status.stored,
|
||||
errors: status.errors,
|
||||
elapsedMs: status.elapsedMs,
|
||||
queueConcurrency: status.queueConcurrency,
|
||||
};
|
||||
}
|
||||
|
||||
async function processNativeCaptureJob({
|
||||
deps,
|
||||
ikCatalog,
|
||||
job,
|
||||
persist,
|
||||
runDir,
|
||||
runId,
|
||||
}: {
|
||||
deps: NativeScannerProcessingServiceDependencies;
|
||||
ikCatalog: IkArtifactCatalog | null;
|
||||
job: NativeCaptureJobPayload;
|
||||
persist: boolean;
|
||||
runDir: string;
|
||||
runId: string;
|
||||
}): Promise<ProcessedNativeJob> {
|
||||
const category = nativeJobCategory(job.category);
|
||||
const imagePath = job.absolutePath || (job.relativePath ? path.join(runDir, job.relativePath) : "");
|
||||
if (!category.artifactProcessingSupported) {
|
||||
return reviewJobResult({
|
||||
category: category.scanResultCategory,
|
||||
error: `Native post-capture processing for category '${category.nativeCategory}' is not implemented yet; IK catalog is available only.`,
|
||||
imagePath,
|
||||
job,
|
||||
runId,
|
||||
});
|
||||
}
|
||||
if (!imagePath || !(await fileExists(imagePath))) {
|
||||
const error = "Card crop image missing.";
|
||||
return reviewJobResult({ category: category.scanResultCategory, error, imagePath, job, runId });
|
||||
}
|
||||
|
||||
try {
|
||||
const capture = await deps.buildCaptureResult(imagePath, job);
|
||||
const parsed = parseArtifactCandidate(capture);
|
||||
const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null;
|
||||
const notes = [...new Set([...(parsed?.notes ?? []), ...(ikMatch?.notes ?? [])])];
|
||||
const needsReview = parsedArtifactNeedsReview(parsed) || Boolean(ikMatch && !ikMatch.matched);
|
||||
const canPersist = parsedArtifactCanPersist(parsed, needsReview);
|
||||
const shouldPersist = Boolean(persist && parsed && canPersist && !needsReview);
|
||||
const storedRecord = parsed && shouldPersist
|
||||
? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked)
|
||||
: null;
|
||||
return {
|
||||
result: {
|
||||
sequence: job.sequence,
|
||||
category: category.scanResultCategory,
|
||||
page: job.page,
|
||||
row: job.row,
|
||||
col: job.col,
|
||||
imagePath,
|
||||
parsed: Boolean(parsed),
|
||||
artifactName: parsed?.name,
|
||||
setName: parsed?.setName,
|
||||
slot: parsed?.slot,
|
||||
confidence: parsed?.confidence ?? 0,
|
||||
needsReview,
|
||||
persisted: shouldPersist,
|
||||
ikMatch: ikMatch ?? undefined,
|
||||
notes,
|
||||
ocr: capture.ocr ?? [],
|
||||
},
|
||||
scanResult: createStoredScanResultEntry({
|
||||
runId,
|
||||
sequence: job.sequence,
|
||||
page: job.page,
|
||||
row: job.row,
|
||||
col: job.col,
|
||||
category: category.scanResultCategory,
|
||||
source: "native-ik-scan",
|
||||
imagePath,
|
||||
parsed,
|
||||
needsReview,
|
||||
confidence: parsed?.confidence ?? 0,
|
||||
capturedAt: job.capturedAt,
|
||||
artifactRecordId: storedRecord?.id,
|
||||
persistedArtifact: shouldPersist,
|
||||
notes,
|
||||
locked: capture.locked,
|
||||
ikMatch: ikMatch ?? undefined,
|
||||
}),
|
||||
storedRecord,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return reviewJobResult({ category: category.scanResultCategory, error: message, imagePath, job, runId });
|
||||
}
|
||||
}
|
||||
|
||||
function reviewJobResult({
|
||||
category,
|
||||
error,
|
||||
imagePath,
|
||||
job,
|
||||
runId,
|
||||
}: {
|
||||
category?: ScanResultCategory;
|
||||
error: string;
|
||||
imagePath: string;
|
||||
job: NativeCaptureJobPayload;
|
||||
runId: string;
|
||||
}): ProcessedNativeJob {
|
||||
const scanResultCategory = category ?? nativeJobCategory(job.category).scanResultCategory;
|
||||
return {
|
||||
result: {
|
||||
sequence: job.sequence,
|
||||
category: scanResultCategory,
|
||||
page: job.page,
|
||||
row: job.row,
|
||||
col: job.col,
|
||||
imagePath,
|
||||
parsed: false,
|
||||
needsReview: true,
|
||||
confidence: 0,
|
||||
ocr: [],
|
||||
error,
|
||||
},
|
||||
scanResult: createStoredScanResultEntry({
|
||||
runId,
|
||||
sequence: job.sequence,
|
||||
page: job.page,
|
||||
row: job.row,
|
||||
col: job.col,
|
||||
category: scanResultCategory,
|
||||
source: "native-ik-scan",
|
||||
imagePath,
|
||||
parsed: null,
|
||||
needsReview: true,
|
||||
confidence: 0,
|
||||
capturedAt: job.capturedAt,
|
||||
error,
|
||||
notes: [error],
|
||||
}),
|
||||
storedRecord: null,
|
||||
};
|
||||
}
|
||||
|
||||
function nativeJobCategory(category: string | undefined): {
|
||||
nativeCategory: string;
|
||||
scanResultCategory: ScanResultCategory;
|
||||
artifactProcessingSupported: boolean;
|
||||
} {
|
||||
const nativeCategory = (category ?? "artifacts").trim().toLowerCase() || "artifacts";
|
||||
if (nativeCategory === "artifact" || nativeCategory === "artifacts") {
|
||||
return { nativeCategory, scanResultCategory: "artifact", artifactProcessingSupported: true };
|
||||
}
|
||||
if (nativeCategory === "weapon" || nativeCategory === "weapons") {
|
||||
return { nativeCategory, scanResultCategory: "weapon", artifactProcessingSupported: false };
|
||||
}
|
||||
if (nativeCategory === "character" || nativeCategory === "characters") {
|
||||
return { nativeCategory, scanResultCategory: "character", artifactProcessingSupported: false };
|
||||
}
|
||||
if (nativeCategory === "material" || nativeCategory === "materials") {
|
||||
return { nativeCategory, scanResultCategory: "material", artifactProcessingSupported: false };
|
||||
}
|
||||
return { nativeCategory, scanResultCategory: "unknown", artifactProcessingSupported: false };
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<TInput, TOutput>(
|
||||
items: readonly TInput[],
|
||||
concurrency: number,
|
||||
worker: (item: TInput, index: number) => Promise<TOutput>,
|
||||
) {
|
||||
const output = Array<TOutput>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(items.length, Math.floor(concurrency)));
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
output[index] = await worker(items[index], index);
|
||||
}
|
||||
}));
|
||||
return output;
|
||||
}
|
||||
|
||||
async function readNativeCaptureJobs(runDir: string): Promise<{ jobsPath: string; jobs: NativeCaptureJobPayload[] }> {
|
||||
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
|
||||
const raw = await fs.readFile(jobsPath, "utf8");
|
||||
const jobs = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as NativeCaptureJobPayload)
|
||||
.filter((job) => Number.isFinite(job.sequence));
|
||||
return { jobsPath, jobs };
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyProcessStatus(error: string): NativeScannerProcessStatus {
|
||||
return {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
jobsPath: "",
|
||||
reportPath: "",
|
||||
scanResultsPath: "",
|
||||
processed: 0,
|
||||
parsed: 0,
|
||||
review: 0,
|
||||
stored: 0,
|
||||
errors: 1,
|
||||
elapsedMs: 0,
|
||||
queueConcurrency: 0,
|
||||
persisted: false,
|
||||
results: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyResultsStatus(error: string): NativeScannerResultsLoadStatus {
|
||||
return {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
path: "",
|
||||
total: 0,
|
||||
results: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyImageStatus(error: string): NativeScannerImageLoadStatus {
|
||||
return {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
path: "",
|
||||
dataUrl: "",
|
||||
width: 0,
|
||||
height: 0,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<StoredScanResultEntry>;
|
||||
return typeof entry.id === "string"
|
||||
&& typeof entry.runId === "string"
|
||||
&& Number.isFinite(entry.sequence)
|
||||
&& typeof entry.source === "string"
|
||||
&& typeof entry.imagePath === "string"
|
||||
&& typeof entry.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
|
||||
function parsedArtifactNeedsReview(parsed: ReturnType<typeof parseArtifactCandidate>) {
|
||||
if (!parsed) return true;
|
||||
if (parsed.confidence < 78) return true;
|
||||
const criticalFields: Array<"name" | "slot" | "mainStat" | "mainValue" | "setName"> = ["name", "slot", "mainStat", "mainValue", "setName"];
|
||||
if (criticalFields.some((field) => (parsed.fields[field]?.confidence ?? 0) < 70)) return true;
|
||||
if (parsed.substats.length === 0) return true;
|
||||
return parsed.notes.some((note) => /not confidently parsed|substats look incomplete|likely OCR misread/i.test(note));
|
||||
}
|
||||
|
||||
function parsedArtifactCanPersist(parsed: ReturnType<typeof parseArtifactCandidate>, needsReview: boolean) {
|
||||
if (!parsed) return false;
|
||||
if (parsed.name === "Unknown artifact") return false;
|
||||
if (parsed.slot === "Unknown slot") return false;
|
||||
if (parsed.setName === "Unknown set") return false;
|
||||
if (parsed.mainStat === "Unknown main stat") return false;
|
||||
if (parsed.mainValue === "?") return false;
|
||||
if (parsed.substats.length === 0) return false;
|
||||
if (!needsReview && parsed.confidence < 68) return false;
|
||||
if (needsReview && parsed.confidence < 60) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
||||
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
||||
import { buildScanResultPromotionSummary, scanResultToStoredArtifact } from "../../src/lib/scanResultPromotion.js";
|
||||
import { implausibleSubstats } from "../../src/lib/substatRolls.js";
|
||||
import type {
|
||||
ArtifactStoreLoadResult,
|
||||
ArtifactStoreSaveResult,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
ReviewSamplePayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
||||
|
||||
export interface NativeScannerResultWorkflowDependencies {
|
||||
resolveRunDir(runDir?: string): string;
|
||||
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
||||
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
||||
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
||||
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
||||
}
|
||||
|
||||
export interface NativeScannerResultWorkflowService {
|
||||
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
|
||||
reviewResult(options: {
|
||||
runDir?: string;
|
||||
resultId: string;
|
||||
action: "approve" | "reject";
|
||||
artifact?: NativeScannerReviewArtifactInput;
|
||||
note?: string;
|
||||
}): Promise<NativeScannerReviewStatus>;
|
||||
}
|
||||
|
||||
export function createNativeScannerResultWorkflowService(
|
||||
deps: NativeScannerResultWorkflowDependencies,
|
||||
): NativeScannerResultWorkflowService {
|
||||
return {
|
||||
async promoteResults(options) {
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
const logPath = runDir ? path.join(runDir, "promotion-log.jsonl") : "";
|
||||
const requestedIds = [...new Set((options.resultIds ?? []).filter((id) => typeof id === "string" && id.trim()))];
|
||||
if (!runDir || requestedIds.length === 0 || !deps.loadArtifacts) {
|
||||
return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Promotion requires a run directory, selected result IDs, and artifact-store access.");
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
||||
const selectedIds = new Set(requestedIds);
|
||||
const selectedResults = scanResults.filter((entry) => selectedIds.has(entry.id));
|
||||
const store = await deps.loadArtifacts();
|
||||
if (!store.ok) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store could not be loaded.");
|
||||
|
||||
const summary = buildScanResultPromotionSummary(selectedResults, store.artifacts);
|
||||
const readyIds = new Set(summary.decisions.filter((decision) => decision.canPersist).map((decision) => decision.resultId));
|
||||
const records = selectedResults
|
||||
.filter((entry) => readyIds.has(entry.id))
|
||||
.map(scanResultToStoredArtifact)
|
||||
.filter((record): record is StoredArtifactRecord => Boolean(record));
|
||||
const saved = records.length > 0
|
||||
? await deps.saveArtifacts(records)
|
||||
: { ok: true, added: 0, updated: 0, total: store.total, path: store.path };
|
||||
if (saved.ok === false) return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Artifact store write failed.");
|
||||
|
||||
const recordIdByResultId = new Map(selectedResults.map((entry) => [entry.id, scanResultToStoredArtifact(entry)?.id]));
|
||||
const promotedResultIds = selectedResults.filter((entry) => readyIds.has(entry.id)).map((entry) => entry.id);
|
||||
const promotedSet = new Set(promotedResultIds);
|
||||
const updatedResults = scanResults.map((entry) => promotedSet.has(entry.id)
|
||||
? { ...entry, artifactRecordId: recordIdByResultId.get(entry.id), persistedArtifact: true }
|
||||
: entry);
|
||||
await writeScanResults(resultsPath, updatedResults);
|
||||
|
||||
const status: NativeScannerPromotionStatus = {
|
||||
ok: true,
|
||||
runDir,
|
||||
logPath,
|
||||
requested: requestedIds.length,
|
||||
selected: selectedResults.length,
|
||||
promoted: promotedResultIds.length,
|
||||
alreadyStored: summary.alreadyStored + summary.persisted,
|
||||
review: summary.review,
|
||||
blocked: summary.blocked + Math.max(0, requestedIds.length - selectedResults.length),
|
||||
added: saved.added,
|
||||
updated: saved.updated,
|
||||
total: saved.total ?? store.total,
|
||||
promotedResultIds,
|
||||
};
|
||||
await appendWorkflowLog(logPath, { at: new Date().toISOString(), ...status });
|
||||
return status;
|
||||
} catch (error) {
|
||||
return emptyPromotionStatus(runDir, logPath, requestedIds.length, errorMessage(error));
|
||||
}
|
||||
},
|
||||
|
||||
async reviewResult(options) {
|
||||
const runDir = deps.resolveRunDir(options.runDir);
|
||||
const logPath = runDir ? path.join(runDir, "review-log.jsonl") : "";
|
||||
const resultId = String(options.resultId ?? "").trim();
|
||||
if (!runDir || !resultId || !["approve", "reject"].includes(options.action)) {
|
||||
return emptyReviewStatus(runDir, logPath, resultId, options.action, "Review requires a run directory, result ID, and valid action.");
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
||||
const index = scanResults.findIndex((entry) => entry.id === resultId);
|
||||
if (index < 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Selected scan result was not found.");
|
||||
const current = scanResults[index];
|
||||
if (current.persistedArtifact) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Persisted results cannot be edited through review.");
|
||||
|
||||
const reviewedAt = new Date().toISOString();
|
||||
const note = String(options.note ?? "").trim().slice(0, 500);
|
||||
let correctedFields: string[] = [];
|
||||
let evalSampleSaved = false;
|
||||
let updated: StoredScanResultEntry;
|
||||
|
||||
if (options.action === "reject") {
|
||||
updated = rejectResult(current, reviewedAt, note);
|
||||
} else {
|
||||
const artifact = normalizeReviewedArtifact(options.artifact);
|
||||
const errors = reviewedArtifactErrors(artifact);
|
||||
if (errors.length > 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, errors.join(" "));
|
||||
const parsed = reviewedArtifactToParsed(artifact);
|
||||
const catalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog() : null;
|
||||
const ikMatch = matchParsedArtifactToIk(parsed, catalog);
|
||||
if (!ikMatch?.matched) {
|
||||
return emptyReviewStatus(runDir, logPath, resultId, options.action, `IK validation failed: ${ikMatch?.notes.join(" ") || "catalog unavailable"}`);
|
||||
}
|
||||
correctedFields = artifactChangedFields(current.artifact, artifact);
|
||||
updated = approveResult(current, artifact, ikMatch, reviewedAt, note, correctedFields);
|
||||
evalSampleSaved = await saveApprovedEvalSample(deps, runDir, current, artifact, parsed);
|
||||
}
|
||||
|
||||
scanResults[index] = updated;
|
||||
await writeScanResults(resultsPath, scanResults);
|
||||
const status: NativeScannerReviewStatus = {
|
||||
ok: true,
|
||||
runDir,
|
||||
logPath,
|
||||
resultId,
|
||||
action: options.action,
|
||||
evalSampleSaved,
|
||||
correctedFields,
|
||||
};
|
||||
await appendWorkflowLog(logPath, { at: reviewedAt, ...status, note });
|
||||
return status;
|
||||
} catch (error) {
|
||||
return emptyReviewStatus(runDir, logPath, resultId, options.action, errorMessage(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadScanResults(runDir: string) {
|
||||
const resultsPath = path.join(runDir, "scan-results.json");
|
||||
const raw = JSON.parse(await fs.readFile(resultsPath, "utf8"));
|
||||
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry) : [];
|
||||
return { path: resultsPath, results };
|
||||
}
|
||||
|
||||
async function writeScanResults(resultsPath: string, results: StoredScanResultEntry[]) {
|
||||
await fs.writeFile(resultsPath, JSON.stringify(results, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function appendWorkflowLog(logPath: string, payload: object) {
|
||||
await fs.appendFile(logPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
|
||||
return {
|
||||
...current,
|
||||
extractionStatus: "review",
|
||||
needsReview: true,
|
||||
valueStatus: "review",
|
||||
notes: [...new Set([...current.notes, note || "Manual review rejected this result."])],
|
||||
review: { status: "rejected", reviewedAt, note: note || undefined, correctedFields: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function approveResult(
|
||||
current: StoredScanResultEntry,
|
||||
artifact: NativeScannerReviewArtifactInput,
|
||||
ikMatch: NonNullable<StoredScanResultEntry["ikMatch"]>,
|
||||
reviewedAt: string,
|
||||
note: string,
|
||||
correctedFields: string[],
|
||||
): StoredScanResultEntry {
|
||||
return {
|
||||
...current,
|
||||
extractionStatus: "parsed",
|
||||
extractionConfidence: 100,
|
||||
needsReview: false,
|
||||
valueStatus: "deferred",
|
||||
valueScore: null,
|
||||
artifact,
|
||||
ikMatch,
|
||||
fieldConfidences: reviewedFieldConfidences(artifact),
|
||||
artifactRecordId: undefined,
|
||||
persistedArtifact: false,
|
||||
notes: note ? [`Manual review approved: ${note}`] : ["Manual review approved."],
|
||||
error: undefined,
|
||||
review: { status: "approved", reviewedAt, note: note || undefined, correctedFields },
|
||||
};
|
||||
}
|
||||
|
||||
async function saveApprovedEvalSample(
|
||||
deps: NativeScannerResultWorkflowDependencies,
|
||||
runDir: string,
|
||||
current: StoredScanResultEntry,
|
||||
artifact: NativeScannerReviewArtifactInput,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
) {
|
||||
if (!deps.saveReviewSample) return false;
|
||||
const ocr = await loadReviewOcr(runDir, current.sequence);
|
||||
if (ocr.length === 0) return false;
|
||||
const saved = await deps.saveReviewSample({
|
||||
reason: "native-review-approved",
|
||||
parsed,
|
||||
capture: {
|
||||
id: `${current.runId}:${current.sequence}`,
|
||||
name: current.imagePath,
|
||||
width: 492,
|
||||
height: 838,
|
||||
capturedAt: current.capturedAt,
|
||||
locked: artifact.locked,
|
||||
ocr,
|
||||
},
|
||||
});
|
||||
return Boolean(saved.ok);
|
||||
}
|
||||
|
||||
function emptyPromotionStatus(runDir: string, logPath: string, requested: number, error: string): NativeScannerPromotionStatus {
|
||||
return {
|
||||
ok: false,
|
||||
runDir,
|
||||
logPath,
|
||||
requested,
|
||||
selected: 0,
|
||||
promoted: 0,
|
||||
alreadyStored: 0,
|
||||
review: 0,
|
||||
blocked: requested,
|
||||
added: 0,
|
||||
updated: 0,
|
||||
total: 0,
|
||||
promotedResultIds: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyReviewStatus(
|
||||
runDir: string,
|
||||
logPath: string,
|
||||
resultId: string,
|
||||
action: "approve" | "reject",
|
||||
error: string,
|
||||
): NativeScannerReviewStatus {
|
||||
return { ok: false, runDir, logPath, resultId, action, evalSampleSaved: false, correctedFields: [], error };
|
||||
}
|
||||
|
||||
function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): NativeScannerReviewArtifactInput {
|
||||
return {
|
||||
name: String(input?.name ?? "").trim(),
|
||||
slot: String(input?.slot ?? "").trim(),
|
||||
level: Math.round(Number(input?.level ?? -1)),
|
||||
setName: String(input?.setName ?? "").trim(),
|
||||
mainStat: String(input?.mainStat ?? "").trim(),
|
||||
mainValue: String(input?.mainValue ?? "").trim(),
|
||||
substats: [...new Set((input?.substats ?? []).map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 4),
|
||||
equipped: String(input?.equipped ?? "Not detected").trim() || "Not detected",
|
||||
locked: typeof input?.locked === "boolean" ? input.locked : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
|
||||
const errors: string[] = [];
|
||||
if (!artifact.name || artifact.name === "Unknown artifact") errors.push("Artifact name is required.");
|
||||
if (!artifact.slot || artifact.slot === "Unknown slot") errors.push("Artifact slot is required.");
|
||||
if (!artifact.setName || artifact.setName === "Unknown set") errors.push("Artifact set is required.");
|
||||
if (!artifact.mainStat || artifact.mainStat === "Unknown main stat") errors.push("Main stat is required.");
|
||||
if (!artifact.mainValue || artifact.mainValue === "?") errors.push("Main value is required.");
|
||||
if (!Number.isInteger(artifact.level) || artifact.level < 0 || artifact.level > 20) errors.push("Level must be between 0 and 20.");
|
||||
if (artifact.substats.length === 0) errors.push("At least one substat is required.");
|
||||
const mainValueError = reviewedMainValueError(artifact.slot, artifact.mainStat, artifact.level, artifact.mainValue);
|
||||
if (mainValueError) errors.push(mainValueError);
|
||||
const implausible = implausibleSubstats(artifact.substats, artifact.level > 16 ? 5 : undefined);
|
||||
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function reviewedArtifactToParsed(artifact: NativeScannerReviewArtifactInput): ParsedArtifactCandidate {
|
||||
const manual = (value: string) => ({ value, confidence: 100, source: "database" as const });
|
||||
return {
|
||||
...artifact,
|
||||
confidence: 100,
|
||||
notes: ["Manually reviewed and approved."],
|
||||
fields: {
|
||||
name: manual(artifact.name),
|
||||
slot: manual(artifact.slot),
|
||||
level: manual(String(artifact.level)),
|
||||
mainStat: manual(artifact.mainStat),
|
||||
mainValue: manual(artifact.mainValue),
|
||||
setName: manual(artifact.setName),
|
||||
equipped: manual(artifact.equipped),
|
||||
substats: manual(artifact.substats.join(", ")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function artifactChangedFields(
|
||||
before: StoredScanResultEntry["artifact"],
|
||||
after: NativeScannerReviewArtifactInput,
|
||||
) {
|
||||
if (!before) return ["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"];
|
||||
return (["name", "slot", "level", "setName", "mainStat", "mainValue", "substats", "equipped", "locked"] as const)
|
||||
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
|
||||
}
|
||||
|
||||
function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
|
||||
return [
|
||||
{ key: "name", label: "Name", value: artifact.name },
|
||||
{ key: "slot", label: "Slot", value: artifact.slot },
|
||||
{ key: "level", label: "Level", value: String(artifact.level) },
|
||||
{ key: "mainStat", label: "Main stat", value: artifact.mainStat },
|
||||
{ key: "mainValue", label: "Main value", value: artifact.mainValue },
|
||||
{ key: "setName", label: "Set", value: artifact.setName },
|
||||
{ key: "equipped", label: "Equipped", value: artifact.equipped },
|
||||
{ key: "substats", label: "Substats", value: artifact.substats.join(", ") },
|
||||
].map((field) => ({ ...field, confidence: 100, source: "database" as const }));
|
||||
}
|
||||
|
||||
async function loadReviewOcr(runDir: string, sequence: number) {
|
||||
try {
|
||||
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
|
||||
const result = Array.isArray(report?.results) ? report.results.find((entry: { sequence?: number }) => entry.sequence === sequence) : null;
|
||||
return Array.isArray(result?.ocr) ? result.ocr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<StoredScanResultEntry>;
|
||||
return typeof entry.id === "string"
|
||||
&& typeof entry.runId === "string"
|
||||
&& Number.isFinite(entry.sequence)
|
||||
&& typeof entry.source === "string"
|
||||
&& typeof entry.imagePath === "string"
|
||||
&& typeof entry.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
+795
-11
@@ -19,6 +19,7 @@ namespace GenshinAssistant.InputHelper;
|
||||
internal static class Program
|
||||
{
|
||||
private static IntPtr _genshinHwnd = IntPtr.Zero;
|
||||
private static readonly NativeScannerService Scanner = new();
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
@@ -104,14 +105,13 @@ internal static class Program
|
||||
|
||||
case "click":
|
||||
{
|
||||
var info = FocusGenshinWindow();
|
||||
var info = FocusGenshinWindow(includeProcessNames: false);
|
||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||
|
||||
var targetX = GetInt(root, "x");
|
||||
var targetY = GetInt(root, "y");
|
||||
// Bare SetCursorPos then a batched down+up click, matching the
|
||||
// verified Inventory Kamera sequence: no extra move event, no
|
||||
// gap between move and click.
|
||||
// Bare SetCursorPos then a batched down+up click: no extra move
|
||||
// event, no gap between move and click.
|
||||
Native.SetCursorPos(targetX, targetY);
|
||||
Native.GetCursorPos(out var pt);
|
||||
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
|
||||
@@ -169,6 +169,23 @@ internal static class Program
|
||||
break;
|
||||
}
|
||||
|
||||
case "key":
|
||||
{
|
||||
var info = FocusGenshinWindow();
|
||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||
|
||||
var key = GetString(root, "key");
|
||||
var sent = SendKeyPressBatch(ResolveVirtualKey(key));
|
||||
response["key"] = key;
|
||||
response["focused"] = info.Focused;
|
||||
response["foregroundProcess"] = info.ForegroundProcess;
|
||||
response["targetProcess"] = info.TargetProcess;
|
||||
response["isElevated"] = IsElevated();
|
||||
response["eventsSent"] = sent;
|
||||
response["inputBlocked"] = sent < 2;
|
||||
break;
|
||||
}
|
||||
|
||||
case "bounds":
|
||||
{
|
||||
var bounds = GetGenshinClientBounds();
|
||||
@@ -221,6 +238,36 @@ internal static class Program
|
||||
break;
|
||||
}
|
||||
|
||||
case "scanner-data-status":
|
||||
response["scanner"] = Scanner.DataStatus(GetString(root, "dataDir"));
|
||||
break;
|
||||
|
||||
case "scanner-catalog":
|
||||
response["scanner"] = IkInventoryLists.CatalogPayload(GetString(root, "dataDir"));
|
||||
break;
|
||||
|
||||
case "scanner-preflight":
|
||||
response["scanner"] = Scanner.Preflight(
|
||||
GetString(root, "dataDir"),
|
||||
NormalizeScannerCategory(GetString(root, "category")));
|
||||
break;
|
||||
|
||||
case "scanner-start":
|
||||
response["scanner"] = Scanner.Start(
|
||||
GetString(root, "dataDir"),
|
||||
GetString(root, "outputRoot"),
|
||||
GetInt(root, "limit"),
|
||||
NormalizeScannerCategory(GetString(root, "category")));
|
||||
break;
|
||||
|
||||
case "scanner-stop":
|
||||
response["scanner"] = Scanner.Stop();
|
||||
break;
|
||||
|
||||
case "scanner-status":
|
||||
response["scanner"] = Scanner.Status();
|
||||
break;
|
||||
|
||||
default:
|
||||
response["ok"] = false;
|
||||
response["error"] = "unknown op";
|
||||
@@ -245,6 +292,24 @@ internal static class Program
|
||||
public bool F9;
|
||||
}
|
||||
|
||||
private static string NormalizeScannerCategory(string category)
|
||||
{
|
||||
var value = (category ?? "").Trim().ToLowerInvariant();
|
||||
return value switch
|
||||
{
|
||||
"" => "artifacts",
|
||||
"artifact" => "artifacts",
|
||||
"artifacts" => "artifacts",
|
||||
"weapon" => "weapons",
|
||||
"weapons" => "weapons",
|
||||
"character" => "characters",
|
||||
"characters" => "characters",
|
||||
"material" => "materials",
|
||||
"materials" => "materials",
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
private struct FocusInfo
|
||||
{
|
||||
public IntPtr Hwnd;
|
||||
@@ -266,14 +331,14 @@ internal static class Program
|
||||
return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 };
|
||||
}
|
||||
|
||||
private static FocusInfo FocusGenshinWindow()
|
||||
private static FocusInfo FocusGenshinWindow(bool includeProcessNames = true)
|
||||
{
|
||||
var hwnd = FindGenshinWindow();
|
||||
var info = new FocusInfo
|
||||
{
|
||||
Hwnd = hwnd,
|
||||
ForegroundProcess = "",
|
||||
TargetProcess = ProcessNameFromHwnd(hwnd),
|
||||
TargetProcess = includeProcessNames ? ProcessNameFromHwnd(hwnd) : "",
|
||||
};
|
||||
if (hwnd == IntPtr.Zero) return info;
|
||||
|
||||
@@ -286,15 +351,14 @@ internal static class Program
|
||||
|
||||
var foreground = Native.GetForegroundWindow();
|
||||
info.Focused = foreground == hwnd;
|
||||
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
|
||||
info.ForegroundProcess = includeProcessNames ? ProcessNameFromHwnd(foreground) : "";
|
||||
return info;
|
||||
}
|
||||
|
||||
// Plain SetForegroundWindow from a background process is silently refused by
|
||||
// Windows' foreground lock. Inventory Kamera and other reliable automation
|
||||
// tools bypass it by attaching the calling thread's input queue to the target
|
||||
// (and current-foreground) window thread and clearing the lock timeout, so the
|
||||
// foreground change is honored. Without this the auto-scan aborts with
|
||||
// Windows' foreground lock. Attach the calling thread's input queue to the
|
||||
// target (and current-foreground) window thread and clear the lock timeout,
|
||||
// so the foreground change is honored. Without this the auto-scan aborts with
|
||||
// "Genshin konnte nicht in den Vordergrund geholt werden".
|
||||
private static bool ForceForeground(IntPtr hwnd)
|
||||
{
|
||||
@@ -423,6 +487,30 @@ internal static class Program
|
||||
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||
}
|
||||
|
||||
private static uint SendKeyPressBatch(int virtualKey)
|
||||
{
|
||||
var inputs = new Native.INPUT[2];
|
||||
inputs[0].type = 1; // INPUT_KEYBOARD
|
||||
inputs[0].mi.dx = virtualKey; // same union bytes as KEYBDINPUT.wVk
|
||||
inputs[1].type = 1;
|
||||
inputs[1].mi.dx = virtualKey;
|
||||
inputs[1].mi.dy = Native.KEYEVENTF_KEYUP; // same union bytes as KEYBDINPUT.dwFlags
|
||||
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||
}
|
||||
|
||||
private static int ResolveVirtualKey(string key)
|
||||
{
|
||||
return key.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"ESC" or "ESCAPE" => 0x1B,
|
||||
"ENTER" => 0x0D,
|
||||
"B" => 0x42,
|
||||
"C" => 0x43,
|
||||
"1" => 0x31,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(key), $"unsupported key: {key}")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetString(JsonElement root, string name)
|
||||
=> root.TryGetProperty(name, out var value) ? value.ToString() : "";
|
||||
|
||||
@@ -437,6 +525,701 @@ internal static class Program
|
||||
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class NativeScannerService
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private ScannerRunStatus current = ScannerRunStatus.Idle();
|
||||
private bool stopRequested;
|
||||
private Task? worker;
|
||||
|
||||
public object DataStatus(string dataDir)
|
||||
{
|
||||
return IkInventoryLists.Load(dataDir).ToPayload();
|
||||
}
|
||||
|
||||
public object Preflight(string dataDir, string category)
|
||||
{
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
var supportedCategories = data.SupportedCategoriesPayload();
|
||||
if (!data.Valid)
|
||||
{
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = false,
|
||||
genshinFound = false,
|
||||
bounds = (object?)null,
|
||||
isSixteenNine = false,
|
||||
grid = NativeGrid.Empty().ToPayload(),
|
||||
ready = false,
|
||||
blockReason = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}",
|
||||
};
|
||||
}
|
||||
|
||||
if (category != "artifacts")
|
||||
{
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = false,
|
||||
genshinFound = false,
|
||||
bounds = (object?)null,
|
||||
isSixteenNine = false,
|
||||
grid = NativeGrid.Empty().ToPayload(),
|
||||
ready = false,
|
||||
blockReason = NativeCaptureUnsupportedMessage(category),
|
||||
};
|
||||
}
|
||||
|
||||
var bounds = GetGenshinClientBounds();
|
||||
var grid = bounds is null ? NativeGrid.Empty() : NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
|
||||
var isSixteenNine = bounds is not null && IsSixteenNine(bounds.Value.Width, bounds.Value.Height);
|
||||
var layoutReady = bounds is not null && isSixteenNine && grid.Targets.Count >= 32;
|
||||
var visual = layoutReady ? CaptureVisualSignal(bounds!.Value) : null;
|
||||
var ready = layoutReady && (visual?.Ready ?? false);
|
||||
var blockReason = ready
|
||||
? ""
|
||||
: bounds is null
|
||||
? "Genshin window not found."
|
||||
: !isSixteenNine
|
||||
? $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9."
|
||||
: grid.Targets.Count < 32
|
||||
? $"Native grid incomplete: {grid.Targets.Count}/32 targets."
|
||||
: visual?.BlockReason ?? "Genshin capture visual preflight failed.";
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = true,
|
||||
genshinFound = bounds is not null,
|
||||
bounds = bounds is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
left = bounds.Value.Left,
|
||||
top = bounds.Value.Top,
|
||||
width = bounds.Value.Width,
|
||||
height = bounds.Value.Height,
|
||||
},
|
||||
isSixteenNine,
|
||||
grid = grid.ToPayload(),
|
||||
visual = visual?.ToPayload(),
|
||||
ready,
|
||||
blockReason,
|
||||
};
|
||||
}
|
||||
|
||||
public object Start(string dataDir, string outputRoot, int limit, string category)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (current.Running) return current.ToPayload();
|
||||
stopRequested = false;
|
||||
var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800);
|
||||
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
current.DataVersion = data.Version;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
if (!data.Valid)
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = "blocked";
|
||||
current.Message = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}";
|
||||
return current.ToPayload();
|
||||
}
|
||||
if (category != "artifacts")
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = "blocked";
|
||||
current.Message = NativeCaptureUnsupportedMessage(category);
|
||||
return current.ToPayload();
|
||||
}
|
||||
worker = Task.Run(() => RunCaptureLoop(dataDir, outputRoot, safeLimit, runId, category));
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
public object Stop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
stopRequested = true;
|
||||
current.Message = current.Running ? "stop requested" : current.Message;
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
public object Status()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunCaptureLoop(string dataDir, string outputRoot, int limit, string runId, string category)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
if (!data.Valid)
|
||||
{
|
||||
Finish("blocked", $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
current.DataVersion = data.Version;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
}
|
||||
|
||||
if (category != "artifacts")
|
||||
{
|
||||
Finish("blocked", NativeCaptureUnsupportedMessage(category));
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = GetGenshinClientBounds();
|
||||
if (bounds is null)
|
||||
{
|
||||
Finish("blocked", "Genshin window not found.");
|
||||
return;
|
||||
}
|
||||
if (!IsSixteenNine(bounds.Value.Width, bounds.Value.Height))
|
||||
{
|
||||
Finish("blocked", $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9.");
|
||||
return;
|
||||
}
|
||||
|
||||
var focus = FocusGenshinWindow(includeProcessNames: false);
|
||||
if (!focus.Focused)
|
||||
{
|
||||
Finish("blocked", "Genshin could not be focused.");
|
||||
return;
|
||||
}
|
||||
|
||||
var visual = CaptureVisualSignal(bounds.Value);
|
||||
if (!visual.Ready)
|
||||
{
|
||||
Finish("blocked", visual.BlockReason);
|
||||
return;
|
||||
}
|
||||
|
||||
var runDir = PrepareRunDirectory(outputRoot, runId);
|
||||
var manifestPath = Path.Combine(runDir, "manifest.json");
|
||||
var jobsPath = Path.Combine(runDir, "capture-jobs.jsonl");
|
||||
var statusPath = Path.Combine(runDir, "status.json");
|
||||
var grid = NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
|
||||
var detailRect = DetailRect(bounds.Value.Width, bounds.Value.Height);
|
||||
lock (gate)
|
||||
{
|
||||
current.RunDir = runDir;
|
||||
current.ManifestPath = manifestPath;
|
||||
current.JobsPath = jobsPath;
|
||||
current.StatusPath = statusPath;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
}
|
||||
WriteRunManifest(manifestPath, data, bounds.Value, grid, detailRect, limit, runId, category);
|
||||
WriteStatusFileSafe();
|
||||
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
|
||||
var captured = 0;
|
||||
var page = 1;
|
||||
|
||||
while (captured < limit)
|
||||
{
|
||||
foreach (var target in grid.Targets)
|
||||
{
|
||||
if (ShouldStop())
|
||||
{
|
||||
Finish("stopped", "stop requested");
|
||||
return;
|
||||
}
|
||||
if (captured >= limit) break;
|
||||
|
||||
var screenX = bounds.Value.Left + target.X;
|
||||
var screenY = bounds.Value.Top + target.Y;
|
||||
Native.SetCursorPos(screenX, screenY);
|
||||
var sent = SendMouseClickBatch();
|
||||
Thread.Sleep(190);
|
||||
|
||||
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
|
||||
using (var card = CaptureArtifactCard(bounds.Value))
|
||||
{
|
||||
card.Save(cardPath, ImageFormat.Png);
|
||||
}
|
||||
|
||||
var job = new NativeCaptureJob(
|
||||
captured + 1,
|
||||
page,
|
||||
target.Row,
|
||||
target.Col,
|
||||
target.X,
|
||||
target.Y,
|
||||
screenX,
|
||||
screenY,
|
||||
sent,
|
||||
Path.GetFileName(cardPath),
|
||||
cardPath,
|
||||
DateTimeOffset.Now,
|
||||
detailRect.Width,
|
||||
detailRect.Height,
|
||||
category);
|
||||
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
|
||||
|
||||
captured++;
|
||||
lock (gate)
|
||||
{
|
||||
current.Captured = captured;
|
||||
current.Queued = captured;
|
||||
current.Clicked = captured;
|
||||
current.Pages = page;
|
||||
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
|
||||
current.LastArtifactPath = cardPath;
|
||||
current.LastJob = job.ToPayload();
|
||||
current.ActiveMs = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
}
|
||||
|
||||
if (captured >= limit) break;
|
||||
ScrollOneArtifactPage(grid, bounds.Value);
|
||||
page++;
|
||||
lock (gate)
|
||||
{
|
||||
current.Pages = page;
|
||||
current.Message = $"scrolled to page {page}";
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
|
||||
Finish("done", $"captured {captured} {category} card crops");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Finish("blocked", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PrepareRunDirectory(string outputRoot, string runId)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(outputRoot)
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GenshinArtifactAssistant", "native-scans")
|
||||
: outputRoot;
|
||||
var runDir = Path.Combine(root, runId);
|
||||
Directory.CreateDirectory(runDir);
|
||||
return runDir;
|
||||
}
|
||||
|
||||
private static Bitmap CaptureArtifactCard(Rect bounds)
|
||||
{
|
||||
var card = DetailRect(bounds.Width, bounds.Height);
|
||||
var bitmap = new Bitmap(card.Width, card.Height, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.CopyFromScreen(bounds.Left + card.Left, bounds.Top + card.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static Rect DetailRect(int width, int height)
|
||||
{
|
||||
return new Rect
|
||||
{
|
||||
Left = (int)Math.Round(width * 0.681),
|
||||
Top = (int)Math.Round(height * 0.111),
|
||||
Width = Math.Max(1, (int)Math.Round(width * 0.256)),
|
||||
Height = Math.Max(1, (int)Math.Round(height * 0.776)),
|
||||
};
|
||||
}
|
||||
|
||||
private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
|
||||
{
|
||||
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
|
||||
Thread.Sleep(25);
|
||||
for (var index = 0; index < 39; index++)
|
||||
{
|
||||
SendMouseWheel(-120);
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldStop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (stopRequested) return true;
|
||||
}
|
||||
var state = GetCursorState();
|
||||
return state.Escape || state.Enter || state.F9;
|
||||
}
|
||||
|
||||
private void Finish(string status, string message)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = status;
|
||||
current.Message = message;
|
||||
current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue
|
||||
? 0
|
||||
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
||||
stopRequested = false;
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
}
|
||||
|
||||
private void WriteStatusFileSafe()
|
||||
{
|
||||
string statusPath;
|
||||
object payload;
|
||||
lock (gate)
|
||||
{
|
||||
statusPath = current.StatusPath;
|
||||
payload = current.ToPayload();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(statusPath)) return;
|
||||
try
|
||||
{
|
||||
NativeScannerFiles.WriteJson(statusPath, new { scanner = payload });
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Status files are a recovery aid; scan control remains in memory.
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRunManifest(
|
||||
string manifestPath,
|
||||
IkInventoryListStatus data,
|
||||
Rect bounds,
|
||||
NativeGrid grid,
|
||||
Rect detailRect,
|
||||
int target,
|
||||
string runId,
|
||||
string category)
|
||||
{
|
||||
NativeScannerFiles.WriteJson(manifestPath, new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
kind = "native-ik-category-card-crop-scan",
|
||||
runId,
|
||||
category,
|
||||
createdAt = DateTimeOffset.Now,
|
||||
target,
|
||||
data = data.ToPayload(),
|
||||
bounds = new
|
||||
{
|
||||
left = bounds.Left,
|
||||
top = bounds.Top,
|
||||
width = bounds.Width,
|
||||
height = bounds.Height,
|
||||
},
|
||||
grid = grid.ToPayload(),
|
||||
detailRect = new
|
||||
{
|
||||
left = detailRect.Left,
|
||||
top = detailRect.Top,
|
||||
width = detailRect.Width,
|
||||
height = detailRect.Height,
|
||||
},
|
||||
outputs = new
|
||||
{
|
||||
jobs = "capture-jobs.jsonl",
|
||||
status = "status.json",
|
||||
},
|
||||
downstream = new
|
||||
{
|
||||
queue = "capture-jobs.jsonl",
|
||||
next = "ocr-parse-store",
|
||||
evaluation = "deferred",
|
||||
category,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsSixteenNine(int width, int height)
|
||||
{
|
||||
if (height <= 0) return false;
|
||||
const double ratio = 16.0 / 9.0;
|
||||
var actual = width / (double)height;
|
||||
return Math.Abs(actual - ratio) <= ratio * 0.02;
|
||||
}
|
||||
|
||||
private static NativeVisualSignal CaptureVisualSignal(Rect bounds)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
return NativeVisualSignal.FromBitmap(bitmap);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NativeVisualSignal.Blocked($"Genshin capture visual preflight failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NativeCaptureUnsupportedMessage(string category)
|
||||
=> $"Native capture for category '{category}' is not implemented yet; IK catalog is available only.";
|
||||
|
||||
}
|
||||
|
||||
private sealed class NativeVisualSignal
|
||||
{
|
||||
public bool Ready { get; private init; }
|
||||
public int Samples { get; private init; }
|
||||
public double WhitePct { get; private init; }
|
||||
public double DarkPct { get; private init; }
|
||||
public double ColorPct { get; private init; }
|
||||
public double LumaStdDev { get; private init; }
|
||||
public string BlockReason { get; private init; } = "";
|
||||
|
||||
public static NativeVisualSignal Blocked(string reason) => new()
|
||||
{
|
||||
Ready = false,
|
||||
Samples = 0,
|
||||
BlockReason = reason,
|
||||
};
|
||||
|
||||
public static NativeVisualSignal FromBitmap(Bitmap bitmap)
|
||||
{
|
||||
var stepX = Math.Max(1, bitmap.Width / 120);
|
||||
var stepY = Math.Max(1, bitmap.Height / 80);
|
||||
var samples = 0;
|
||||
var white = 0;
|
||||
var dark = 0;
|
||||
var colorful = 0;
|
||||
double lumaSum = 0;
|
||||
double lumaSqSum = 0;
|
||||
|
||||
for (var y = 0; y < bitmap.Height; y += stepY)
|
||||
{
|
||||
for (var x = 0; x < bitmap.Width; x += stepX)
|
||||
{
|
||||
var pixel = bitmap.GetPixel(x, y);
|
||||
var max = Math.Max(pixel.R, Math.Max(pixel.G, pixel.B));
|
||||
var min = Math.Min(pixel.R, Math.Min(pixel.G, pixel.B));
|
||||
var luma = 0.2126 * pixel.R + 0.7152 * pixel.G + 0.0722 * pixel.B;
|
||||
samples++;
|
||||
if (pixel.R >= 245 && pixel.G >= 245 && pixel.B >= 245) white++;
|
||||
if (pixel.R <= 12 && pixel.G <= 12 && pixel.B <= 12) dark++;
|
||||
if (max - min >= 18) colorful++;
|
||||
lumaSum += luma;
|
||||
lumaSqSum += luma * luma;
|
||||
}
|
||||
}
|
||||
|
||||
if (samples <= 0) return Blocked("Genshin capture visual preflight produced no samples.");
|
||||
|
||||
var mean = lumaSum / samples;
|
||||
var variance = Math.Max(0, (lumaSqSum / samples) - mean * mean);
|
||||
var stdDev = Math.Sqrt(variance);
|
||||
var whitePct = white * 100.0 / samples;
|
||||
var darkPct = dark * 100.0 / samples;
|
||||
var colorPct = colorful * 100.0 / samples;
|
||||
var blankWhite = whitePct >= 96 && stdDev <= 10;
|
||||
var blankDark = darkPct >= 96 && stdDev <= 10;
|
||||
var tooUniform = stdDev <= 4 && colorPct <= 1.5;
|
||||
var ready = !(blankWhite || blankDark || tooUniform);
|
||||
var reason = ready
|
||||
? ""
|
||||
: blankWhite
|
||||
? "Genshin capture is blank or almost entirely white."
|
||||
: blankDark
|
||||
? "Genshin capture is blank or almost entirely black."
|
||||
: "Genshin capture is too uniform for native scanning.";
|
||||
|
||||
return new NativeVisualSignal
|
||||
{
|
||||
Ready = ready,
|
||||
Samples = samples,
|
||||
WhitePct = Math.Round(whitePct, 1),
|
||||
DarkPct = Math.Round(darkPct, 1),
|
||||
ColorPct = Math.Round(colorPct, 1),
|
||||
LumaStdDev = Math.Round(stdDev, 1),
|
||||
BlockReason = reason,
|
||||
};
|
||||
}
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
ready = Ready,
|
||||
samples = Samples,
|
||||
whitePct = WhitePct,
|
||||
darkPct = DarkPct,
|
||||
colorPct = ColorPct,
|
||||
lumaStdDev = LumaStdDev,
|
||||
blockReason = BlockReason,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ScannerRunStatus
|
||||
{
|
||||
public bool Running { get; set; }
|
||||
public string Status { get; set; } = "idle";
|
||||
public string RunId { get; set; } = "";
|
||||
public int Target { get; set; }
|
||||
public int Captured { get; set; }
|
||||
public int Queued { get; set; }
|
||||
public int Clicked { get; set; }
|
||||
public int Pages { get; set; }
|
||||
public int ActiveMs { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public string OutputRoot { get; set; } = "";
|
||||
public string RunDir { get; set; } = "";
|
||||
public string ManifestPath { get; set; } = "";
|
||||
public string JobsPath { get; set; } = "";
|
||||
public string StatusPath { get; set; } = "";
|
||||
public string DataVersion { get; set; } = "";
|
||||
public string Category { get; set; } = "artifacts";
|
||||
public string LastArtifactPath { get; set; } = "";
|
||||
public object? SupportedCategories { get; set; }
|
||||
public object? LastJob { get; set; }
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
|
||||
|
||||
public static ScannerRunStatus Started(string runId, int target, string outputRoot, string category) => new()
|
||||
{
|
||||
Running = true,
|
||||
Status = "running",
|
||||
RunId = runId,
|
||||
Category = category,
|
||||
Target = target,
|
||||
OutputRoot = outputRoot,
|
||||
StartedAt = DateTimeOffset.Now,
|
||||
Message = $"native {category} scanner started",
|
||||
};
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
running = Running,
|
||||
status = Status,
|
||||
runId = RunId,
|
||||
target = Target,
|
||||
captured = Captured,
|
||||
queued = Queued,
|
||||
clicked = Clicked,
|
||||
pages = Pages,
|
||||
activeMs = ActiveMs,
|
||||
message = Message,
|
||||
outputRoot = OutputRoot,
|
||||
runDir = RunDir,
|
||||
manifestPath = ManifestPath,
|
||||
jobsPath = JobsPath,
|
||||
statusPath = StatusPath,
|
||||
dataVersion = DataVersion,
|
||||
category = Category,
|
||||
lastArtifactPath = LastArtifactPath,
|
||||
supportedCategories = SupportedCategories,
|
||||
lastJob = LastJob,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record NativeCaptureJob(
|
||||
int Sequence,
|
||||
int Page,
|
||||
int Row,
|
||||
int Col,
|
||||
int ClientX,
|
||||
int ClientY,
|
||||
int ScreenX,
|
||||
int ScreenY,
|
||||
uint ClickEventsSent,
|
||||
string RelativePath,
|
||||
string AbsolutePath,
|
||||
DateTimeOffset CapturedAt,
|
||||
int DetailWidth,
|
||||
int DetailHeight,
|
||||
string Category)
|
||||
{
|
||||
public object ToPayload() => new
|
||||
{
|
||||
sequence = Sequence,
|
||||
category = Category,
|
||||
page = Page,
|
||||
row = Row,
|
||||
col = Col,
|
||||
clientX = ClientX,
|
||||
clientY = ClientY,
|
||||
screenX = ScreenX,
|
||||
screenY = ScreenY,
|
||||
clickEventsSent = ClickEventsSent,
|
||||
relativePath = RelativePath,
|
||||
absolutePath = AbsolutePath,
|
||||
capturedAt = CapturedAt,
|
||||
detail = new
|
||||
{
|
||||
width = DetailWidth,
|
||||
height = DetailHeight,
|
||||
},
|
||||
kind = $"{Category.TrimEnd('s')}-detail-card-crop",
|
||||
downstream = "ocr-parse-store",
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class NativeGrid
|
||||
{
|
||||
public List<NativeGridTarget> Targets { get; init; } = new();
|
||||
public int Rows { get; init; }
|
||||
public int Cols { get; init; }
|
||||
public int AnchorX { get; init; }
|
||||
public int AnchorY { get; init; }
|
||||
|
||||
public static NativeGrid Empty() => new();
|
||||
|
||||
public static NativeGrid ForClient(int width, int height)
|
||||
{
|
||||
const int rows = 4;
|
||||
const int cols = 8;
|
||||
var startX = (int)Math.Round(width * 0.093);
|
||||
var startY = (int)Math.Round(height * 0.235);
|
||||
var stepX = (int)Math.Round(width * 0.076);
|
||||
var stepY = (int)Math.Round(height * 0.163);
|
||||
var targets = new List<NativeGridTarget>();
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
targets.Add(new NativeGridTarget(startX + col * stepX, startY + row * stepY, row, col));
|
||||
}
|
||||
}
|
||||
return new NativeGrid
|
||||
{
|
||||
Rows = rows,
|
||||
Cols = cols,
|
||||
Targets = targets,
|
||||
AnchorX = Math.Max(1, (int)Math.Round(width * 0.36)),
|
||||
AnchorY = Math.Max(1, (int)Math.Round(height * 0.5)),
|
||||
};
|
||||
}
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
rows = Rows,
|
||||
cols = Cols,
|
||||
count = Targets.Count,
|
||||
anchorX = AnchorX,
|
||||
anchorY = AnchorY,
|
||||
first = Targets.FirstOrDefault()?.ToPayload(),
|
||||
last = Targets.LastOrDefault()?.ToPayload(),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record NativeGridTarget(int X, int Y, int Row, int Col)
|
||||
{
|
||||
public object ToPayload() => new { x = X, y = Y, row = Row, col = Col };
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Native
|
||||
@@ -445,6 +1228,7 @@ internal static class Native
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
public const int KEYEVENTF_KEYUP = 0x0002;
|
||||
public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
|
||||
public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
|
||||
public const uint SPIF_SENDCHANGE = 0x0002;
|
||||
|
||||
@@ -16,6 +16,23 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"eval": "vitest run src/eval/ocrEval.test.ts",
|
||||
"eval:review-candidates": "node scripts/export-review-eval-candidates.cjs",
|
||||
"eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs",
|
||||
"scan:soak": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1",
|
||||
"scan:goal": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun",
|
||||
"scan:goal:validated": "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
|
||||
"scan:goal:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
|
||||
"scan:iterate": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
|
||||
"scan:iterate:validated": "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
"scan:iterate:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
"scan:repeatability": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
|
||||
"scan:repeatability:wait": "npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
|
||||
"scan:live:preflight": "node scripts/live-preflight.cjs",
|
||||
"scan:live:preflight:wait": "node scripts/live-preflight.cjs --wait=120",
|
||||
"scan:assessment:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment",
|
||||
"scan:assessment:validate": "node scripts/validate-scan-assessment.cjs",
|
||||
"scan:native:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1",
|
||||
"scan:native:smoke:5": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1 -Limit 5",
|
||||
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
|
||||
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
||||
},
|
||||
@@ -57,6 +74,13 @@
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "data/ik-inventorylists",
|
||||
"to": "ik-inventorylists",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
param(
|
||||
[string]$ProjectRoot
|
||||
[string]$ProjectRoot,
|
||||
[string]$OcrWorkers = ""
|
||||
)
|
||||
|
||||
# Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd),
|
||||
@@ -20,6 +21,10 @@ try {
|
||||
|
||||
Write-Host "Projekt: $project"
|
||||
Write-Host "Admin-Log: $logPath"
|
||||
if ($OcrWorkers) {
|
||||
$env:GAA_OCR_WORKERS = $OcrWorkers
|
||||
Write-Host "GAA_OCR_WORKERS: $env:GAA_OCR_WORKERS"
|
||||
}
|
||||
|
||||
# A UAC-elevated process gets its environment rebuilt fresh from the
|
||||
# registry; it does NOT inherit PATH edits that only exist in the calling
|
||||
@@ -56,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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
param(
|
||||
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path,
|
||||
[string]$OcrWorkers = $env:GAA_OCR_WORKERS
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -19,6 +20,9 @@ try {
|
||||
"-File $(Quote-ProcessArgument $script)",
|
||||
"-ProjectRoot $(Quote-ProcessArgument $project)"
|
||||
) -join " "
|
||||
if ($OcrWorkers) {
|
||||
$arguments += " -OcrWorkers $(Quote-ProcessArgument $OcrWorkers)"
|
||||
}
|
||||
|
||||
Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const os = require("node:os");
|
||||
|
||||
const DEFAULT_LIMIT = 80;
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function defaultReviewSamplesPath() {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "genshin-artifact-assistant", "review-samples.jsonl");
|
||||
}
|
||||
|
||||
function simplifyId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^0-9A-Za-z]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "unknown";
|
||||
}
|
||||
|
||||
function parsedSummary(parsed) {
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return {
|
||||
name: parsed.name,
|
||||
slot: parsed.slot,
|
||||
level: parsed.level,
|
||||
mainStat: parsed.mainStat,
|
||||
mainValue: parsed.mainValue,
|
||||
setName: parsed.setName,
|
||||
equipped: parsed.equipped,
|
||||
substats: Array.isArray(parsed.substats) ? parsed.substats : [],
|
||||
confidence: parsed.confidence,
|
||||
notes: Array.isArray(parsed.notes) ? parsed.notes : [],
|
||||
};
|
||||
}
|
||||
|
||||
const REQUIRED_FAST_OCR_FIELDS = [
|
||||
"artifact-name",
|
||||
"artifact-slot",
|
||||
"artifact-main-stat-label",
|
||||
"artifact-level",
|
||||
"artifact-substats",
|
||||
];
|
||||
|
||||
function ocrMap(record) {
|
||||
const entries = record?.sample?.capture?.ocr;
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
const result = {};
|
||||
for (const entry of entries) {
|
||||
if (typeof entry?.id === "string" && typeof entry?.text === "string") {
|
||||
result[entry.id] = entry.text;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function candidateFromRecord(record, index) {
|
||||
const ocr = ocrMap(record);
|
||||
if (!ocr) return null;
|
||||
const parsed = parsedSummary(record?.sample?.parsed);
|
||||
const reason = record?.sample?.reason || "missing-reason";
|
||||
const savedAt = record?.savedAt || "unknown";
|
||||
const capture = record?.sample?.capture || {};
|
||||
const ocrFieldIds = Object.keys(ocr).sort();
|
||||
const missingFastFields = REQUIRED_FAST_OCR_FIELDS.filter((field) => !ocr[field]);
|
||||
const hasEquippedFooterOcr = Boolean(ocr["artifact-footer"]);
|
||||
const locked = typeof capture.locked === "boolean" ? capture.locked : null;
|
||||
return {
|
||||
id: `review-${simplifyId(savedAt)}-${index}`,
|
||||
savedAt,
|
||||
reason,
|
||||
confirmed: false,
|
||||
resolution: capture.width && capture.height ? `${capture.width}x${capture.height}` : "",
|
||||
ocrFieldIds,
|
||||
missingFastFields,
|
||||
hasEquippedFooterOcr,
|
||||
locked,
|
||||
likelyStaleCapture: missingFastFields.includes("artifact-slot"),
|
||||
parsed,
|
||||
ocr,
|
||||
reviewPrompt: {
|
||||
action: "Confirm or correct parsed fields before moving this case into src/eval/corpus/confirmedReviewCorpus.ts.",
|
||||
expectedFields: {
|
||||
name: parsed.name || "",
|
||||
slot: parsed.slot || "",
|
||||
level: parsed.level ?? "",
|
||||
mainStat: parsed.mainStat || "",
|
||||
mainValue: parsed.mainValue || "",
|
||||
setName: parsed.setName || "",
|
||||
equipped: parsed.equipped || "",
|
||||
substats: parsed.substats || [],
|
||||
},
|
||||
validationChecks: {
|
||||
equipped: hasEquippedFooterOcr ? "Confirm character name or mark Not detected." : "No footer OCR in this sample.",
|
||||
locked: locked === null ? "No lock-state payload in this sample." : `Confirm locked=${locked}.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function candidateKey(candidate) {
|
||||
return [
|
||||
candidate.reason,
|
||||
candidate.parsed.name,
|
||||
candidate.parsed.slot,
|
||||
candidate.parsed.mainStat,
|
||||
candidate.parsed.setName,
|
||||
JSON.stringify(candidate.parsed.substats || []),
|
||||
JSON.stringify(candidate.ocr),
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function candidatePriority(candidate) {
|
||||
const reason = candidate.reason || "";
|
||||
const parsed = candidate.parsed || {};
|
||||
const missingCount = candidate.missingFastFields?.length || 0;
|
||||
const hasUnknownCritical = [parsed.name, parsed.slot, parsed.mainStat, parsed.setName].some((value) => String(value || "").startsWith("Unknown"));
|
||||
if (candidate.likelyStaleCapture) return 4;
|
||||
if (missingCount === 0 && /low-field|low-total|capture-rejected|initial-selection/i.test(reason)) return 0;
|
||||
if (missingCount === 0 && (parsed.notes || []).some((note) => /fuzzy|incomplete|not confidently|low/i.test(note))) return 1;
|
||||
if (missingCount === 0) return 2;
|
||||
if (missingCount <= 1 && !hasUnknownCritical) return 3;
|
||||
return 5;
|
||||
}
|
||||
|
||||
async function readCandidates(inputPath, limit) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
let total = 0;
|
||||
let invalid = 0;
|
||||
const rl = readline.createInterface({ input: fs.createReadStream(inputPath, { encoding: "utf8" }) });
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
total++;
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const candidate = candidateFromRecord(record, total);
|
||||
if (!candidate) continue;
|
||||
const key = candidateKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
const priority = candidatePriority(left) - candidatePriority(right);
|
||||
if (priority) return priority;
|
||||
const missingDiff = (left.missingFastFields?.length || 0) - (right.missingFastFields?.length || 0);
|
||||
if (missingDiff) return missingDiff;
|
||||
return String(right.savedAt).localeCompare(String(left.savedAt));
|
||||
});
|
||||
return { total, invalid, candidates: candidates.slice(0, limit), uniqueCandidates: candidates.length };
|
||||
}
|
||||
|
||||
function increment(map, key) {
|
||||
const normalizedKey = key || "unknown";
|
||||
map[normalizedKey] = (map[normalizedKey] || 0) + 1;
|
||||
}
|
||||
|
||||
function buildExportStats(candidates) {
|
||||
const reasonCounts = {};
|
||||
const missingFastFieldCounts = {};
|
||||
let completeFastFields = 0;
|
||||
let likelyStaleCaptures = 0;
|
||||
let equippedFooterCandidates = 0;
|
||||
let lockedTrueCandidates = 0;
|
||||
let lockedFalseCandidates = 0;
|
||||
for (const candidate of candidates) {
|
||||
increment(reasonCounts, candidate.reason);
|
||||
if (candidate.likelyStaleCapture) likelyStaleCaptures++;
|
||||
if (candidate.hasEquippedFooterOcr) equippedFooterCandidates++;
|
||||
if (candidate.locked === true) lockedTrueCandidates++;
|
||||
if (candidate.locked === false) lockedFalseCandidates++;
|
||||
const missingFields = candidate.missingFastFields || [];
|
||||
if (missingFields.length === 0) completeFastFields++;
|
||||
for (const field of missingFields) increment(missingFastFieldCounts, field);
|
||||
}
|
||||
return {
|
||||
completeFastFields,
|
||||
likelyStaleCaptures,
|
||||
equippedFooterCandidates,
|
||||
lockedTrueCandidates,
|
||||
lockedFalseCandidates,
|
||||
reasonCounts,
|
||||
missingFastFieldCounts,
|
||||
};
|
||||
}
|
||||
|
||||
function markdownFor(summary, candidates) {
|
||||
const lines = [
|
||||
"# Review Eval Candidates",
|
||||
"",
|
||||
`Source: ${summary.inputPath}`,
|
||||
`Generated: ${summary.generatedAt}`,
|
||||
`Records read: ${summary.recordsRead}`,
|
||||
`Unique candidates: ${summary.uniqueCandidates}`,
|
||||
`Exported candidates: ${summary.exportedCandidates}`,
|
||||
`Complete fast-field candidates: ${summary.exportStats.completeFastFields}`,
|
||||
`Likely stale captures: ${summary.exportStats.likelyStaleCaptures}`,
|
||||
`Equipped footer candidates: ${summary.exportStats.equippedFooterCandidates}`,
|
||||
`Locked=true candidates: ${summary.exportStats.lockedTrueCandidates}`,
|
||||
`Locked=false candidates: ${summary.exportStats.lockedFalseCandidates}`,
|
||||
"",
|
||||
"These cases are not ground truth yet. Confirm or correct the expected fields before committing any case into `src/eval/corpus/`.",
|
||||
"",
|
||||
"## Export stats",
|
||||
"",
|
||||
"Reason counts:",
|
||||
"",
|
||||
...Object.entries(summary.exportStats.reasonCounts).map(([reason, count]) => `- ${reason}: ${count}`),
|
||||
"",
|
||||
"Missing fast-field counts:",
|
||||
"",
|
||||
...Object.entries(summary.exportStats.missingFastFieldCounts).map(([field, count]) => `- ${field}: ${count}`),
|
||||
"",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
lines.push(`## ${candidate.id}`);
|
||||
lines.push("");
|
||||
lines.push(`- savedAt: ${candidate.savedAt}`);
|
||||
lines.push(`- reason: ${candidate.reason}`);
|
||||
lines.push(`- missingFastFields: ${candidate.missingFastFields.join(", ") || "none"}`);
|
||||
lines.push(`- likelyStaleCapture: ${candidate.likelyStaleCapture ? "yes" : "no"}`);
|
||||
lines.push(`- hasEquippedFooterOcr: ${candidate.hasEquippedFooterOcr ? "yes" : "no"}`);
|
||||
lines.push(`- locked: ${candidate.locked === null ? "unknown" : candidate.locked}`);
|
||||
lines.push(`- parsed: ${candidate.parsed.name || "?"} | ${candidate.parsed.slot || "?"} | ${candidate.parsed.mainStat || "?"} | ${candidate.parsed.setName || "?"} | equipped=${candidate.parsed.equipped || "?"}`);
|
||||
lines.push(`- substats: ${(candidate.parsed.substats || []).join(", ") || "?"}`);
|
||||
lines.push("");
|
||||
lines.push("OCR:");
|
||||
for (const [id, text] of Object.entries(candidate.ocr)) {
|
||||
lines.push(`- ${id}: ${JSON.stringify(text)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = path.resolve(argValue("input", defaultReviewSamplesPath()));
|
||||
const outputDir = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates")));
|
||||
const limit = Math.max(1, Math.min(500, Number(argValue("limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT));
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
throw new Error(`Review sample file not found: ${inputPath}`);
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const result = await readCandidates(inputPath, limit);
|
||||
const summary = {
|
||||
inputPath,
|
||||
outputDir,
|
||||
generatedAt: new Date().toISOString(),
|
||||
recordsRead: result.total,
|
||||
invalidRecords: result.invalid,
|
||||
uniqueCandidates: result.uniqueCandidates,
|
||||
exportedCandidates: result.candidates.length,
|
||||
exportStats: buildExportStats(result.candidates),
|
||||
};
|
||||
const payload = { summary, candidates: result.candidates };
|
||||
const jsonPath = path.join(outputDir, "review-eval-candidates.json");
|
||||
const mdPath = path.join(outputDir, "review-eval-candidates.md");
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(mdPath, markdownFor(summary, result.candidates), "utf8");
|
||||
console.log(JSON.stringify({ ok: true, ...summary, jsonPath, mdPath }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildExportStats,
|
||||
candidateFromRecord,
|
||||
candidatePriority,
|
||||
defaultReviewSamplesPath,
|
||||
markdownFor,
|
||||
readCandidates,
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,25 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
||||
$devControlPort = 17317
|
||||
|
||||
$killed = 0
|
||||
$candidatePids = @{}
|
||||
|
||||
try {
|
||||
$health = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/health" -TimeoutSec 2 -ErrorAction Stop
|
||||
if ($health.appBuild -and $health.appBuild.cwd -eq $project) {
|
||||
Write-Host "Dev-Control-Port $devControlPort gehoert zu diesem Projekt (PID $($health.appBuild.pid), Signatur $($health.appBuild.signature)). Versuche Self-Shutdown..."
|
||||
try {
|
||||
Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/dev/shutdown?reason=restart" -TimeoutSec 2 -ErrorAction Stop | Out-Null
|
||||
Start-Sleep -Milliseconds 900
|
||||
} catch {
|
||||
Write-Host "Self-Shutdown nicht verfuegbar oder fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# No dev-control server or an older/stuck process; continue with process scan.
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
@@ -26,15 +43,47 @@ Get-CimInstance Win32_Process |
|
||||
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
|
||||
} |
|
||||
ForEach-Object {
|
||||
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))"
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
$killed++
|
||||
$candidatePids[[int]$_.ProcessId] = $_.Name
|
||||
}
|
||||
|
||||
try {
|
||||
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
|
||||
ForEach-Object {
|
||||
if ($_.OwningProcess -gt 0) {
|
||||
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
||||
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Get-NetTCPConnection can be unavailable on some machines; process matching
|
||||
# above still handles the normal non-elevated path.
|
||||
}
|
||||
|
||||
$failed = 0
|
||||
foreach ($entry in $candidatePids.GetEnumerator()) {
|
||||
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
|
||||
try {
|
||||
Stop-Process -Id $entry.Key -Force -ErrorAction Stop
|
||||
$killed++
|
||||
} catch {
|
||||
Write-Host "WARNUNG: Konnte PID $($entry.Key) nicht beenden: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
if ($killed -gt 0) {
|
||||
# Windows braucht einen Moment, um Ports/Handles wirklich freizugeben.
|
||||
Start-Sleep -Milliseconds 500
|
||||
Write-Host "$killed alte Prozess(e) beendet."
|
||||
} else {
|
||||
Write-Host "Keine alten Instanzen gefunden."
|
||||
}
|
||||
|
||||
if ($failed -gt 0) {
|
||||
Write-Host "$failed alte Prozess(e) konnten nicht beendet werden. Wenn das ein Admin-Prozess ist, starte npm run dev:admin und bestaetige UAC oder schliesse die alte App manuell." -ForegroundColor Yellow
|
||||
throw "$failed alte Prozess(e) konnten nicht beendet werden."
|
||||
}
|
||||
|
||||
if ($killed -eq 0 -and $failed -eq 0) {
|
||||
Write-Host "Keine alten Instanzen gefunden."
|
||||
} else {
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
|
||||
@@ -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,265 @@
|
||||
param(
|
||||
[string]$BaseUrl = "http://127.0.0.1:17317",
|
||||
[int]$Limit = 2,
|
||||
[ValidateSet("artifacts", "weapons", "characters", "materials")]
|
||||
[string]$Category = "artifacts",
|
||||
[int]$PollIntervalSeconds = 1,
|
||||
[int]$TimeoutSeconds = 180,
|
||||
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\native-live-smoke"),
|
||||
[switch]$SkipProbe,
|
||||
[switch]$Persist
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function ConvertTo-SafeFilePart([string]$Value) {
|
||||
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
|
||||
$safe = $safe.Trim("-")
|
||||
if ($safe.Length -eq 0) { return "item" }
|
||||
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
|
||||
return $safe
|
||||
}
|
||||
|
||||
function Invoke-DevJson([string]$Path) {
|
||||
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
|
||||
try {
|
||||
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
|
||||
} catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($response) {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Save-Json([string]$Name, [object]$Payload) {
|
||||
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
|
||||
$Payload | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $path -Encoding UTF8
|
||||
return $path
|
||||
}
|
||||
|
||||
function Test-ProbeSucceeded([object]$ProbePayload) {
|
||||
if ($ProbePayload.ok) { return $true }
|
||||
if ($ProbePayload.changed) { return $true }
|
||||
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-NativeScanner([object]$StatusPayload) {
|
||||
if ($StatusPayload.nativeScanner) { return $StatusPayload.nativeScanner }
|
||||
if ($StatusPayload.scanner) { return $StatusPayload.scanner }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-ExpectedAppSignature() {
|
||||
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
|
||||
$mainSource = Get-Content -LiteralPath $mainPath -Raw
|
||||
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
|
||||
if (-not $match.Success) {
|
||||
throw "APP_RUNTIME_SIGNATURE not found in electron/main.ts."
|
||||
}
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
|
||||
function UriEscape([string]$Value) {
|
||||
return [System.Uri]::EscapeDataString($Value)
|
||||
}
|
||||
|
||||
$safeLimit = [Math]::Max(1, [Math]::Min(100, $Limit))
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$RunDir = Join-Path $OutputRoot $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
|
||||
|
||||
$summary = [ordered]@{
|
||||
ok = $false
|
||||
createdAt = (Get-Date).ToString("o")
|
||||
limit = $safeLimit
|
||||
category = $Category
|
||||
expectedSignature = ""
|
||||
outputDir = $RunDir
|
||||
persistRequested = [bool]$Persist
|
||||
health = $null
|
||||
data = $null
|
||||
preflight = $null
|
||||
probe = $null
|
||||
finalStatus = $null
|
||||
process = $null
|
||||
results = $null
|
||||
errors = @()
|
||||
}
|
||||
|
||||
try {
|
||||
$expectedSignature = Get-ExpectedAppSignature
|
||||
$summary.expectedSignature = $expectedSignature
|
||||
$health = Invoke-DevJson "/health"
|
||||
$summary.health = @{
|
||||
ok = [bool]$health.ok
|
||||
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
|
||||
}
|
||||
Save-Json "01-health" $health | Out-Null
|
||||
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
|
||||
}
|
||||
|
||||
$data = Invoke-DevJson "/scanner/native/data"
|
||||
$summary.data = @{
|
||||
ok = [bool]$data.ok
|
||||
version = [string]$data.status.version
|
||||
totalEntries = [int]$data.status.totalEntries
|
||||
valid = [bool]$data.status.valid
|
||||
}
|
||||
Save-Json "02-native-data" $data | Out-Null
|
||||
if (-not $data.ok) {
|
||||
throw "Native IK data check failed."
|
||||
}
|
||||
|
||||
$preflight = Invoke-DevJson "/scanner/native/preflight?category=$Category"
|
||||
$summary.preflight = @{
|
||||
ok = [bool]$preflight.ok
|
||||
ready = [bool]$preflight.status.ready
|
||||
category = [string]$preflight.status.category
|
||||
categoryReady = [bool]$preflight.status.categoryReady
|
||||
genshinFound = [bool]$preflight.status.genshinFound
|
||||
isSixteenNine = [bool]$preflight.status.isSixteenNine
|
||||
gridCount = [int]$preflight.status.grid.count
|
||||
visualReady = if ($preflight.status.visual) { [bool]$preflight.status.visual.ready } else { $null }
|
||||
visualWhitePct = if ($preflight.status.visual) { [double]$preflight.status.visual.whitePct } else { $null }
|
||||
visualDarkPct = if ($preflight.status.visual) { [double]$preflight.status.visual.darkPct } else { $null }
|
||||
visualColorPct = if ($preflight.status.visual) { [double]$preflight.status.visual.colorPct } else { $null }
|
||||
visualLumaStdDev = if ($preflight.status.visual) { [double]$preflight.status.visual.lumaStdDev } else { $null }
|
||||
blockReason = [string]$preflight.status.blockReason
|
||||
}
|
||||
Save-Json "03-native-preflight" $preflight | Out-Null
|
||||
if (-not $preflight.ok) {
|
||||
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
|
||||
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
|
||||
}
|
||||
|
||||
if (-not $SkipProbe) {
|
||||
$probe = Invoke-DevJson "/automation/probe-click?index=1"
|
||||
$summary.probe = @{
|
||||
ok = [bool](Test-ProbeSucceeded $probe)
|
||||
endpointOk = [bool]$probe.ok
|
||||
changed = [bool]$probe.changed
|
||||
}
|
||||
Save-Json "04-probe-click" $probe | Out-Null
|
||||
if (-not (Test-ProbeSucceeded $probe)) {
|
||||
throw "Probe click did not prove safe input delivery."
|
||||
}
|
||||
}
|
||||
|
||||
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
||||
Save-Json "05-native-start" $start | Out-Null
|
||||
$startedScanner = Get-NativeScanner $start
|
||||
if ($null -eq $startedScanner) {
|
||||
throw "Native scanner start returned no scanner payload."
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$polls = @()
|
||||
do {
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
$status = Invoke-DevJson "/scanner/status"
|
||||
$scanner = Get-NativeScanner $status
|
||||
$polls += [pscustomobject]@{
|
||||
at = (Get-Date).ToString("o")
|
||||
status = if ($scanner) { [string]$scanner.status } else { "missing" }
|
||||
running = if ($scanner) { [bool]$scanner.running } else { $false }
|
||||
captured = if ($scanner) { [int]$scanner.captured } else { 0 }
|
||||
activeMs = if ($scanner) { [int]$scanner.activeMs } else { 0 }
|
||||
message = if ($scanner) { [string]$scanner.message } else { "missing native scanner payload" }
|
||||
}
|
||||
if ($null -ne $scanner -and -not $scanner.running) {
|
||||
break
|
||||
}
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
Save-Json "06-native-polls" $polls | Out-Null
|
||||
$final = Invoke-DevJson "/scanner/status"
|
||||
Save-Json "07-native-final-status" $final | Out-Null
|
||||
$finalScanner = Get-NativeScanner $final
|
||||
if ($null -eq $finalScanner) {
|
||||
throw "Native scanner final status returned no scanner payload."
|
||||
}
|
||||
|
||||
$summary.finalStatus = @{
|
||||
status = [string]$finalScanner.status
|
||||
running = [bool]$finalScanner.running
|
||||
runDir = [string]$finalScanner.runDir
|
||||
captured = [int]$finalScanner.captured
|
||||
clicked = [int]$finalScanner.clicked
|
||||
pages = [int]$finalScanner.pages
|
||||
activeMs = [int]$finalScanner.activeMs
|
||||
message = [string]$finalScanner.message
|
||||
}
|
||||
|
||||
if ($finalScanner.running) {
|
||||
throw "Native scanner did not finish before timeout."
|
||||
}
|
||||
if ([string]$finalScanner.status -ne "done") {
|
||||
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
||||
}
|
||||
if ([int]$finalScanner.captured -lt $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
|
||||
}
|
||||
|
||||
$runDirParam = UriEscape([string]$finalScanner.runDir)
|
||||
$persistParam = if ($Persist) { "1" } else { "0" }
|
||||
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
|
||||
Save-Json "08-native-process" $process | Out-Null
|
||||
$summary.process = @{
|
||||
ok = [bool]$process.ok
|
||||
processed = [int]$process.status.processed
|
||||
parsed = [int]$process.status.parsed
|
||||
review = [int]$process.status.review
|
||||
errors = [int]$process.status.errors
|
||||
stored = [int]$process.status.stored
|
||||
persisted = [bool]$process.status.persisted
|
||||
queueConcurrency = [int]$process.status.queueConcurrency
|
||||
elapsedMs = [int]$process.status.elapsedMs
|
||||
}
|
||||
if (-not $process.ok) {
|
||||
throw "Native post-capture processing failed."
|
||||
}
|
||||
|
||||
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
|
||||
Save-Json "09-native-results" $results | Out-Null
|
||||
$summary.results = @{
|
||||
ok = [bool]$results.ok
|
||||
total = [int]$results.status.total
|
||||
loaded = [int]$results.status.results.Count
|
||||
}
|
||||
if (-not $results.ok) {
|
||||
throw "Native scan results could not be loaded."
|
||||
}
|
||||
|
||||
$summary.ok = $true
|
||||
} catch {
|
||||
$summary.errors += [string]$_.Exception.Message
|
||||
throw
|
||||
} finally {
|
||||
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
|
||||
Write-Host "Native live smoke summary: $summaryPath"
|
||||
if ($summary.ok) {
|
||||
Write-Host ("ok limit={0} captured={1} parsed={2} review={3} errors={4} stored={5} persisted={6}" -f `
|
||||
$summary.limit,
|
||||
$summary.finalStatus.captured,
|
||||
$summary.process.parsed,
|
||||
$summary.process.review,
|
||||
$summary.process.errors,
|
||||
$summary.process.stored,
|
||||
$summary.process.persisted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const EVAL_FIELDS = new Set(["name", "slot", "level", "mainStat", "mainValue", "setName", "equipped", "substats"]);
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function parseExpectedFields() {
|
||||
const inlineJson = argValue("expect-json");
|
||||
const expectFile = argValue("expect-file");
|
||||
if (!inlineJson && !expectFile) {
|
||||
throw new Error("Missing expected labels. Pass --expect-json=... or --expect-file=...");
|
||||
}
|
||||
const raw = (inlineJson || fs.readFileSync(path.resolve(expectFile), "utf8")).replace(/^\uFEFF/, "");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Expected labels must be a JSON object.");
|
||||
}
|
||||
const expect = {};
|
||||
for (const [field, value] of Object.entries(parsed)) {
|
||||
if (!EVAL_FIELDS.has(field)) throw new Error(`Unknown expected field: ${field}`);
|
||||
if (field === "level") {
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error("Expected level must be a non-negative integer.");
|
||||
expect[field] = value;
|
||||
continue;
|
||||
}
|
||||
if (field === "substats") {
|
||||
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim())) {
|
||||
throw new Error("Expected substats must be a non-empty string array.");
|
||||
}
|
||||
expect[field] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`Expected ${field} must be a non-empty string.`);
|
||||
expect[field] = value;
|
||||
}
|
||||
if (Object.keys(expect).length === 0) throw new Error("Expected labels must include at least one field.");
|
||||
return expect;
|
||||
}
|
||||
|
||||
function loadCandidate(inputPath, candidateId) {
|
||||
const payload = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8"));
|
||||
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
|
||||
const candidate = candidates.find((entry) => entry.id === candidateId);
|
||||
if (!candidate) throw new Error(`Candidate not found: ${candidateId}`);
|
||||
if (!candidate.ocr || typeof candidate.ocr !== "object" || Object.keys(candidate.ocr).length === 0) {
|
||||
throw new Error(`Candidate has no OCR payload: ${candidateId}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function stableId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^0-9A-Za-z]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "review-case";
|
||||
}
|
||||
|
||||
function objectLiteral(value, indent = 2) {
|
||||
return JSON.stringify(value, null, indent).replace(/"([A-Za-z_$][0-9A-Za-z_$]*)":/g, "$1:");
|
||||
}
|
||||
|
||||
function snippetFor(candidate, expect) {
|
||||
const id = stableId(argValue("id", `confirmed-${candidate.id}`));
|
||||
const entry = {
|
||||
id,
|
||||
confirmed: true,
|
||||
ocr: candidate.ocr,
|
||||
expect,
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: candidate.resolution || undefined,
|
||||
note: `${candidate.reason || "review-sample"} | savedAt=${candidate.savedAt || "unknown"} | sourceCandidate=${candidate.id}`,
|
||||
},
|
||||
};
|
||||
return `${objectLiteral(entry, 2)},\n`;
|
||||
}
|
||||
|
||||
function defaultInputPath() {
|
||||
return path.join(process.cwd(), "outputs", "review-eval-candidates", "review-eval-candidates.json");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const inputPath = argValue("input", defaultInputPath());
|
||||
const candidateId = argValue("candidate");
|
||||
if (!candidateId) throw new Error("Missing candidate id. Pass --candidate=<id>.");
|
||||
const outputPath = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates", `${stableId(candidateId)}.confirmed.ts`)));
|
||||
const candidate = loadCandidate(inputPath, candidateId);
|
||||
const expect = parseExpectedFields();
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
const snippet = snippetFor(candidate, expect);
|
||||
fs.writeFileSync(outputPath, snippet, "utf8");
|
||||
console.log(JSON.stringify({ ok: true, candidateId, outputPath, labeledFields: Object.keys(expect) }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadCandidate,
|
||||
parseExpectedFields,
|
||||
snippetFor,
|
||||
stableId,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
+1434
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { confirmedReviewCorpus, ocrEvalCorpus, seedCorpus, validateConfirmedReviewCorpus } from ".";
|
||||
|
||||
describe("confirmed review corpus", () => {
|
||||
it("contains only human-confirmed review labels", () => {
|
||||
expect(validateConfirmedReviewCorpus()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not duplicate eval case ids", () => {
|
||||
const ids = ocrEvalCorpus.map((entry) => entry.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("is included in the full OCR eval corpus", () => {
|
||||
expect(ocrEvalCorpus).toHaveLength(seedCorpus.length + confirmedReviewCorpus.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { OcrEvalCase } from "../ocrEvalHarness";
|
||||
|
||||
export interface ConfirmedReviewEvalCase extends OcrEvalCase {
|
||||
confirmed: true;
|
||||
meta: NonNullable<OcrEvalCase["meta"]> & {
|
||||
source: "review-sample";
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Human-confirmed review samples belong here after their `expect` values were
|
||||
// checked against the real artifact. Do not paste unconfirmed exporter output
|
||||
// directly from outputs/review-eval-candidates/.
|
||||
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [
|
||||
{
|
||||
id: "native-review-crown-befallen-decimal-loss",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Crown of the Befallen",
|
||||
"artifact-slot": "Circlet of Logos",
|
||||
"artifact-main-stat-label": "CRIT Rate",
|
||||
"artifact-main-stat-value": "311%",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ HP+508\n- DEF+44\n- DEF+1.7%\n+ CRIT DMG+13.2%",
|
||||
"artifact-set-effects": ":2-Piece Set: Increases Elementa\nMastery by 80.\nZ4-Piece Set: When nearby party\nmembers trigger Lunar",
|
||||
"artifact-footer": "Equipped: Zibai",
|
||||
},
|
||||
expect: {
|
||||
name: "Crown of the Befallen",
|
||||
slot: "Circlet of Logos",
|
||||
level: 20,
|
||||
mainStat: "CRIT Rate",
|
||||
mainValue: "31.1%",
|
||||
setName: "Night of the Sky's Unveiling",
|
||||
equipped: "Zibai",
|
||||
substats: ["HP+508", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "native-review-approved | run=20260709-223441 | sequence=6",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-viridescent-extra-digit",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Viridescent Arrow Feather",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-main-stat-value": "",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- DEF+39\n+ HP+687\n+ HP+5.3%\n+ ATK+156.7%",
|
||||
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
|
||||
"artifact-footer": "Equipped: Sucrose",
|
||||
},
|
||||
expect: {
|
||||
name: "Viridescent Arrow Feather",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
setName: "Viridescent Venerer",
|
||||
equipped: "Sucrose",
|
||||
substats: ["DEF+39", "HP+687", "HP%+5.3%", "ATK%+15.7%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "native-review-approved | run=20260709-223441 | sequence=60",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "native-review-gladiator-flat-hp-comma",
|
||||
confirmed: true,
|
||||
ocr: {
|
||||
"artifact-name": "Gladiator's Intoxication",
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-main-stat-label": "Dendro DMG Bonus",
|
||||
"artifact-main-stat-value": "46.6%",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ HP+1,165\n+ HP+5.8%\n+ CRIT Rate+3.9%\n- Energy Recharge+11.7%",
|
||||
"artifact-set-effects": "2-Piece Set: ATK +18%.\n4-Piece Set: If the wielder of this artifact set uses a Sword",
|
||||
"artifact-footer": "Equipped: Tighnari",
|
||||
},
|
||||
expect: {
|
||||
name: "Gladiator's Intoxication",
|
||||
slot: "Goblet of Eonothem",
|
||||
level: 20,
|
||||
mainStat: "Dendro DMG Bonus",
|
||||
mainValue: "46.6%",
|
||||
setName: "Gladiator's Finale",
|
||||
equipped: "Tighnari",
|
||||
substats: ["HP+1,165", "HP%+5.8%", "CRIT Rate+3.9%", "Energy Recharge+11.7%"],
|
||||
},
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: "492x838",
|
||||
note: "native-review-approved | run=20260709-223441 | sequence=76",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { formatSummary, parseWaitSeconds, validatePreflight } = require("../../scripts/live-preflight.cjs") as {
|
||||
formatSummary: (result: unknown) => string;
|
||||
parseWaitSeconds: (value: string) => number;
|
||||
validatePreflight: (input: unknown) => { ok: boolean; errors: string[]; signature: string; isElevated?: boolean; genshinFound?: boolean };
|
||||
};
|
||||
|
||||
function health(signature = "sig-current") {
|
||||
return { ok: true, appBuild: { signature } };
|
||||
}
|
||||
|
||||
function status(runtime = { isElevated: true, genshinFound: true, targetProcess: "GenshinImpact.exe", foregroundProcess: "GenshinImpact.exe" }) {
|
||||
return { ok: true, status: { runtimeInfo: runtime } };
|
||||
}
|
||||
|
||||
describe("live preflight script", () => {
|
||||
it("accepts a matching elevated Genshin runtime", () => {
|
||||
const result = validatePreflight({ health: health(), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.signature).toBe("sig-current");
|
||||
});
|
||||
|
||||
it("rejects stale runtime signatures", () => {
|
||||
const result = validatePreflight({ health: health("old"), status: status(), expected: "sig-current" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors.join("\n")).toContain("does not match");
|
||||
});
|
||||
|
||||
it("rejects non-elevated or missing Genshin runtime by default", () => {
|
||||
const result = validatePreflight({
|
||||
health: health(),
|
||||
status: status({ isElevated: false, genshinFound: false, targetProcess: "", foregroundProcess: "explorer.exe" }),
|
||||
expected: "sig-current",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors).toContain("Runtime is not elevated.");
|
||||
expect(result.errors).toContain("Genshin process/window was not found.");
|
||||
});
|
||||
|
||||
it("formats a concise human summary", () => {
|
||||
const summary = formatSummary(validatePreflight({ health: health(), status: status(), expected: "sig-current" }));
|
||||
expect(summary).toContain("live preflight: PASS");
|
||||
expect(summary).toContain("elevated: yes");
|
||||
expect(summary).toContain("genshin: yes");
|
||||
});
|
||||
|
||||
it("parses optional wait seconds strictly", () => {
|
||||
expect(parseWaitSeconds("")).toBe(0);
|
||||
expect(parseWaitSeconds("120")).toBe(120);
|
||||
expect(() => parseWaitSeconds("-1")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("1.5")).toThrow("--wait");
|
||||
expect(() => parseWaitSeconds("soon")).toThrow("--wait");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createNativeScannerProcessingService } from "../../electron/services/nativeScannerProcessingService";
|
||||
import type { NativeCaptureJobPayload } from "../../electron/services/nativeScannerProcessingService";
|
||||
import type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
|
||||
import { captureFromOcr } from "./ocrEvalHarness";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeRunDir() {
|
||||
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-process-"));
|
||||
tempDirs.push(runDir);
|
||||
return runDir;
|
||||
}
|
||||
|
||||
async function writeJobs(runDir: string, jobs: NativeCaptureJobPayload[]) {
|
||||
const lines = jobs.map((job) => JSON.stringify(job)).join("\n");
|
||||
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
|
||||
const absolutePath = path.join(runDir, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, "test image placeholder", "utf8");
|
||||
return { relativePath, absolutePath };
|
||||
}
|
||||
|
||||
async function writePreviewPng(runDir: string, relativePath = "cards/preview.png") {
|
||||
const absolutePath = path.join(runDir, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absolutePath,
|
||||
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "base64"),
|
||||
);
|
||||
return { relativePath, absolutePath };
|
||||
}
|
||||
|
||||
function safePlumeCapture() {
|
||||
return captureFromOcr({
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\n+ ATK+5.8%",
|
||||
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
|
||||
"artifact-footer": "Equipped: Aino",
|
||||
});
|
||||
}
|
||||
|
||||
function safePlumeIkCatalog(overrides: Partial<IkArtifactCatalog["artifacts"][number]["pieces"][number]> = {}): IkArtifactCatalog {
|
||||
return {
|
||||
artifacts: [
|
||||
{
|
||||
normalizedName: "silkenmoonsserenade",
|
||||
setName: "Silken Moon's Serenade",
|
||||
good: "SilkenMoonsSerenade",
|
||||
pieces: [
|
||||
{
|
||||
slot: "plume",
|
||||
artifactName: "Pristine Plume of the Blessed",
|
||||
good: "PristinePlumeOfTheBlessed",
|
||||
normalizedName: "pristineplumeoftheblessed",
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewOnlyCapture() {
|
||||
return captureFromOcr({
|
||||
"artifact-title": "Moonlit Offering's Final\nSands of Eon",
|
||||
"artifact-main-stat": "46.6%\n1S 2.5.8 J\nSe",
|
||||
"artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%",
|
||||
"artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.",
|
||||
"artifact-footer": "",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("native scanner processing service", () => {
|
||||
it("reads native capture jobs and writes a processing report without persisting by default", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath, absolutePath } = await writeCrop(runDir);
|
||||
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
|
||||
|
||||
const savedRecords: StoredArtifactRecord[] = [];
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async (imagePath, job) => {
|
||||
expect(imagePath).toBe(absolutePath);
|
||||
expect(job.sequence).toBe(7);
|
||||
return safePlumeCapture();
|
||||
},
|
||||
saveArtifacts: async (records) => {
|
||||
savedRecords.push(...records);
|
||||
return { added: records.length, updated: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const status = await service.processRun();
|
||||
const loadedResults = await service.loadResults();
|
||||
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
|
||||
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
|
||||
expect(status.processed).toBe(1);
|
||||
expect(status.parsed).toBe(1);
|
||||
expect(status.stored).toBe(0);
|
||||
expect(status.persisted).toBe(false);
|
||||
expect(status.results[0]).toMatchObject({
|
||||
sequence: 7,
|
||||
page: 2,
|
||||
row: 1,
|
||||
col: 3,
|
||||
imagePath: absolutePath,
|
||||
parsed: true,
|
||||
artifactName: "Pristine Plume of the Blessed",
|
||||
setName: "Silken Moon's Serenade",
|
||||
slot: "Plume of Death",
|
||||
persisted: false,
|
||||
});
|
||||
expect(report.processed).toBe(1);
|
||||
expect(loadedResults).toMatchObject({
|
||||
ok: true,
|
||||
runDir,
|
||||
path: path.join(runDir, "scan-results.json"),
|
||||
total: 1,
|
||||
});
|
||||
expect(loadedResults.results[0]).toMatchObject({
|
||||
sequence: 7,
|
||||
extractionStatus: "parsed",
|
||||
valueStatus: "deferred",
|
||||
});
|
||||
expect(scanResults).toHaveLength(1);
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
runId: path.basename(runDir),
|
||||
sequence: 7,
|
||||
category: "artifact",
|
||||
source: "native-ik-scan",
|
||||
extractionStatus: "parsed",
|
||||
valueStatus: "deferred",
|
||||
valueScore: null,
|
||||
persistedArtifact: false,
|
||||
artifact: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
setName: "Silken Moon's Serenade",
|
||||
},
|
||||
});
|
||||
expect(savedRecords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns a safe empty status when native scan results are not available", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const loadedResults = await service.loadResults();
|
||||
|
||||
expect(loadedResults.ok).toBe(false);
|
||||
expect(loadedResults.runDir).toBe(runDir);
|
||||
expect(loadedResults.path).toBe(path.join(runDir, "scan-results.json"));
|
||||
expect(loadedResults.results).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads native crop previews only from inside the run directory", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { absolutePath } = await writePreviewPng(runDir);
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const preview = await service.loadImage({ imagePath: absolutePath });
|
||||
const blocked = await service.loadImage({ imagePath: path.join(os.tmpdir(), "outside.png") });
|
||||
|
||||
expect(preview).toMatchObject({
|
||||
ok: true,
|
||||
runDir,
|
||||
path: absolutePath,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
expect(preview.dataUrl).toMatch(/^data:image\/png;base64,/);
|
||||
expect(blocked.ok).toBe(false);
|
||||
expect(blocked.error).toMatch(/outside/);
|
||||
});
|
||||
|
||||
it("marks missing crop images as review errors and skips OCR processing", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
await writeJobs(runDir, [{ sequence: 1, relativePath: "cards/missing.png" }]);
|
||||
let buildCalls = 0;
|
||||
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => {
|
||||
buildCalls += 1;
|
||||
return safePlumeCapture();
|
||||
},
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const status = await service.processRun({ persist: true });
|
||||
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.processed).toBe(1);
|
||||
expect(status.parsed).toBe(0);
|
||||
expect(status.review).toBe(1);
|
||||
expect(status.errors).toBe(1);
|
||||
expect(status.stored).toBe(0);
|
||||
expect(status.results[0]).toMatchObject({
|
||||
sequence: 1,
|
||||
parsed: false,
|
||||
needsReview: true,
|
||||
error: "Card crop image missing.",
|
||||
});
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
extractionStatus: "missing_crop",
|
||||
valueStatus: "review",
|
||||
needsReview: true,
|
||||
error: "Card crop image missing.",
|
||||
});
|
||||
expect(buildCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks non-artifact native jobs before OCR processing", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath, absolutePath } = await writeCrop(runDir, "cards/weapon-0001.png");
|
||||
await writeJobs(runDir, [{ sequence: 2, category: "weapons", page: 1, row: 0, col: 1, relativePath }]);
|
||||
let buildCalls = 0;
|
||||
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => {
|
||||
buildCalls += 1;
|
||||
return safePlumeCapture();
|
||||
},
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const status = await service.processRun({ persist: true });
|
||||
const scanResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.processed).toBe(1);
|
||||
expect(status.parsed).toBe(0);
|
||||
expect(status.review).toBe(1);
|
||||
expect(status.errors).toBe(1);
|
||||
expect(status.stored).toBe(0);
|
||||
expect(status.results[0]).toMatchObject({
|
||||
sequence: 2,
|
||||
category: "weapon",
|
||||
imagePath: absolutePath,
|
||||
parsed: false,
|
||||
needsReview: true,
|
||||
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
|
||||
});
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
category: "weapon",
|
||||
extractionStatus: "error",
|
||||
valueStatus: "review",
|
||||
needsReview: true,
|
||||
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
|
||||
});
|
||||
expect(buildCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("persists safe parsed artifacts only when persistence is explicitly enabled", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath } = await writeCrop(runDir);
|
||||
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
|
||||
const savedRecords: StoredArtifactRecord[] = [];
|
||||
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async (records) => {
|
||||
savedRecords.push(...records);
|
||||
return { added: records.length, updated: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const dryRun = await service.processRun({ persist: false });
|
||||
const persistedRun = await service.processRun({ persist: true });
|
||||
|
||||
expect(dryRun.stored).toBe(0);
|
||||
expect(dryRun.results[0].persisted).toBe(false);
|
||||
expect(persistedRun.stored).toBe(1);
|
||||
expect(persistedRun.results[0].persisted).toBe(true);
|
||||
expect(persistedRun.scanResultsPath).toBe(path.join(runDir, "scan-results.json"));
|
||||
expect(savedRecords).toHaveLength(1);
|
||||
expect(savedRecords[0]).toMatchObject({
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
setName: "Silken Moon's Serenade",
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
source: "native-ik-scan",
|
||||
needsReview: false,
|
||||
});
|
||||
|
||||
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
extractionStatus: "parsed",
|
||||
valueStatus: "deferred",
|
||||
artifactRecordId: savedRecords[0].id,
|
||||
persistedArtifact: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the native capture timestamp in durable scan results", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath } = await writeCrop(runDir);
|
||||
const capturedAt = "2026-07-09T22:30:50.5886316+02:00";
|
||||
await writeJobs(runDir, [{ sequence: 1, relativePath, capturedAt }]);
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const status = await service.processRun({ persist: false });
|
||||
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||
|
||||
expect(scanResults[0].capturedAt).toBe(capturedAt);
|
||||
});
|
||||
|
||||
it("promotes only explicitly selected safe results and writes a durable log", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const safeResult: StoredScanResultEntry = {
|
||||
id: "safe-result",
|
||||
runId: "run-1",
|
||||
sequence: 1,
|
||||
category: "artifact",
|
||||
source: "native-ik-scan",
|
||||
imagePath: "artifact-0001.png",
|
||||
capturedAt: "2026-07-09T22:30:50.000Z",
|
||||
extractionStatus: "parsed",
|
||||
extractionConfidence: 96,
|
||||
needsReview: false,
|
||||
valueStatus: "deferred",
|
||||
valueScore: null,
|
||||
persistedArtifact: false,
|
||||
notes: [],
|
||||
artifact: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
setName: "Silken Moon's Serenade",
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
substats: ["CRIT DMG+7.0%", "DEF+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
|
||||
equipped: "Aino",
|
||||
},
|
||||
ikMatch: {
|
||||
matched: true,
|
||||
confidence: 100,
|
||||
source: "ik-inventorylists",
|
||||
setGood: "SilkenMoonsSerenade",
|
||||
artifactGood: "PristinePlumeOfTheBlessed",
|
||||
notes: [],
|
||||
},
|
||||
fieldConfidences: [],
|
||||
};
|
||||
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([safeResult], null, 2), "utf8");
|
||||
const savedRecords: StoredArtifactRecord[] = [];
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
loadArtifacts: async () => ({ ok: true, artifacts: [], total: 0, path: path.join(runDir, "artifact-store.json") }),
|
||||
saveArtifacts: async (records) => {
|
||||
savedRecords.push(...records);
|
||||
return { ok: true, added: records.length, updated: 0, total: records.length, path: path.join(runDir, "artifact-store.json") };
|
||||
},
|
||||
});
|
||||
|
||||
const status = await service.promoteResults({ resultIds: [safeResult.id] });
|
||||
const updatedResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
const logLines = (await fs.readFile(path.join(runDir, "promotion-log.jsonl"), "utf8")).trim().split(/\r?\n/);
|
||||
|
||||
expect(status).toMatchObject({ ok: true, requested: 1, selected: 1, promoted: 1, added: 1, updated: 0 });
|
||||
expect(savedRecords).toHaveLength(1);
|
||||
expect(savedRecords[0]).toMatchObject({ source: "native-ik-scan", needsReview: false });
|
||||
expect(updatedResults[0]).toMatchObject({ persistedArtifact: true, artifactRecordId: savedRecords[0].id });
|
||||
expect(logLines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("approves a corrected review result only after structural and IK validation", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const reviewResult: StoredScanResultEntry = {
|
||||
id: "review-result",
|
||||
runId: "run-review",
|
||||
sequence: 6,
|
||||
category: "artifact",
|
||||
source: "native-ik-scan",
|
||||
imagePath: path.join(runDir, "artifact-0006.png"),
|
||||
capturedAt: "2026-07-09T22:34:43.000Z",
|
||||
extractionStatus: "review",
|
||||
extractionConfidence: 96,
|
||||
needsReview: true,
|
||||
valueStatus: "review",
|
||||
valueScore: null,
|
||||
persistedArtifact: false,
|
||||
notes: ["Substat value has no valid roll combination: DEF+1."],
|
||||
artifact: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
setName: "Silken Moon's Serenade",
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+1"],
|
||||
equipped: "Aino",
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([reviewResult], null, 2), "utf8");
|
||||
await fs.writeFile(path.join(runDir, "processing-report.json"), JSON.stringify({
|
||||
results: [{ sequence: 6, ocr: [{ id: "artifact-substats", label: "Substats", text: "DEF+1", confidence: 80 }] }],
|
||||
}), "utf8");
|
||||
const evalSamples: unknown[] = [];
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
loadIkArtifactCatalog: async () => safePlumeIkCatalog(),
|
||||
saveReviewSample: async (sample) => {
|
||||
evalSamples.push(sample);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const invalidRarity = await service.reviewResult({
|
||||
resultId: reviewResult.id,
|
||||
action: "approve",
|
||||
artifact: {
|
||||
...reviewResult.artifact!,
|
||||
level: 20,
|
||||
substats: ["CRIT Rate+2.3%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
|
||||
},
|
||||
});
|
||||
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
|
||||
expect(invalidRarity.error).toContain("Implausible substats: CRIT Rate+2.3%");
|
||||
|
||||
const status = await service.reviewResult({
|
||||
resultId: reviewResult.id,
|
||||
action: "approve",
|
||||
note: "Crop confirms DEF+23.",
|
||||
artifact: {
|
||||
...reviewResult.artifact!,
|
||||
level: 20,
|
||||
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
|
||||
},
|
||||
});
|
||||
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
const log = await fs.readFile(path.join(runDir, "review-log.jsonl"), "utf8");
|
||||
|
||||
expect(status).toMatchObject({ ok: true, action: "approve", evalSampleSaved: true, correctedFields: ["substats"] });
|
||||
expect(updated[0]).toMatchObject({ extractionStatus: "parsed", needsReview: false, valueStatus: "deferred" });
|
||||
expect(updated[0].artifact.substats).toContain("DEF+23");
|
||||
expect(updated[0].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
|
||||
expect(evalSamples).toHaveLength(1);
|
||||
expect(log).toContain("review-result");
|
||||
});
|
||||
|
||||
it("keeps rejected review results blocked from promotion", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const result = {
|
||||
id: "reject-result",
|
||||
runId: "run-review",
|
||||
sequence: 7,
|
||||
category: "artifact",
|
||||
source: "native-ik-scan",
|
||||
imagePath: "artifact-0007.png",
|
||||
capturedAt: "2026-07-09T22:34:44.000Z",
|
||||
extractionStatus: "review",
|
||||
extractionConfidence: 70,
|
||||
needsReview: true,
|
||||
valueStatus: "review",
|
||||
valueScore: null,
|
||||
persistedArtifact: false,
|
||||
notes: [],
|
||||
} satisfies StoredScanResultEntry;
|
||||
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const status = await service.reviewResult({ resultId: result.id, action: "reject", note: "Crop unreadable." });
|
||||
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||
|
||||
expect(status).toMatchObject({ ok: true, action: "reject", evalSampleSaved: false });
|
||||
expect(updated[0]).toMatchObject({ extractionStatus: "review", needsReview: true, valueStatus: "review" });
|
||||
expect(updated[0].review).toMatchObject({ status: "rejected" });
|
||||
});
|
||||
|
||||
it("uses IK inventorylists matching to force review on catalog conflicts", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath } = await writeCrop(runDir);
|
||||
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => safePlumeCapture(),
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
loadIkArtifactCatalog: async () => safePlumeIkCatalog({ slot: "flower" }),
|
||||
});
|
||||
|
||||
const status = await service.processRun({ persist: true });
|
||||
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||
|
||||
expect(status.review).toBe(1);
|
||||
expect(status.stored).toBe(0);
|
||||
expect(status.results[0].ikMatch).toMatchObject({
|
||||
matched: false,
|
||||
source: "ik-inventorylists",
|
||||
artifactGood: "PristinePlumeOfTheBlessed",
|
||||
slotKey: "flower",
|
||||
});
|
||||
expect(status.results[0].notes?.join(" ")).toContain("IK slot mismatch");
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
extractionStatus: "review",
|
||||
valueStatus: "review",
|
||||
persistedArtifact: false,
|
||||
ikMatch: {
|
||||
matched: false,
|
||||
artifactGood: "PristinePlumeOfTheBlessed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("processes native crops through a bounded post-capture queue while preserving result order", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const jobs: NativeCaptureJobPayload[] = [];
|
||||
for (let sequence = 1; sequence <= 5; sequence++) {
|
||||
const { relativePath } = await writeCrop(runDir, `cards/artifact-${sequence}.png`);
|
||||
jobs.push({ sequence, relativePath });
|
||||
}
|
||||
await writeJobs(runDir, jobs);
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
active -= 1;
|
||||
return safePlumeCapture();
|
||||
},
|
||||
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||
});
|
||||
|
||||
const status = await service.processRun();
|
||||
|
||||
expect(status.queueConcurrency).toBe(4);
|
||||
expect(maxActive).toBeGreaterThan(1);
|
||||
expect(status.results.map((result) => result.sequence)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("keeps review-only parsed artifacts out of the persistent store", async () => {
|
||||
const runDir = await makeRunDir();
|
||||
const { relativePath } = await writeCrop(runDir);
|
||||
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
|
||||
const savedRecords: StoredArtifactRecord[] = [];
|
||||
|
||||
const service = createNativeScannerProcessingService({
|
||||
resolveRunDir: () => runDir,
|
||||
buildCaptureResult: async () => reviewOnlyCapture(),
|
||||
saveArtifacts: async (records) => {
|
||||
savedRecords.push(...records);
|
||||
return { added: records.length, updated: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const status = await service.processRun({ persist: true });
|
||||
|
||||
expect(status.processed).toBe(1);
|
||||
expect(status.parsed).toBe(1);
|
||||
expect(status.review).toBe(1);
|
||||
expect(status.stored).toBe(0);
|
||||
expect(status.results[0]).toMatchObject({
|
||||
parsed: true,
|
||||
needsReview: true,
|
||||
persisted: false,
|
||||
slot: "Sands of Eon",
|
||||
});
|
||||
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||
expect(scanResults[0]).toMatchObject({
|
||||
extractionStatus: "review",
|
||||
valueStatus: "review",
|
||||
persistedArtifact: false,
|
||||
artifact: {
|
||||
slot: "Sands of Eon",
|
||||
},
|
||||
});
|
||||
expect(savedRecords).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatReport, runOcrEval } from "./ocrEvalHarness";
|
||||
import { seedCorpus } from "./corpus/seedCorpus";
|
||||
import { ocrEvalCorpus } from "./corpus";
|
||||
|
||||
// Regression gate: the seed corpus is verified ground truth, so the parser must
|
||||
// read every labeled field correctly. A drop here means an OCR/parser change
|
||||
// regressed a previously-correct read - look at the printed failures. If a
|
||||
// change intentionally alters a correct output, update the corpus label in the
|
||||
// same commit (the label is the source of truth, not the code).
|
||||
// Regression gate: every case in the combined corpus is verified ground truth,
|
||||
// so the parser must read every labeled field correctly. A drop here means an
|
||||
// OCR/parser change regressed a previously-correct read - look at the printed
|
||||
// failures. If a change intentionally alters a correct output, update the corpus
|
||||
// label in the same commit (the label is the source of truth, not the code).
|
||||
|
||||
describe("OCR eval harness", () => {
|
||||
const report = runOcrEval(seedCorpus);
|
||||
const report = runOcrEval(ocrEvalCorpus);
|
||||
|
||||
it("prints the accuracy report", () => {
|
||||
// Surfaced in test output for humans; not an assertion.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n" + formatReport(report) + "\n");
|
||||
expect(report.totalCases).toBe(seedCorpus.length);
|
||||
expect(report.totalCases).toBe(ocrEvalCorpus.length);
|
||||
});
|
||||
|
||||
it("reads every labeled field on the seed corpus correctly", () => {
|
||||
it("reads every labeled field on the eval corpus correctly", () => {
|
||||
const failureSummary = report.failures
|
||||
.map((failure) => {
|
||||
const wrong = failure.fields
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { pngBufferToBitmap } from "../../electron/services/pngBitmap";
|
||||
import { lockSignalRatio } from "../lib/lockDetection";
|
||||
|
||||
function chunk(type: string, data: Buffer) {
|
||||
const result = Buffer.alloc(12 + data.length);
|
||||
result.writeUInt32BE(data.length, 0);
|
||||
result.write(type, 4, 4, "ascii");
|
||||
data.copy(result, 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
function rgbPng1x1(r: number, g: number, b: number) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(1, 0);
|
||||
ihdr.writeUInt32BE(1, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
const raw = Buffer.from([0, r, g, b]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("pngBufferToBitmap", () => {
|
||||
it("decodes RGB PNG pixels for lock detection", () => {
|
||||
const bitmap = pngBufferToBitmap(rgbPng1x1(235, 92, 90));
|
||||
expect(bitmap.width).toBe(1);
|
||||
expect(bitmap.height).toBe(1);
|
||||
expect(lockSignalRatio(bitmap)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function writeCandidatePayload(dir: string) {
|
||||
const inputPath = path.join(dir, "review-eval-candidates.json");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify({
|
||||
summary: {},
|
||||
candidates: [
|
||||
{
|
||||
id: "review-2026-07-08T15-02-39-765Z-152",
|
||||
savedAt: "2026-07-08T15:02:39.765Z",
|
||||
reason: "automatic:parser-notes:p1:r2c3",
|
||||
resolution: "1920x1080",
|
||||
ocr: {
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- Energy Recharge+10.4%",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("prepare confirmed review case script", () => {
|
||||
it("creates a confirmed corpus snippet from a candidate and explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
const expectPath = path.join(dir, "expect.json");
|
||||
const outputPath = path.join(dir, "snippet.ts");
|
||||
writeFileSync(
|
||||
expectPath,
|
||||
`\uFEFF${JSON.stringify({
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: "Plume of Death",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
setName: "Silken Moon's Serenade",
|
||||
})}`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/prepare-confirmed-review-case.cjs",
|
||||
`--input=${inputPath}`,
|
||||
"--candidate=review-2026-07-08T15-02-39-765Z-152",
|
||||
`--expect-file=${expectPath}`,
|
||||
`--out=${outputPath}`,
|
||||
],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
);
|
||||
|
||||
const snippet = readFileSync(outputPath, "utf8");
|
||||
expect(snippet).toContain("confirmed: true");
|
||||
expect(snippet).toContain("Pristine Plume of the Blessed");
|
||||
expect(snippet).toContain('"artifact-name": "Pristine Plume of the Blessed"');
|
||||
expect(snippet).toContain("sourceCandidate=review-2026-07-08T15-02-39-765Z-152");
|
||||
expect(snippet).not.toContain("confirmed: false");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing explicit labels", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-confirmed-review-"));
|
||||
try {
|
||||
const inputPath = writeCandidatePayload(dir);
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
"node",
|
||||
["scripts/prepare-confirmed-review-case.cjs", `--input=${inputPath}`, "--candidate=review-2026-07-08T15-02-39-765Z-152"],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function reviewRecord(savedAt: string, reason: string, ocr: Array<{ id: string; text: string }>, locked?: boolean) {
|
||||
return {
|
||||
savedAt,
|
||||
sample: {
|
||||
reason,
|
||||
capture: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
ocr,
|
||||
locked,
|
||||
},
|
||||
parsed: {
|
||||
name: "Pristine Plume of the Blessed",
|
||||
slot: ocr.some((entry) => entry.id === "artifact-slot") ? "Plume of Death" : "Unknown Slot",
|
||||
level: 20,
|
||||
mainStat: "ATK",
|
||||
mainValue: "311",
|
||||
setName: "Silken Moon's Serenade",
|
||||
equipped: ocr.some((entry) => entry.id === "artifact-footer") ? "Aino" : "Not detected",
|
||||
substats: ["Energy Recharge+10.4%"],
|
||||
confidence: 0.9,
|
||||
notes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const completeOcr = [
|
||||
{ id: "artifact-name", text: "Pristine Plume of the Blessed" },
|
||||
{ id: "artifact-slot", text: "Plume of Death" },
|
||||
{ id: "artifact-main-stat-label", text: "ATK" },
|
||||
{ id: "artifact-level", text: "+20" },
|
||||
{ id: "artifact-substats", text: "- Energy Recharge+10.4%" },
|
||||
{ id: "artifact-footer", text: "Equipped: Aino" },
|
||||
];
|
||||
|
||||
describe("review eval candidate exporter", () => {
|
||||
it("exports deduplicated candidates with stats and stale markers", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-review-candidates-"));
|
||||
try {
|
||||
const inputPath = path.join(dir, "review-samples.jsonl");
|
||||
const outDir = path.join(dir, "out");
|
||||
const staleOcr = completeOcr.filter((entry) => entry.id !== "artifact-slot");
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
[
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:00:00.000Z", "automatic:missing-crops-or-ocr:p1:r0c0", completeOcr)),
|
||||
JSON.stringify(reviewRecord("2026-07-08T15:01:00.000Z", "automatic:capture-rejected:p1:r0c1", staleOcr, true)),
|
||||
"{not json",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execFileSync("node", ["scripts/export-review-eval-candidates.cjs", `--input=${inputPath}`, `--out=${outDir}`, "--limit=10"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const payload = JSON.parse(readFileSync(path.join(outDir, "review-eval-candidates.json"), "utf8"));
|
||||
const markdown = readFileSync(path.join(outDir, "review-eval-candidates.md"), "utf8");
|
||||
|
||||
expect(payload.summary.recordsRead).toBe(4);
|
||||
expect(payload.summary.invalidRecords).toBe(1);
|
||||
expect(payload.summary.uniqueCandidates).toBe(2);
|
||||
expect(payload.summary.exportedCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.completeFastFields).toBe(1);
|
||||
expect(payload.summary.exportStats.likelyStaleCaptures).toBe(1);
|
||||
expect(payload.summary.exportStats.equippedFooterCandidates).toBe(2);
|
||||
expect(payload.summary.exportStats.lockedTrueCandidates).toBe(1);
|
||||
expect(payload.candidates[0].missingFastFields).toEqual([]);
|
||||
expect(payload.candidates[0].parsed.equipped).toBe("Aino");
|
||||
expect(payload.candidates[0].reviewPrompt.expectedFields.equipped).toBe("Aino");
|
||||
expect(payload.candidates[1].likelyStaleCapture).toBe(true);
|
||||
expect(payload.candidates[1].locked).toBe(true);
|
||||
expect(markdown).toContain("## Export stats");
|
||||
expect(markdown).toContain("- artifact-slot: 1");
|
||||
expect(markdown).toContain("equipped=Aino");
|
||||
expect(markdown).toContain("Locked=true candidates: 1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type { OcrEvalCase } from "./ocrEvalHarness";
|
||||
// itself). The intended flow is:
|
||||
// 1. reviewSampleToEvalCase() extracts the OCR + the parser's current guess.
|
||||
// 2. A human confirms or corrects `expect` in the produced case.
|
||||
// 3. The corrected case is committed into src/eval/corpus/.
|
||||
// 3. The corrected case is committed into src/eval/corpus/confirmedReviewCorpus.ts.
|
||||
// The `confirmed` flag records whether step 2 happened.
|
||||
|
||||
export interface ReviewSampleEvalCase extends OcrEvalCase {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function validAssessment() {
|
||||
return {
|
||||
createdAt: "2026-07-09T12:00:00.000Z",
|
||||
goal100Decision: "qualified: winner=current",
|
||||
goal100: {
|
||||
limit: 100,
|
||||
singleEngine: true,
|
||||
winnerEngine: "current",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 393,
|
||||
winnerActiveProjectedMsFor100: 39300,
|
||||
winnerAverageCaptureRoundTripMs: 333,
|
||||
winnerAverageCaptureRoundTripOverheadMs: 148,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0,
|
||||
engines: [
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 333, averageCaptureRoundTripOverheadMs: 148 },
|
||||
],
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
limit: 20,
|
||||
singleEngine: true,
|
||||
winnerEngine: "current",
|
||||
winnerQualified: true,
|
||||
winnerActiveAverageMsPerParsed: 390,
|
||||
winnerActiveProjectedMsFor100: 39000,
|
||||
winnerAverageCaptureRoundTripMs: 364,
|
||||
winnerAverageCaptureRoundTripOverheadMs: 152,
|
||||
winnerMissRate: 0,
|
||||
winnerReviewRate: 0.05,
|
||||
engines: [
|
||||
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05, averageCaptureRoundTripMs: 364, averageCaptureRoundTripOverheadMs: 152 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function writeAssessment(dir: string, payload: unknown) {
|
||||
const inputPath = path.join(dir, "scan-performance-assessment.json");
|
||||
writeFileSync(inputPath, JSON.stringify(payload), "utf8");
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
describe("scan assessment validator", () => {
|
||||
it("accepts a qualified 100-artifact current run", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a matching expected winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(JSON.parse(output).ok).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints an opt-in human summary", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain(`input: ${inputPath}`);
|
||||
expect(output).toContain("createdAt: 2026-07-09T12:00:00.000Z");
|
||||
expect(output).toContain("limit: 100");
|
||||
expect(output).toContain("winner: current");
|
||||
expect(output).toContain("activeAvg: 393ms/artifact");
|
||||
expect(output).toContain("captureRoundTripOverhead: 148ms");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a qualified 20-artifact current run", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain("limit: 20");
|
||||
expect(output).toContain("winner: current");
|
||||
expect(output).toContain("activeAvg: 390ms/artifact");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts explicit speed and capture-overhead budgets when the winner stays inside them", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/validate-scan-assessment.cjs",
|
||||
`--input=${inputPath}`,
|
||||
"--summary",
|
||||
"--limit=20",
|
||||
"--max-active-average-ms=400",
|
||||
"--max-capture-roundtrip-overhead-ms=160",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(output).toContain("scan assessment: PASS");
|
||||
expect(output).toContain("captureRoundTripOverhead: 152ms");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects speed and capture-overhead budgets when the winner exceeds them", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/validate-scan-assessment.cjs",
|
||||
`--input=${inputPath}`,
|
||||
"--summary",
|
||||
"--limit=20",
|
||||
"--max-active-average-ms=333",
|
||||
"--max-capture-roundtrip-overhead-ms=120",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner active average timing exceeds 333ms");
|
||||
expect(stdout).toContain("Winner capture roundtrip overhead exceeds 120ms");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid requested limits", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=0"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("--limit must be a positive integer");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a requested limit that is missing from the assessment", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=45"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Missing limit=45 assessment");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => {
|
||||
const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
||||
expect(packageJson.scripts["scan:goal:validated"]).toBe(
|
||||
"npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
|
||||
);
|
||||
expect(packageJson.scripts["scan:goal:validated:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate"]).toBe(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate:validated"]).toBe(
|
||||
"npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
);
|
||||
expect(packageJson.scripts["scan:iterate:validated:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
|
||||
);
|
||||
expect(packageJson.scripts["scan:repeatability"]).toBe(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
|
||||
);
|
||||
expect(packageJson.scripts["scan:repeatability:wait"]).toBe(
|
||||
"npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid expected winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const inputPath = writeAssessment(dir, validAssessment());
|
||||
expect(() =>
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=old-engine"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints errors in summary mode for rejected assessments", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("goal100Decision is not a qualified 100-artifact run");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unqualified winner", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
|
||||
payload.goal100.winnerQualified = false;
|
||||
payload.goal100.engines[0].qualified = false;
|
||||
payload.goal100.engines[0].reviewRate = 0.3;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
expect(() =>
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100.engines[0] as { missRate?: number }).missRate;
|
||||
delete (payload.goal100.engines[0] as { reviewRate?: number }).reviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing summary quality rates", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerMissRate?: number }).winnerMissRate;
|
||||
delete (payload.goal100 as { winnerReviewRate?: number }).winnerReviewRate;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner summary missRate must be a finite number");
|
||||
expect(stdout).toContain("Winner summary reviewRate must be a finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a winner with missing speed timing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const payload = validAssessment();
|
||||
delete (payload.goal100 as { winnerActiveAverageMsPerParsed?: number }).winnerActiveAverageMsPerParsed;
|
||||
delete (payload.goal100 as { winnerActiveProjectedMsFor100?: number }).winnerActiveProjectedMsFor100;
|
||||
const inputPath = writeAssessment(dir, payload);
|
||||
let stdout = "";
|
||||
try {
|
||||
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
stdout = String((error as { stdout?: string }).stdout || "");
|
||||
}
|
||||
expect(stdout).toContain("scan assessment: FAIL");
|
||||
expect(stdout).toContain("Winner active average timing must be a positive finite number");
|
||||
expect(stdout).toContain("Winner projected100 timing must be a positive finite number");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("validates the latest assessment under a live-soak root", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
|
||||
try {
|
||||
const older = path.join(dir, "2026-07-09T10-00-00");
|
||||
const newer = path.join(dir, "2026-07-09T11-00-00");
|
||||
mkdirSync(older);
|
||||
mkdirSync(newer);
|
||||
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
|
||||
writeAssessment(newer, validAssessment());
|
||||
|
||||
const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", "--latest", `--root=${dir}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.inputPath).toContain("2026-07-09T11-00-00");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -144,13 +144,16 @@ export async function captureSelectedSourceAction(
|
||||
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
||||
}
|
||||
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame;
|
||||
if (!quietArtifactScanCapture) {
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
}
|
||||
return capture;
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useInventoryViewModel } from "./hooks/useInventoryViewModel";
|
||||
import type { InventoryViewProps } from "./types";
|
||||
|
||||
export function InventoryView({ snapshot }: InventoryViewProps) {
|
||||
const {
|
||||
rows,
|
||||
selectedRow,
|
||||
selectedPreview,
|
||||
previewStatus,
|
||||
filter,
|
||||
sort,
|
||||
loading,
|
||||
statusText,
|
||||
promotionStatus,
|
||||
promotionPending,
|
||||
promotionConfirming,
|
||||
reviewEditing,
|
||||
reviewPending,
|
||||
reviewStatus,
|
||||
reviewDraft,
|
||||
totalRows,
|
||||
nativeCount,
|
||||
storedCount,
|
||||
reviewCount,
|
||||
catalogSummary,
|
||||
promotionSummary,
|
||||
pipelineSummary,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
setFilter,
|
||||
setSort,
|
||||
setSelectedId,
|
||||
refresh,
|
||||
requestSelectedPromotion,
|
||||
cancelSelectedPromotion,
|
||||
confirmSelectedPromotion,
|
||||
startSelectedReview,
|
||||
cancelSelectedReview,
|
||||
setReviewDraft,
|
||||
submitSelectedReview,
|
||||
} = useInventoryViewModel({ snapshot });
|
||||
|
||||
return (
|
||||
<section className="inventory-view">
|
||||
<div className="inventory-toolbar">
|
||||
<div>
|
||||
<p className="eyebrow">Native Artifact Inventory</p>
|
||||
<h2>Artifact Scan-Ergebnisse</h2>
|
||||
</div>
|
||||
<div className="inventory-toolbar-actions">
|
||||
<div className="inventory-stats">
|
||||
<span>{totalRows} gesamt</span>
|
||||
<span>{nativeCount} native</span>
|
||||
<span>{storedCount} store</span>
|
||||
<span>{reviewCount} review</span>
|
||||
</div>
|
||||
<button className="ghost-button icon-button" onClick={() => void refresh()} disabled={loading} title="Inventory aktualisieren">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`inventory-catalog-strip ${catalogSummary.className}`}>
|
||||
<div>
|
||||
<span>{catalogSummary.ok ? "Artifact IK bereit" : "IK Listen pruefen"}</span>
|
||||
<strong>{catalogSummary.title}</strong>
|
||||
<small>{catalogSummary.detail}</small>
|
||||
</div>
|
||||
{catalogSummary.counts.length > 0 && (
|
||||
<div className="inventory-catalog-counts">
|
||||
{catalogSummary.counts.map((entry) => (
|
||||
<span key={entry.label}>
|
||||
<strong>{entry.value}</strong>
|
||||
<small>{entry.label}</small>
|
||||
{entry.sample && <em>{entry.sample}</em>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inventory-pipeline-strip" aria-label="Native artifact pipeline status">
|
||||
{pipelineSummary.cards.map((card) => (
|
||||
<span key={card.id} className={card.className} title={card.detail}>
|
||||
<strong>{card.title}</strong>
|
||||
<small>{card.value}</small>
|
||||
<em>{card.detail}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="inventory-pipeline-caption">{pipelineSummary.caption}</p>
|
||||
|
||||
<div className={`inventory-promotion-strip ${promotionSummary.className}`}>
|
||||
<div>
|
||||
<span>{promotionSummary.title}</span>
|
||||
<strong>{promotionSummary.detail}</strong>
|
||||
<small>Analyse aus scan-results.json und lokalem Artifact-Store. Store-Schreibzugriff bleibt ein expliziter naechster Schritt.</small>
|
||||
</div>
|
||||
<div className="inventory-promotion-counts">
|
||||
<span><strong>{promotionSummary.ready}</strong><small>Speicherbar</small></span>
|
||||
<span><strong>{promotionSummary.alreadyStored + promotionSummary.persisted}</strong><small>Im Store</small></span>
|
||||
<span><strong>{promotionSummary.review}</strong><small>Review</small></span>
|
||||
<span><strong>{promotionSummary.blocked}</strong><small>Blockiert</small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inventory-controls">
|
||||
<div className="segmented-control">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={filter === option.id ? "active" : ""}
|
||||
onClick={() => setFilter(option.id)}
|
||||
>
|
||||
{option.label} <span>{option.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="inventory-sort">
|
||||
<span>Sort</span>
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="inventory-browser">
|
||||
<div className="inventory-list" aria-label="Artifact inventory results">
|
||||
{rows.length > 0 ? rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
className={`inventory-row ${selectedRow?.id === row.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedId(row.id)}
|
||||
>
|
||||
<span className="inventory-row-sequence">{row.sequenceLabel}</span>
|
||||
<span className="inventory-row-main">
|
||||
<strong>{row.title}</strong>
|
||||
<span>{row.slot} - {row.mainStat} {row.mainValue} - {row.levelLabel}</span>
|
||||
</span>
|
||||
<span className="inventory-row-source">{row.sourceLabel}</span>
|
||||
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<p className="inventory-empty">Keine Scan-Ergebnisse geladen.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="inventory-detail">
|
||||
{selectedRow ? (
|
||||
<>
|
||||
<div className="inventory-detail-heading">
|
||||
<p className="eyebrow">{selectedRow.sourceLabel} {selectedRow.sequenceLabel}</p>
|
||||
<h3>{selectedRow.title}</h3>
|
||||
<span className={`result-rail-status ${selectedRow.statusClassName}`}>{selectedRow.statusLabel}</span>
|
||||
</div>
|
||||
<div className={`inventory-action-card ${selectedRow.nextActionClassName}`}>
|
||||
<span>Naechster Schritt</span>
|
||||
<strong>{selectedRow.nextActionLabel}</strong>
|
||||
<small>{selectedRow.nextActionDetail}</small>
|
||||
{selectedRow.canPromote && !promotionConfirming && (
|
||||
<button className="primary-button inventory-promote-button" onClick={requestSelectedPromotion} disabled={promotionPending}>
|
||||
Ausgewaehltes Ergebnis promoten
|
||||
</button>
|
||||
)}
|
||||
{selectedRow.canPromote && promotionConfirming && (
|
||||
<div className="inventory-promotion-confirm">
|
||||
<strong>Dieses gepruefte Ergebnis jetzt in den lokalen Store schreiben?</strong>
|
||||
<div>
|
||||
<button className="primary-button" onClick={() => void confirmSelectedPromotion()} disabled={promotionPending}>Bestaetigen</button>
|
||||
<button className="ghost-button" onClick={cancelSelectedPromotion} disabled={promotionPending}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedRow.source === "native" && selectedRow.needsReview && !reviewEditing && (
|
||||
<button className="primary-button inventory-promote-button" onClick={startSelectedReview} disabled={reviewPending}>
|
||||
Review bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{reviewEditing && reviewDraft && (
|
||||
<div className="inventory-review-editor">
|
||||
<div className="inventory-review-heading">
|
||||
<div>
|
||||
<span>Manuelle Korrektur</span>
|
||||
<strong>Crop pruefen, Felder korrigieren, dann freigeben oder ablehnen.</strong>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={cancelSelectedReview} disabled={reviewPending}>Schliessen</button>
|
||||
</div>
|
||||
<div className="inventory-review-grid">
|
||||
<label><span>Name</span><input value={reviewDraft.name} onChange={(event) => setReviewDraft({ ...reviewDraft, name: event.target.value })} /></label>
|
||||
<label><span>Set</span><input value={reviewDraft.setName} onChange={(event) => setReviewDraft({ ...reviewDraft, setName: event.target.value })} /></label>
|
||||
<label><span>Slot</span><select value={reviewDraft.slot} onChange={(event) => setReviewDraft({ ...reviewDraft, slot: event.target.value })}>
|
||||
{["Flower of Life", "Plume of Death", "Sands of Eon", "Goblet of Eonothem", "Circlet of Logos"].map((slot) => <option key={slot}>{slot}</option>)}
|
||||
</select></label>
|
||||
<label><span>Level</span><input type="number" min="0" max="20" value={reviewDraft.level} onChange={(event) => setReviewDraft({ ...reviewDraft, level: event.target.value })} /></label>
|
||||
<label><span>Main Stat</span><input value={reviewDraft.mainStat} onChange={(event) => setReviewDraft({ ...reviewDraft, mainStat: event.target.value })} /></label>
|
||||
<label><span>Main Value</span><input value={reviewDraft.mainValue} onChange={(event) => setReviewDraft({ ...reviewDraft, mainValue: event.target.value })} /></label>
|
||||
<label><span>Equipped</span><input value={reviewDraft.equipped} onChange={(event) => setReviewDraft({ ...reviewDraft, equipped: event.target.value })} /></label>
|
||||
<label className="inventory-review-lock"><input type="checkbox" checked={reviewDraft.locked} onChange={(event) => setReviewDraft({ ...reviewDraft, locked: event.target.checked })} /><span>Locked</span></label>
|
||||
<label className="inventory-review-wide"><span>Substats, eine Zeile pro Wert</span><textarea rows={4} value={reviewDraft.substats} onChange={(event) => setReviewDraft({ ...reviewDraft, substats: event.target.value })} /></label>
|
||||
<label className="inventory-review-wide"><span>Review-Notiz</span><input value={reviewDraft.note} onChange={(event) => setReviewDraft({ ...reviewDraft, note: event.target.value })} /></label>
|
||||
</div>
|
||||
<div className="inventory-review-actions">
|
||||
<button className="primary-button" onClick={() => void submitSelectedReview("approve")} disabled={reviewPending}>Korrektur freigeben</button>
|
||||
<button className="ghost-button danger" onClick={() => void submitSelectedReview("reject")} disabled={reviewPending}>Ergebnis ablehnen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`inventory-preview ${selectedPreview ? "has-preview" : "is-empty"}`}>
|
||||
{selectedPreview ? (
|
||||
<img src={selectedPreview.dataUrl} alt={`Preview ${selectedRow.title}`} />
|
||||
) : (
|
||||
<span>{previewStatus || "Kein Crop-Preview geladen"}</span>
|
||||
)}
|
||||
</div>
|
||||
{previewStatus && selectedPreview && <p className="inventory-preview-status">{previewStatus}</p>}
|
||||
<div className="inventory-detail-grid">
|
||||
<span>Set</span><strong>{selectedRow.setName}</strong>
|
||||
<span>Slot</span><strong>{selectedRow.slot}</strong>
|
||||
<span>Main</span><strong>{selectedRow.mainStat} {selectedRow.mainValue}</strong>
|
||||
<span>Level</span><strong>{selectedRow.levelLabel}</strong>
|
||||
<span>Value</span><strong>{selectedRow.valueLabel}</strong>
|
||||
<span>IK</span><strong>{selectedRow.ikLabel}</strong>
|
||||
<span>GOOD</span><strong>{selectedRow.ikGoodLabel}</strong>
|
||||
<span>Promotion</span><strong>{selectedRow.promotionLabel}</strong>
|
||||
<span>Confidence</span><strong>{selectedRow.confidence}%</strong>
|
||||
<span>Equipped</span><strong>{selectedRow.equipped}</strong>
|
||||
</div>
|
||||
<div className="inventory-substats">
|
||||
{selectedRow.substats.length > 0 ? selectedRow.substats.map((substat) => (
|
||||
<span key={`${selectedRow.id}-${substat}`}>{substat}</span>
|
||||
)) : <span>Keine Substats gelesen</span>}
|
||||
</div>
|
||||
{selectedRow.fieldConfidences.length > 0 && (
|
||||
<div className="inventory-field-confidence">
|
||||
{selectedRow.fieldConfidences.map((field) => (
|
||||
<span key={`${selectedRow.id}-${field.key}`} className={field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"}>
|
||||
<strong>{field.confidence}%</strong>
|
||||
<small>{field.label} · {field.source}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(selectedRow.notes.length > 0 || selectedRow.promotionReasons.length > 0 || selectedRow.error || selectedRow.imagePath || selectedRow.promotionRecordId) && (
|
||||
<div className="inventory-notes">
|
||||
{selectedRow.imagePath && <span>{selectedRow.imagePath}</span>}
|
||||
{selectedRow.promotionRecordId && <span>{selectedRow.promotionRecordId}</span>}
|
||||
{selectedRow.error && <strong>{selectedRow.error}</strong>}
|
||||
{selectedRow.promotionReasons.map((reason) => <span key={`${selectedRow.id}-promotion-${reason}`}>{reason}</span>)}
|
||||
{selectedRow.notes.map((note) => <span key={`${selectedRow.id}-${note}`}>{note}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="inventory-empty">Waehle ein Ergebnis aus.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<p className="inventory-status">{statusText}</p>
|
||||
{promotionStatus && <p className="inventory-status">{promotionStatus}</p>}
|
||||
{reviewStatus && <p className="inventory-status">{reviewStatus}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
buildArtifactPipelineSummary,
|
||||
buildInventoryCatalogSummary,
|
||||
buildInventoryRows,
|
||||
filterInventoryRows,
|
||||
sortInventoryRows,
|
||||
type InventoryCatalogSummary,
|
||||
type InventoryBrowserFilter,
|
||||
type InventoryBrowserRow,
|
||||
type InventoryBrowserSort,
|
||||
type InventoryPipelineSummary,
|
||||
} from "../../../lib/inventoryBrowser";
|
||||
import { buildScanResultPromotionSummary, type ScanResultPromotionSummary } from "../../../lib/scanResultPromotion";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerReviewArtifactInput } from "../../../types/global";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
|
||||
import type { InventoryViewProps } from "../types";
|
||||
|
||||
export interface InventoryViewModel {
|
||||
rows: InventoryBrowserRow[];
|
||||
selectedRow: InventoryBrowserRow | null;
|
||||
selectedPreview: { dataUrl: string; width: number; height: number; path: string } | null;
|
||||
previewStatus: string;
|
||||
filter: InventoryBrowserFilter;
|
||||
sort: InventoryBrowserSort;
|
||||
loading: boolean;
|
||||
statusText: string;
|
||||
promotionStatus: string;
|
||||
promotionPending: boolean;
|
||||
promotionConfirming: boolean;
|
||||
reviewEditing: boolean;
|
||||
reviewPending: boolean;
|
||||
reviewStatus: string;
|
||||
reviewDraft: InventoryReviewDraft | null;
|
||||
totalRows: number;
|
||||
nativeCount: number;
|
||||
storedCount: number;
|
||||
reviewCount: number;
|
||||
catalogSummary: InventoryCatalogSummary;
|
||||
promotionSummary: ScanResultPromotionSummary;
|
||||
pipelineSummary: InventoryPipelineSummary;
|
||||
filterOptions: Array<{ id: InventoryBrowserFilter; label: string; count: number }>;
|
||||
sortOptions: Array<{ id: InventoryBrowserSort; label: string }>;
|
||||
setFilter: (filter: InventoryBrowserFilter) => void;
|
||||
setSort: (sort: InventoryBrowserSort) => void;
|
||||
setSelectedId: (id: string) => void;
|
||||
refresh: () => Promise<void>;
|
||||
requestSelectedPromotion: () => void;
|
||||
cancelSelectedPromotion: () => void;
|
||||
confirmSelectedPromotion: () => Promise<void>;
|
||||
startSelectedReview: () => void;
|
||||
cancelSelectedReview: () => void;
|
||||
setReviewDraft: (draft: InventoryReviewDraft) => void;
|
||||
submitSelectedReview: (action: "approve" | "reject") => Promise<void>;
|
||||
}
|
||||
|
||||
export interface InventoryReviewDraft {
|
||||
name: string;
|
||||
slot: string;
|
||||
level: string;
|
||||
setName: string;
|
||||
mainStat: string;
|
||||
mainValue: string;
|
||||
substats: string;
|
||||
equipped: string;
|
||||
locked: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export function useInventoryViewModel({ snapshot }: InventoryViewProps): InventoryViewModel {
|
||||
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||
const [storedArtifacts, setStoredArtifacts] = useState<StoredArtifactRecord[]>([]);
|
||||
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
|
||||
const [nativeRunDir, setNativeRunDir] = useState("");
|
||||
const [nativeDataStatus, setNativeDataStatus] = useState<NativeScannerDataStatus | null>(null);
|
||||
const [nativeCatalog, setNativeCatalog] = useState<NativeScannerCatalogStatus | null>(null);
|
||||
const [selectedPreview, setSelectedPreview] = useState<{ dataUrl: string; width: number; height: number; path: string } | null>(null);
|
||||
const [previewStatus, setPreviewStatus] = useState("");
|
||||
const [filter, setFilter] = useState<InventoryBrowserFilter>("all");
|
||||
const [sort, setSort] = useState<InventoryBrowserSort>("newest");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [promotionStatus, setPromotionStatus] = useState("");
|
||||
const [promotionPending, setPromotionPending] = useState(false);
|
||||
const [promotionConfirmingId, setPromotionConfirmingId] = useState("");
|
||||
const [reviewEditingId, setReviewEditingId] = useState("");
|
||||
const [reviewDraft, setReviewDraft] = useState<InventoryReviewDraft | null>(null);
|
||||
const [reviewPending, setReviewPending] = useState(false);
|
||||
const [reviewStatus, setReviewStatus] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [artifactResult, nativeResult, dataResult, catalogResult] = await Promise.allSettled([
|
||||
repositories?.artifacts.loadAll(),
|
||||
repositories?.automation.nativeScannerLoadResults({ limit: 500 }),
|
||||
repositories?.automation.nativeScannerDataStatus(),
|
||||
repositories?.automation.nativeScannerCatalog(),
|
||||
]);
|
||||
|
||||
if (artifactResult.status === "fulfilled" && artifactResult.value?.ok) {
|
||||
setStoredArtifacts(artifactResult.value.artifacts);
|
||||
}
|
||||
if (nativeResult.status === "fulfilled" && nativeResult.value?.ok) {
|
||||
setNativeResults(nativeResult.value.results);
|
||||
setNativeRunDir(nativeResult.value.runDir);
|
||||
}
|
||||
if (dataResult.status === "fulfilled" && dataResult.value?.valid !== undefined) {
|
||||
setNativeDataStatus(dataResult.value);
|
||||
}
|
||||
if (catalogResult.status === "fulfilled" && catalogResult.value?.data?.valid !== undefined) {
|
||||
setNativeCatalog(catalogResult.value);
|
||||
setNativeDataStatus(catalogResult.value.data);
|
||||
}
|
||||
if (artifactResult.status === "rejected") {
|
||||
setError(errorMessage(artifactResult.reason));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repositories]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() =>
|
||||
buildInventoryRows({
|
||||
nativeRunDir,
|
||||
nativeResults,
|
||||
storedArtifacts,
|
||||
snapshotArtifacts: snapshot.artifacts,
|
||||
}),
|
||||
[nativeResults, nativeRunDir, snapshot.artifacts, storedArtifacts],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => sortInventoryRows(filterInventoryRows(allRows, filter), sort),
|
||||
[allRows, filter, sort],
|
||||
);
|
||||
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null;
|
||||
const promotionConfirming = Boolean(selectedRow && promotionConfirmingId === selectedRow.id);
|
||||
const reviewEditing = Boolean(selectedRow && reviewEditingId === selectedRow.id && reviewDraft);
|
||||
|
||||
const requestSelectedPromotion = useCallback(() => {
|
||||
if (selectedRow?.canPromote) setPromotionConfirmingId(selectedRow.id);
|
||||
}, [selectedRow]);
|
||||
|
||||
const cancelSelectedPromotion = useCallback(() => setPromotionConfirmingId(""), []);
|
||||
|
||||
const confirmSelectedPromotion = useCallback(async () => {
|
||||
if (!selectedRow?.canPromote || !selectedRow.runDir || !selectedRow.scanResultId || !repositories?.automation) return;
|
||||
setPromotionPending(true);
|
||||
setPromotionStatus("Promotion wird geprueft und geschrieben...");
|
||||
try {
|
||||
const result = await repositories.automation.nativeScannerPromoteResults({
|
||||
runDir: selectedRow.runDir,
|
||||
resultIds: [selectedRow.scanResultId],
|
||||
});
|
||||
if (!result.ok) {
|
||||
setPromotionStatus(result.error ?? "Promotion wurde blockiert.");
|
||||
return;
|
||||
}
|
||||
setPromotionStatus(`${result.promoted} Ergebnis promotet, ${result.added} neu, ${result.updated} aktualisiert. Log: ${result.logPath}`);
|
||||
setPromotionConfirmingId("");
|
||||
await refresh();
|
||||
} catch (promotionError) {
|
||||
setPromotionStatus(errorMessage(promotionError));
|
||||
} finally {
|
||||
setPromotionPending(false);
|
||||
}
|
||||
}, [refresh, repositories, selectedRow]);
|
||||
|
||||
const startSelectedReview = useCallback(() => {
|
||||
if (!selectedRow || selectedRow.source !== "native" || !selectedRow.needsReview) return;
|
||||
setReviewEditingId(selectedRow.id);
|
||||
setReviewDraft({
|
||||
name: selectedRow.title,
|
||||
slot: selectedRow.slot,
|
||||
level: selectedRow.levelLabel.replace("+", ""),
|
||||
setName: selectedRow.setName,
|
||||
mainStat: selectedRow.mainStat,
|
||||
mainValue: selectedRow.mainValue,
|
||||
substats: selectedRow.substats.join("\n"),
|
||||
equipped: selectedRow.equipped,
|
||||
locked: Boolean(selectedRow.locked),
|
||||
note: "",
|
||||
});
|
||||
}, [selectedRow]);
|
||||
|
||||
const cancelSelectedReview = useCallback(() => {
|
||||
setReviewEditingId("");
|
||||
setReviewDraft(null);
|
||||
}, []);
|
||||
|
||||
const submitSelectedReview = useCallback(async (action: "approve" | "reject") => {
|
||||
if (!selectedRow?.runDir || !selectedRow.scanResultId || !reviewDraft || !repositories?.automation) return;
|
||||
setReviewPending(true);
|
||||
setReviewStatus(action === "approve" ? "Korrektur wird validiert..." : "Ergebnis wird abgelehnt...");
|
||||
try {
|
||||
const artifact: NativeScannerReviewArtifactInput = {
|
||||
name: reviewDraft.name,
|
||||
slot: reviewDraft.slot,
|
||||
level: Number(reviewDraft.level),
|
||||
setName: reviewDraft.setName,
|
||||
mainStat: reviewDraft.mainStat,
|
||||
mainValue: reviewDraft.mainValue,
|
||||
substats: reviewDraft.substats.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean),
|
||||
equipped: reviewDraft.equipped,
|
||||
locked: reviewDraft.locked,
|
||||
};
|
||||
const result = await repositories.automation.nativeScannerReviewResult({
|
||||
runDir: selectedRow.runDir,
|
||||
resultId: selectedRow.scanResultId,
|
||||
action,
|
||||
artifact: action === "approve" ? artifact : undefined,
|
||||
note: reviewDraft.note,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setReviewStatus(result.error ?? "Review wurde blockiert.");
|
||||
return;
|
||||
}
|
||||
setReviewStatus(action === "approve"
|
||||
? `Review freigegeben. ${result.correctedFields.length} Feld(er) korrigiert. Eval-Sample: ${result.evalSampleSaved ? "gespeichert" : "ohne OCR-Payload"}.`
|
||||
: "Ergebnis wurde abgelehnt und bleibt von Promotion ausgeschlossen.");
|
||||
setReviewEditingId("");
|
||||
setReviewDraft(null);
|
||||
await refresh();
|
||||
} catch (reviewError) {
|
||||
setReviewStatus(errorMessage(reviewError));
|
||||
} finally {
|
||||
setReviewPending(false);
|
||||
}
|
||||
}, [refresh, repositories, reviewDraft, selectedRow]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setSelectedPreview(null);
|
||||
const automation = repositories?.automation;
|
||||
if (!automation) {
|
||||
setPreviewStatus("Electron bridge nicht verfuegbar.");
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
if (!selectedRow?.runDir || !selectedRow.imagePath || selectedRow.source !== "native") {
|
||||
setPreviewStatus(selectedRow?.imagePath ? "Preview nur fuer native Run-Crops verfuegbar." : "");
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setPreviewStatus("Preview wird geladen...");
|
||||
void automation.nativeScannerLoadImage({
|
||||
runDir: selectedRow.runDir,
|
||||
imagePath: selectedRow.imagePath,
|
||||
}).then((result) => {
|
||||
if (canceled) return;
|
||||
if (result?.ok) {
|
||||
setSelectedPreview({
|
||||
dataUrl: result.dataUrl,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
path: result.path,
|
||||
});
|
||||
setPreviewStatus(`${result.width}x${result.height}`);
|
||||
} else {
|
||||
setPreviewStatus(result?.error ?? "Preview konnte nicht geladen werden.");
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
if (!canceled) setPreviewStatus(errorMessage(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [repositories, selectedRow?.id, selectedRow?.imagePath, selectedRow?.runDir, selectedRow?.source]);
|
||||
|
||||
const reviewCount = allRows.filter((row) => row.needsReview).length;
|
||||
const nativeCount = allRows.filter((row) => row.source === "native").length;
|
||||
const storedCount = allRows.filter((row) => row.source === "store").length;
|
||||
const catalogSummary = useMemo(
|
||||
() => buildInventoryCatalogSummary(nativeDataStatus, nativeCatalog),
|
||||
[nativeCatalog, nativeDataStatus],
|
||||
);
|
||||
const promotionSummary = useMemo(
|
||||
() => buildScanResultPromotionSummary(nativeResults, storedArtifacts),
|
||||
[nativeResults, storedArtifacts],
|
||||
);
|
||||
const pipelineSummary = useMemo(
|
||||
() => buildArtifactPipelineSummary(nativeDataStatus, nativeResults, promotionSummary),
|
||||
[nativeDataStatus, nativeResults, promotionSummary],
|
||||
);
|
||||
const statusText = loading
|
||||
? "Inventory wird geladen..."
|
||||
: error || `${rows.length}/${allRows.length} Eintraege sichtbar`;
|
||||
|
||||
return {
|
||||
rows,
|
||||
selectedRow,
|
||||
selectedPreview,
|
||||
previewStatus,
|
||||
filter,
|
||||
sort,
|
||||
loading,
|
||||
statusText,
|
||||
promotionStatus,
|
||||
promotionPending,
|
||||
promotionConfirming,
|
||||
reviewEditing,
|
||||
reviewPending,
|
||||
reviewStatus,
|
||||
reviewDraft,
|
||||
totalRows: allRows.length,
|
||||
nativeCount,
|
||||
storedCount,
|
||||
reviewCount,
|
||||
catalogSummary,
|
||||
promotionSummary,
|
||||
pipelineSummary,
|
||||
filterOptions: [
|
||||
{ id: "all", label: "Alle", count: allRows.length },
|
||||
{ id: "native", label: "Native", count: nativeCount },
|
||||
{ id: "parsed", label: "Gelesen", count: allRows.length - reviewCount },
|
||||
{ id: "review", label: "Review", count: reviewCount },
|
||||
{ id: "promotable", label: "Speicherbar", count: promotionSummary.ready },
|
||||
{ id: "stored", label: "Store", count: storedCount },
|
||||
],
|
||||
sortOptions: [
|
||||
{ id: "newest", label: "Neueste" },
|
||||
{ id: "name", label: "Name" },
|
||||
{ id: "status", label: "Status" },
|
||||
],
|
||||
setFilter,
|
||||
setSort,
|
||||
setSelectedId,
|
||||
refresh,
|
||||
requestSelectedPromotion,
|
||||
cancelSelectedPromotion,
|
||||
confirmSelectedPromotion,
|
||||
startSelectedReview,
|
||||
cancelSelectedReview,
|
||||
setReviewDraft,
|
||||
submitSelectedReview,
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(value: unknown) {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
|
||||
export interface InventoryViewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Layers3, Radar, Wand2, Eye, Wrench } from "lucide-react";
|
||||
import { Archive, Layers3, Radar, Wand2, Eye, Wrench } from "lucide-react";
|
||||
import type { AppNavigationItem } from "./types";
|
||||
|
||||
export const appNavigationItems: AppNavigationItem[] = [
|
||||
{ id: "scan", label: "Scan", icon: Radar },
|
||||
{ id: "inventory", label: "Inventory", icon: Archive },
|
||||
{ id: "triage", label: "Triage", icon: Layers3 },
|
||||
{ id: "builds", label: "Builds", icon: Wand2 },
|
||||
{ id: "overlay", label: "Overlay", icon: Eye },
|
||||
{ id: "diagnose", label: "Diagnose", icon: Wrench },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ComponentType, type ReactNode } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
|
||||
export type NavigationId = "scan" | "triage" | "builds" | "overlay" | "diagnose";
|
||||
export type NavigationId = "scan" | "inventory" | "triage" | "builds" | "overlay" | "diagnose";
|
||||
|
||||
export interface AppNavigationItem {
|
||||
id: NavigationId;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Download, Play, Upload, Wrench } from "lucide-react";
|
||||
import { AlertTriangle, BadgeCheck, Camera, ClipboardList, Download, Gauge, Play, Target, Upload, Wrench } from "lucide-react";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import type { ScanViewControllerResult } from "../types";
|
||||
import { FieldConfidenceList } from "./ScanResultCards";
|
||||
@@ -14,6 +14,54 @@ interface DiagnosticsViewProps {
|
||||
canDemoScan?: boolean;
|
||||
}
|
||||
|
||||
const appDiagnosisSections = [
|
||||
{
|
||||
title: "Was die App kann",
|
||||
tone: "ok",
|
||||
icon: BadgeCheck,
|
||||
items: [
|
||||
"Genshin-Fenster erkennen, Smart Capture ausfuehren und fokussierte Artifact-Crops erzeugen.",
|
||||
"Artifact-Felder deterministisch gegen das lokale Genshin-Datenpaket parsen.",
|
||||
"Auto-Scan read-only aus der sichtbaren Inventory-Seite starten, inklusive Grid, Verifikation, Dedupe und Store.",
|
||||
"Review-Samples, lokale Lernregeln, GOOD Import/Export, Equipped-Footer und Lock-Status im Store nutzen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was noch fehlt",
|
||||
tone: "warn",
|
||||
icon: ClipboardList,
|
||||
items: [
|
||||
"Empfehlungen und Build-UX sind bewusst noch nicht der naechste Hauptfokus.",
|
||||
"Auto-Entry-Modi wie paimon-menu und direct-inventory bleiben Dev-Control Experimente, nicht Produktionspfad.",
|
||||
"3 Artifacts/Sekunde ist noch nicht bewiesen; der sichtbare Pfad liegt knapp darunter.",
|
||||
"Mehr bestaetigte OCR-/Review-Corpus-Faelle fehlen, bevor wir breite Qualitaet behaupten.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Wo es Probleme macht",
|
||||
tone: "risk",
|
||||
icon: AlertTriangle,
|
||||
items: [
|
||||
"Scanner ist stark im aktuellen sichtbaren Inventory-Pfad; Repeatability ueber spaetere Sessions ist der naechste Pruefpunkt.",
|
||||
"OCR ist schnell genug fuer gute Runs, bleibt aber der groesste Qualitaets- und Speed-Hebel.",
|
||||
"Capture-Roundtrip ist sichtbar: Roundtrip und Overhead muessen bei 20/45/100 Runs mit bewertet werden.",
|
||||
"Groessere Runs brauchen weiter Beobachtung auf Scroll-Uebergaenge, Wiederholseiten, Duplicate-Rate und Review-Quote.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Naechste Verbesserungen",
|
||||
tone: "next",
|
||||
icon: Target,
|
||||
items: [
|
||||
"Mehr Live-Wiederholungen: 20/45/100 Runs in spaeteren Sessions als Repeatability dokumentieren.",
|
||||
"Review-Samples sauber labeln und ueber `npm run eval:prepare-confirmed` ins Eval-Corpus uebernehmen.",
|
||||
"Scanner-UI weiter beruhigen, aber den funktionierenden visible-inventory Pfad nicht umbauen.",
|
||||
"Capture-Roundtrip-Overhead als eigenen Optimierungspunkt pruefen, bevor weitere OCR-Engine-Wechsel priorisiert werden.",
|
||||
"Erst danach Empfehlungen wieder staerker nach vorne ziehen.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// All developer / diagnostic surfaces live here, separated from the Scan
|
||||
// workspace: runtime + rights, grid detection, learning + data-package status,
|
||||
// fingerprint, auto-scan counters, the automation log, and the raw crop/OCR/
|
||||
@@ -39,6 +87,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
playerProgress,
|
||||
reviewStatus,
|
||||
automationLogLines,
|
||||
diagnosticEvents,
|
||||
canSaveReviewSample,
|
||||
handleSaveReviewSample,
|
||||
} = useScanDiagnosticsModalModel({
|
||||
@@ -106,6 +155,37 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card app-diagnosis-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Aktueller App-Stand</p>
|
||||
<h3>Scanner zuerst, Empfehlungen danach</h3>
|
||||
</div>
|
||||
<span className="diagnosis-source">
|
||||
<Gauge size={14} />
|
||||
Quelle: Docs + Live-Status
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-diagnosis-grid">
|
||||
{appDiagnosisSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<article className={`app-diagnosis-section ${section.tone}`} key={section.title}>
|
||||
<div className="app-diagnosis-title">
|
||||
<Icon size={16} />
|
||||
<strong>{section.title}</strong>
|
||||
</div>
|
||||
<ul>
|
||||
{section.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-grid">
|
||||
<div className="diagnose-card">
|
||||
<p className="eyebrow">Status</p>
|
||||
@@ -180,7 +260,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
</div>
|
||||
</div>
|
||||
<p className="scanner-subcopy">
|
||||
Exportiert den Scan-Store als GOOD (Genshin Optimizer / Inventory Kamera / Akasha) oder importiert eine GOOD-Datei in den Store.
|
||||
Exportiert den Scan-Store als GOOD-kompatible Datei oder importiert eine GOOD-Datei in den Store.
|
||||
</p>
|
||||
{interopStatus && <p className="review-status">{interopStatus}</p>}
|
||||
</div>
|
||||
@@ -196,6 +276,67 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Evidence timeline</p>
|
||||
<h3>Scan-Flugschreiber</h3>
|
||||
</div>
|
||||
<span className="diagnosis-source">
|
||||
<Camera size={14} />
|
||||
letzte {diagnosticEvents.length}
|
||||
</span>
|
||||
</div>
|
||||
{diagnosticEvents.length > 0 ? (
|
||||
<div className="scan-evidence-timeline">
|
||||
{diagnosticEvents.slice().reverse().map((event) => (
|
||||
<article className={`scan-evidence-event ${event.severity}`} key={event.id}>
|
||||
<div className="scan-evidence-header">
|
||||
<span>{new Date(event.at).toLocaleTimeString()}</span>
|
||||
<strong>{event.phase}</strong>
|
||||
<em>{event.severity}</em>
|
||||
</div>
|
||||
<p>{event.message}</p>
|
||||
{event.details && (
|
||||
<div className="scan-evidence-details">
|
||||
{Object.entries(event.details).map(([key, value]) => (
|
||||
<span key={key}>{key}: {String(value ?? "-")}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{event.capture && (
|
||||
<div className="scan-evidence-capture">
|
||||
<div>
|
||||
<strong>{event.capture.name}</strong>
|
||||
<span>{event.capture.width}x{event.capture.height} · {event.capture.target ?? "capture"} · fp {event.capture.fingerprint}</span>
|
||||
{event.capture.grid && (
|
||||
<span>grid {event.capture.grid.cols}x{event.capture.grid.rows} · {event.capture.grid.targets} targets · {event.capture.grid.confidence}% · {event.capture.grid.source}</span>
|
||||
)}
|
||||
{event.capture.count && (
|
||||
<span>count {event.capture.count.current}/{event.capture.count.total || "?"} · {event.capture.count.confidence}% · {event.capture.count.text || "-"}</span>
|
||||
)}
|
||||
{event.capture.artifactDetail && (
|
||||
<span>detail {event.capture.artifactDetail.present ? "yes" : "no"} · {event.capture.artifactDetail.confidence}% · orange {event.capture.artifactDetail.orangeHits} · text {event.capture.artifactDetail.textHits}</span>
|
||||
)}
|
||||
{event.capture.paimonMenu && (
|
||||
<span>paimon {event.capture.paimonMenu.present ? "yes" : "no"} · {event.capture.paimonMenu.confidence}%</span>
|
||||
)}
|
||||
{event.capture.layoutWarning && <span className="evidence-warning">{event.capture.layoutWarning}</span>}
|
||||
</div>
|
||||
<div className="scan-evidence-images">
|
||||
{event.capture.screenshots?.inventory && <img src={event.capture.screenshots.inventory} alt={`${event.phase} inventory`} />}
|
||||
{event.capture.screenshots?.detail && <img src={event.capture.screenshots.detail} alt={`${event.phase} detail`} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="result-empty">Noch keine Evidence-Events. Starte einen Capture oder Auto-Scan, dann erscheinen hier Schritte mit Screenshots.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="diagnose-card">
|
||||
<div className="diagnose-card-heading">
|
||||
<p className="eyebrow">Crops, OCR & Confidence</p>
|
||||
|
||||
@@ -7,49 +7,50 @@ export function ScanMainSection({
|
||||
latestCapture,
|
||||
captureStatus,
|
||||
parsedArtifact,
|
||||
sourceLabel,
|
||||
gridLabel,
|
||||
inventoryLabel,
|
||||
recentArtifacts,
|
||||
nativeScanResults,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
autoScanRunning,
|
||||
canOpenReviewQueue,
|
||||
openReviewQueue,
|
||||
openInventory,
|
||||
}: ScanMainSectionProps) {
|
||||
const {
|
||||
canOpenDetails,
|
||||
handleOpenDetails,
|
||||
handleOpenInventory,
|
||||
handleOpenReviewQueue,
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture,
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
recentRows,
|
||||
recentEmptyText,
|
||||
canOpenInventory,
|
||||
} = useScanMainSectionModel({
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
recentArtifacts,
|
||||
nativeScanResults,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
openInventory,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scanner-main-grid">
|
||||
<div className="capture-stage-shell">
|
||||
<div className={`capture-stage-shell ${hasCapture ? "has-capture" : "is-empty"}`}>
|
||||
<div className="capture-stage">
|
||||
{hasCapture ? (
|
||||
<img src={captureImageSrc} alt={captureImageAlt} />
|
||||
@@ -61,10 +62,6 @@ export function ScanMainSection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="capture-stage-meta">
|
||||
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
|
||||
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="scanner-result-panel">
|
||||
@@ -75,15 +72,47 @@ export function ScanMainSection({
|
||||
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
|
||||
<p className="result-empty">{noArtifactText}</p>
|
||||
)}
|
||||
<div className="scanner-result-rail">
|
||||
<div className="result-rail-heading">
|
||||
<p className="eyebrow">Letzte Ergebnisse</p>
|
||||
<span>{recentRows.length}</span>
|
||||
</div>
|
||||
{recentRows.length > 0 ? (
|
||||
<div className="result-rail-list">
|
||||
{recentRows.map((row) => (
|
||||
<button
|
||||
className="result-rail-row"
|
||||
key={row.id}
|
||||
onClick={handleOpenInventory}
|
||||
disabled={!canOpenInventory}
|
||||
title="Im Inventory oeffnen"
|
||||
>
|
||||
<span className="result-rail-index">{row.sequence}</span>
|
||||
<span className="result-rail-main">
|
||||
<strong>{row.title}</strong>
|
||||
<span>{row.meta}</span>
|
||||
</span>
|
||||
<span className="result-rail-score">{row.score}</span>
|
||||
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="result-empty compact">{recentEmptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="scanner-result-brief">
|
||||
<span>{targetLabel}</span>
|
||||
<span>{dbLabel}</span>
|
||||
<span>{reviewLabel}</span>
|
||||
</div>
|
||||
<div className="scanner-result-actions">
|
||||
<button className="ghost-button" onClick={handleOpenDetails} disabled={!canOpenDetails}>
|
||||
Details
|
||||
</button>
|
||||
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
|
||||
<AlertTriangle size={15} />
|
||||
Review Queue
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
<p className="scanner-result-caption">{captureStatus}</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ScanTopControlsSection({
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
@@ -46,7 +46,6 @@ export function ScanTopControlsSection({
|
||||
refreshCaptureSourcesTitle,
|
||||
diagnosticsButtonTitle,
|
||||
scanSetupButtonTitle,
|
||||
showPlayerProgress,
|
||||
progressWidth,
|
||||
progressStats,
|
||||
} = useScanTopControlsModel({
|
||||
@@ -64,8 +63,7 @@ export function ScanTopControlsSection({
|
||||
<div className="scanner-header">
|
||||
<div>
|
||||
<p className="eyebrow">Scanner</p>
|
||||
<h2>Artifact capture workspace</h2>
|
||||
<p className="scanner-subcopy">Quelle waehlen, Auto-Scan starten, Ergebnis rechts pruefen. Dev-Details im Diagnose-Tab.</p>
|
||||
<h2>Artifact Scan</h2>
|
||||
</div>
|
||||
<div className="scanner-header-pills">
|
||||
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
|
||||
@@ -119,46 +117,42 @@ export function ScanTopControlsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-scan-actions">
|
||||
<button
|
||||
className="primary-button scan-cta"
|
||||
onClick={runVisibleGridScan}
|
||||
disabled={!canStartAutoScan}
|
||||
title={autoScanButtonTitle}
|
||||
>
|
||||
<Play size={16} />
|
||||
{autoScanButtonLabel}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={runAutoReviewScan}
|
||||
disabled={!canStartManualScan}
|
||||
title={manualScanButtonTitle}
|
||||
>
|
||||
<Radar size={15} />
|
||||
Manueller Scan
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={captureSingleArtifact}
|
||||
disabled={!canCaptureSingle}
|
||||
title={captureSingleButtonTitle}
|
||||
>
|
||||
<Camera size={15} />
|
||||
Einzelnes Artifact lesen
|
||||
</button>
|
||||
{autoScanRunning && (
|
||||
<button className="stop-button" onClick={stopScan}>
|
||||
Stop
|
||||
<div className="player-scan-lower">
|
||||
<div className="player-scan-actions">
|
||||
<button
|
||||
className="primary-button scan-cta"
|
||||
onClick={runGuidedAutoScan}
|
||||
disabled={!canStartAutoScan}
|
||||
title={autoScanButtonTitle}
|
||||
>
|
||||
<Play size={16} />
|
||||
{autoScanButtonLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={runAutoReviewScan}
|
||||
disabled={!canStartManualScan}
|
||||
title={manualScanButtonTitle}
|
||||
>
|
||||
<Radar size={15} />
|
||||
Manueller Scan
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={captureSingleArtifact}
|
||||
disabled={!canCaptureSingle}
|
||||
title={captureSingleButtonTitle}
|
||||
>
|
||||
<Camera size={15} />
|
||||
Einzelnes Artifact
|
||||
</button>
|
||||
{autoScanRunning && (
|
||||
<button className="stop-button" onClick={stopScan}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="player-status">
|
||||
{playerStatusText}
|
||||
</p>
|
||||
|
||||
{showPlayerProgress && (
|
||||
<div className="player-progress">
|
||||
<div className="player-progress-bar">
|
||||
<div style={{ width: `${progressWidth}%` }} />
|
||||
@@ -171,7 +165,11 @@ export function ScanTopControlsSection({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="player-status">
|
||||
{playerStatusText}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export function ScanViewLayout({
|
||||
bridgeReady,
|
||||
controller,
|
||||
isScanning,
|
||||
onOpenInventory,
|
||||
}: ScanViewLayoutProps) {
|
||||
const {
|
||||
setDetailsOpen,
|
||||
@@ -24,6 +25,8 @@ export function ScanViewLayout({
|
||||
reviewSampleTotal,
|
||||
autoScanRunning,
|
||||
storedTotal,
|
||||
recentArtifacts,
|
||||
nativeScanResults,
|
||||
learningRulesLoaded,
|
||||
parsedArtifact,
|
||||
learningRuleCount,
|
||||
@@ -52,6 +55,8 @@ export function ScanViewLayout({
|
||||
latestCapture={latestCapture}
|
||||
captureStatus={captureStatus}
|
||||
parsedArtifact={parsedArtifact}
|
||||
recentArtifacts={recentArtifacts}
|
||||
nativeScanResults={nativeScanResults}
|
||||
sourceLabel={sourceLabel}
|
||||
gridLabel={gridLabel}
|
||||
inventoryLabel={inventoryLabel}
|
||||
@@ -64,6 +69,7 @@ export function ScanViewLayout({
|
||||
openReviewQueue={openReviewQueue}
|
||||
autoScanRunning={autoScanRunning}
|
||||
setDetailsOpen={setDetailsOpen}
|
||||
openInventory={onOpenInventory}
|
||||
/>
|
||||
|
||||
<ScanModalsSection
|
||||
|
||||
@@ -1,50 +1,68 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ScanMainSectionProps } from "../types";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../../types/storage";
|
||||
|
||||
export interface ScanResultRailRow {
|
||||
id: string;
|
||||
sequence: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
score: string;
|
||||
statusLabel: string;
|
||||
statusClassName: string;
|
||||
}
|
||||
|
||||
export interface ScanMainSectionModel {
|
||||
canOpenDetails: boolean;
|
||||
handleOpenDetails: () => void;
|
||||
handleOpenInventory: () => void;
|
||||
handleOpenReviewQueue: () => void;
|
||||
captureImageSrc: string;
|
||||
captureImageAlt: string;
|
||||
hasCapture: boolean;
|
||||
captureModeText: string;
|
||||
resultHeading: string;
|
||||
noArtifactText: string;
|
||||
noCaptureMessage: string;
|
||||
targetLabel: string;
|
||||
dbLabel: string;
|
||||
reviewLabel: string;
|
||||
rulesLabel: string;
|
||||
recentRows: ScanResultRailRow[];
|
||||
recentEmptyText: string;
|
||||
canOpenInventory: boolean;
|
||||
}
|
||||
|
||||
type UseScanMainSectionModelProps = Pick<
|
||||
ScanMainSectionProps,
|
||||
| "latestCapture"
|
||||
| "parsedArtifact"
|
||||
| "recentArtifacts"
|
||||
| "nativeScanResults"
|
||||
| "activeTargetCount"
|
||||
| "storedTotal"
|
||||
| "reviewSampleTotal"
|
||||
| "learningRulesLoaded"
|
||||
| "learningRuleCount"
|
||||
| "setDetailsOpen"
|
||||
| "openReviewQueue"
|
||||
| "openInventory"
|
||||
>;
|
||||
|
||||
export function useScanMainSectionModel({
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
recentArtifacts,
|
||||
nativeScanResults,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
openInventory,
|
||||
}: UseScanMainSectionModelProps): ScanMainSectionModel {
|
||||
const handleOpenDetails = useCallback(() => {
|
||||
setDetailsOpen(true);
|
||||
}, [setDetailsOpen]);
|
||||
const handleOpenInventory = useCallback(() => {
|
||||
openInventory?.();
|
||||
}, [openInventory]);
|
||||
const handleOpenReviewQueue = useCallback(() => {
|
||||
void openReviewQueue();
|
||||
}, [openReviewQueue]);
|
||||
@@ -53,29 +71,85 @@ export function useScanMainSectionModel({
|
||||
const captureImageAlt = latestCapture
|
||||
? `Latest capture from ${latestCapture.name}`
|
||||
: "Latest capture is not available yet";
|
||||
const captureModeText = latestCapture ? "Erkannt" : "Warte";
|
||||
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
|
||||
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
|
||||
const noCaptureMessage = noArtifactText;
|
||||
const targetLabel = `Ziel ${activeTargetCount}`;
|
||||
const dbLabel = `DB ${storedTotal ?? "-"}`;
|
||||
const reviewLabel = `Review ${reviewSampleTotal}`;
|
||||
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
|
||||
const nativeRows = nativeScanResults
|
||||
.slice(-6)
|
||||
.reverse()
|
||||
.map(nativeScanResultToRailRow);
|
||||
const storedRows = recentArtifacts.map(storedArtifactToRailRow);
|
||||
const recentRows = nativeRows.length > 0 ? nativeRows : storedRows;
|
||||
const recentEmptyText = nativeRows.length > 0
|
||||
? "Native Ergebnisse werden geladen."
|
||||
: "Noch keine gespeicherten Scan-Ergebnisse.";
|
||||
|
||||
return {
|
||||
canOpenDetails,
|
||||
handleOpenDetails,
|
||||
handleOpenInventory,
|
||||
handleOpenReviewQueue,
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture: Boolean(latestCapture),
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
recentRows,
|
||||
recentEmptyText,
|
||||
canOpenInventory: Boolean(openInventory && recentRows.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function storedArtifactToRailRow(artifact: StoredArtifactRecord, index: number): ScanResultRailRow {
|
||||
const statusLabel = artifact.needsReview
|
||||
? "Review"
|
||||
: (artifact.timesSeen ?? 1) > 1
|
||||
? "Duplikat"
|
||||
: "Gelesen";
|
||||
const statusClassName = artifact.needsReview
|
||||
? "review"
|
||||
: (artifact.timesSeen ?? 1) > 1
|
||||
? "duplicate"
|
||||
: "stored";
|
||||
const levelText = typeof artifact.level === "number" ? `+${artifact.level}` : "+?";
|
||||
return {
|
||||
id: artifact.id,
|
||||
sequence: `#${index + 1}`,
|
||||
title: artifact.name || `${artifact.slot} ${artifact.setName}`,
|
||||
meta: `${artifact.slot} - ${artifact.mainStat} - ${levelText}`,
|
||||
score: artifact.needsReview ? "Review" : "Wert offen",
|
||||
statusLabel,
|
||||
statusClassName,
|
||||
};
|
||||
}
|
||||
|
||||
function nativeScanResultToRailRow(entry: StoredScanResultEntry): ScanResultRailRow {
|
||||
const artifact = entry.artifact;
|
||||
const levelText = typeof artifact?.level === "number" ? `+${artifact.level}` : "+?";
|
||||
const isClean = entry.extractionStatus === "parsed" && !entry.needsReview;
|
||||
const statusLabel = isClean
|
||||
? entry.persistedArtifact ? "Gespeichert" : "Geparst"
|
||||
: entry.extractionStatus === "missing_crop"
|
||||
? "Crop fehlt"
|
||||
: "Review";
|
||||
const statusClassName = isClean ? entry.persistedArtifact ? "stored" : "parsed" : "review";
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
sequence: `#${entry.sequence}`,
|
||||
title: artifact?.name || `Artifact ${entry.sequence}`,
|
||||
meta: artifact
|
||||
? `${artifact.slot} - ${artifact.mainStat} - ${levelText}`
|
||||
: entry.error || entry.extractionStatus,
|
||||
score: entry.valueStatus === "deferred" ? "Wert offen" : "Review",
|
||||
statusLabel,
|
||||
statusClassName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ScanSummaryFooterProps } from "../types";
|
||||
import type { ScanSummaryFooterProps } from "../types";
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (ms <= 0) return "0s";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
|
||||
}
|
||||
|
||||
export interface ScanSummaryFooterModel {
|
||||
summaryCopy: string;
|
||||
@@ -12,10 +20,10 @@ export function useScanSummaryFooterModel({
|
||||
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
|
||||
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
|
||||
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen in ${formatDuration(scanSummary.elapsedMs)} (${scanSummary.averageMsPerParsed || 0} ms/Artifact). Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
|
||||
const devCopy = devMode
|
||||
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
|
||||
? `clicked ${scanSummary.clicked} | attempted ${scanSummary.attempted} | verified ${scanSummary.verified} | parsed ${scanSummary.parsed} | misses ${scanSummary.misses} | pages ${scanSummary.pages} | active ${formatDuration(scanSummary.activeScanMs)} | flush ${scanSummary.writeFlushMs}ms | capture ${scanSummary.averageCaptureMs}ms | roundtrip ${scanSummary.averageCaptureRoundTripMs}ms | ocr ${scanSummary.averageOcrMs}ms | ${scanSummary.artifactsPerMinute}/min | active ${scanSummary.activeArtifactsPerMinute}/min | 100 projected ${formatDuration(scanSummary.projectedMsFor100)}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ScanTopControlsModel {
|
||||
openDiagnostics: () => void;
|
||||
captureSingleArtifact: () => void;
|
||||
stopScan: () => void;
|
||||
runVisibleGridScan: () => void;
|
||||
runGuidedAutoScan: () => void;
|
||||
runAutoReviewScan: () => void;
|
||||
bridgeStatusText: string;
|
||||
bridgePillClass: string;
|
||||
@@ -47,7 +47,7 @@ export function useScanTopControlsModel({
|
||||
setSettingsOpen,
|
||||
setDiagnosticsOpen,
|
||||
requestScanStop,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
autoScanRunning,
|
||||
canCaptureSource,
|
||||
@@ -77,17 +77,20 @@ export function useScanTopControlsModel({
|
||||
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
|
||||
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
|
||||
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
|
||||
const startGuidedAutoScan = useCallback(() => {
|
||||
void runGuidedAutoScan();
|
||||
}, [runGuidedAutoScan]);
|
||||
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
|
||||
const bridgePillClass = bridgeReady ? "elevated" : "standard";
|
||||
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
|
||||
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
|
||||
const playerStatusText = reviewStatus
|
||||
|| (runtimeInfo?.isElevated
|
||||
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
|
||||
? "App laeuft als Administrator. Auto-Scan prueft den Screen ohne OCR und startet erst, wenn eine Artifact-Detailkarte offen ist."
|
||||
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
|
||||
const autoScanButtonTitle = requiresAdminForAutoScan
|
||||
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
|
||||
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
|
||||
: "Prueft zuerst ohne OCR den Screen, oeffnet bei Bedarf per ESC/B-Fallback das Artifact-Inventar und scannt erst mit sichtbarer Detailkarte.";
|
||||
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
|
||||
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
|
||||
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
|
||||
@@ -104,11 +107,20 @@ export function useScanTopControlsModel({
|
||||
{ label: "Klicks", value: autoScanStats.clicked },
|
||||
{ label: "Positionen", value: autoScanStats.attempted },
|
||||
{ label: "Verifiziert", value: autoScanStats.verified },
|
||||
{ label: "ms/Artifact", value: autoScanStats.averageMsPerParsed || "-" },
|
||||
{ label: "Gespeichert", value: autoScanStats.stored },
|
||||
{ label: "Review", value: autoScanStats.review },
|
||||
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
|
||||
],
|
||||
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
|
||||
[
|
||||
autoScanStats.clicked,
|
||||
autoScanStats.attempted,
|
||||
autoScanStats.verified,
|
||||
autoScanStats.averageMsPerParsed,
|
||||
autoScanStats.stored,
|
||||
autoScanStats.review,
|
||||
storedTotal,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -124,7 +136,7 @@ export function useScanTopControlsModel({
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runGuidedAutoScan: startGuidedAutoScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ScanSettingsModalProps } from "./types";
|
||||
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
|
||||
import type { StepperControlModel } from "./hooks/useScanSettingsModalModel";
|
||||
|
||||
export function ScanSettingsModal({
|
||||
open,
|
||||
@@ -16,9 +17,9 @@ export function ScanSettingsModal({
|
||||
|
||||
const {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
scanLimitControl,
|
||||
skipRowsControl,
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
@@ -50,33 +51,17 @@ export function ScanSettingsModal({
|
||||
<button className="ghost-button" onClick={closeSettings}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="scan-config-strip">
|
||||
<label>
|
||||
<span>Anzahl Artifacts</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1800}
|
||||
value={scanLimit}
|
||||
onChange={handleScanLimitChange}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zeilen ueberspringen</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={8}
|
||||
value={skipRows}
|
||||
onChange={handleSkipRowsChange}
|
||||
/>
|
||||
</label>
|
||||
<p>
|
||||
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
|
||||
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
|
||||
</p>
|
||||
<div className="scan-settings-layout">
|
||||
<div className="scan-settings-controls">
|
||||
<StepperControl control={scanLimitControl} />
|
||||
<StepperControl control={skipRowsControl} />
|
||||
</div>
|
||||
<div className="scan-settings-note">
|
||||
<strong>Manuelle Werte bleiben erhalten.</strong>
|
||||
<span>Die erkannte Inventar-Anzahl ist nur ein Vorschlag und oberer Deckel. Startzeilen brauchst du nur, wenn du nicht oben beginnst.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scanner-preflight diagnostics-preflight">
|
||||
<div className="scanner-preflight settings-preflight">
|
||||
<div className={inventoryClassName}>
|
||||
<span>Inventarzaehler</span>
|
||||
<strong>{inventoryCountText}</strong>
|
||||
@@ -97,3 +82,42 @@ export function ScanSettingsModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperControl({ control }: { control: StepperControlModel }) {
|
||||
return (
|
||||
<section className="settings-stepper" aria-label={control.label}>
|
||||
<div className="settings-stepper-head">
|
||||
<div>
|
||||
<span>{control.label}</span>
|
||||
<small>{control.helper}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-stepper-row">
|
||||
<button type="button" className="stepper-button" onClick={control.decrement} aria-label={`${control.label} verringern`}>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
value={control.value}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
onKeyDown={control.onKeyDown}
|
||||
aria-label={control.label}
|
||||
/>
|
||||
<button type="button" className="stepper-button" onClick={control.increment} aria-label={`${control.label} erhoehen`}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-presets" aria-label={`${control.label} Presets`}>
|
||||
{control.presets.map((value) => (
|
||||
<button type="button" key={value} onClick={() => control.applyPreset(value)}>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,21 +33,24 @@ export function useScanDetailsModalModel({
|
||||
|
||||
const crops = latestCapture?.crops ?? [];
|
||||
const ocr = latestCapture?.ocr ?? [];
|
||||
const cropRows = crops
|
||||
.filter((crop) => Boolean(crop.dataUrl))
|
||||
.map((crop) => ({
|
||||
id: crop.id,
|
||||
dataUrl: crop.dataUrl ?? "",
|
||||
label: crop.label,
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
}));
|
||||
|
||||
return {
|
||||
closeDetails,
|
||||
stopPropagation,
|
||||
parsedNotes,
|
||||
showParsedNotes: parsedNotes.length > 0,
|
||||
cropRows: crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
dataUrl: crop.dataUrl,
|
||||
label: crop.label,
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
})),
|
||||
cropRows,
|
||||
ocrRows: ocr.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
@@ -55,7 +58,7 @@ export function useScanDetailsModalModel({
|
||||
text: entry.text || "No text detected",
|
||||
})),
|
||||
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
|
||||
showCrops: crops.length > 0,
|
||||
showCrops: cropRows.length > 0,
|
||||
showOcr: ocr.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
|
||||
import { dataGeneratedAt, sourceVersion } from "../../../../../lib/genshinData";
|
||||
import { dataPackageStatus } from "../../../../../lib/dataPackageStatus";
|
||||
import { validateLookupPackage } from "../../../../../lib/genshinLookup";
|
||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||
import type { ScanDiagnosticsModalProps } from "../types";
|
||||
import type { ScanDiagnosticEvent } from "../../../../../lib/scanDiagnosticsLog";
|
||||
|
||||
export interface ScanDiagnosticsModelProgress {
|
||||
width: number;
|
||||
@@ -37,6 +39,7 @@ export interface ScanDiagnosticsModalModel {
|
||||
showDevRows: boolean;
|
||||
reviewStatus: string;
|
||||
automationLogLines: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
canSaveReviewSample: boolean;
|
||||
}
|
||||
|
||||
@@ -98,7 +101,11 @@ export function useScanDiagnosticsModalModel({
|
||||
|
||||
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
|
||||
const dataStaleness = dataPackageStatus(dataGeneratedAt, sourceVersion);
|
||||
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
|
||||
const lookupStatus = validateLookupPackage();
|
||||
const lookupText = lookupStatus.valid
|
||||
? `Lookup OK: ${lookupStatus.summary.artifactSets} sets / ${lookupStatus.summary.artifactPieces} pieces`
|
||||
: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown error"}`;
|
||||
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. ${lookupText}. Data package: ${sourceVersion}${dataStaleness.warning ? ` - ${dataStaleness.warning}` : ""}`;
|
||||
|
||||
const playerProgress = useMemo(() => {
|
||||
const width = Math.min(
|
||||
@@ -128,6 +135,27 @@ export function useScanDiagnosticsModalModel({
|
||||
{ label: "duplicates", value: controller.autoScanStats.duplicates },
|
||||
{ label: "misses", value: controller.autoScanStats.misses },
|
||||
{ label: "pages", value: controller.autoScanStats.pages },
|
||||
{ label: "elapsedMs", value: controller.autoScanStats.elapsedMs },
|
||||
{ label: "activeScanMs", value: controller.autoScanStats.activeScanMs },
|
||||
{ label: "writeFlushMs", value: controller.autoScanStats.writeFlushMs },
|
||||
{ label: "avgMs", value: controller.autoScanStats.averageMsPerParsed },
|
||||
{ label: "activeAvgMs", value: controller.autoScanStats.activeAverageMsPerParsed },
|
||||
{ label: "avgCaptureMs", value: controller.autoScanStats.averageCaptureMs },
|
||||
{ label: "avgCaptureRoundTripMs", value: controller.autoScanStats.averageCaptureRoundTripMs },
|
||||
{ label: "avgCaptureRoundTripOverheadMs", value: controller.autoScanStats.averageCaptureRoundTripOverheadMs },
|
||||
{ label: "captureP50Ms", value: controller.autoScanStats.captureP50Ms },
|
||||
{ label: "captureP90Ms", value: controller.autoScanStats.captureP90Ms },
|
||||
{ label: "avgOcrMs", value: controller.autoScanStats.averageOcrMs },
|
||||
{ label: "ocrP50Ms", value: controller.autoScanStats.ocrP50Ms },
|
||||
{ label: "ocrP90Ms", value: controller.autoScanStats.ocrP90Ms },
|
||||
{ label: "cardReadyAvgMs", value: controller.autoScanStats.averageCardReadyMs },
|
||||
{ label: "cardReadyCount", value: controller.autoScanStats.cardReadyCount },
|
||||
{ label: "scrollReadyAvgMs", value: controller.autoScanStats.averageScrollReadyMs },
|
||||
{ label: "scrollReadyCount", value: controller.autoScanStats.scrollReadyCount },
|
||||
{ label: "perMin x10", value: Math.round(controller.autoScanStats.artifactsPerMinute * 10) },
|
||||
{ label: "activePerMin x10", value: Math.round(controller.autoScanStats.activeArtifactsPerMinute * 10) },
|
||||
{ label: "projected100Ms", value: controller.autoScanStats.projectedMsFor100 },
|
||||
{ label: "activeProjected100Ms", value: controller.autoScanStats.activeProjectedMsFor100 },
|
||||
],
|
||||
[
|
||||
controller.autoScanStats.clicked,
|
||||
@@ -139,6 +167,27 @@ export function useScanDiagnosticsModalModel({
|
||||
controller.autoScanStats.duplicates,
|
||||
controller.autoScanStats.misses,
|
||||
controller.autoScanStats.pages,
|
||||
controller.autoScanStats.elapsedMs,
|
||||
controller.autoScanStats.activeScanMs,
|
||||
controller.autoScanStats.writeFlushMs,
|
||||
controller.autoScanStats.averageMsPerParsed,
|
||||
controller.autoScanStats.activeAverageMsPerParsed,
|
||||
controller.autoScanStats.averageCaptureMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripMs,
|
||||
controller.autoScanStats.averageCaptureRoundTripOverheadMs,
|
||||
controller.autoScanStats.captureP50Ms,
|
||||
controller.autoScanStats.captureP90Ms,
|
||||
controller.autoScanStats.averageOcrMs,
|
||||
controller.autoScanStats.ocrP50Ms,
|
||||
controller.autoScanStats.ocrP90Ms,
|
||||
controller.autoScanStats.averageCardReadyMs,
|
||||
controller.autoScanStats.cardReadyCount,
|
||||
controller.autoScanStats.averageScrollReadyMs,
|
||||
controller.autoScanStats.scrollReadyCount,
|
||||
controller.autoScanStats.artifactsPerMinute,
|
||||
controller.autoScanStats.activeArtifactsPerMinute,
|
||||
controller.autoScanStats.projectedMsFor100,
|
||||
controller.autoScanStats.activeProjectedMsFor100,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -186,6 +235,7 @@ export function useScanDiagnosticsModalModel({
|
||||
showDevRows: controller.devMode,
|
||||
reviewStatus: controller.reviewStatus,
|
||||
automationLogLines: controller.automationLog,
|
||||
diagnosticEvents: controller.diagnosticEvents,
|
||||
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
||||
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
|
||||
|
||||
export interface StepperControlModel {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: string;
|
||||
min: number;
|
||||
max: number;
|
||||
presets: number[];
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur: (event: FocusEvent<HTMLInputElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
decrement: () => void;
|
||||
increment: () => void;
|
||||
applyPreset: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface ScanSettingsModalModel {
|
||||
closeSettings: () => void;
|
||||
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
scanLimitControl: StepperControlModel;
|
||||
skipRowsControl: StepperControlModel;
|
||||
inventoryCountText: string;
|
||||
inventoryClassName: string;
|
||||
scanLimitClassName: string;
|
||||
@@ -36,17 +51,71 @@ export function useScanSettingsModalModel({
|
||||
setScanLimitTouched: (touched: boolean) => void;
|
||||
setSkipRows: (rows: number) => void;
|
||||
}): ScanSettingsModalModel {
|
||||
const [scanLimitText, setScanLimitText] = useState(String(scanLimit));
|
||||
const [skipRowsText, setSkipRowsText] = useState(String(skipRows));
|
||||
|
||||
useEffect(() => {
|
||||
setScanLimitText(String(scanLimit));
|
||||
}, [scanLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
setSkipRowsText(String(skipRows));
|
||||
}, [skipRows]);
|
||||
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
||||
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setScanLimitTouched(true);
|
||||
setScanLimit(clampScanLimit(Number(event.target.value)));
|
||||
}, [setScanLimit, setScanLimitTouched]);
|
||||
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSkipRows(clampSkipRows(Number(event.target.value)));
|
||||
}, [setSkipRows]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const applyScanLimit = useCallback((value: number) => {
|
||||
const next = clampScanLimit(value);
|
||||
setScanLimitTouched(true);
|
||||
setScanLimit(next);
|
||||
setScanLimitText(String(next));
|
||||
}, [setScanLimit, setScanLimitTouched]);
|
||||
|
||||
const applySkipRows = useCallback((value: number) => {
|
||||
const next = clampSkipRows(value);
|
||||
setSkipRows(next);
|
||||
setSkipRowsText(String(next));
|
||||
}, [setSkipRows]);
|
||||
|
||||
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setScanLimitText(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
}, []);
|
||||
|
||||
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSkipRowsText(event.target.value.replace(/\D/g, "").slice(0, 2));
|
||||
}, []);
|
||||
|
||||
const commitScanLimit = useCallback((rawValue: string) => {
|
||||
applyScanLimit(rawValue.trim() === "" ? scanLimit : Number(rawValue));
|
||||
}, [applyScanLimit, scanLimit]);
|
||||
|
||||
const commitSkipRows = useCallback((rawValue: string) => {
|
||||
applySkipRows(rawValue.trim() === "" ? skipRows : Number(rawValue));
|
||||
}, [applySkipRows, skipRows]);
|
||||
|
||||
const handleScanLimitBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
|
||||
commitScanLimit(event.target.value);
|
||||
}, [commitScanLimit]);
|
||||
|
||||
const handleSkipRowsBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
|
||||
commitSkipRows(event.target.value);
|
||||
}, [commitSkipRows]);
|
||||
|
||||
const handleScanLimitKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
commitScanLimit(event.currentTarget.value);
|
||||
event.currentTarget.blur();
|
||||
}, [commitScanLimit]);
|
||||
|
||||
const handleSkipRowsKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
commitSkipRows(event.currentTarget.value);
|
||||
event.currentTarget.blur();
|
||||
}, [commitSkipRows]);
|
||||
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
|
||||
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
|
||||
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
|
||||
@@ -56,14 +125,40 @@ export function useScanSettingsModalModel({
|
||||
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
|
||||
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
|
||||
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
|
||||
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
|
||||
const activeTargetText = `${activeTargetCount} Ziele · ${focusModeText}`;
|
||||
const scanSummaryText = "Auto-Scan zaehlt Positionen, verifizierte Ansichten und gespeicherte Artifacts getrennt.";
|
||||
|
||||
return {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
scanLimitControl: {
|
||||
label: "Scan-Ziel",
|
||||
value: scanLimitText,
|
||||
helper: "Wie viele sichtbare Positionen verarbeitet werden.",
|
||||
min: 1,
|
||||
max: 1800,
|
||||
presets: [16, 20, 50, 100],
|
||||
onChange: handleScanLimitChange,
|
||||
onBlur: handleScanLimitBlur,
|
||||
onKeyDown: handleScanLimitKeyDown,
|
||||
decrement: () => applyScanLimit(scanLimit - 1),
|
||||
increment: () => applyScanLimit(scanLimit + 1),
|
||||
applyPreset: applyScanLimit,
|
||||
},
|
||||
skipRowsControl: {
|
||||
label: "Startzeilen ueberspringen",
|
||||
value: skipRowsText,
|
||||
helper: "Nur nutzen, wenn du mitten in der Liste beginnst.",
|
||||
min: 0,
|
||||
max: 8,
|
||||
presets: [0, 1, 2, 3],
|
||||
onChange: handleSkipRowsChange,
|
||||
onBlur: handleSkipRowsBlur,
|
||||
onKeyDown: handleSkipRowsKeyDown,
|
||||
decrement: () => applySkipRows(skipRows - 1),
|
||||
increment: () => applySkipRows(skipRows + 1),
|
||||
applyPreset: applySkipRows,
|
||||
},
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
|
||||
@@ -33,12 +33,15 @@ export interface ScanViewLayoutProps {
|
||||
bridgeReady: boolean;
|
||||
controller: ScanViewControllerResult;
|
||||
isScanning: boolean;
|
||||
onOpenInventory?: ScanViewProps["onOpenInventory"];
|
||||
}
|
||||
|
||||
export interface ScanMainSectionProps {
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
captureStatus: string;
|
||||
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
|
||||
recentArtifacts: ScanViewControllerResult["recentArtifacts"];
|
||||
nativeScanResults: ScanViewControllerResult["nativeScanResults"];
|
||||
sourceLabel: string;
|
||||
gridLabel: string;
|
||||
inventoryLabel: string;
|
||||
@@ -51,6 +54,7 @@ export interface ScanMainSectionProps {
|
||||
autoScanRunning: boolean;
|
||||
openReviewQueue: () => void;
|
||||
setDetailsOpen: (value: boolean) => void;
|
||||
openInventory?: ScanViewProps["onOpenInventory"];
|
||||
}
|
||||
|
||||
export interface ScanTopControlsSectionProps {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { ScanActionContext } from "./scanViewScanActions";
|
||||
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type {
|
||||
ReviewStateContext,
|
||||
} from "./scanViewReviewHelpers";
|
||||
@@ -42,6 +43,7 @@ export interface ScanActionContextInput {
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (
|
||||
capture: CaptureResult | null,
|
||||
@@ -49,6 +51,14 @@ export interface ScanActionContextInput {
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
) => Promise<number>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -60,7 +70,11 @@ export interface ScanActionContextInput {
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (
|
||||
delayMs?: number,
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult | null>;
|
||||
}
|
||||
|
||||
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
|
||||
@@ -98,8 +112,10 @@ export function createScanActionContext(input: ScanActionContextInput): ScanActi
|
||||
setReviewStatus: input.setReviewStatus,
|
||||
appendAutomationLog: input.appendAutomationLog,
|
||||
appendClickDiagnostics: input.appendClickDiagnostics,
|
||||
appendDiagnosticEvent: input.appendDiagnosticEvent,
|
||||
parseArtifact: input.parseArtifact,
|
||||
persistParsedArtifact: input.persistParsedArtifact,
|
||||
persistParsedArtifactsBatch: input.persistParsedArtifactsBatch,
|
||||
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
|
||||
saveReviewSample: input.saveReviewSample,
|
||||
focusDashboard: input.focusDashboard,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
artifactTabClickTarget,
|
||||
keyPressBlocked,
|
||||
validateAutoScanEntryPreflight,
|
||||
type ScanEntryMode,
|
||||
} from "../../../lib/autoScanEntry";
|
||||
import { wait } from "../../../lib/scanReviewUtils";
|
||||
import {
|
||||
summarizeClickResult,
|
||||
summarizeKeyPressResult,
|
||||
type createScanDiagnosticEvent,
|
||||
} from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||
|
||||
export interface PrepareAutoScanEntryInput {
|
||||
mode: ScanEntryMode;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}
|
||||
|
||||
export async function prepareAutoScanEntry({
|
||||
mode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: PrepareAutoScanEntryInput) {
|
||||
if (mode === "visible-inventory") {
|
||||
const capture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (candidate) => validateAutoScanEntryPreflight(candidate).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-visible",
|
||||
severity: capture ? "ok" : "error",
|
||||
message: capture && validateAutoScanEntryPreflight(capture).ok
|
||||
? "Visible inventory preflight capture ready."
|
||||
: "Visible inventory preflight capture failed.",
|
||||
capture,
|
||||
});
|
||||
return capture;
|
||||
}
|
||||
|
||||
if (mode === "direct-inventory") {
|
||||
return tryInventoryEntrySequence({
|
||||
label: "direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "auto-entry") {
|
||||
const directCapture = await tryInventoryEntrySequence({
|
||||
label: "auto-direct",
|
||||
sendEscapeFirst: false,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const directPreflight = validateAutoScanEntryPreflight(directCapture);
|
||||
if (directPreflight.ok) return directCapture;
|
||||
appendAutomationLog(`auto-entry direct path failed: ${directPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-fallback",
|
||||
severity: "info",
|
||||
message: `Direct inventory entry did not reach an artifact detail card. Trying ESC/B fallback. ${directPreflight.reason}`,
|
||||
capture: directCapture,
|
||||
});
|
||||
}
|
||||
|
||||
return tryInventoryEntrySequence({
|
||||
label: "paimon",
|
||||
sendEscapeFirst: true,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
}
|
||||
|
||||
async function tryInventoryEntrySequence({
|
||||
label,
|
||||
sendEscapeFirst,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
}: {
|
||||
label: string;
|
||||
sendEscapeFirst: boolean;
|
||||
automationRepo: AutomationRepositoryPort;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
}) {
|
||||
if (sendEscapeFirst) {
|
||||
const escapeResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC: ${escapeResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(escapeResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC`,
|
||||
details: summarizeKeyPressResult(escapeResult),
|
||||
});
|
||||
if (keyPressBlocked(escapeResult)) return null;
|
||||
const menuProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: menuProbe ? "info" : "warn",
|
||||
message: `${label} capture after ESC step.`,
|
||||
capture: menuProbe,
|
||||
});
|
||||
if (menuProbe?.paimonMenu?.present) {
|
||||
const closeMenuResult = await automationRepo.keyPress?.("ESC");
|
||||
appendAutomationLog(`${label} entry key ESC close menu: ${closeMenuResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(closeMenuResult) ? "error" : "ok",
|
||||
message: `${label} entry key ESC close menu`,
|
||||
details: summarizeKeyPressResult(closeMenuResult),
|
||||
});
|
||||
if (keyPressBlocked(closeMenuResult)) return menuProbe;
|
||||
const worldProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 750,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: worldProbe ? "info" : "warn",
|
||||
message: `${label} capture after closing Paimon menu.`,
|
||||
capture: worldProbe,
|
||||
});
|
||||
if (worldProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after second ESC`);
|
||||
return worldProbe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inventoryResult = await automationRepo.keyPress?.("B");
|
||||
appendAutomationLog(`${label} entry key B: ${inventoryResult?.ok ? "ok" : "blocked"}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-key",
|
||||
severity: keyPressBlocked(inventoryResult) ? "error" : "ok",
|
||||
message: `${label} entry key B`,
|
||||
details: summarizeKeyPressResult(inventoryResult),
|
||||
});
|
||||
if (keyPressBlocked(inventoryResult)) return null;
|
||||
const tabProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 1200,
|
||||
predicate: (capture) => Boolean(capture && !capture.paimonMenu?.present && capture.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: tabProbe ? "info" : "warn",
|
||||
message: `${label} capture after Inventory-key step.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (tabProbe?.paimonMenu?.present) {
|
||||
appendAutomationLog(`${label} entry stopped: Paimon menu still visible after Inventory key`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: "info",
|
||||
message: `${label} entry stopped before tab click because Paimon menu is still visible.`,
|
||||
capture: tabProbe,
|
||||
});
|
||||
return tabProbe;
|
||||
}
|
||||
if (!tabProbe?.inventoryGrid || tabProbe.inventoryGrid.source === "missing") return tabProbe;
|
||||
|
||||
const target = artifactTabClickTarget(tabProbe);
|
||||
appendAutomationLog(`${label} entry artifact tab -> ${target.x},${target.y}`);
|
||||
const click = await automationRepo.clickScreen(target.x, target.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: click.inputBlocked || click.clicked === false || click.moved === false ? "warn" : "ok",
|
||||
message: `${label} artifact tab click at ${target.x},${target.y}`,
|
||||
details: summarizeClickResult(click),
|
||||
capture: tabProbe,
|
||||
});
|
||||
if (click.inputBlocked || click.clicked === false) return null;
|
||||
const gridProbe = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => Boolean(capture?.inventoryGrid && capture.inventoryGrid.source !== "missing"),
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: gridProbe ? "info" : "warn",
|
||||
message: `${label} capture after artifact-tab click before first tile selection.`,
|
||||
capture: gridProbe,
|
||||
});
|
||||
const firstTarget = gridProbe?.inventoryGrid?.centers?.[0];
|
||||
if (!firstTarget) return gridProbe;
|
||||
appendAutomationLog(`${label} entry first artifact tile -> ${firstTarget.x},${firstTarget.y}`);
|
||||
const firstTileClick = await automationRepo.clickScreen(firstTarget.x, firstTarget.y);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-click",
|
||||
severity: firstTileClick.inputBlocked || firstTileClick.clicked === false || firstTileClick.moved === false ? "warn" : "ok",
|
||||
message: `${label} first artifact tile click at ${firstTarget.x},${firstTarget.y}`,
|
||||
details: summarizeClickResult(firstTileClick),
|
||||
capture: gridProbe,
|
||||
});
|
||||
if (firstTileClick.inputBlocked || firstTileClick.clicked === false) return null;
|
||||
const finalCapture = await waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs: 900,
|
||||
predicate: (capture) => validateAutoScanEntryPreflight(capture).ok,
|
||||
});
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-capture",
|
||||
severity: finalCapture ? "info" : "warn",
|
||||
message: `${label} final capture after first artifact selection.`,
|
||||
capture: finalCapture,
|
||||
});
|
||||
return finalCapture;
|
||||
}
|
||||
|
||||
async function waitForEntryCapture({
|
||||
captureFastSelectedSource,
|
||||
timeoutMs,
|
||||
pollMs = 150,
|
||||
predicate,
|
||||
}: {
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
timeoutMs: number;
|
||||
pollMs?: number;
|
||||
predicate: (capture: CaptureResult | null) => boolean;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let latest: CaptureResult | null = null;
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
latest = await captureFastSelectedSource(0, true);
|
||||
if (predicate(latest)) return latest;
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) break;
|
||||
await wait(Math.min(pollMs, remaining));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, inco
|
||||
}
|
||||
|
||||
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
|
||||
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||
return mergeLearningRulePayloads({}, loadedRules?.rules);
|
||||
}
|
||||
|
||||
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
|
||||
@@ -213,17 +213,47 @@ export async function mergeLearningRules(
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
|
||||
const merged = {
|
||||
textReplacements: {
|
||||
...currentRules.textReplacements,
|
||||
...(nextRules.textReplacements ?? {}),
|
||||
},
|
||||
};
|
||||
const merged = mergeLearningRulePayloads(currentRules, nextRules);
|
||||
context.setScannerLearningRules(merged);
|
||||
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||
return { result, merged };
|
||||
}
|
||||
|
||||
function mergeLearningRulePayloads(
|
||||
current: Partial<ScannerLearningRules> | null | undefined,
|
||||
next: Partial<ScannerLearningRules> | null | undefined,
|
||||
): ScannerLearningRules {
|
||||
return {
|
||||
textReplacements: {
|
||||
...(current?.textReplacements ?? {}),
|
||||
...(next?.textReplacements ?? {}),
|
||||
},
|
||||
fieldAliases: mergeNestedRuleMap(current?.fieldAliases, next?.fieldAliases),
|
||||
constrainedFixes: {
|
||||
...(current?.constrainedFixes ?? {}),
|
||||
...(next?.constrainedFixes ?? {}),
|
||||
},
|
||||
cropAdjustments: {
|
||||
...(current?.cropAdjustments ?? {}),
|
||||
...(next?.cropAdjustments ?? {}),
|
||||
},
|
||||
uiProfileAdjustments: {
|
||||
...(current?.uiProfileAdjustments ?? {}),
|
||||
...(next?.uiProfileAdjustments ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNestedRuleMap(
|
||||
current: Record<string, Record<string, string>> | undefined,
|
||||
next: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, aliases] of Object.entries(current ?? {})) merged[field] = { ...(aliases ?? {}) };
|
||||
for (const [field, aliases] of Object.entries(next ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(aliases ?? {}) };
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function persistParsedArtifact(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
@@ -256,6 +286,47 @@ export async function persistParsedArtifact(
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistParsedArtifactsBatch(
|
||||
items: Array<{
|
||||
capture: CaptureResult | null;
|
||||
parsed: ParsedArtifactCandidate;
|
||||
source: string;
|
||||
needsReview: boolean;
|
||||
}>,
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||
if (!artifactRepo?.saveMany || items.length === 0) return 0;
|
||||
|
||||
const records: StoredArtifactRecord[] = [];
|
||||
for (const item of items) {
|
||||
const rejection = captureRejectionReason(item.capture, item.parsed);
|
||||
if (rejection) {
|
||||
appendAutomationLog(`persist skip: ${rejection}`);
|
||||
continue;
|
||||
}
|
||||
if (!shouldPersistParsedArtifact(item.parsed, item.needsReview)) {
|
||||
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${item.parsed.name})`);
|
||||
continue;
|
||||
}
|
||||
records.push(toStoredArtifact(item.parsed, item.source, item.needsReview, item.capture?.locked));
|
||||
}
|
||||
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
return records.length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveReviewSample(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
@@ -277,29 +348,52 @@ export async function saveReviewSample(
|
||||
}
|
||||
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||
|
||||
const compactAutomaticSample = /^automatic:/i.test(reason);
|
||||
const sampleCapture = compactAutomaticSample
|
||||
? {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
}
|
||||
: {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
dataUrl: capture.dataUrl,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
};
|
||||
|
||||
const result = await reviewSamplesRepo.saveSample({
|
||||
reason,
|
||||
capture: {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
dataUrl: capture.dataUrl,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
locked: capture.locked,
|
||||
ocr: capture.ocr,
|
||||
},
|
||||
capture: sampleCapture,
|
||||
parsed,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||
import { validateAutoScanEntryPreflight, type ScanEntryMode } from "../../../lib/autoScanEntry";
|
||||
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
|
||||
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { addCaptureTiming, clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import { type createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type {
|
||||
AutomationGuard,
|
||||
BooleanResult,
|
||||
@@ -15,8 +17,8 @@ import type {
|
||||
ScrollResult,
|
||||
} from "../../../types/global";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { prepareAutoScanEntry } from "./scanViewEntryActions";
|
||||
|
||||
export interface ScanActionContext {
|
||||
autoScanRunning: boolean;
|
||||
@@ -36,10 +38,12 @@ export interface ScanActionContext {
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
|
||||
persistParsedArtifactsBatch?: (items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>) => Promise<number>;
|
||||
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
|
||||
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
|
||||
focusDashboard: () => Promise<void>;
|
||||
@@ -47,6 +51,8 @@ export interface ScanActionContext {
|
||||
|
||||
export interface VisibleGridScanOptions {
|
||||
scanLimit?: number;
|
||||
scanEntryMode?: ScanEntryMode;
|
||||
processInitialSelection?: boolean;
|
||||
}
|
||||
|
||||
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||
@@ -68,6 +74,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
captureSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
focusDashboard,
|
||||
@@ -83,6 +90,11 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
|
||||
const startedAt = Date.now();
|
||||
const updateManualStats = () => {
|
||||
updateScanTiming(stats, startedAt);
|
||||
setAutoScanStats({ ...stats });
|
||||
};
|
||||
let idleTicks = 0;
|
||||
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
const maxIdleTicks = 90;
|
||||
@@ -96,7 +108,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
if (capture && rejection) {
|
||||
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
|
||||
stats.review++;
|
||||
setAutoScanStats({ ...stats });
|
||||
updateManualStats();
|
||||
}
|
||||
idleTicks++;
|
||||
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
|
||||
@@ -117,6 +129,7 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
stats.attempted++;
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
addCaptureTiming(stats, capture.timings, capture.elapsedMs);
|
||||
|
||||
const reason = getAutoReviewReason(capture, parsed);
|
||||
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||
@@ -127,12 +140,13 @@ export async function runAutoReviewScan(context: ScanActionContext): Promise<voi
|
||||
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
|
||||
stats.stored++;
|
||||
}
|
||||
setAutoScanStats({ ...stats });
|
||||
updateManualStats();
|
||||
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
|
||||
await wait(700);
|
||||
}
|
||||
|
||||
setAutoScanRunning(false);
|
||||
updateScanTiming(stats, startedAt);
|
||||
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
|
||||
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
|
||||
await focusDashboard();
|
||||
@@ -165,10 +179,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
scanLimit: configuredScanLimit,
|
||||
@@ -177,8 +193,13 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
focusDashboard,
|
||||
} = context;
|
||||
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
|
||||
const scanEntryMode = options.scanEntryMode ?? "visible-inventory";
|
||||
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||
if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) {
|
||||
setReviewStatus("Auto-Scan-Einstieg ist nicht verfuegbar: Keypress-Bridge fehlt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||
if (requiresAdminForAutoScan) {
|
||||
@@ -200,6 +221,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setScanSummary(null);
|
||||
setAutoScanStats(emptyAutoScanStats);
|
||||
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
appendDiagnosticEvent({
|
||||
phase: "scan-start",
|
||||
severity: "info",
|
||||
message: `Auto-scan start requested (${scanEntryMode})`,
|
||||
details: { scanLimit, skipRows, detectedInventoryCount },
|
||||
});
|
||||
|
||||
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
|
||||
const adminBlockReason = automationBlockReason(freshRuntime);
|
||||
@@ -207,6 +234,12 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(adminBlockReason);
|
||||
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
|
||||
appendDiagnosticEvent({
|
||||
phase: "preflight",
|
||||
severity: "error",
|
||||
message: adminBlockReason,
|
||||
details: { elevated: freshRuntime?.isElevated, genshinFound: freshRuntime?.genshinFound },
|
||||
});
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
@@ -220,6 +253,17 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
if (freshRuntime) {
|
||||
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
|
||||
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "runtime",
|
||||
severity: freshRuntime.genshinFound ? "ok" : "warn",
|
||||
message: `Runtime ping: ${required}`,
|
||||
details: {
|
||||
elevated: freshRuntime.isElevated,
|
||||
foreground: freshRuntime.foregroundProcess,
|
||||
target: freshRuntime.targetProcess,
|
||||
helperPid: freshRuntime.helperPid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const focusGenshinForScanStart = automationRepo.focusGenshinForScanStart ?? automationRepo.focusGenshin;
|
||||
@@ -234,6 +278,18 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
appendAutomationLog(
|
||||
`focus attempt ${attempt}/3: ${current.focused ? "ok" : "failed"} found:${current.genshinFound ? "yes" : "no"} setForeground:${current.setForegroundResult ?? "n/a"} target:${current.targetProcess || "?"} fg:${current.foregroundProcess || "?"}`,
|
||||
);
|
||||
appendDiagnosticEvent({
|
||||
phase: "focus",
|
||||
severity: current.focused ? "ok" : "warn",
|
||||
message: `Focus attempt ${attempt}/3 ${current.focused ? "succeeded" : "failed"}`,
|
||||
details: {
|
||||
found: current.genshinFound,
|
||||
setForeground: current.setForegroundResult,
|
||||
target: current.targetProcess,
|
||||
foreground: current.foregroundProcess,
|
||||
alreadyForeground: current.alreadyForeground,
|
||||
},
|
||||
});
|
||||
if (current.focused) break;
|
||||
if (!current.genshinFound) break;
|
||||
}
|
||||
@@ -246,6 +302,16 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
|
||||
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
|
||||
setReviewStatus(reason);
|
||||
appendDiagnosticEvent({
|
||||
phase: "focus",
|
||||
severity: "error",
|
||||
message: reason,
|
||||
details: {
|
||||
found: focusResult?.genshinFound,
|
||||
target: focusResult?.targetProcess,
|
||||
foreground: focusResult?.foregroundProcess,
|
||||
},
|
||||
});
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
@@ -256,7 +322,46 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
setReviewStatus(scanEntryMode !== "visible-inventory"
|
||||
? "Genshin ist im Vordergrund. Oeffne Artifact-Inventar und warte auf Detailkarte..."
|
||||
: "Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
|
||||
const entryCapture = await prepareAutoScanEntry({
|
||||
mode: scanEntryMode,
|
||||
automationRepo,
|
||||
captureFastSelectedSource,
|
||||
appendAutomationLog,
|
||||
appendDiagnosticEvent,
|
||||
});
|
||||
const entryPreflight = validateAutoScanEntryPreflight(entryCapture);
|
||||
if (!entryPreflight.ok) {
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(entryPreflight.reason);
|
||||
appendAutomationLog(`entry blocked: ${entryPreflight.reason}`);
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-preflight",
|
||||
severity: "error",
|
||||
message: entryPreflight.reason,
|
||||
capture: entryCapture,
|
||||
});
|
||||
setScanSummary({
|
||||
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||
gridLabel: entryPreflight.reason,
|
||||
});
|
||||
await focusDashboard();
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewStatus("Artifact-Inventar ist bereit. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
appendDiagnosticEvent({
|
||||
phase: "entry-preflight",
|
||||
severity: "ok",
|
||||
message: "Artifact inventory preflight passed.",
|
||||
capture: entryCapture,
|
||||
});
|
||||
|
||||
const result = await runAutoScanLoop(
|
||||
{
|
||||
@@ -285,10 +390,11 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
automationRepo?.getAutomationGuard?.() ??
|
||||
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, options),
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
persistParsedArtifactsBatch,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
@@ -302,6 +408,8 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
|
||||
skipInitialGridTarget: scanEntryMode !== "visible-inventory",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -310,7 +418,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
|
||||
await focusDashboard();
|
||||
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
|
||||
status: result.status,
|
||||
...result.stats,
|
||||
targetCount: result.targetCount,
|
||||
|
||||
@@ -9,6 +9,7 @@ interface ScanCommandListenerInput {
|
||||
isScanning: boolean;
|
||||
selectedSourceId: string;
|
||||
requestScanStop: (reason: string) => void;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export function useScanCommandListener({
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
}: ScanCommandListenerInput) {
|
||||
useEffect(() => {
|
||||
@@ -28,10 +30,14 @@ export function useScanCommandListener({
|
||||
return;
|
||||
}
|
||||
const commandType = typeof command === "string" ? command : command.type;
|
||||
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||
const options = typeof command === "string" ? undefined : { scanLimit: command.scanLimit };
|
||||
void runVisibleGridScan(options);
|
||||
const canStartNativeScan = Boolean(automationRepo.nativeScannerStart);
|
||||
if (commandType === "start-auto" && !autoScanRunning && !isScanning && (selectedSourceId || canStartNativeScan)) {
|
||||
if (typeof command === "string" || !command.scanEntryMode) {
|
||||
void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit });
|
||||
return;
|
||||
}
|
||||
void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode });
|
||||
}
|
||||
});
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import type { ArtifactRepositoryPort, ScanExportPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
|
||||
interface UseScanGoodInteropInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
exportRepo?: ScanExportPort;
|
||||
onStoredArtifactsChanged?: () => Promise<void>;
|
||||
setStoredTotal: (value: number | null) => void;
|
||||
bridgeReady: boolean;
|
||||
}
|
||||
|
||||
export function useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
}: UseScanGoodInteropInput) {
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged, setStoredTotal]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
|
||||
return {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSessio
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import { validateLookupPackage } from "../../../lib/genshinLookup";
|
||||
import type { ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
|
||||
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
|
||||
|
||||
@@ -17,6 +19,7 @@ interface ScanSnapshotPublisherInput {
|
||||
snapshot: AppSnapshot;
|
||||
latestInventoryGrid: InventoryGrid | null | undefined;
|
||||
automationLog: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
storedTotal: number | null;
|
||||
learningRuleCount: number;
|
||||
@@ -33,6 +36,7 @@ export function useScanSnapshotPublisher({
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
@@ -52,9 +56,33 @@ export function useScanSnapshotPublisher({
|
||||
snapshotBuilds: snapshot.builds.length,
|
||||
grid: latestInventoryGrid ?? null,
|
||||
automationLog: automationLog.slice(-12),
|
||||
diagnosticEvents: diagnosticEvents.slice(-8).map((event) => ({
|
||||
...event,
|
||||
capture: event.capture
|
||||
? {
|
||||
...event.capture,
|
||||
screenshots: event.capture.screenshots
|
||||
? {
|
||||
detail: event.capture.screenshots.detail ? "[detail screenshot available in Diagnose]" : undefined,
|
||||
inventory: event.capture.screenshots.inventory ? "[inventory screenshot available in Diagnose]" : undefined,
|
||||
full: event.capture.screenshots.full ? "[full screenshot omitted]" : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
lookupStatus: validateLookupPackage(),
|
||||
ocrEngine: "current",
|
||||
entryMode: scanSummary?.mode.includes("auto-entry")
|
||||
? "auto-entry"
|
||||
: scanSummary?.mode.includes("direct-inventory")
|
||||
? "direct-inventory"
|
||||
: scanSummary?.mode.includes("paimon-menu")
|
||||
? "paimon-menu"
|
||||
: "visible-inventory",
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).catch(() => undefined);
|
||||
}, [
|
||||
@@ -67,6 +95,7 @@ export function useScanSnapshotPublisher({
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction, type VisibleGridScanOptions } from "./scanViewScanActions";
|
||||
import {
|
||||
initializeLearningState,
|
||||
loadReviewQueue as loadReviewQueueFromRepo,
|
||||
persistParsedArtifact as persistParsedArtifactHelper,
|
||||
persistParsedArtifactsBatch as persistParsedArtifactsBatchHelper,
|
||||
saveReviewSample as saveReviewSampleHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
|
||||
import { useScanCommandListener } from "./useScanCommandListener";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, NativeScannerPreflightStatus, NativeScannerRunStatus, ReviewSampleRecord } from "../../../types/global";
|
||||
import type {
|
||||
ArtifactRepositoryPort,
|
||||
ReviewSampleRepositoryPort,
|
||||
@@ -19,8 +20,12 @@ import type {
|
||||
RuntimeRepositoryPort,
|
||||
AutomationRepositoryPort,
|
||||
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { StoredScanResultEntry } from "../../../types/storage";
|
||||
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry";
|
||||
import { wait } from "../../../lib/scanReviewUtils";
|
||||
|
||||
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
|
||||
type NumberSetter = Dispatch<SetStateAction<number>>;
|
||||
@@ -30,6 +35,64 @@ type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
|
||||
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>;
|
||||
type NativeScanResultsSetter = Dispatch<SetStateAction<StoredScanResultEntry[]>>;
|
||||
|
||||
function nativeScanSummaryStatus(status: NativeScannerRunStatus["status"]): ScanSummary["status"] {
|
||||
if (status === "stopped") return "stopped";
|
||||
if (status === "blocked") return "blocked";
|
||||
return "done";
|
||||
}
|
||||
|
||||
function nativeScannerStats(status: NativeScannerRunStatus): AutoScanStats {
|
||||
const captured = Math.max(0, Math.round(status.captured ?? 0));
|
||||
const activeMs = Math.max(0, Math.round(status.activeMs ?? 0));
|
||||
const averageMsPerCaptured = captured > 0 ? Math.round(activeMs / captured) : 0;
|
||||
return {
|
||||
...emptyAutoScanStats,
|
||||
clicked: Math.max(0, Math.round(status.clicked ?? captured)),
|
||||
attempted: captured,
|
||||
verified: captured,
|
||||
pages: Math.max(0, Math.round(status.pages ?? 0)),
|
||||
elapsedMs: activeMs,
|
||||
activeScanMs: activeMs,
|
||||
captureMs: activeMs,
|
||||
captureRoundTripMs: activeMs,
|
||||
averageCaptureMs: averageMsPerCaptured,
|
||||
averageCaptureRoundTripMs: averageMsPerCaptured,
|
||||
captureP50Ms: averageMsPerCaptured,
|
||||
captureP90Ms: averageMsPerCaptured,
|
||||
cardReadyMs: activeMs,
|
||||
cardReadyCount: captured,
|
||||
averageCardReadyMs: averageMsPerCaptured,
|
||||
artifactsPerMinute: activeMs > 0 && captured > 0 ? Math.round((captured * 60000 / activeMs) * 10) / 10 : 0,
|
||||
activeArtifactsPerMinute: activeMs > 0 && captured > 0 ? Math.round((captured * 60000 / activeMs) * 10) / 10 : 0,
|
||||
projectedMsFor100: averageMsPerCaptured > 0 ? averageMsPerCaptured * 100 : 0,
|
||||
activeProjectedMsFor100: averageMsPerCaptured > 0 ? averageMsPerCaptured * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function nativePreflightBlockReason(preflight: NativeScannerPreflightStatus) {
|
||||
if (preflight.blockReason) {
|
||||
return preflight.blockReason;
|
||||
}
|
||||
if (preflight.categoryReady === false) {
|
||||
return `Native IK-Erfassung fuer Kategorie '${preflight.category ?? "unbekannt"}' ist noch nicht implementiert.`;
|
||||
}
|
||||
if (!preflight.data.valid) {
|
||||
return `IK inventorylists fehlen oder sind unvollstaendig: ${preflight.data.missing.join(", ") || "unbekannt"}.`;
|
||||
}
|
||||
if (!preflight.genshinFound) {
|
||||
return "Genshin-Fenster wurde nicht gefunden.";
|
||||
}
|
||||
if (!preflight.isSixteenNine) {
|
||||
const size = preflight.bounds ? `${preflight.bounds.width}x${preflight.bounds.height}` : "unbekannt";
|
||||
return `Genshin-Layout ${size} ist nicht 16:9. Native IK-Erfassung ist aktuell auf 16:9 begrenzt.`;
|
||||
}
|
||||
if (preflight.grid.count < 32) {
|
||||
return `Native Grid-Erkennung ist unvollstaendig (${preflight.grid.count}/32 Ziele).`;
|
||||
}
|
||||
return "Native IK-Erfassung ist noch nicht bereit.";
|
||||
}
|
||||
|
||||
interface ScanViewActionInput {
|
||||
autoScanRunning: boolean;
|
||||
@@ -49,6 +112,7 @@ interface ScanViewActionInput {
|
||||
setReviewStatus: StringSetter;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
appendDiagnosticEvent: (event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||
@@ -59,6 +123,7 @@ interface ScanViewActionInput {
|
||||
setLearningRulesLoaded: BooleanSetter;
|
||||
setScannerLearningRules: ScannerRulesSetter;
|
||||
setStoredTotal: NumberOrNullSetter;
|
||||
setNativeScanResults: NativeScanResultsSetter;
|
||||
scannerLearningRules: ScannerLearningRules;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
latestCapture: CaptureResult | null;
|
||||
@@ -77,6 +142,7 @@ export interface ScanViewActionResult {
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
|
||||
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -99,6 +165,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
@@ -109,6 +176,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setStoredTotal,
|
||||
setNativeScanResults,
|
||||
scannerLearningRules,
|
||||
captureSelectedSource,
|
||||
latestCapture,
|
||||
@@ -116,12 +184,20 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
canCaptureSource,
|
||||
setReviewQueueOpen,
|
||||
} = input;
|
||||
const learningInitializedRef = useRef(false);
|
||||
|
||||
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
|
||||
stopVisibleScanRef.current = true;
|
||||
void automationRepo?.nativeScannerStop?.()
|
||||
.then((status) => {
|
||||
if (status.message) appendAutomationLog(`native stop: ${status.message}`);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort stop; the existing renderer loop also watches the ref.
|
||||
});
|
||||
appendAutomationLog(`stop requested: ${reason}`);
|
||||
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
|
||||
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]);
|
||||
}, [appendAutomationLog, automationRepo, setReviewStatus, stopVisibleScanRef]);
|
||||
|
||||
const reviewContext = useMemo(
|
||||
() =>
|
||||
@@ -154,8 +230,11 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (learningInitializedRef.current) return;
|
||||
if (!learningRepo && !reviewSamplesRepo && !artifactRepo) return;
|
||||
learningInitializedRef.current = true;
|
||||
void initializeLearningState(reviewContext);
|
||||
}, [reviewContext]);
|
||||
}, [artifactRepo, learningRepo, reviewContext, reviewSamplesRepo]);
|
||||
|
||||
const focusDashboard = useCallback(async () => {
|
||||
try {
|
||||
@@ -177,6 +256,15 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const parseArtifactsAndPersistBatch = useCallback(
|
||||
async function parseArtifactsAndPersistBatch(
|
||||
items: Array<{ capture: CaptureResult | null; parsed: ParsedArtifactCandidate; source: string; needsReview: boolean }>,
|
||||
) {
|
||||
return persistParsedArtifactsBatchHelper(items, reviewContext);
|
||||
},
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const handleSaveReviewSample = useCallback(
|
||||
async function handleSaveReviewSample(
|
||||
capture: CaptureResult | null = latestCapture,
|
||||
@@ -215,12 +303,26 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
persistParsedArtifact: parseArtifactAndPersist,
|
||||
persistParsedArtifactsBatch: parseArtifactsAndPersistBatch,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
|
||||
ocrMode: "artifact",
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
...options,
|
||||
}),
|
||||
captureFastSelectedSource: (delayMs = 0, focusGenshin = false, options) => captureSelectedSource(delayMs, focusGenshin, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
...options,
|
||||
}),
|
||||
}),
|
||||
[
|
||||
autoScanRunning,
|
||||
@@ -240,8 +342,10 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
parseArtifactAndPersist,
|
||||
parseArtifactsAndPersistBatch,
|
||||
handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
@@ -276,12 +380,183 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
scanActionContext,
|
||||
]);
|
||||
|
||||
const runGuidedAutoScan = useCallback(async (options: { scanLimit?: number } = {}) => {
|
||||
if (autoScanRunning || !bridgeReady || !automationRepo) {
|
||||
return;
|
||||
}
|
||||
if (automationRepo.nativeScannerPreflight && automationRepo.nativeScannerStart && automationRepo.nativeScannerStatus && automationRepo.nativeScannerStop) {
|
||||
const targetCount = resolveScanTargetCount(clampScanLimit(options.scanLimit ?? scanLimit), detectedInventoryCount);
|
||||
setAutoScanRunning(true);
|
||||
stopVisibleScanRef.current = false;
|
||||
setScanSummary(null);
|
||||
setAutoScanStats(emptyAutoScanStats);
|
||||
setNativeScanResults([]);
|
||||
setReviewStatus("Native IK-Erfassung prueft Daten, Genshin-Fenster und 16:9-Grid...");
|
||||
|
||||
const preflight = await automationRepo.nativeScannerPreflight({ category: "artifacts" });
|
||||
appendAutomationLog(
|
||||
`native preflight: category=${preflight.category ?? "artifacts"} data=${preflight.data.valid ? "ok" : "missing"} version=${preflight.data.version || "?"} genshin=${preflight.genshinFound ? "yes" : "no"} grid=${preflight.grid.count}/32 visual=${preflight.visual?.ready === false ? "blocked" : "ok"}`,
|
||||
);
|
||||
appendDiagnosticEvent({
|
||||
phase: "native-preflight",
|
||||
severity: preflight.ready ? "ok" : "error",
|
||||
message: preflight.ready ? "Native IK preflight passed." : nativePreflightBlockReason(preflight),
|
||||
details: {
|
||||
dataValid: preflight.data.valid,
|
||||
version: preflight.data.version,
|
||||
artifactSets: preflight.data.artifactSets,
|
||||
artifactPieces: preflight.data.artifactPieces,
|
||||
artifactEntries: preflight.data.artifactSets + preflight.data.artifactPieces,
|
||||
category: preflight.category,
|
||||
categoryReady: preflight.categoryReady,
|
||||
genshinFound: preflight.genshinFound,
|
||||
width: preflight.bounds?.width,
|
||||
height: preflight.bounds?.height,
|
||||
isSixteenNine: preflight.isSixteenNine,
|
||||
gridTargets: preflight.grid.count,
|
||||
visualReady: preflight.visual?.ready,
|
||||
visualWhitePct: preflight.visual?.whitePct,
|
||||
visualDarkPct: preflight.visual?.darkPct,
|
||||
visualColorPct: preflight.visual?.colorPct,
|
||||
visualLumaStdDev: preflight.visual?.lumaStdDev,
|
||||
},
|
||||
});
|
||||
|
||||
if (!preflight.ready) {
|
||||
const reason = nativePreflightBlockReason(preflight);
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(reason);
|
||||
setScanSummary({
|
||||
mode: "Native IK-Erfassung",
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount,
|
||||
gridLabel: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let status = await automationRepo.nativeScannerStart({ limit: targetCount, category: "artifacts" });
|
||||
const applyNativeStatus = (nextStatus: NativeScannerRunStatus) => {
|
||||
const stats = nativeScannerStats(nextStatus);
|
||||
setAutoScanStats(stats);
|
||||
setReviewStatus(
|
||||
`Native IK-Erfassung: ${nextStatus.captured}/${nextStatus.target || targetCount} Karten-Crops erfasst, ${nextStatus.pages || 1} Seite(n). ${nextStatus.message}`,
|
||||
);
|
||||
return stats;
|
||||
};
|
||||
|
||||
applyNativeStatus(status);
|
||||
while (status.running && !stopVisibleScanRef.current) {
|
||||
await wait(350);
|
||||
status = await automationRepo.nativeScannerStatus();
|
||||
applyNativeStatus(status);
|
||||
}
|
||||
if (status.running && stopVisibleScanRef.current) {
|
||||
status = await automationRepo.nativeScannerStop();
|
||||
applyNativeStatus(status);
|
||||
}
|
||||
|
||||
setAutoScanRunning(false);
|
||||
await focusDashboard();
|
||||
const finalStats = nativeScannerStats(status);
|
||||
setAutoScanStats(finalStats);
|
||||
const processing = status.runDir
|
||||
? await automationRepo.nativeScannerProcessRun?.({ runDir: status.runDir, persist: false }).catch(() => null)
|
||||
: null;
|
||||
const loadedResults = processing?.ok && status.runDir
|
||||
? await automationRepo.nativeScannerLoadResults?.({ runDir: status.runDir, limit: 24 }).catch(() => null)
|
||||
: null;
|
||||
if (loadedResults?.ok) {
|
||||
setNativeScanResults(loadedResults.results);
|
||||
}
|
||||
setScanSummary({
|
||||
mode: "Native IK-Erfassung",
|
||||
status: nativeScanSummaryStatus(status.status),
|
||||
...finalStats,
|
||||
targetCount: status.target || targetCount,
|
||||
gridLabel: processing?.ok
|
||||
? `${status.message} Post-Processing: ${processing.parsed}/${processing.processed} geparst, ${processing.review} Review, Results: ${loadedResults?.results.length ?? 0}/${loadedResults?.total ?? 0}`
|
||||
: `${status.message} OCR, Parsing und Evaluierung sind fuer Geschwindigkeit in diesem Schritt bewusst ausgelassen. Output: ${status.runDir || status.outputRoot || "native-scans"}`,
|
||||
});
|
||||
setReviewStatus(
|
||||
processing?.ok
|
||||
? `Native IK-Erfassung ${nativeScanSummaryStatus(status.status)}. ${status.captured}/${status.target || targetCount} Crops; Post-Processing ${processing.parsed}/${processing.processed} geparst, ${processing.review} Review.`
|
||||
: `Native IK-Erfassung ${nativeScanSummaryStatus(status.status)}. ${status.captured}/${status.target || targetCount} Karten-Crops erfasst; Queue liegt unter ${status.runDir || "native-scans"}.`,
|
||||
);
|
||||
appendAutomationLog(`native scan finished: status=${status.status} captured=${status.captured} activeMs=${status.activeMs}`);
|
||||
if (processing?.ok) {
|
||||
appendAutomationLog(`native process finished: parsed=${processing.parsed}/${processing.processed} review=${processing.review} report=${processing.reportPath}`);
|
||||
}
|
||||
if (loadedResults?.ok) {
|
||||
appendAutomationLog(`native results loaded: ${loadedResults.results.length}/${loadedResults.total} entries`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSourceId || !automationRepo.clickScreen || !automationRepo.scrollScreen) {
|
||||
return;
|
||||
}
|
||||
setReviewStatus("Auto-Scan prueft den Startzustand ohne OCR...");
|
||||
const preflightCapture = await captureSelectedSource(0, true, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitCrops: true,
|
||||
omitCropImages: true,
|
||||
omitLockState: true,
|
||||
});
|
||||
const visibleInventoryReady = validateAutoScanEntryPreflight(preflightCapture).ok;
|
||||
appendDiagnosticEvent({
|
||||
phase: "guided-start",
|
||||
severity: visibleInventoryReady ? "ok" : "info",
|
||||
message: visibleInventoryReady
|
||||
? "Artifact inventory detail view already visible; starting scan directly."
|
||||
: "Artifact detail view is not ready; guided scan waits for a visible artifact detail card instead of navigating.",
|
||||
capture: preflightCapture,
|
||||
});
|
||||
if (!visibleInventoryReady) {
|
||||
setReviewStatus("Auto-Scan wartet: Bitte Artifact-Inventar mit sichtbarer Detailkarte oeffnen und erneut starten.");
|
||||
return;
|
||||
}
|
||||
await runVisibleGridScanAction(scanActionContext, {
|
||||
scanLimit: options.scanLimit,
|
||||
scanEntryMode: "visible-inventory",
|
||||
processInitialSelection: true,
|
||||
});
|
||||
}, [
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
selectedSourceId,
|
||||
automationRepo?.clickScreen,
|
||||
automationRepo?.scrollScreen,
|
||||
automationRepo?.nativeScannerPreflight,
|
||||
automationRepo?.nativeScannerStart,
|
||||
automationRepo?.nativeScannerStatus,
|
||||
automationRepo?.nativeScannerStop,
|
||||
automationRepo?.nativeScannerProcessRun,
|
||||
automationRepo?.nativeScannerLoadResults,
|
||||
setAutoScanRunning,
|
||||
setAutoScanStats,
|
||||
setScanSummary,
|
||||
setReviewStatus,
|
||||
setNativeScanResults,
|
||||
scanLimit,
|
||||
detectedInventoryCount,
|
||||
stopVisibleScanRef,
|
||||
appendAutomationLog,
|
||||
captureSelectedSource,
|
||||
appendDiagnosticEvent,
|
||||
focusDashboard,
|
||||
scanActionContext,
|
||||
]);
|
||||
|
||||
useScanCommandListener({
|
||||
automationRepo,
|
||||
autoScanRunning,
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
});
|
||||
|
||||
@@ -291,6 +566,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
parseLearnedArtifact as parseLearnedArtifactHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
|
||||
import { useScanGoodInterop } from "./useScanGoodInterop";
|
||||
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
|
||||
import { useScanViewActions } from "./useScanViewActions";
|
||||
import { useScanViewStateSync } from "./useScanViewStateSync";
|
||||
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
import { goodDatabaseToStoredArtifacts, type GoodImportDatabase, storedArtifactsToGood } from "../../../lib/goodInterop";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
|
||||
export function useScanViewController({
|
||||
@@ -52,10 +53,13 @@ export function useScanViewController({
|
||||
const stopVisibleScanRef = useRef(false);
|
||||
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
|
||||
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
|
||||
const [recentArtifacts, setRecentArtifacts] = useState<StoredArtifactRecord[]>([]);
|
||||
const [nativeScanResults, setNativeScanResults] = useState<StoredScanResultEntry[]>([]);
|
||||
const [scanLimit, setScanLimit] = useState(16);
|
||||
const [scanLimitTouched, setScanLimitTouched] = useState(false);
|
||||
const [skipRows, setSkipRows] = useState(0);
|
||||
const [automationLog, setAutomationLog] = useState<string[]>([]);
|
||||
const [diagnosticEvents, setDiagnosticEvents] = useState<ScanDiagnosticEvent[]>([]);
|
||||
const [storedTotal, setStoredTotal] = useState<number | null>(null);
|
||||
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
|
||||
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
|
||||
@@ -77,7 +81,7 @@ export function useScanViewController({
|
||||
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
|
||||
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
|
||||
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
const activeTargetCount = autoScanRunning
|
||||
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
|
||||
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
@@ -92,12 +96,28 @@ export function useScanViewController({
|
||||
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
|
||||
}, []);
|
||||
|
||||
const appendDiagnosticEvent = useCallback((event: Omit<Parameters<typeof createScanDiagnosticEvent>[0], "includeFullScreenshot">) => {
|
||||
setDiagnosticEvents((previous) => [
|
||||
...previous.slice(-23),
|
||||
createScanDiagnosticEvent({
|
||||
...event,
|
||||
includeFullScreenshot: false,
|
||||
}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
|
||||
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
|
||||
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
|
||||
const blocked = result.inputBlocked ? " input:blocked" : "";
|
||||
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
|
||||
}, [appendAutomationLog]);
|
||||
appendDiagnosticEvent({
|
||||
phase: "input",
|
||||
severity: result.inputBlocked || result.clicked === false || result.moved === false ? "warn" : "ok",
|
||||
message: `${prefix}: click ${result.clicked ? "sent" : "not sent"}`,
|
||||
details: summarizeClickResult(result),
|
||||
});
|
||||
}, [appendAutomationLog, appendDiagnosticEvent]);
|
||||
|
||||
const toggleDevMode = useCallback(() => {
|
||||
setDevMode((previous) => {
|
||||
@@ -118,6 +138,7 @@ export function useScanViewController({
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
} = useScanViewActions({
|
||||
autoScanRunning,
|
||||
@@ -137,6 +158,7 @@ export function useScanViewController({
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
appendDiagnosticEvent,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
@@ -147,6 +169,7 @@ export function useScanViewController({
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setStoredTotal,
|
||||
setNativeScanResults,
|
||||
scannerLearningRules,
|
||||
captureSelectedSource,
|
||||
latestCapture,
|
||||
@@ -155,48 +178,27 @@ export function useScanViewController({
|
||||
setReviewQueueOpen,
|
||||
});
|
||||
|
||||
const canGoodInterop = bridgeReady && Boolean(artifactRepo?.loadAll) && Boolean(artifactRepo?.saveMany);
|
||||
|
||||
const exportGoodFromStore = useCallback(async () => {
|
||||
if (!artifactRepo?.loadAll || !exportRepo?.exportGood) return { ok: false, count: 0 };
|
||||
const loaded = await artifactRepo.loadAll();
|
||||
const records = loaded.artifacts ?? [];
|
||||
const good = storedArtifactsToGood(records);
|
||||
const result = await exportRepo.exportGood(good);
|
||||
return { ok: Boolean(result.ok), path: result.path, count: good.artifacts.length };
|
||||
}, [artifactRepo, exportRepo]);
|
||||
|
||||
const importGoodArtifacts = useCallback(async (records: StoredArtifactRecord[]) => {
|
||||
if (!artifactRepo?.saveMany || records.length === 0) return { ok: false, added: 0, updated: 0 };
|
||||
const result = await artifactRepo.saveMany(records);
|
||||
if (typeof result.total === "number") setStoredTotal(result.total);
|
||||
await onStoredArtifactsChanged?.();
|
||||
return { ok: Boolean(result.ok), added: result.added ?? 0, updated: result.updated ?? 0 };
|
||||
}, [artifactRepo, onStoredArtifactsChanged]);
|
||||
|
||||
const importGoodFromFile = useCallback(async () => {
|
||||
if (!exportRepo?.importGoodFile || !artifactRepo?.saveMany) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, error: "GOOD import is unavailable." };
|
||||
}
|
||||
const fileResult = await exportRepo.importGoodFile();
|
||||
if (fileResult.canceled) return { ok: false, added: 0, updated: 0, count: 0, canceled: true };
|
||||
if (!fileResult.ok) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: fileResult.error };
|
||||
}
|
||||
const records = goodDatabaseToStoredArtifacts(fileResult.database as GoodImportDatabase);
|
||||
if (records.length === 0) {
|
||||
return { ok: false, added: 0, updated: 0, count: 0, path: fileResult.path, error: "No valid GOOD artifacts found." };
|
||||
}
|
||||
const saved = await importGoodArtifacts(records);
|
||||
return { ...saved, count: records.length, path: fileResult.path };
|
||||
}, [artifactRepo, exportRepo, importGoodArtifacts]);
|
||||
const {
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
importGoodFromFile,
|
||||
importGoodArtifacts,
|
||||
} = useScanGoodInterop({
|
||||
artifactRepo,
|
||||
exportRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setStoredTotal,
|
||||
bridgeReady,
|
||||
});
|
||||
|
||||
useScanViewStateSync({
|
||||
artifactRepo,
|
||||
latestCapture,
|
||||
scanLimitTouched,
|
||||
storedTotal,
|
||||
setScanLimit,
|
||||
setStoredTotal,
|
||||
setRecentArtifacts,
|
||||
});
|
||||
|
||||
useScanSnapshotPublisher({
|
||||
@@ -209,6 +211,7 @@ export function useScanViewController({
|
||||
snapshot,
|
||||
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
@@ -230,7 +233,10 @@ export function useScanViewController({
|
||||
scanLimitTouched,
|
||||
skipRows,
|
||||
automationLog,
|
||||
diagnosticEvents,
|
||||
storedTotal,
|
||||
recentArtifacts,
|
||||
nativeScanResults,
|
||||
devMode,
|
||||
scannerLearningRules,
|
||||
learningRulesLoaded,
|
||||
@@ -267,6 +273,7 @@ export function useScanViewController({
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runGuidedAutoScan,
|
||||
runVisibleGridScan,
|
||||
canGoodInterop,
|
||||
exportGoodFromStore,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import { clampScanLimit } from "../../../lib/scannerSession";
|
||||
|
||||
@@ -8,19 +9,23 @@ interface ScanViewStateSyncInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
latestCapture: CaptureResult | null;
|
||||
scanLimitTouched: boolean;
|
||||
storedTotal: number | null;
|
||||
setScanLimit: Dispatch<SetStateAction<number>>;
|
||||
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||
setRecentArtifacts: Dispatch<SetStateAction<StoredArtifactRecord[]>>;
|
||||
}
|
||||
|
||||
export function useScanViewStateSync({
|
||||
artifactRepo,
|
||||
latestCapture,
|
||||
scanLimitTouched,
|
||||
storedTotal,
|
||||
setScanLimit,
|
||||
setStoredTotal,
|
||||
setRecentArtifacts,
|
||||
}: ScanViewStateSyncInput) {
|
||||
useEffect(() => {
|
||||
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
|
||||
if (!scanLimitTouched && detectedCount > 0) {
|
||||
setScanLimit(clampScanLimit(detectedCount));
|
||||
}
|
||||
@@ -28,8 +33,17 @@ export function useScanViewStateSync({
|
||||
|
||||
useEffect(() => {
|
||||
artifactRepo?.loadAll().then((result) => {
|
||||
if (result?.ok) setStoredTotal(result.total);
|
||||
if (!result?.ok) return;
|
||||
setStoredTotal(result.total);
|
||||
setRecentArtifacts(
|
||||
[...result.artifacts]
|
||||
.sort((left, right) => {
|
||||
const rightTime = Date.parse(right.lastSeenAt ?? right.firstSeenAt ?? "") || 0;
|
||||
const leftTime = Date.parse(left.lastSeenAt ?? left.firstSeenAt ?? "") || 0;
|
||||
return rightTime - leftTime;
|
||||
})
|
||||
.slice(0, 6),
|
||||
);
|
||||
}).catch(() => undefined);
|
||||
}, [artifactRepo, setStoredTotal]);
|
||||
}, [artifactRepo, setRecentArtifacts, setStoredTotal, storedTotal]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../types/storage";
|
||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../types/storage";
|
||||
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
|
||||
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
|
||||
import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog";
|
||||
import type { ScannerLearningRules } from "../../lib/scannerLearning";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
|
||||
@@ -19,6 +20,7 @@ export interface ScanViewProps {
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
bridgeReady: boolean;
|
||||
onStoredArtifactsChanged?: () => Promise<void>;
|
||||
onOpenInventory?: () => void;
|
||||
}
|
||||
|
||||
export interface ScanViewControllerResult {
|
||||
@@ -32,10 +34,13 @@ export interface ScanViewControllerResult {
|
||||
autoScanRunning: boolean;
|
||||
autoScanStats: AutoScanStats;
|
||||
scanSummary: ScanSummary | null;
|
||||
recentArtifacts: StoredArtifactRecord[];
|
||||
nativeScanResults: StoredScanResultEntry[];
|
||||
scanLimit: number;
|
||||
scanLimitTouched: boolean;
|
||||
skipRows: number;
|
||||
automationLog: string[];
|
||||
diagnosticEvents: ScanDiagnosticEvent[];
|
||||
storedTotal: number | null;
|
||||
devMode: boolean;
|
||||
scannerLearningRules: ScannerLearningRules;
|
||||
@@ -79,6 +84,7 @@ export interface ScanViewControllerResult {
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
canGoodInterop: boolean;
|
||||
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
|
||||
|
||||
@@ -22,11 +22,21 @@ import type {
|
||||
RuntimeInfo,
|
||||
SaveResultWithPath,
|
||||
ScrollResult,
|
||||
KeyPressResult,
|
||||
AutomationGuard,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
SaveScannerLearningRulesResult,
|
||||
GoodImportFileResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
} from "../../types/global";
|
||||
|
||||
const EMPTY_SNAPSHOT: AppSnapshot | null = null;
|
||||
@@ -74,6 +84,162 @@ const EMPTY_GOOD_IMPORT_FILE_RESULT: GoodImportFileResult = {
|
||||
path: "",
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_DATA_STATUS: NativeScannerDataStatus = {
|
||||
directory: "",
|
||||
version: "",
|
||||
artifactSets: 0,
|
||||
artifactPieces: 0,
|
||||
weapons: 0,
|
||||
characters: 0,
|
||||
materials: 0,
|
||||
totalEntries: 0,
|
||||
categories: {
|
||||
artifacts: {
|
||||
file: "artifacts.json",
|
||||
setCount: 0,
|
||||
pieceCount: 0,
|
||||
supported: false,
|
||||
catalogAvailable: false,
|
||||
nativeCaptureSupported: false,
|
||||
scanStatus: "missing_data",
|
||||
},
|
||||
weapons: {
|
||||
file: "weapons.json",
|
||||
count: 0,
|
||||
supported: false,
|
||||
catalogAvailable: false,
|
||||
nativeCaptureSupported: false,
|
||||
scanStatus: "missing_data",
|
||||
},
|
||||
characters: {
|
||||
file: "characters.json",
|
||||
count: 0,
|
||||
supported: false,
|
||||
catalogAvailable: false,
|
||||
nativeCaptureSupported: false,
|
||||
scanStatus: "missing_data",
|
||||
},
|
||||
materials: {
|
||||
file: "materials.json",
|
||||
count: 0,
|
||||
supported: false,
|
||||
catalogAvailable: false,
|
||||
nativeCaptureSupported: false,
|
||||
scanStatus: "missing_data",
|
||||
},
|
||||
},
|
||||
missing: ["Electron bridge unavailable."],
|
||||
valid: false,
|
||||
source: "InventoryKamera inventorylists",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_PREFLIGHT_STATUS: NativeScannerPreflightStatus = {
|
||||
data: EMPTY_NATIVE_SCANNER_DATA_STATUS,
|
||||
category: "artifacts",
|
||||
supportedCategories: EMPTY_NATIVE_SCANNER_DATA_STATUS.categories,
|
||||
categoryReady: false,
|
||||
genshinFound: false,
|
||||
bounds: null,
|
||||
isSixteenNine: false,
|
||||
grid: { rows: 0, cols: 0, count: 0, anchorX: 0, anchorY: 0 },
|
||||
visual: {
|
||||
ready: false,
|
||||
samples: 0,
|
||||
whitePct: 0,
|
||||
darkPct: 0,
|
||||
colorPct: 0,
|
||||
lumaStdDev: 0,
|
||||
blockReason: "Electron bridge unavailable.",
|
||||
},
|
||||
ready: false,
|
||||
blockReason: "Electron bridge unavailable.",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_CATALOG_STATUS: NativeScannerCatalogStatus = {
|
||||
data: EMPTY_NATIVE_SCANNER_DATA_STATUS,
|
||||
artifacts: [],
|
||||
weapons: [],
|
||||
characters: [],
|
||||
materials: [],
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_RUN_STATUS: NativeScannerRunStatus = {
|
||||
running: false,
|
||||
status: "blocked",
|
||||
runId: "",
|
||||
category: "artifacts",
|
||||
target: 0,
|
||||
captured: 0,
|
||||
queued: 0,
|
||||
clicked: 0,
|
||||
pages: 0,
|
||||
activeMs: 0,
|
||||
message: "Electron bridge unavailable.",
|
||||
outputRoot: "",
|
||||
runDir: "",
|
||||
manifestPath: "",
|
||||
jobsPath: "",
|
||||
statusPath: "",
|
||||
dataVersion: "",
|
||||
lastArtifactPath: "",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_PROCESS_STATUS: NativeScannerProcessStatus = {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
jobsPath: "",
|
||||
reportPath: "",
|
||||
scanResultsPath: "",
|
||||
processed: 0,
|
||||
parsed: 0,
|
||||
review: 0,
|
||||
stored: 0,
|
||||
errors: 1,
|
||||
elapsedMs: 0,
|
||||
queueConcurrency: 0,
|
||||
persisted: false,
|
||||
error: "Electron bridge unavailable.",
|
||||
results: [],
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_RESULTS_LOAD_STATUS: NativeScannerResultsLoadStatus = {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
path: "",
|
||||
total: 0,
|
||||
results: [],
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_PROMOTION_STATUS: NativeScannerPromotionStatus = {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
logPath: "",
|
||||
requested: 0,
|
||||
selected: 0,
|
||||
promoted: 0,
|
||||
alreadyStored: 0,
|
||||
review: 0,
|
||||
blocked: 0,
|
||||
added: 0,
|
||||
updated: 0,
|
||||
total: 0,
|
||||
promotedResultIds: [],
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_REVIEW_STATUS: NativeScannerReviewStatus = {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
logPath: "",
|
||||
resultId: "",
|
||||
action: "approve",
|
||||
evalSampleSaved: false,
|
||||
correctedFields: [],
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
const EMPTY_NATIVE_SCANNER_IMAGE_LOAD_STATUS: NativeScannerImageLoadStatus = {
|
||||
ok: false,
|
||||
runDir: "",
|
||||
path: "",
|
||||
dataUrl: "",
|
||||
width: 0,
|
||||
height: 0,
|
||||
error: "Electron bridge unavailable.",
|
||||
};
|
||||
|
||||
async function createBridgeSafeCall<TResult>(
|
||||
callback: () => Promise<TResult> | TResult | null | undefined,
|
||||
@@ -111,6 +277,10 @@ function emptyScrollResult(): ScrollResult {
|
||||
return { ok: false, notchesSent: 0, inputBlocked: false };
|
||||
}
|
||||
|
||||
function emptyKeyPressResult(key = ""): KeyPressResult {
|
||||
return { ok: false, key, inputBlocked: false, eventsSent: 0 };
|
||||
}
|
||||
|
||||
function emptyBooleanResult(): BooleanResult {
|
||||
return { ok: false };
|
||||
}
|
||||
@@ -203,6 +373,29 @@ export function createRendererRepositories(): RendererRepositories | null {
|
||||
clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()),
|
||||
scrollScreen: (notches, anchorX, anchorY) =>
|
||||
createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()),
|
||||
keyPress: (key) => createBridgeSafeCall(() => bridge.keyPress(key), emptyKeyPressResult(key)),
|
||||
nativeScannerDataStatus: () =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerDataStatus(), EMPTY_NATIVE_SCANNER_DATA_STATUS),
|
||||
nativeScannerCatalog: () =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerCatalog(), EMPTY_NATIVE_SCANNER_CATALOG_STATUS),
|
||||
nativeScannerPreflight: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerPreflight(options), EMPTY_NATIVE_SCANNER_PREFLIGHT_STATUS),
|
||||
nativeScannerStart: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerStart(options), EMPTY_NATIVE_SCANNER_RUN_STATUS),
|
||||
nativeScannerStop: () =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerStop(), EMPTY_NATIVE_SCANNER_RUN_STATUS),
|
||||
nativeScannerStatus: () =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerStatus(), EMPTY_NATIVE_SCANNER_RUN_STATUS),
|
||||
nativeScannerProcessRun: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerProcessRun(options), EMPTY_NATIVE_SCANNER_PROCESS_STATUS),
|
||||
nativeScannerLoadResults: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerLoadResults(options), EMPTY_NATIVE_SCANNER_RESULTS_LOAD_STATUS),
|
||||
nativeScannerPromoteResults: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerPromoteResults(options), EMPTY_NATIVE_SCANNER_PROMOTION_STATUS),
|
||||
nativeScannerReviewResult: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerReviewResult(options), EMPTY_NATIVE_SCANNER_REVIEW_STATUS),
|
||||
nativeScannerLoadImage: (options) =>
|
||||
createBridgeSafeCall(() => bridge.nativeScannerLoadImage(options), EMPTY_NATIVE_SCANNER_IMAGE_LOAD_STATUS),
|
||||
onCommand: bridge.onScannerCommand,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,17 @@ import type {
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
FocusGenshinResult,
|
||||
NativeScannerCatalogStatus,
|
||||
NativeScannerDataStatus,
|
||||
NativeScannerImageLoadStatus,
|
||||
NativeScannerPreflightStatus,
|
||||
NativeScannerProcessStatus,
|
||||
NativeScannerPromotionStatus,
|
||||
NativeScannerReviewArtifactInput,
|
||||
NativeScannerReviewStatus,
|
||||
NativeScannerResultsLoadStatus,
|
||||
NativeScannerRunStatus,
|
||||
NativeScannerStartCategory,
|
||||
RuntimeInfo,
|
||||
ScannerCommand,
|
||||
LoadScannerLearningRulesResult,
|
||||
@@ -20,6 +31,7 @@ import type {
|
||||
SaveResultWithPath,
|
||||
ScrollResult,
|
||||
ScannerLearningRulePayload,
|
||||
KeyPressResult,
|
||||
} from "../../types/global";
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
import type { StoredArtifactRecord } from "../../types/storage";
|
||||
@@ -64,6 +76,18 @@ export interface AutomationRepositoryPort {
|
||||
focusMainWindow(): Promise<BooleanResult>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
keyPress(key: string): Promise<KeyPressResult>;
|
||||
nativeScannerDataStatus(): Promise<NativeScannerDataStatus>;
|
||||
nativeScannerCatalog(): Promise<NativeScannerCatalogStatus>;
|
||||
nativeScannerPreflight(options?: { category?: NativeScannerStartCategory }): Promise<NativeScannerPreflightStatus>;
|
||||
nativeScannerStart(options?: { limit?: number; category?: NativeScannerStartCategory }): Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStop(): Promise<NativeScannerRunStatus>;
|
||||
nativeScannerStatus(): Promise<NativeScannerRunStatus>;
|
||||
nativeScannerProcessRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise<NativeScannerProcessStatus>;
|
||||
nativeScannerLoadResults(options?: { runDir?: string; limit?: number }): Promise<NativeScannerResultsLoadStatus>;
|
||||
nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
|
||||
nativeScannerReviewResult(options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }): Promise<NativeScannerReviewStatus>;
|
||||
nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
|
||||
onCommand(callback: (command: ScannerCommand) => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseArtifactCandidate } from "./artifactOcrParser";
|
||||
import { parseArtifactCandidate, reviewedMainValueError } from "./artifactOcrParser";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
|
||||
function captureFromOcr(textById: Record<string, string>): CaptureResult {
|
||||
@@ -20,6 +20,11 @@ function captureFromOcr(textById: Record<string, string>): CaptureResult {
|
||||
}
|
||||
|
||||
describe("parseArtifactCandidate", () => {
|
||||
it("validates manually reviewed main stats against slot and level references", () => {
|
||||
expect(reviewedMainValueError("Circlet of Logos", "CRIT Rate", 20, "31.1%")).toBe("");
|
||||
expect(reviewedMainValueError("Circlet of Logos", "CRIT Rate", 20, "311%")).toContain("canonical maximum");
|
||||
expect(reviewedMainValueError("Plume of Death", "CRIT Rate", 20, "31.1%")).toContain("not an allowed main stat");
|
||||
});
|
||||
it("matches artifact data from the generated Genshin data package", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "A Note in Spring's Lei\n| Sands of Eon Vi",
|
||||
@@ -81,6 +86,22 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.equipped).toBe("Aino");
|
||||
});
|
||||
|
||||
it("derives a unique piece and set from a distinctive partial OCR fragment", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Wontiroms Creation pan",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ HP+239\n+ Energy Recharge+4.5%\n+ Elemental Mastery+23\n- ATK+4.1% unactivated",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Sharpness That Ceased Upon Wondrous Creation");
|
||||
expect(parsed?.slot).toBe("Plume of Death");
|
||||
expect(parsed?.setName).toBe("Disenchantment in Deep Shadow");
|
||||
expect(parsed?.fields.name.source).toBe("fallback");
|
||||
expect(parsed?.fields.name.confidence).toBe(72);
|
||||
});
|
||||
|
||||
it("keeps goblet elemental damage main stats separate from crit substats", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Deep Gallery's Bestowed Banquet\nGoblet of Eonothem",
|
||||
@@ -95,6 +116,128 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
});
|
||||
|
||||
it("derives main stat value when the fast OCR profile skips the value crop", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Maidens Fading Beauty",
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-main-stat-label": "Cryo DMG Bonus",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT Rate+6.6%\n+ CRIT DMG+12.4%\n+ HP+269\n+ ATK+16.3%",
|
||||
"artifact-footer": "Equipped: Skirk",
|
||||
}));
|
||||
|
||||
expect(parsed?.mainStat).toBe("Cryo DMG Bonus");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("uses split main stat value OCR when level OCR is missing", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Heldenepos's Unspoken Tale",
|
||||
"artifact-slot": "Goblet of Eonothem",
|
||||
"artifact-main-stat-label": "Pyro DMG Bonus",
|
||||
"artifact-main-stat-value": "46.6%",
|
||||
"artifact-level": "",
|
||||
"artifact-substats": "+ Energy Recharge+13.0%\n+ ATK+5.8%\n+ Elemental Mastery+58\n+ CRIT DMG+14.0%",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Goblet of Eonothem");
|
||||
expect(parsed?.mainStat).toBe("Pyro DMG Bonus");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainValue.source).toBe("ocr");
|
||||
});
|
||||
|
||||
it("derives fixed flower and plume values without a value crop", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator's Nostalgia",
|
||||
"artifact-slot": "Flower of Life",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.mainStat).toBe("HP");
|
||||
expect(parsed?.mainValue).toBe("4,780");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("derives percent main stat values when slot rules disallow flat HP ATK or DEF", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Moonlit Offering's Final Hour",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ ATK+19\n- Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.mainStat).toBe("HP%");
|
||||
expect(parsed?.mainValue).toBe("46.6%");
|
||||
expect(parsed?.fields.mainStat.source).toBe("derived");
|
||||
expect(parsed?.fields.mainValue.source).toBe("derived");
|
||||
});
|
||||
|
||||
it("recovers a known piece from a truncated Viridescent Determination OCR tail", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Determmation oT",
|
||||
"artifact-main-stat-label": "Energy Recharge",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "- ATK+5.8%\n- Elemental Mastery+37\nHP+11.7%\n+ ATK+54",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Viridescent Venerer's Determination");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Viridescent Venerer");
|
||||
expect(parsed?.mainStat).toBe("Energy Recharge");
|
||||
expect(parsed?.mainValue).toBe("51.8%");
|
||||
});
|
||||
|
||||
it("recovers long Disenchantment piece names from truncated OCR fragments", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Postintty That Ceased Upon",
|
||||
"artifact-main-stat-label": "DEF",
|
||||
"artifact-level": "+0",
|
||||
"artifact-substats": "+ DEF+21\n+ Energy Recharge+4.5%\n+ CRIT Rate+3.1%\n- ATK+14",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Moment That Ceased Upon Waking From Grand Dreams");
|
||||
expect(parsed?.slot).toBe("Sands of Eon");
|
||||
expect(parsed?.setName).toBe("Disenchantment in Deep Shadow");
|
||||
expect(parsed?.mainStat).toBe("DEF%");
|
||||
expect(parsed?.mainValue).toBe("8.7%");
|
||||
});
|
||||
|
||||
it("derives slot and set from the piece name when fast auto-scan skips slot OCR", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator's Nostalgia",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Flower of Life");
|
||||
expect(parsed?.fields.slot.source).toBe("derived");
|
||||
expect(parsed?.setName).toBe("Gladiator's Finale");
|
||||
expect(parsed?.fields.setName.source).toBe("derived");
|
||||
expect(parsed?.mainStat).toBe("HP");
|
||||
expect(parsed?.mainValue).toBe("4,780");
|
||||
});
|
||||
|
||||
it("caps derived slot and set confidence when the piece name is fuzzy", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Gladiator Nostalg",
|
||||
"artifact-main-stat-label": "HP",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68",
|
||||
}));
|
||||
|
||||
expect(parsed?.name).toBe("Gladiator's Nostalgia");
|
||||
expect(parsed?.fields.name.confidence).toBeLessThan(94);
|
||||
expect(parsed?.slot).toBe("Flower of Life");
|
||||
expect(parsed?.fields.slot.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
|
||||
expect(parsed?.setName).toBe("Gladiator's Finale");
|
||||
expect(parsed?.fields.setName.confidence).toBeLessThanOrEqual(parsed?.fields.name.confidence ?? 0);
|
||||
});
|
||||
|
||||
it("does not let substats override circlet crit main stats", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Holy Crown of the Believer\nCirclet of Logos",
|
||||
@@ -109,6 +252,22 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.mainValue).toBe("31.1%");
|
||||
});
|
||||
|
||||
it("repairs a missing decimal in a level 20 percent main value from the canonical reference", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"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+11.7%\n+ CRIT DMG+13.2%",
|
||||
"artifact-set-effects": "Night of the Sky's Unveiling:\n2-Piece Set: Increases Elemental Mastery by 80.",
|
||||
"artifact-footer": "Equipped: Zibai",
|
||||
}));
|
||||
|
||||
expect(parsed?.mainValue).toBe("31.1%");
|
||||
expect(parsed?.fields.mainValue).toMatchObject({ confidence: 94, source: "derived" });
|
||||
});
|
||||
|
||||
it("keeps percent substats distinct from flat ATK HP and DEF", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Beast Tamer's Talisman\nFlower of Life",
|
||||
@@ -188,6 +347,46 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
});
|
||||
|
||||
it("recognizes equipped characters when OCR splits the footer label and name", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Gladiator's Nostalgia\nFlower of Life",
|
||||
"artifact-main-stat": "HP\n4,780",
|
||||
"artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%",
|
||||
"artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%",
|
||||
"artifact-footer": "Equipped:\nBennett",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Bennett");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("does not persist unknown one-letter equipped fragments as characters", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Vessel of Plenty",
|
||||
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
|
||||
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
|
||||
"artifact-footer": "Equipped: I -",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Not detected");
|
||||
expect(parsed?.fields.equipped.source).toBe("missing");
|
||||
});
|
||||
|
||||
it("normalizes equipped footer trailing fragments to a known character", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-name": "Pristine Plume of the Blessed",
|
||||
"artifact-slot": "Plume of Death",
|
||||
"artifact-main-stat-label": "ATK",
|
||||
"artifact-level": "+20",
|
||||
"artifact-substats": "+ HP+16.9%\n- ATK+8.7%\n+ CRIT DMG+13.2%\n+ Elemental Mastery+21",
|
||||
"artifact-set-effects": "2-Piece Set: Energy Recharge +20%.",
|
||||
"artifact-footer": "Equipped: Linnea l",
|
||||
}));
|
||||
|
||||
expect(parsed?.equipped).toBe("Linnea");
|
||||
expect(parsed?.fields.equipped.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => {
|
||||
const sands = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Myths of the Night Realm\nSands of Eon",
|
||||
@@ -342,4 +541,18 @@ describe("parseArtifactCandidate", () => {
|
||||
expect(sands?.mainValue).toBe("46.6%");
|
||||
expect(sands?.mainStat).toBe("Unknown main stat");
|
||||
});
|
||||
|
||||
it("derives DEF percent for goblet max-value reads when the OCR stat label is garbled", () => {
|
||||
const parsed = parseArtifactCandidate(captureFromOcr({
|
||||
"artifact-title": "Vessel of Plenty",
|
||||
"artifact-main-stat": "Goblet of Eonothem\nDLT\n58.3%",
|
||||
"artifact-substats": "- DEF+58\n- Elemental Mastery+47\n+ CRIT Rate+5.8%\n+ HP+299",
|
||||
"artifact-footer": "Equipped: I -",
|
||||
}));
|
||||
|
||||
expect(parsed?.slot).toBe("Goblet of Eonothem");
|
||||
expect(parsed?.mainValue).toBe("58.3%");
|
||||
expect(parsed?.mainStat).toBe("DEF%");
|
||||
expect(parsed?.setName).toBe("Night of the Sky's Unveiling");
|
||||
});
|
||||
});
|
||||
|
||||
+213
-20
@@ -19,7 +19,13 @@ import {
|
||||
textReplacements,
|
||||
} from "./genshinData.js";
|
||||
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
||||
import { implausibleSubstats } from "./substatRolls.js";
|
||||
import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js";
|
||||
import {
|
||||
implausibleSubstats,
|
||||
isPlausibleSubstat,
|
||||
parseSubstatEntry,
|
||||
possibleSubstatRollCounts,
|
||||
} from "./substatRolls.js";
|
||||
|
||||
type MainStatValueReference = { stat: string; base: number; max: number };
|
||||
|
||||
@@ -55,6 +61,24 @@ export interface ParsedArtifactCandidate {
|
||||
const mainStatNames = sortLongestFirst(globalMainStats);
|
||||
const substatNames = sortLongestFirst(globalSubstats);
|
||||
|
||||
export function reviewedMainValueError(slot: string, mainStat: string, level: number, mainValue: string) {
|
||||
const allowed = allowedMainStatsForSlot(slot);
|
||||
if (!allowed.includes(mainStat)) return `${mainStat} is not an allowed main stat for ${slot}.`;
|
||||
const reference = getSlotMainStatValueReferences(slot).find((candidate) => candidate.stat === mainStat);
|
||||
if (!reference) return `No canonical main-value reference exists for ${slot} / ${mainStat}.`;
|
||||
const numeric = parseNumericValue(mainValue);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return `Main value is not numeric: ${mainValue}.`;
|
||||
const tolerance = toleranceForMainStatValue(mainStat, mainValue);
|
||||
if (numeric > reference.max + tolerance) return `Main value exceeds the canonical maximum for ${mainStat}: ${mainValue}.`;
|
||||
if (level > 16) {
|
||||
const expected = expectedMainStatValue(reference, level);
|
||||
if (Math.abs(numeric - expected) > tolerance) {
|
||||
return `Main value does not match the canonical +${level} value ${formatExpectedValue(reference, level)}.`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArtifactCandidate | null {
|
||||
if (!capture?.ocr?.length) return null;
|
||||
|
||||
@@ -62,18 +86,26 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
(capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]),
|
||||
);
|
||||
const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n"));
|
||||
const titleText = byId.get("artifact-title") ?? "";
|
||||
const mainText = byId.get("artifact-main-stat") ?? "";
|
||||
const nameText = byId.get("artifact-name") ?? "";
|
||||
const slotOnlyText = byId.get("artifact-slot") ?? "";
|
||||
const legacyTitleText = byId.get("artifact-title") ?? "";
|
||||
const titleText = [nameText, slotOnlyText].filter(Boolean).join("\n") || legacyTitleText;
|
||||
const mainLabelText = byId.get("artifact-main-stat-label") ?? "";
|
||||
const mainValueText = byId.get("artifact-main-stat-value") ?? "";
|
||||
const legacyMainText = byId.get("artifact-main-stat") ?? "";
|
||||
const mainText = [mainLabelText, mainValueText].filter(Boolean).join("\n") || legacyMainText;
|
||||
const levelText = byId.get("artifact-level") ?? "";
|
||||
const substatText = byId.get("artifact-substats") ?? "";
|
||||
const setText = byId.get("artifact-set-effects") ?? "";
|
||||
const footerText = byId.get("artifact-footer") ?? "";
|
||||
|
||||
const nameField = parseArtifactName(titleText);
|
||||
const slotField = parseSlot(titleText + "\n" + allText, nameField.value);
|
||||
const levelField = parseArtifactLevel(substatText + "\n" + mainText + "\n" + allText);
|
||||
const slotField = parseSlot([slotOnlyText, titleText, allText].filter(Boolean).join("\n"), nameField);
|
||||
const levelField = parseArtifactLevel([levelText, substatText, mainText, allText].filter(Boolean).join("\n"));
|
||||
const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null;
|
||||
const level = parsedLevel ?? 0;
|
||||
let mainStatField = inferMainStat(slotField.value, mainText);
|
||||
mainStatField = promoteSlotPercentMainStat(slotField.value, mainStatField);
|
||||
let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel);
|
||||
if (!mainStatField.value && mainValueField.value) {
|
||||
const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel);
|
||||
@@ -98,9 +130,14 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
mainValueField = noisyReferenceMatch.mainValue;
|
||||
}
|
||||
}
|
||||
const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"));
|
||||
const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing");
|
||||
const setField = parseSetName(setText, nameField.value);
|
||||
const parsedSubstats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"), parsedLevel);
|
||||
const substats = parsedSubstats.values;
|
||||
const substatsField = field(
|
||||
substats.join(", "),
|
||||
substats.length >= 4 ? (parsedSubstats.repaired.length > 0 ? 92 : 96) : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0,
|
||||
substats.length ? (parsedSubstats.repaired.length > 0 ? "derived" : "ocr") : "missing",
|
||||
);
|
||||
const setField = parseSetName(setText, nameField);
|
||||
const equippedField = parseEquippedCharacter(footerText + "\n" + allText);
|
||||
const notes: string[] = [];
|
||||
|
||||
@@ -111,7 +148,10 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
||||
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
|
||||
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
|
||||
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
|
||||
const implausible = implausibleSubstats(substats);
|
||||
if (parsedSubstats.repaired.length > 0) {
|
||||
notes.push(`Substat OCR repaired from legal roll-count constraints: ${parsedSubstats.repaired.join(", ")}.`);
|
||||
}
|
||||
const implausible = implausibleSubstats(substats, level > 16 ? 5 : undefined);
|
||||
if (implausible.length) notes.push(`Substat value has no valid roll combination (likely OCR misread): ${implausible.join(", ")}.`);
|
||||
if (!setField.value) notes.push("Set name not confidently parsed.");
|
||||
|
||||
@@ -161,10 +201,17 @@ function parseArtifactName(titleText: string): ParsedField {
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of titleLines) {
|
||||
const match = matchPiece(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
||||
}
|
||||
const alias = normalizePieceAlias(line);
|
||||
if (alias) return field(alias, 96, "database");
|
||||
}
|
||||
|
||||
const partialPiece = derivePieceFromDistinctivePartialName(titleText);
|
||||
if (partialPiece) return field(partialPiece, 72, "fallback");
|
||||
|
||||
const knownPiece = fuzzyFindKnown(titleText, knownPieceNames, 0.72);
|
||||
if (knownPiece) return field(knownPiece.value, Math.round(knownPiece.score * 100), knownPiece.score >= 0.98 ? "database" : "fallback");
|
||||
|
||||
@@ -172,13 +219,17 @@ function parseArtifactName(titleText: string): ParsedField {
|
||||
return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function parseSlot(text: string, artifactName: string): ParsedField {
|
||||
function parseSlot(text: string, artifactName: ParsedField): ParsedField {
|
||||
const slotLines = text
|
||||
.split("\n")
|
||||
.map((line) => cleanupOcrLabel(line))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of slotLines) {
|
||||
const match = matchSlot(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "ocr");
|
||||
}
|
||||
const alias = normalizeSlotAlias(line);
|
||||
if (alias) return field(alias, 96, "ocr");
|
||||
}
|
||||
@@ -186,18 +237,22 @@ function parseSlot(text: string, artifactName: string): ParsedField {
|
||||
const directSlot = fuzzyFindKnown(text, slotNames, 0.68);
|
||||
if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback");
|
||||
|
||||
const derivedSlot = artifactName ? pieceToSlot.get(artifactName) ?? "" : "";
|
||||
return derivedSlot ? field(derivedSlot, 94, "derived") : field("", 0, "missing");
|
||||
const derivedSlot = artifactName.value ? pieceToSlot.get(artifactName.value) ?? "" : "";
|
||||
return derivedSlot ? field(derivedSlot, derivedConfidence(artifactName, 94), "derived") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function parseSetName(setText: string, artifactName: string): ParsedField {
|
||||
const setFromPiece = artifactName ? pieceToSet.get(artifactName) : undefined;
|
||||
function parseSetName(setText: string, artifactName: ParsedField): ParsedField {
|
||||
const setFromPiece = artifactName.value ? pieceToSet.get(artifactName.value) : undefined;
|
||||
const candidateLines = setText
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/:$/, ""))
|
||||
.filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line));
|
||||
|
||||
for (const line of candidateLines) {
|
||||
const match = matchSet(line);
|
||||
if (match.value) {
|
||||
return field(match.value, match.confidence, match.source === "exact" || match.source === "alias" ? "database" : "fallback");
|
||||
}
|
||||
const alias = normalizeSetAlias(line);
|
||||
if (alias) return field(alias, 96, "database");
|
||||
}
|
||||
@@ -206,10 +261,17 @@ function parseSetName(setText: string, artifactName: string): ParsedField {
|
||||
|
||||
const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64);
|
||||
if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback");
|
||||
if (setFromPiece) return field(setFromPiece, 92, "derived");
|
||||
if (setFromPiece) return field(setFromPiece, derivedConfidence(artifactName, 92), "derived");
|
||||
const partialSetFromPiece = deriveSetFromPartialPieceName(artifactName.value);
|
||||
if (partialSetFromPiece) return field(partialSetFromPiece, 72, "derived");
|
||||
return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing");
|
||||
}
|
||||
|
||||
function derivedConfidence(sourceField: ParsedField, maxConfidence: number) {
|
||||
if (sourceField.source === "database" || sourceField.confidence >= maxConfidence) return maxConfidence;
|
||||
return Math.max(45, Math.min(maxConfidence, sourceField.confidence));
|
||||
}
|
||||
|
||||
function parseArtifactLevel(text: string): ParsedField {
|
||||
const lines = normalizeText(text)
|
||||
.split("\n")
|
||||
@@ -250,6 +312,36 @@ function firstUsefulLine(text: string, rejectIncludes: string[]) {
|
||||
.find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? "";
|
||||
}
|
||||
|
||||
function deriveSetFromPartialPieceName(text: string) {
|
||||
const words = cleanupOcrLabel(text)
|
||||
.split(/\s+/)
|
||||
.map((word) => simplifyForMatch(word))
|
||||
.filter((word) => word.length >= 5);
|
||||
if (words.length < 2) return "";
|
||||
|
||||
const candidates = knownPieceNames.filter((piece) => {
|
||||
const normalizedPiece = simplifyForMatch(piece);
|
||||
const hits = words.filter((word) => normalizedPiece.includes(word)).length;
|
||||
return hits >= 2;
|
||||
});
|
||||
const sets = [...new Set(candidates.map((piece) => pieceToSet.get(piece)).filter((set): set is string => Boolean(set)))];
|
||||
return sets.length === 1 ? sets[0] : "";
|
||||
}
|
||||
|
||||
function derivePieceFromDistinctivePartialName(text: string) {
|
||||
const words = cleanupOcrLabel(text)
|
||||
.split(/\s+/)
|
||||
.map((word) => simplifyForMatch(word))
|
||||
.filter((word) => word.length >= 8);
|
||||
if (words.length === 0) return "";
|
||||
|
||||
const candidates = knownPieceNames.filter((piece) => {
|
||||
const normalizedPiece = simplifyForMatch(piece);
|
||||
return words.some((word) => normalizedPiece.includes(word));
|
||||
});
|
||||
return candidates.length === 1 ? candidates[0] : "";
|
||||
}
|
||||
|
||||
function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField {
|
||||
const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0");
|
||||
const percentValue = extractPercentValue(cleaned);
|
||||
@@ -263,6 +355,29 @@ function findMainValue(text: string, mainStat: string, slot: string, level: numb
|
||||
if (!ocrField.value) return derivedField;
|
||||
if (!derivedField.value) return ocrField;
|
||||
|
||||
if (isPercentMainStat(mainStat)) {
|
||||
const ocrNumeric = parseNumericValue(ocrField.value);
|
||||
const derivedNumeric = parseNumericValue(derivedField.value);
|
||||
const delta = Math.abs(ocrNumeric - derivedNumeric);
|
||||
if (!Number.isFinite(ocrNumeric) || !Number.isFinite(derivedNumeric)) {
|
||||
return field(ocrField.value, 55, "ocr");
|
||||
}
|
||||
if (delta <= toleranceForMainStatValue(mainStat, ocrField.value)) return ocrField;
|
||||
|
||||
const ocrDigits = ocrField.value.replace(/[^0-9]/g, "");
|
||||
const derivedDigits = derivedField.value.replace(/[^0-9]/g, "");
|
||||
const missingDecimalOnly = ocrDigits.length >= 2 && ocrDigits === derivedDigits;
|
||||
const guaranteedFiveStar = level !== null && level > 16;
|
||||
if (missingDecimalOnly || guaranteedFiveStar) {
|
||||
return field(derivedField.value, missingDecimalOnly ? 94 : 90, "derived");
|
||||
}
|
||||
|
||||
// Without rarity evidence, a conflicting <= +16 percent value may belong
|
||||
// to a 4-star artifact. Keep the OCR text reviewable instead of silently
|
||||
// replacing it with the 5-star reference table.
|
||||
return field(ocrField.value, 55, "ocr");
|
||||
}
|
||||
|
||||
if (["HP", "ATK", "DEF", "Elemental Mastery"].includes(mainStat)) {
|
||||
const ocrNumeric = parseNumericValue(ocrField.value);
|
||||
const derivedNumeric = parseNumericValue(derivedField.value);
|
||||
@@ -294,6 +409,10 @@ function inferMainStat(slot: string, text: string): ParsedField {
|
||||
if (direct) return field(promotePercentVariant(direct, text), 94, "ocr");
|
||||
|
||||
const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot));
|
||||
const lookup = matchStat(text);
|
||||
if (lookup.value && allowedForSlot.includes(lookup.value)) {
|
||||
return field(promotePercentVariant(lookup.value, text), lookup.confidence, lookup.source === "fuzzy" ? "fallback" : "ocr");
|
||||
}
|
||||
const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68);
|
||||
if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback");
|
||||
|
||||
@@ -332,6 +451,7 @@ function findDirectMainStat(text: string) {
|
||||
if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%";
|
||||
if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%";
|
||||
if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%";
|
||||
if (hasPercentValue && /(^|\s)dlt(\s|$)/i.test(text)) return "DEF%";
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -416,7 +536,7 @@ function deriveMainStatAndValueFromNoisyReference(slot: string, text: string, le
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubstats(text: string) {
|
||||
function parseSubstats(text: string, level: number | null) {
|
||||
const normalized = normalizeText(text)
|
||||
.replace(/CRIT\s*DMG/gi, "CRIT DMG")
|
||||
.replace(/CRIT\s*Rate/gi, "CRIT Rate")
|
||||
@@ -424,14 +544,69 @@ function parseSubstats(text: string) {
|
||||
.replace(/Elemental\s*Mastery/gi, "Elemental Mastery")
|
||||
.replace(/([A-Z]{2,4})\s*\+/g, "$1+");
|
||||
|
||||
const statPattern = new RegExp(`(${substatNames.map(escapeRegex).join("|")})\\s*\\+\\s*([0-9]+(?:\\.[0-9])?%?)`, "gi");
|
||||
const statPattern = new RegExp(`(${substatNames.map(escapeRegex).join("|")})\\s*\\+\\s*([0-9][0-9,]*(?:\\.[0-9])?%?)`, "gi");
|
||||
const results: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = statPattern.exec(normalized))) {
|
||||
const stat = canonicalSubstatName(match[1], match[2]);
|
||||
results.push(`${stat}+${match[2]}`);
|
||||
}
|
||||
return [...new Set(results)].slice(0, 4);
|
||||
const rawValues = [...new Set(results)].slice(0, 4);
|
||||
const values = repairSubstatOcr(rawValues, level);
|
||||
const repaired = values
|
||||
.map((value, index) => value !== rawValues[index] ? `${rawValues[index]} -> ${value}` : "")
|
||||
.filter(Boolean);
|
||||
return { values, repaired };
|
||||
}
|
||||
|
||||
function repairSubstatOcr(substats: string[], level: number | null) {
|
||||
if (level !== 20 || substats.length !== 4) return substats;
|
||||
const options = substats.map((entry) => substatRepairOptions(entry, 5));
|
||||
if (options.some((entries) => entries.length === 0)) return substats;
|
||||
const candidates = cartesianProduct(options)
|
||||
.filter((entries) => artifactRollCountFits(entries, level));
|
||||
const unique = [...new Map(candidates.map((entries) => [entries.join("\u001f"), entries])).values()];
|
||||
return unique.length === 1 ? unique[0] : substats;
|
||||
}
|
||||
|
||||
function substatRepairOptions(entry: string, rarity?: 4 | 5) {
|
||||
if (isPlausibleSubstat(entry, rarity)) return [entry];
|
||||
const parsed = parseSubstatEntry(entry);
|
||||
if (!parsed?.percent) return [];
|
||||
const raw = entry.slice(entry.indexOf("+") + 1).replace("%", "").replace(/,/g, "").trim();
|
||||
const [integer = "", decimal = "0"] = raw.split(".");
|
||||
const numbers = new Set<number>();
|
||||
if (integer.length === 1) {
|
||||
for (let digit = 1; digit <= 9; digit++) numbers.add(Number(`${digit}${integer}.${decimal}`));
|
||||
}
|
||||
if (integer.length >= 3) {
|
||||
for (let index = 0; index < integer.length; index++) {
|
||||
const candidate = integer.slice(0, index) + integer.slice(index + 1);
|
||||
if (candidate) numbers.add(Number(`${candidate}.${decimal}`));
|
||||
}
|
||||
}
|
||||
return [...numbers]
|
||||
.map((value) => `${parsed.stat}+${value.toFixed(1)}%`)
|
||||
.filter((candidate) => isPlausibleSubstat(candidate, rarity));
|
||||
}
|
||||
|
||||
function cartesianProduct(options: string[][]) {
|
||||
return options.reduce<string[][]>(
|
||||
(products, entries) => products.flatMap((product) => entries.map((entry) => [...product, entry])),
|
||||
[[]],
|
||||
);
|
||||
}
|
||||
|
||||
function artifactRollCountFits(substats: string[], level: number) {
|
||||
const allowedTotals = level === 20 ? new Set([8, 9]) : new Set<number>();
|
||||
if (allowedTotals.size === 0) return false;
|
||||
let totals = new Set<number>([0]);
|
||||
for (const entry of substats) {
|
||||
const counts = possibleSubstatRollCounts(entry, level > 16 ? 5 : undefined);
|
||||
if (counts.length === 0) return false;
|
||||
totals = new Set([...totals].flatMap((total) => counts.map((count) => total + count)));
|
||||
}
|
||||
return [...totals].some((total) => allowedTotals.has(total));
|
||||
}
|
||||
|
||||
function leadingSetEffectText(text: string) {
|
||||
@@ -461,6 +636,10 @@ function parseEquippedCharacter(text: string): ParsedField {
|
||||
if (!equippedLine) return field("Not detected", 45, "missing");
|
||||
|
||||
const afterLabel = cleanupCharacterNoise(equippedLine);
|
||||
const match = afterLabel ? matchCharacter(afterLabel) : null;
|
||||
if (match?.value) {
|
||||
return field(match.value, match.confidence, match.source === "fuzzy" ? "fallback" : "database");
|
||||
}
|
||||
const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : "";
|
||||
if (alias) return field(alias, 96, "database");
|
||||
const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null;
|
||||
@@ -470,7 +649,7 @@ function parseEquippedCharacter(text: string): ParsedField {
|
||||
const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null;
|
||||
if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback");
|
||||
|
||||
return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing");
|
||||
return field("Not detected", 45, "missing");
|
||||
}
|
||||
|
||||
function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField {
|
||||
@@ -486,6 +665,20 @@ function promotePercentVariant(stat: string, text: string) {
|
||||
return stat;
|
||||
}
|
||||
|
||||
function promoteSlotPercentMainStat(slot: string, mainStat: ParsedField): ParsedField {
|
||||
if (!["ATK", "HP", "DEF"].includes(mainStat.value)) return mainStat;
|
||||
const references = getSlotMainStatValueReferences(slot);
|
||||
const hasFlat = references.some((candidate) => candidate.stat === mainStat.value);
|
||||
const hasPercent = references.some((candidate) => candidate.stat === `${mainStat.value}%`);
|
||||
if (hasFlat || !hasPercent) return mainStat;
|
||||
return {
|
||||
...mainStat,
|
||||
value: `${mainStat.value}%`,
|
||||
confidence: Math.max(mainStat.confidence, 90),
|
||||
source: "derived",
|
||||
};
|
||||
}
|
||||
|
||||
function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] {
|
||||
const valueReferences = mainStatValueReferences[slot];
|
||||
return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : [];
|
||||
@@ -573,6 +766,7 @@ function cleanupOcrLabel(line: string) {
|
||||
function cleanupCharacterNoise(text: string) {
|
||||
return text
|
||||
.replace(/^.*?equipped\s*:?\s*/i, "")
|
||||
.replace(/^(?:by|to)\s+/i, "")
|
||||
.replace(/[^A-Za-z'\-\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
@@ -585,4 +779,3 @@ function sortLongestFirst(values: string[]) {
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ describe("autoScanController", () => {
|
||||
expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" });
|
||||
});
|
||||
|
||||
it("separates stuck detail views from duplicates", () => {
|
||||
it("treats repeated readable signatures as duplicates", () => {
|
||||
const seen = new Set(["same"]);
|
||||
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "duplicate" });
|
||||
expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" });
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export function classifyAutoScanCapture({
|
||||
seen: ReadonlySet<string>;
|
||||
}): AutoScanCaptureDecision {
|
||||
if (!signature) return { kind: "unreadable", countAsMiss: true };
|
||||
if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature };
|
||||
if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature };
|
||||
return { kind: "new", countAsParsed: true, signature };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureResult } from "../types/global";
|
||||
import { artifactTabClickTarget, buildAutoScanEntryPlan, keyPressBlocked, validateAutoScanEntryPreflight } from "./autoScanEntry";
|
||||
|
||||
function capture(overrides: Partial<CaptureResult> = {}): CaptureResult {
|
||||
return {
|
||||
id: "test",
|
||||
name: "test",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dataUrl: "",
|
||||
capturedAt: new Date(0).toISOString(),
|
||||
inventoryGrid: {
|
||||
centers: [{ x: 179, y: 254, row: 0, col: 0 }],
|
||||
rows: 4,
|
||||
cols: 8,
|
||||
confidence: 76,
|
||||
source: "detected",
|
||||
},
|
||||
captureTarget: "genshin-client",
|
||||
artifactDetail: { present: true, confidence: 84, orangeHits: 14, greenHits: 8, textHits: 30 },
|
||||
layout: { aspect: "1.78:1", isSixteenNine: true, warning: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("autoScanEntry", () => {
|
||||
it("plans only read-only keys/clicks for the Paimon entry", () => {
|
||||
expect(buildAutoScanEntryPlan("paimon-menu")).toEqual([
|
||||
{ type: "key", key: "ESC", label: "Leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
|
||||
{ type: "key", key: "B", label: "Open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans a guided auto entry with direct world path before ESC/B fallback", () => {
|
||||
expect(buildAutoScanEntryPlan("auto-entry")).toEqual([
|
||||
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
|
||||
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the artifact-tab coordinate ratio", () => {
|
||||
expect(artifactTabClickTarget(capture())).toEqual({ x: 672, y: 47 });
|
||||
});
|
||||
|
||||
it("blocks invalid lookup and unsupported layouts before auto-scan", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture(), { valid: false, errors: ["bad"], warnings: [], summary: { artifactSets: 0, artifactPieces: 0, characters: 0, stats: 0, generatedAt: "", sourceVersion: "" } }).reason).toContain("Lookup invalid");
|
||||
expect(validateAutoScanEntryPreflight(capture({ layout: { aspect: "2.39:1", isSixteenNine: false, warning: "nicht 16:9" } })).reason).toContain("nicht 16:9");
|
||||
});
|
||||
|
||||
it("blocks primary-screen and missing detail-card starts", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture({ captureTarget: "primary-screen" })).reason).toContain("Primary Screen");
|
||||
expect(validateAutoScanEntryPreflight(capture({ artifactDetail: { present: false, confidence: 12, orangeHits: 0, greenHits: 1, textHits: 4 } })).reason).toContain("Keine Artifact-Detailansicht");
|
||||
});
|
||||
|
||||
it("blocks the Paimon menu before grid clicks or OCR", () => {
|
||||
expect(validateAutoScanEntryPreflight(capture({
|
||||
artifactDetail: {
|
||||
present: false,
|
||||
confidence: 8,
|
||||
orangeHits: 12,
|
||||
greenHits: 154,
|
||||
textHits: 20,
|
||||
titleOrangeHits: 4,
|
||||
upperTextHits: 11,
|
||||
lowerGreenHits: 0,
|
||||
},
|
||||
paimonMenu: {
|
||||
present: true,
|
||||
confidence: 81,
|
||||
profileLightPct: 56,
|
||||
profileCreamPct: 31.1,
|
||||
menuTileDarkPct: 67.7,
|
||||
},
|
||||
})).reason).toContain("Paimon-Menue erkannt");
|
||||
});
|
||||
|
||||
it("detects blocked key input", () => {
|
||||
expect(keyPressBlocked({ ok: false, key: "B", inputBlocked: true })).toBe(true);
|
||||
expect(keyPressBlocked({ ok: true, key: "B", inputBlocked: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CaptureResult, KeyPressResult } from "../types/global";
|
||||
import { validateLookupPackage, type LookupValidationStatus } from "./genshinLookup";
|
||||
|
||||
export type ScanEntryMode = "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
|
||||
|
||||
export interface AutoScanEntryAction {
|
||||
type: "key" | "click-artifact-tab" | "click-first-artifact" | "capture-preflight";
|
||||
key?: "ESC" | "B";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AutoScanEntryPreflight {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function buildAutoScanEntryPlan(mode: ScanEntryMode): AutoScanEntryAction[] {
|
||||
if (mode === "visible-inventory") {
|
||||
return [{ type: "capture-preflight", label: "Capture visible artifact inventory" }];
|
||||
}
|
||||
if (mode === "direct-inventory") {
|
||||
return [
|
||||
{ type: "key", key: "B", label: "Open inventory from the current world state" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
if (mode === "auto-entry") {
|
||||
return [
|
||||
{ type: "key", key: "B", label: "Try direct inventory entry from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab if inventory opened" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
|
||||
{ type: "key", key: "B", label: "Fallback: open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Fallback: select first visible artifact" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ type: "key", key: "ESC", label: "Leave the already-open Paimon menu" },
|
||||
{ type: "key", key: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
|
||||
{ type: "key", key: "B", label: "Open inventory from world" },
|
||||
{ type: "click-artifact-tab", label: "Select artifact inventory tab" },
|
||||
{ type: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
|
||||
{ type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
|
||||
];
|
||||
}
|
||||
|
||||
export function artifactTabClickTarget(capture: CaptureResult): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.round(capture.width * (448 / 1280)),
|
||||
y: Math.round(capture.height * (31 / 720)),
|
||||
};
|
||||
}
|
||||
|
||||
export function keyPressBlocked(result: KeyPressResult | null | undefined) {
|
||||
return !result?.ok || Boolean(result.inputBlocked);
|
||||
}
|
||||
|
||||
export function validateAutoScanEntryPreflight(
|
||||
capture: CaptureResult | null,
|
||||
lookupStatus: LookupValidationStatus = validateLookupPackage(),
|
||||
): AutoScanEntryPreflight {
|
||||
if (!lookupStatus.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Lookup invalid: ${lookupStatus.errors[0] ?? "unknown lookup error"}`,
|
||||
};
|
||||
}
|
||||
if (!capture) return { ok: false, reason: "Keine Capture-Daten fuer den Auto-Scan-Start." };
|
||||
if (capture.captureTarget === "primary-screen") {
|
||||
return { ok: false, reason: "Auto-Scan blockiert: Capture stammt vom Primary Screen statt vom Genshin-Client." };
|
||||
}
|
||||
if (capture.layout?.warning) return { ok: false, reason: capture.layout.warning };
|
||||
if (capture.paimonMenu?.present) {
|
||||
return { ok: false, reason: `Paimon-Menue erkannt (${capture.paimonMenu.confidence}%). Auto-Scan startet erst im Artifact-Inventar mit sichtbarer Detailkarte.` };
|
||||
}
|
||||
if (!capture.inventoryGrid || capture.inventoryGrid.source === "missing" || capture.inventoryGrid.centers.length === 0) {
|
||||
return { ok: false, reason: "Kein verlaessliches Artifact-Grid erkannt. Artifact-Inventar sichtbar lassen." };
|
||||
}
|
||||
if (!capture.artifactDetail?.present) {
|
||||
const confidence = capture.artifactDetail ? ` (${capture.artifactDetail.confidence}% Detail-Marker)` : "";
|
||||
return { ok: false, reason: `Keine Artifact-Detailansicht erkannt${confidence}. Artifact-Inventar mit sichtbarer Detailkarte oeffnen.` };
|
||||
}
|
||||
return { ok: true, reason: "" };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user