feat(scanner): add native artifact pipeline

Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
This commit is contained in:
AzuTear
2026-07-09 23:30:42 +02:00
parent 28d60eb915
commit 639b0b7f59
92 changed files with 13606 additions and 1703 deletions
+1
View File
@@ -8,6 +8,7 @@ outputs/dist/
outputs/admin-start/ outputs/admin-start/
outputs/live-capture/ outputs/live-capture/
outputs/live-soak/ outputs/live-soak/
outputs/native-live-smoke/
outputs/review-eval-candidates/ outputs/review-eval-candidates/
# Logs # Logs
+10 -9
View File
@@ -1,23 +1,24 @@
# Genshin Artifact Assistant # 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 ## Current MVP
- Electron + React + TypeScript app shell. - Electron + React + TypeScript app shell with a C# sidecar for fast read-only scan input/capture.
- Dark purple fintech/glassmorphism UI direction. - Artifact-first scanner scope. Weapons, materials, and character details are intentionally not active scanner features yet.
- Genshin capture source discovery. - Smart Capture for the currently visible artifact detail view with focused OCR crops, parser confidence, and review notes.
- Smart Capture that focuses Genshin, hides the app, captures the primary screen, and restores the app. - Visible-inventory auto-scan baseline with read-only tile selection, detail verification, OCR, parse, store/review, scroll, and summary.
- Artifact detail-panel crop detection with focused OCR regions. - Native IK-style artifact capture path that writes card crops and run artifacts for post-capture processing.
- OCR parser for artifact name, slot, main stat, substats, set, equipped character, confidence, and review notes. - Vendored Inventory Kamera `inventorylists` under `data/ik-inventorylists` as artifact reference data.
- Demo triage and build suggestion views. - 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.
- Read-only overlay preview shell. - GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and read-only overlay preview shell.
## Documentation ## Documentation
This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app: This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app:
- [AGENTS.md](AGENTS.md) - [AGENTS.md](AGENTS.md)
- [docs/CURRENT_STATUS.md](docs/CURRENT_STATUS.md)
- [docs/PROJECT.md](docs/PROJECT.md) - [docs/PROJECT.md](docs/PROJECT.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md) - [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
+20
View File
@@ -0,0 +1,20 @@
# IK inventorylists
Quelle: lokales Inventory-Kamera-1.4.4-Paket, Unterordner `inventorylists`
Kopiert am: 2026-07-09
Version laut `version.txt`: `6.7.0`
Geladene Kategorien laut Helper:
- artifacts: `61` Sets / `289` Pieces
- weapons: `247`
- characters: `119`
- materials: `715`
- totalEntries: `1370`
Die Dateien wurden 1:1 uebernommen, damit der native Scanner dieselben Listen
fuer Artefakte, Waffen, Charaktere und Materialien wie Inventory Kamera nutzen
kann. Die App soll diese Daten nur laden und abgleichen, nicht manuell
nachmodellieren.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+717
View File
@@ -0,0 +1,717 @@
{
"aberrantcoreofthedeepshadow": "AberrantCoreOfTheDeepShadow",
"abidingangelfish": "AbidingAngelfish",
"adhigamawood": "AdhigamaWood",
"adventurersexperience": "AdventurersExperience",
"afloweryettobloom": "AFlowerYetToBloom",
"afterglowoflongnightflint": "AfterglowOfLongNightFlint",
"agentssacrificialknife": "AgentsSacrificialKnife",
"agnidusagatechunk": "AgnidusAgateChunk",
"agnidusagatefragment": "AgnidusAgateFragment",
"agnidusagategemstone": "AgnidusAgateGemstone",
"agnidusagatesliver": "AgnidusAgateSliver",
"aizenmedaka": "AizenMedaka",
"ajilenakhnut": "AjilenakhNut",
"akaimaou": "AkaiMaou",
"akossakevessel": "AkosSakeVessel",
"alderwood": "AlderWood",
"alienlifecore": "AlienLifeCore",
"alkahest": "Alkahest",
"almond": "Almond",
"amakumofruit": "AmakumoFruit",
"amethystlump": "AmethystLump",
"araliawood": "AraliaWood",
"artfuldevicefragment": "ArtfulDeviceFragment",
"artfuldeviceinheritance": "ArtfulDeviceInheritance",
"artfuldevicereplica": "ArtfulDeviceReplica",
"artfuldevicewish": "ArtfulDeviceWish",
"artificeddynamicgear": "ArtificedDynamicGear",
"artificedspareclockworkcomponentcoppelia": "ArtificedSpareClockworkComponentCoppelia",
"artificedspareclockworkcomponentcoppelius": "ArtificedSpareClockworkComponentCoppelius",
"ascendedsampleknight": "AscendedSampleKnight",
"ascendedsamplequeen": "AscendedSampleQueen",
"ascendedsamplerook": "AscendedSampleRook",
"ashenaratikuwood": "AshenAratikuWood",
"ashenheart": "AshenHeart",
"ashwood": "AshWood",
"athelwood": "AthelWood",
"aureateradianceofthefarnorthscions": "AureateRadianceOfTheFarNorthScions",
"axisofthesecretsource": "AxisOfTheSecretSource",
"azuregazecrystaleye": "AzuregazeCrystalEye",
"bacon": "Bacon",
"bamboosegment": "BambooSegment",
"bambooshoot": "BambooShoot",
"basaltpillar": "BasaltPillar",
"berry": "Berry",
"berrybait": "BerryBait",
"berylconch": "BerylConch",
"betta": "Betta",
"bewilderingbroadleaf": "BewilderingBroadleaf",
"birchwood": "BirchWood",
"birdegg": "BirdEgg",
"bitofaerosiderite": "BitOfAerosiderite",
"bitterpufferfish": "BitterPufferfish",
"blackbronzehorn": "BlackBronzeHorn",
"blackcrystalhorn": "BlackCrystalHorn",
"blazeoflongnightflint": "BlazeOfLongNightFlint",
"blazingaxeheadfish": "BlazingAxeheadFish",
"blazingheartfeatherbass": "BlazingHeartfeatherBass",
"blazingprismshell": "BlazingPrismshell",
"blazingsacrificialheartshesitance": "BlazingSacrificialHeartsHesitance",
"blazingsacrificialheartsresolve": "BlazingSacrificialHeartsResolve",
"blazingsacrificialheartssplendor": "BlazingSacrificialHeartsSplendor",
"blazingsacrificialheartsterror": "BlazingSacrificialHeartsTerror",
"bloodjadebranch": "BloodjadeBranch",
"bluedye": "BlueDye",
"borderlandbowbillet": "BorderlandBowBillet",
"borderlandcatalystbillet": "BorderlandCatalystBillet",
"borderlandclaymorebillet": "BorderlandClaymoreBillet",
"borderlandpolearmbillet": "BorderlandPolearmBillet",
"borderlandswordbillet": "BorderlandSwordBillet",
"borealwolfsbrokenfang": "BorealWolfsBrokenFang",
"borealwolfscrackedtooth": "BorealWolfsCrackedTooth",
"borealwolfsmilktooth": "BorealWolfsMilkTooth",
"borealwolfsnostalgia": "BorealWolfsNostalgia",
"brightwood": "Brightwood",
"brilliantchrysanthemum": "BrilliantChrysanthemum",
"brilliantdiamondchunk": "BrilliantDiamondChunk",
"brilliantdiamondfragment": "BrilliantDiamondFragment",
"brilliantdiamondgemstone": "BrilliantDiamondGemstone",
"brilliantdiamondsliver": "BrilliantDiamondSliver",
"brokendriveshaft": "BrokenDriveShaft",
"brokengobletofthepristinesea": "BrokenGobletOfThePristineSea",
"brownshirakodai": "BrownShirakodai",
"butter": "Butter",
"butterflywings": "ButterflyWings",
"cabbage": "Cabbage",
"cacahuatl": "Cacahuatl",
"callalily": "CallaLily",
"camandcablesoflaw": "CamAndCablesOfLaw",
"carrot": "Carrot",
"cecilia": "Cecilia",
"ceruleandoppeldrake": "CeruleanDoppeldrake",
"chainsofthedandeliongladiator": "ChainsOfTheDandelionGladiator",
"chaosaxis": "ChaosAxis",
"chaosbolt": "ChaosBolt",
"chaoscircuit": "ChaosCircuit",
"chaoscore": "ChaosCore",
"chaosdevice": "ChaosDevice",
"chaosgear": "ChaosGear",
"chaosmodule": "ChaosModule",
"chaosoculus": "ChaosOculus",
"chaosstorage": "ChaosStorage",
"chapterofanancientchord": "ChapterOfAnAncientChord",
"chasmlightfin": "ChasmlightFin",
"cheese": "Cheese",
"chenyuadeptea": "ChenyuAdeptea",
"chilledmeat": "ChilledMeat",
"chunkofaerosiderite": "ChunkOfAerosiderite",
"cleansingheart": "CleansingHeart",
"clearwaterjade": "ClearwaterJade",
"cloudseamscale": "CloudseamScale",
"coffeebeans": "CoffeeBeans",
"coldcrackedshellshard": "ColdCrackedShellshard",
"commonaxeheadfish": "CommonAxeheadFish",
"compositebowtuningkit": "CompositeBowTuningKit",
"concealedclaw": "ConcealedClaw",
"concealedtalon": "ConcealedTalon",
"concealedunguis": "ConcealedUnguis",
"condessencecrystal": "CondessenceCrystal",
"congealedpupawax": "CongealedPupaWax",
"coppertalismanoftheforestdew": "CopperTalismanOfTheForestDew",
"coralbranchofadistantsea": "CoralBranchOfADistantSea",
"corlapis": "CorLapis",
"counterfeitresin": "CounterfeitResin",
"crab": "Crab",
"crabroe": "CrabRoe",
"crafteditems": "CraftedItems",
"cream": "Cream",
"crownofinsight": "CrownOfInsight",
"crystalchunk": "CrystalChunk",
"crystalcore": "CrystalCore",
"crystalfish": "Crystalfish",
"crystallinebloom": "CrystallineBloom",
"crystallinecystdust": "CrystallineCystDust",
"crystalmarrow": "CrystalMarrow",
"crystalprism": "CrystalPrism",
"cuihuawood": "CuihuaWood",
"cyclicmilitarykuuvahkicore": "CyclicMilitaryKuuvahkiCore",
"cypresswood": "CypressWood",
"dakasbell": "DakasBell",
"damagedmask": "DamagedMask",
"damagedprism": "DamagedPrism",
"dandelionbookmark": "DandelionBookmark",
"dandelionseed": "DandelionSeed",
"darkstatuette": "DarkStatuette",
"dawncatcher": "Dawncatcher",
"deadleylinebranch": "DeadLeyLineBranch",
"deadleylineleaves": "DeadLeyLineLeaves",
"deathlystatuette": "DeathlyStatuette",
"debrisofdecarabianscity": "DebrisOfDecarabiansCity",
"deliriousdecadenceofthesacredlord": "DeliriousDecadenceOfTheSacredLord",
"deliriousdemeanorofthesacredlord": "DeliriousDemeanorOfTheSacredLord",
"deliriousdesolationofthesacredlord": "DeliriousDesolationOfTheSacredLord",
"deliriousdivinityofthesacredlord": "DeliriousDivinityOfTheSacredLord",
"dendrobium": "Dendrobium",
"denialandjudgment": "DenialAndJudgment",
"depletedlunariron": "DepletedLunarIron",
"desiccatedshell": "DesiccatedShell",
"dewofrepudiation": "DewOfRepudiation",
"dismalprism": "DismalPrism",
"divdaray": "DivdaRay",
"divinebodyfromguyun": "DivineBodyFromGuyun",
"divingrapidfightingfish": "DivingRapidfightingFish",
"diviningscroll": "DiviningScroll",
"dormantfungalnucleus": "DormantFungalNucleus",
"dracolite": "Dracolite",
"dragonheirsfalsefin": "DragonheirsFalseFin",
"dragonlordscrown": "DragonLordsCrown",
"dreamofscorchingmight": "DreamOfScorchingMight",
"dreamofthedandeliongladiator": "DreamOfTheDandelionGladiator",
"driedfish": "DriedFish",
"drifterscrystalmarrow": "DriftersCrystalMarrow",
"dropoftaintedwater": "DropOfTaintedWater",
"drossofpuresacreddewdrop": "DrossOfPureSacredDewdrop",
"dusksunfish": "DuskSunfish",
"dvalinsclaw": "DvalinsClaw",
"dvalinsplume": "DvalinsPlume",
"dvalinssigh": "DvalinsSigh",
"echoofanancientchord": "EchoOfAnAncientChord",
"echoofscorchingmight": "EchoOfScorchingMight",
"eelmeat": "EelMeat",
"electrocrystal": "ElectroCrystal",
"elixiroftheheretic": "ElixirOfTheHeretic",
"embercoreflower": "EmbercoreFlower",
"emberglowbait": "EmberglowBait",
"emberoflongnightflint": "EmberOfLongNightFlint",
"emperorsbalsam": "EmperorsBalsam",
"emperorsresolution": "EmperorsResolution",
"empowereddragontooth": "EmpoweredDragontooth",
"energynectar": "EnergyNectar",
"enhancementore": "EnhancementOre",
"ensnaringgaze": "EnsnaringGaze",
"erodedhorn": "ErodedHorn",
"erodedscalefeather": "ErodedScaleFeather",
"erodedsunfire": "ErodedSunfire",
"essenceofpuresacreddewdrop": "EssenceOfPureSacredDewdrop",
"etherwingmoth": "EtherwingMoth",
"everamber": "Everamber",
"everflameseed": "EverflameSeed",
"evergloomring": "EvergloomRing",
"exaltedearth": "ExaltedEarth",
"fabric": "Fabric",
"fadedflaminghilt": "FadedFlamingHilt",
"fadedredsatin": "FadedRedSatin",
"fadingcandle": "FadingCandle",
"fakeflybait": "FakeFlyBait",
"falsewormbait": "FalseWormBait",
"famedhandguard": "FamedHandguard",
"featheryfin": "FeatheryFin",
"fermentedjuice": "FermentedJuice",
"festeringdragonmarrow": "FesteringDragonMarrow",
"fettersofthedandeliongladiator": "FettersOfTheDandelionGladiator",
"fineenhancementore": "FineEnhancementOre",
"firmarrowhead": "FirmArrowhead",
"firwood": "FirWood",
"fish": "Fish",
"flamingflowerstamen": "FlamingFlowerStamen",
"flammabombwood": "FlammabombWood",
"flareoflongnightflint": "FlareOfLongNightFlint",
"flashingmaintenancemekbait": "FlashingMaintenanceMekBait",
"floralrapidfightingfish": "FloralRapidfightingFish",
"flour": "Flour",
"fluorescentfungus": "FluorescentFungus",
"fontemerunihorn": "FontemerUnihorn",
"forbiddencursescroll": "ForbiddenCurseScroll",
"foreignsynapse": "ForeignSynapse",
"formaloray": "FormaloRay",
"fossilizedboneshard": "FossilizedBoneShard",
"fowl": "Fowl",
"fowls": "FowlS",
"fracturedeyeofthedeepshadow": "FracturedEyeOfTheDeepShadow",
"fracturedflaminghilt": "FracturedFlamingHilt",
"fracturedlunariron": "FracturedLunarIron",
"fragileboneshard": "FragileBoneShard",
"fragmentofagoldenmelody": "FragmentOfAGoldenMelody",
"fragmentofanancientchord": "FragmentOfAnAncientChord",
"fragmentofdecarabiansepic": "FragmentOfDecarabiansEpic",
"fragmentsofinnocence": "FragmentsOfInnocence",
"fragrantcedarwood": "FragrantCedarWood",
"frog": "Frog",
"frostedaxeheadfish": "FrostedAxeheadFish",
"frostetchedwarrant": "FrostEtchedWarrant",
"frostlampflower": "FrostlampFlower",
"frostnightsglimmer": "FrostnightsGlimmer",
"frostnightsglory": "FrostnightsGlory",
"frostnightsglow": "FrostnightsGlow",
"fruitpastebait": "FruitPasteBait",
"fungalspores": "FungalSpores",
"gildedscale": "GildedScale",
"glabrousbeans": "GlabrousBeans",
"glazelily": "GlazeLily",
"glazemedaka": "GlazeMedaka",
"gloomystatuette": "GloomyStatuette",
"glowgrassbait": "GlowgrassBait",
"glowinggem": "GlowingGem",
"glowinghornshroom": "GlowingHornshroom",
"glowingremains": "GlowingRemains",
"goldenbranchofadistantsea": "GoldenBranchOfADistantSea",
"goldengobletofthepristinesea": "GoldenGobletOfThePristineSea",
"goldenkoi": "GoldenKoi",
"goldenraveninsignia": "GoldenRavenInsignia",
"goldenstrings": "GoldenStrings",
"goldentalismanoftheforestdew": "GoldenTalismanOfTheForestDew",
"goldinscribedsecretsourcecore": "GoldInscribedSecretSourceCore",
"grainfruit": "Grainfruit",
"grainofaerosiderite": "GrainOfAerosiderite",
"greenwavesunfish": "GreenwaveSunfish",
"guidetoadmonition": "GuideToAdmonition",
"guidetoballad": "GuideToBallad",
"guidetoconflict": "GuideToConflict",
"guidetocontention": "GuideToContention",
"guidetodiligence": "GuideToDiligence",
"guidetoelegance": "GuideToElegance",
"guidetoelysium": "GuideToElysium",
"guidetoequity": "GuideToEquity",
"guidetofreedom": "GuideToFreedom",
"guidetogold": "GuideToGold",
"guidetoingenuity": "GuideToIngenuity",
"guidetojustice": "GuideToJustice",
"guidetokindling": "GuideToKindling",
"guidetolight": "GuideToLight",
"guidetomoonlight": "GuideToMoonlight",
"guidetoorder": "GuideToOrder",
"guidetopraxis": "GuideToPraxis",
"guidetoprosperity": "GuideToProsperity",
"guidetoresistance": "GuideToResistance",
"guidetotransience": "GuideToTransience",
"guidetovagrancy": "GuideToVagrancy",
"halcyonjadeaxemarlin": "HalcyonJadeAxeMarlin",
"ham": "Ham",
"harrafruit": "HarraFruit",
"hazelnutwood": "HazelnutWood",
"heartofthesecretsource": "HeartOfTheSecretSource",
"heavyhorn": "HeavyHorn",
"hellfirebutterfly": "HellfireButterfly",
"hennaberry": "HennaBerry",
"heroswit": "HerosWit",
"hoarfrostcore": "HoarfrostCore",
"hookedbeakofthedeepshadow": "HookedBeakOfTheDeepShadow",
"horsetail": "Horsetail",
"hunterssacrificialknife": "HuntersSacrificialKnife",
"hurricaneseed": "HurricaneSeed",
"icypebble": "IcyPebble",
"ignitedseedoflife": "IgnitedSeedOfLife",
"ignitedseeingeye": "IgnitedSeeingEye",
"ignitedstone": "IgnitedStone",
"illusoryleafcoil": "IllusoryLeafcoil",
"immaculatewarrant": "ImmaculateWarrant",
"inactivatedfungalnucleus": "InactivatedFungalNucleus",
"inspectorssacrificialknife": "InspectorsSacrificialKnife",
"ironchunk": "IronChunk",
"irontalismanoftheforestdew": "IronTalismanOfTheForestDew",
"jadebranchofadistantsea": "JadeBranchOfADistantSea",
"jadeheartfeatherbass": "JadeHeartfeatherBass",
"jam": "Jam",
"jeweledbranchofadistantsea": "JeweledBranchOfADistantSea",
"jeweledflaminghilt": "JeweledFlamingHilt",
"jueyunchili": "JueyunChili",
"juvenilefang": "JuvenileFang",
"juvenilejade": "JuvenileJade",
"kageuchihandguard": "KageuchiHandguard",
"kalpalatalotus": "KalpalataLotus",
"karmaphalawood": "KarmaphalaWood",
"lakelightlily": "LakelightLily",
"lakkaberry": "Lakkaberry",
"lanternfiber": "LanternFiber",
"lavendermelon": "LavenderMelon",
"lazuriteaxemarlin": "LazuriteAxeMarlin",
"leylinesprout": "LeyLineSprout",
"lieutenantsinsignia": "LieutenantsInsignia",
"lightbearingscalefeather": "LightbearingScaleFeather",
"lightguidingtetrahedron": "LightGuidingTetrahedron",
"lightlessbone": "LightlessBone",
"lightlesseyeofthemaelstrom": "LightlessEyeOfTheMaelstrom",
"lightlessmass": "LightlessMass",
"lightlesssilkstring": "LightlessSilkString",
"lightningprism": "LightningPrism",
"lindenwood": "LindenWood",
"lizardtail": "LizardTail",
"loachpearl": "LoachPearl",
"locusofaclearwill": "LocusOfAClearWill",
"lotushead": "LotusHead",
"lumidoucebell": "LumidouceBell",
"luminescentpollen": "LuminescentPollen",
"luminescentspine": "LuminescentSpine",
"luminoussandsfromguyun": "LuminousSandsFromGuyun",
"lumitoile": "Lumitoile",
"lunarfin": "LunarFin",
"lungedstickleback": "LungedStickleback",
"lustrousstonefromguyun": "LustrousStoneFromGuyun",
"madmansrestraint": "MadmansRestraint",
"magicalcrystalchunk": "MagicalCrystalChunk",
"magmarapidfightingfish": "MagmaRapidfightingFish",
"maintenancemekgoldleader": "MaintenanceMekGoldLeader",
"maintenancemekinitialconfiguration": "MaintenanceMekInitialConfiguration",
"maintenancemekplatinumcollection": "MaintenanceMekPlatinumCollection",
"maintenancemeksituationcontroller": "MaintenanceMekSituationController",
"maintenancemekwaterbodycleaner": "MaintenanceMekWaterBodyCleaner",
"majestichookedbeak": "MajesticHookedBeak",
"mallowwood": "MallowWood",
"maplewood": "MapleWood",
"marcotte": "Marcotte",
"marionettecore": "MarionetteCore",
"markedshell": "MarkedShell",
"markofthebindingblessing": "MarkOfTheBindingBlessing",
"martensomnifix": "MartensOmniFix",
"maskofthekijin": "MaskOfTheKijin",
"maskoftheonehorned": "MaskOfTheOneHorned",
"maskofthetigersbite": "MaskOfTheTigersBite",
"maskofthevirtuousdoctor": "MaskOfTheVirtuousDoctor",
"maskofthewickedlieutenant": "MaskOfTheWickedLieutenant",
"matsutake": "Matsutake",
"mechanicalspurgear": "MechanicalSpurGear",
"medaka": "Medaka",
"meshinggear": "MeshingGear",
"midlanderbowbillet": "MidlanderBowBillet",
"midlandercatalystbillet": "MidlanderCatalystBillet",
"midlanderclaymorebillet": "MidlanderClaymoreBillet",
"midlanderpolearmbillet": "MidlanderPolearmBillet",
"midlanderswordbillet": "MidlanderSwordBillet",
"midsommarberry": "MidsommarBerry",
"milk": "Milk",
"mint": "Mint",
"mirrorofmushin": "MirrorOfMushin",
"mistflowercorolla": "MistFlowerCorolla",
"mistgrass": "MistGrass",
"mistgrasspollen": "MistGrassPollen",
"mistgrasswick": "MistGrassWick",
"mistshroudhelmet": "MistshroudHelmet",
"mistshroudmanifestation": "MistshroudManifestation",
"mistshroudplate": "MistshroudPlate",
"mistveiledgoldelixir": "MistVeiledGoldElixir",
"mistveiledleadelixir": "MistVeiledLeadElixir",
"mistveiledmercuryelixir": "MistVeiledMercuryElixir",
"mistveiledprimoelixir": "MistVeiledPrimoElixir",
"moltenmoment": "MoltenMoment",
"moonfallsilver": "MoonfallSilver",
"mountaindatewood": "MountainDateWood",
"mourningflower": "MourningFlower",
"movementofanancientchord": "MovementOfAnAncientChord",
"mudraofthemaleficgeneral": "MudraOfTheMaleficGeneral",
"mushroom": "Mushroom",
"mysteriousmeat": "MysteriousMeat",
"mysticenhancementore": "MysticEnhancementOre",
"nagadusemeraldchunk": "NagadusEmeraldChunk",
"nagadusemeraldfragment": "NagadusEmeraldFragment",
"nagadusemeraldgemstone": "NagadusEmeraldGemstone",
"nagadusemeraldsliver": "NagadusEmeraldSliver",
"nakuweed": "NakuWeed",
"narukamisaffection": "NarukamisAffection",
"narukamisjoy": "NarukamisJoy",
"narukamisvalor": "NarukamisValor",
"narukamiswisdom": "NarukamisWisdom",
"neonmaulershark": "NeonMaulerShark",
"newborntaintedhydrophantasm": "NewbornTaintedHydroPhantasm",
"nightgazecrystaleye": "NightgazeCrystalEye",
"nightwindsmysticaugury": "NightWindsMysticAugury",
"nightwindsmysticconsideration": "NightWindsMysticConsideration",
"nightwindsmysticpremonition": "NightWindsMysticPremonition",
"nightwindsmysticrevelation": "NightWindsMysticRevelation",
"nilotpalalotus": "NilotpalaLotus",
"noctilucousjade": "NoctilucousJade",
"nocturnalblossom": "NocturnalBlossom",
"northlanderbowbillet": "NorthlanderBowBillet",
"northlandercatalystbillet": "NorthlanderCatalystBillet",
"northlanderclaymorebillet": "NorthlanderClaymoreBillet",
"northlanderpolearmbillet": "NorthlanderPolearmBillet",
"northlanderswordbillet": "NorthlanderSwordBillet",
"oasisgardenskindness": "OasisGardensKindness",
"oasisgardensmourning": "OasisGardensMourning",
"oasisgardensreminiscence": "OasisGardensReminiscence",
"oasisgardenstruth": "OasisGardensTruth",
"oblationofthefarnorthscions": "OblationOfTheFarNorthScions",
"ointmentofsight": "OintmentOfSight",
"oldendaysofscorchingmight": "OldenDaysOfScorchingMight",
"oldhandguard": "OldHandguard",
"oldoperativespocketwatch": "OldOperativesPocketWatch",
"ominousmask": "OminousMask",
"onikabuto": "Onikabuto",
"onion": "Onion",
"operativesconstancy": "OperativesConstancy",
"operativesstandardpocketwatch": "OperativesStandardPocketWatch",
"originalfishointment": "OriginalFishOintment",
"otogiwood": "OtogiWood",
"overripeflamegranate": "OverripeFlamegranate",
"padisarah": "Padisarah",
"parasoltalcum": "ParasolTalcum",
"peachofthedeepwaves": "PeachOfTheDeepWaves",
"peachpalmwood": "PeachPalmWood",
"pedunculateoakwood": "PedunculateOakWood",
"pepper": "Pepper",
"perpetualcaliber": "PerpetualCaliber",
"perpetualheart": "PerpetualHeart",
"philanemomushroom": "PhilanemoMushroom",
"philosophiesofadmonition": "PhilosophiesOfAdmonition",
"philosophiesofballad": "PhilosophiesOfBallad",
"philosophiesofconflict": "PhilosophiesOfConflict",
"philosophiesofcontention": "PhilosophiesOfContention",
"philosophiesofdiligence": "PhilosophiesOfDiligence",
"philosophiesofelegance": "PhilosophiesOfElegance",
"philosophiesofelysium": "PhilosophiesOfElysium",
"philosophiesofequity": "PhilosophiesOfEquity",
"philosophiesoffreedom": "PhilosophiesOfFreedom",
"philosophiesofgold": "PhilosophiesOfGold",
"philosophiesofingenuity": "PhilosophiesOfIngenuity",
"philosophiesofjustice": "PhilosophiesOfJustice",
"philosophiesofkindling": "PhilosophiesOfKindling",
"philosophiesoflight": "PhilosophiesOfLight",
"philosophiesofmoonlight": "PhilosophiesOfMoonlight",
"philosophiesoforder": "PhilosophiesOfOrder",
"philosophiesofpraxis": "PhilosophiesOfPraxis",
"philosophiesofprosperity": "PhilosophiesOfProsperity",
"philosophiesofresistance": "PhilosophiesOfResistance",
"philosophiesoftransience": "PhilosophiesOfTransience",
"philosophiesofvagrancy": "PhilosophiesOfVagrancy",
"phonyphlogistonunihornfish": "PhonyPhlogistonUnihornfish",
"pieceofaerosiderite": "PieceOfAerosiderite",
"pineamber": "PineAmber",
"pinecone": "Pinecone",
"pinewood": "PineWood",
"plaustriteshard": "PlaustriteShard",
"pluielotus": "PluieLotus",
"plumeofthechangingwinds": "PlumeOfTheChangingWinds",
"plumeofthefallenwatcher": "PlumeOfTheFallenWatcher",
"polarizingprism": "PolarizingPrism",
"portablebearing": "PortableBearing",
"potato": "Potato",
"precisiondriveshaft": "PrecisionDriveShaft",
"precisionkuuvahkistampingdie": "PrecisionKuuvahkiStampingDie",
"primordialessence": "PrimordialEssence",
"primordialgreenbloom": "PrimordialGreenbloom",
"prismaticseveredtail": "PrismaticSeveredTail",
"prithivatopazchunk": "PrithivaTopazChunk",
"prithivatopazfragment": "PrithivaTopazFragment",
"prithivatopazgemstone": "PrithivaTopazGemstone",
"prithivatopazsliver": "PrithivaTopazSliver",
"profanedsprout": "ProfanedSprout",
"pseudosharkunihornfish": "PseudosharkUnihornfish",
"pseudostamens": "PseudoStamens",
"pufferfish": "Pufferfish",
"puppetstrings": "PuppetStrings",
"purpleshirakodai": "PurpleShirakodai",
"qingxin": "Qingxin",
"quelledcreeper": "QuelledCreeper",
"quenepaberry": "QuenepaBerry",
"radiantantler": "RadiantAntler",
"radiantexoskeleton": "RadiantExoskeleton",
"radiantprism": "RadiantPrism",
"radish": "Radish",
"raimeiangelfish": "RaimeiAngelfish",
"rainbowdropcrystal": "RainbowdropCrystal",
"rainbowrose": "RainbowRose",
"rawmeat": "RawMeat",
"rawmeats": "RawMeatS",
"recruitsinsignia": "RecruitsInsignia",
"redberryshroom": "RedBerryshroom",
"reddye": "RedDye",
"redrotbait": "RedrotBait",
"refinedheart": "RefinedHeart",
"refractivebud": "RefractiveBud",
"refreshinglakkabait": "RefreshingLakkaBait",
"reinforceddriveshaft": "ReinforcedDriveShaft",
"relicfromguyun": "RelicFromGuyun",
"remnantglowofscorchingmight": "RemnantGlowOfScorchingMight",
"remnantofthedreadwing": "RemnantOfTheDreadwing",
"rice": "Rice",
"richredbrocade": "RichRedBrocade",
"riftbornregalia": "RiftbornRegalia",
"riftcore": "RiftCore",
"ringofboreas": "RingOfBoreas",
"ripplingheartfeatherbass": "RipplingHeartfeatherBass",
"robustfungalnucleus": "RobustFungalNucleus",
"romaritimeflower": "RomaritimeFlower",
"ruinedhilt": "RuinedHilt",
"rukkhashavamushrooms": "RukkhashavaMushrooms",
"runicfang": "RunicFang",
"rustykoi": "RustyKoi",
"rye": "Rye",
"ryeflour": "RyeFlour",
"sakurabloom": "SakuraBloom",
"salt": "Salt",
"sandbearerwood": "SandbearerWood",
"sandgreasepupa": "SandGreasePupa",
"sandstormangler": "SandstormAngler",
"sangopearl": "SangoPearl",
"saurianclawsucculent": "SaurianClawSucculent",
"sauriancrownedwarriorsgoldenwhistle": "SaurianCrownedWarriorsGoldenWhistle",
"sausage": "Sausage",
"scarab": "Scarab",
"scatteredpieceofdecarabiansdream": "ScatteredPieceOfDecarabiansDream",
"scoopoftaintedwater": "ScoopOfTaintedWater",
"seaganoderma": "SeaGanoderma",
"seagrass": "Seagrass",
"sealedscroll": "SealedScroll",
"seasonedfang": "SeasonedFang",
"secretsourceairflowaccumulator": "SecretSourceAirflowAccumulator",
"secretsourcescoutsweeper": "SecretSourceScoutSweeper",
"sentryswoodenwhistle": "SentrysWoodenWhistle",
"sergeantsinsignia": "SergeantsInsignia",
"shacklesofthedandeliongladiator": "ShacklesOfTheDandelionGladiator",
"shadowofthewarrior": "ShadowOfTheWarrior",
"shardofafoullegacy": "ShardOfAFoulLegacy",
"shardofashatteredwill": "ShardOfAShatteredWill",
"sharparrowhead": "SharpArrowhead",
"sheathofthesecretsource": "SheathOfTheSecretSource",
"shimmeringnectar": "ShimmeringNectar",
"shivadajadechunk": "ShivadaJadeChunk",
"shivadajadefragment": "ShivadaJadeFragment",
"shivadajadegemstone": "ShivadaJadeGemstone",
"shivadajadesliver": "ShivadaJadeSliver",
"shrimpmeat": "ShrimpMeat",
"shuttleofodara": "ShuttleOfOdara",
"sigilofastridingwill": "SigilOfAStridingWill",
"silkenfeather": "SilkenFeather",
"silkflower": "SilkFlower",
"silverfirwood": "SilverFirWood",
"silvergobletofthepristinesea": "SilverGobletOfThePristineSea",
"silverlotus": "SilverLotus",
"silverraveninsignia": "SilverRavenInsignia",
"silvertalismanoftheforestdew": "SilverTalismanOfTheForestDew",
"skysplitgembloom": "SkysplitGembloom",
"slimeconcentrate": "SlimeConcentrate",
"slimecondensate": "SlimeCondensate",
"slimesecretions": "SlimeSecretions",
"smalllampgrass": "SmallLampGrass",
"smetana": "Smetana",
"smokedfish": "SmokedFish",
"smokedfowl": "SmokedFowl",
"smolderingpearl": "SmolderingPearl",
"smolderingphosphorescentflame": "SmolderingPhosphorescentFlame",
"snapdragon": "Snapdragon",
"snowstrider": "Snowstrider",
"sourbait": "SourBait",
"sparklessstatuecore": "SparklessStatueCore",
"spectralheart": "SpectralHeart",
"spectralhusk": "SpectralHusk",
"spectralnucleus": "SpectralNucleus",
"spice": "Spice",
"spinelfruit": "SpinelFruit",
"spinelgrainbait": "SpinelgrainBait",
"spiritlocketofboreas": "SpiritLocketOfBoreas",
"splinteredhilt": "SplinteredHilt",
"sprayfeathergill": "SprayfeatherGill",
"springofpuresacreddewdrop": "SpringOfPureSacredDewdrop",
"springofthefirstdewdrop": "SpringOfTheFirstDewdrop",
"stainedmask": "StainedMask",
"starconch": "Starconch",
"starsilver": "Starsilver",
"stillsmolderinghilt": "StillSmolderingHilt",
"stormbeads": "StormBeads",
"strangetooth": "StrangeTooth",
"streamingaxemarlin": "StreamingAxeMarlin",
"sturdyboneshard": "SturdyBoneShard",
"sturdyshell": "SturdyShell",
"subdetectionunit": "SubdetectionUnit",
"sublimationofpuresacreddewdrop": "SublimationOfPureSacredDewdrop",
"sugar": "Sugar",
"sugardewbait": "SugardewBait",
"sumerurose": "SumeruRose",
"sunderedgloryofthefarnorthscions": "SunderedGloryOfTheFarNorthScions",
"sunsetcloudangler": "SunsetCloudAngler",
"superduperinvincibleshiningsparklymagiccrystal": "SuperDuperInvincibleShiningSparklyMagicCrystal",
"surgingsacredchalice": "SurgingSacredChalice",
"sweetflower": "SweetFlower",
"sweetflowermedaka": "SweetFlowerMedaka",
"tailofboreas": "TailOfBoreas",
"talismanoftheenigmaticland": "TalismanOfTheEnigmaticLand",
"tatteredwarrant": "TatteredWarrant",
"teachingsofadmonition": "TeachingsOfAdmonition",
"teachingsofballad": "TeachingsOfBallad",
"teachingsofconflict": "TeachingsOfConflict",
"teachingsofcontention": "TeachingsOfContention",
"teachingsofdiligence": "TeachingsOfDiligence",
"teachingsofelegance": "TeachingsOfElegance",
"teachingsofelysium": "TeachingsOfElysium",
"teachingsofequity": "TeachingsOfEquity",
"teachingsoffreedom": "TeachingsOfFreedom",
"teachingsofgold": "TeachingsOfGold",
"teachingsofingenuity": "TeachingsOfIngenuity",
"teachingsofjustice": "TeachingsOfJustice",
"teachingsofkindling": "TeachingsOfKindling",
"teachingsoflight": "TeachingsOfLight",
"teachingsofmoonlight": "TeachingsOfMoonlight",
"teachingsoforder": "TeachingsOfOrder",
"teachingsofpraxis": "TeachingsOfPraxis",
"teachingsofprosperity": "TeachingsOfProsperity",
"teachingsofresistance": "TeachingsOfResistance",
"teachingsoftransience": "TeachingsOfTransience",
"teachingsofvagrancy": "TeachingsOfVagrancy",
"teacoloredshirakodai": "TeaColoredShirakodai",
"teardropofthemoon": "TeardropOfTheMoon",
"tearsofthecalamitousgod": "TearsOfTheCalamitousGod",
"thecornerstoneofstarsandflames": "TheCornerstoneOfStarsAndFlames",
"themeaningofaeons": "TheMeaningOfAeons",
"theseassilentshade": "TheSeasSilentShade",
"thevisiblewinds": "TheVisibleWinds",
"thunderclapfruitcore": "ThunderclapFruitcore",
"tidalga": "Tidalga",
"tileofdecarabianstower": "TileOfDecarabiansTower",
"tofu": "Tofu",
"tomato": "Tomato",
"torchwood": "TorchWood",
"tourbillondevice": "TourbillonDevice",
"transoceanicchunk": "TransoceanicChunk",
"transoceanicpearl": "TransoceanicPearl",
"treasuredflower": "TreasuredFlower",
"treasurehoarderinsignia": "TreasureHoarderInsignia",
"trimmedredsilk": "TrimmedRedSilk",
"trishiraite": "Trishiraite",
"truefruitangler": "TrueFruitAngler",
"turbidprism": "TurbidPrism",
"tuskofmonoceroscaeli": "TuskOfMonocerosCaeli",
"twistedwitheredbranch": "TwistedWitheredBranch",
"tyrantsfang": "TyrantsFang",
"unblemishedlunariron": "UnblemishedLunarIron",
"unfadingsilkygrace": "UnfadingSilkyGrace",
"unyieldingdelusionofthefarnorthscions": "UnyieldingDelusionOfTheFarNorthScions",
"vajradaamethystchunk": "VajradaAmethystChunk",
"vajradaamethystfragment": "VajradaAmethystFragment",
"vajradaamethystgemstone": "VajradaAmethystGemstone",
"vajradaamethystsliver": "VajradaAmethystSliver",
"valberry": "Valberry",
"varunadalazuritechunk": "VarunadaLazuriteChunk",
"varunadalazuritefragment": "VarunadaLazuriteFragment",
"varunadalazuritegemstone": "VarunadaLazuriteGemstone",
"varunadalazuritesliver": "VarunadaLazuriteSliver",
"vayudaturquoisechunk": "VayudaTurquoiseChunk",
"vayudaturquoisefragment": "VayudaTurquoiseFragment",
"vayudaturquoisegemstone": "VayudaTurquoiseGemstone",
"vayudaturquoisesliver": "VayudaTurquoiseSliver",
"veggiemaulershark": "VeggieMaulerShark",
"venomspinefish": "VenomspineFish",
"violetgrass": "Violetgrass",
"viparyas": "Viparyas",
"wanderersadvice": "WanderersAdvice",
"wanderersbloomingflower": "WanderersBloomingFlower",
"warmbackshell": "WarmBackShell",
"warriorsmetalwhistle": "WarriorsMetalWhistle",
"waterthatfailedtotranscend": "WaterThatFailedToTranscend",
"weatheredarrowhead": "WeatheredArrowhead",
"wheat": "Wheat",
"whitechestnutoakwood": "WhiteChestnutOakWood",
"whiteironchunk": "WhiteIronChunk",
"whopperflowernectar": "WhopperflowerNectar",
"wickmaterial": "WickMaterial",
"windrestflower": "WindrestFlower",
"windwheelaster": "WindwheelAster",
"winegobletofthepristinesea": "WineGobletOfThePristineSea",
"wintericelea": "WinterIcelea",
"witheringpurpurbloom": "WitheringPurpurbloom",
"wolfhook": "Wolfhook",
"worldspanfern": "WorldspanFern",
"xenochromaticcrystal": "XenochromaticCrystal",
"yellowdye": "YellowDye",
"yumemiruwood": "YumemiruWood",
"zaytunpeach": "ZaytunPeach"
}
+1
View File
@@ -0,0 +1 @@
6.7.0
+249
View File
@@ -0,0 +1,249 @@
{
"absolution": "Absolution",
"akuoumaru": "Akuoumaru",
"alleyhunter": "AlleyHunter",
"amberbead": "AmberBead",
"amenomakageuchi": "AmenomaKageuchi",
"amosbow": "AmosBow",
"angelosheptades": "AngelosHeptades",
"apprenticesnotes": "ApprenticesNotes",
"aquasimulacra": "AquaSimulacra",
"aquilafavonia": "AquilaFavonia",
"ashgravendrinkinghorn": "AshGravenDrinkingHorn",
"astralvulturescrimsonplumage": "AstralVulturesCrimsonPlumage",
"ateaspoonoftranscendence": "ATeaspoonOfTranscendence",
"athameartis": "AthameArtis",
"athousandblazingsuns": "AThousandBlazingSuns",
"athousandfloatingdreams": "AThousandFloatingDreams",
"azurelight": "Azurelight",
"balladoftheboundlessblue": "BalladOfTheBoundlessBlue",
"balladofthefjords": "BalladOfTheFjords",
"beaconofthereedsea": "BeaconOfTheReedSea",
"beginnersprotector": "BeginnersProtector",
"blackcliffagate": "BlackcliffAgate",
"blackclifflongsword": "BlackcliffLongsword",
"blackcliffpole": "BlackcliffPole",
"blackcliffslasher": "BlackcliffSlasher",
"blackcliffwarbow": "BlackcliffWarbow",
"blackmarrowlantern": "BlackmarrowLantern",
"blacktassel": "BlackTassel",
"bloodsoakedruins": "BloodsoakedRuins",
"bloodtaintedgreatsword": "BloodtaintedGreatsword",
"calamityofeshu": "CalamityOfEshu",
"calamityqueller": "CalamityQueller",
"cashflowsupervision": "CashflowSupervision",
"chainbreaker": "ChainBreaker",
"cinnabarspindle": "CinnabarSpindle",
"cloudforged": "Cloudforged",
"compoundbow": "CompoundBow",
"coolsteel": "CoolSteel",
"cranesechoingcall": "CranesEchoingCall",
"crescentpike": "CrescentPike",
"crimsonmoonssemblance": "CrimsonMoonsSemblance",
"darkironsword": "DarkIronSword",
"dawningfrost": "DawningFrost",
"deathmatch": "Deathmatch",
"debateclub": "DebateClub",
"deicide": "Deicide",
"dialoguesofthedesertsages": "DialoguesOfTheDesertSages",
"disasterandremorse": "DisasterAndRemorse",
"dodocotales": "DodocoTales",
"dragonsbane": "DragonsBane",
"dragonspinespear": "DragonspineSpear",
"dullblade": "DullBlade",
"earthshaker": "EarthShaker",
"ebonybow": "EbonyBow",
"elegyfortheend": "ElegyForTheEnd",
"emeraldorb": "EmeraldOrb",
"endoftheline": "EndOfTheLine",
"engulfinglightning": "EngulfingLightning",
"etherlightspindlelute": "EtherlightSpindlelute",
"everlastingmoonglow": "EverlastingMoonglow",
"eyeofperception": "EyeOfPerception",
"fadingtwilight": "FadingTwilight",
"fangofthemountainking": "FangOfTheMountainKing",
"favoniuscodex": "FavoniusCodex",
"favoniusgreatsword": "FavoniusGreatsword",
"favoniuslance": "FavoniusLance",
"favoniussword": "FavoniusSword",
"favoniuswarbow": "FavoniusWarbow",
"ferrousshadow": "FerrousShadow",
"festeringdesire": "FesteringDesire",
"filletblade": "FilletBlade",
"finaleofthedeep": "FinaleOfTheDeep",
"flameforgedinsight": "FlameForgedInsight",
"fleuvecendreferryman": "FleuveCendreFerryman",
"flowerwreathedfeathers": "FlowerWreathedFeathers",
"flowingpurity": "FlowingPurity",
"fluteofezpitzal": "FluteOfEzpitzal",
"footprintoftherainbow": "FootprintOfTheRainbow",
"forestregalia": "ForestRegalia",
"fracturedhalo": "FracturedHalo",
"freedomsworn": "FreedomSworn",
"frostbearer": "Frostbearer",
"fruitfulhook": "FruitfulHook",
"fruitoffulfillment": "FruitOfFulfillment",
"gestofthemightywolf": "GestOfTheMightyWolf",
"goldenfrostboundoath": "GoldenFrostboundOath",
"hakushinring": "HakushinRing",
"halberd": "Halberd",
"hamayumi": "Hamayumi",
"harangeppakufutsu": "HaranGeppakuFutsu",
"harbingerofdawn": "HarbingerOfDawn",
"huntersbow": "HuntersBow",
"hunterspath": "HuntersPath",
"ibispiercer": "IbisPiercer",
"ironpoint": "IronPoint",
"ironsting": "IronSting",
"jadefallssplendor": "JadefallsSplendor",
"kagotsurubeisshin": "KagotsurubeIsshin",
"kagurasverity": "KagurasVerity",
"katsuragikirinagamasa": "KatsuragikiriNagamasa",
"keyofkhajnisut": "KeyOfKhajNisut",
"kingssquire": "KingsSquire",
"kitaincrossspear": "KitainCrossSpear",
"kunwuswyrmbane": "KunwusWyrmbane",
"lightbearingmoonshard": "LightbearingMoonshard",
"lightoffoliarincision": "LightOfFoliarIncision",
"lionsroar": "LionsRoar",
"lithicblade": "LithicBlade",
"lithicspear": "LithicSpear",
"lostballade": "LostBallade",
"lostprayertothesacredwinds": "LostPrayerToTheSacredWinds",
"lumidouceelegy": "LumidouceElegy",
"luxurioussealord": "LuxuriousSeaLord",
"magicguide": "MagicGuide",
"mailedflower": "MailedFlower",
"makhairaaquamarine": "MakhairaAquamarine",
"mappamare": "MappaMare",
"masterkey": "MasterKey",
"memoryofdust": "MemoryOfDust",
"messenger": "Messenger",
"mirrorbreaker": "MirrorBreaker",
"missivewindspear": "MissiveWindspear",
"mistsplitterreforged": "MistsplitterReforged",
"mitternachtswaltz": "MitternachtsWaltz",
"moonpiercer": "Moonpiercer",
"moonweaversdawn": "MoonweaversDawn",
"mountainbracingbolt": "MountainBracingBolt",
"mouunsmoon": "MouunsMoon",
"nightweaverslookingglass": "NightweaversLookingGlass",
"nocturnescurtaincall": "NocturnesCurtainCall",
"oathsworneye": "OathswornEye",
"oldmercspal": "OldMercsPal",
"oneside": "OneSide",
"otherworldlystory": "OtherworldlyStory",
"peakpatrolsong": "PeakPatrolSong",
"pocketgrimoire": "PocketGrimoire",
"polarstar": "PolarStar",
"portablepowersaw": "PortablePowerSaw",
"predator": "Predator",
"primordialjadecutter": "PrimordialJadeCutter",
"primordialjadegreatsword": "PrimordialJadeGreatsword",
"primordialjadevista": "PrimordialJadeVista",
"primordialjadewingedspear": "PrimordialJadeWingedSpear",
"prizedisshinblade": "PrizedIsshinBlade",
"prospectorsdrill": "ProspectorsDrill",
"prospectorsshovel": "ProspectorsShovel",
"prototypeamber": "PrototypeAmber",
"prototypearchaic": "PrototypeArchaic",
"prototypecrescent": "PrototypeCrescent",
"prototyperancour": "PrototypeRancour",
"prototypestarglitter": "PrototypeStarglitter",
"quartz": "Quartz",
"rainbowserpentsrainbow": "RainbowSerpentsRainBow",
"rainslasher": "Rainslasher",
"rangegauge": "RangeGauge",
"ravenbow": "RavenBow",
"recurvebow": "RecurveBow",
"redhornstonethresher": "RedhornStonethresher",
"reliquaryoftruth": "ReliquaryOfTruth",
"rightfulreward": "RightfulReward",
"ringofyaxche": "RingOfYaxche",
"royalbow": "RoyalBow",
"royalgreatsword": "RoyalGreatsword",
"royalgrimoire": "RoyalGrimoire",
"royallongsword": "RoyalLongsword",
"royalspear": "RoyalSpear",
"rust": "Rust",
"sacrificersstaff": "SacrificersStaff",
"sacrificialbow": "SacrificialBow",
"sacrificialfragments": "SacrificialFragments",
"sacrificialgreatsword": "SacrificialGreatsword",
"sacrificialjade": "SacrificialJade",
"sacrificialsword": "SacrificialSword",
"sapwoodblade": "SapwoodBlade",
"scionoftheblazingsun": "ScionOfTheBlazingSun",
"seasonedhuntersbow": "SeasonedHuntersBow",
"sequenceofsolitude": "SequenceOfSolitude",
"serenityscall": "SerenitysCall",
"serpentspine": "SerpentSpine",
"sharpshootersoath": "SharpshootersOath",
"silvershowerheartstrings": "SilvershowerHeartstrings",
"silversword": "SilverSword",
"skyridergreatsword": "SkyriderGreatsword",
"skyridersword": "SkyriderSword",
"skywardatlas": "SkywardAtlas",
"skywardblade": "SkywardBlade",
"skywardharp": "SkywardHarp",
"skywardpride": "SkywardPride",
"skywardspine": "SkywardSpine",
"slingshot": "Slingshot",
"snarehook": "SnareHook",
"snowtombedstarsilver": "SnowTombedStarsilver",
"solarpearl": "SolarPearl",
"songofbrokenpines": "SongOfBrokenPines",
"songofstillness": "SongOfStillness",
"splendoroftranquilwaters": "SplendorOfTranquilWaters",
"staffofhoma": "StaffOfHoma",
"staffofthescarletsands": "StaffOfTheScarletSands",
"starcallerswatch": "StarcallersWatch",
"sturdybone": "SturdyBone",
"summitshaper": "SummitShaper",
"sunnymorningsleepin": "SunnyMorningSleepIn",
"surfsup": "SurfsUp",
"swordofdescension": "SwordOfDescension",
"swordofnarzissenkreuz": "SwordOfNarzissenkreuz",
"symphonistofscents": "SymphonistOfScents",
"talkingstick": "TalkingStick",
"tamayurateinoohanashi": "TamayurateiNoOhanashi",
"thealleyflash": "TheAlleyFlash",
"thebell": "TheBell",
"theblacksword": "TheBlackSword",
"thecatch": "TheCatch",
"thedaybreakchronicles": "TheDaybreakChronicles",
"thedockhandsassistant": "TheDockhandsAssistant",
"thefirstgreatmagic": "TheFirstGreatMagic",
"theflagstaff": "TheFlagstaff",
"theflute": "TheFlute",
"theotherside": "TheOtherSide",
"thestringless": "TheStringless",
"theunforged": "TheUnforged",
"theviridescenthunt": "TheViridescentHunt",
"thewidsith": "TheWidsith",
"thrillingtalesofdragonslayers": "ThrillingTalesOfDragonSlayers",
"thunderingpulse": "ThunderingPulse",
"tidalshadow": "TidalShadow",
"tomeoftheeternalflow": "TomeOfTheEternalFlow",
"toukaboushigure": "ToukabouShigure",
"travelershandysword": "TravelersHandySword",
"tulaytullahsremembrance": "TulaytullahsRemembrance",
"twinnephrite": "TwinNephrite",
"ultimateoverlordsmegamagicsword": "UltimateOverlordsMegaMagicSword",
"urakumisugiri": "UrakuMisugiri",
"verdict": "Verdict",
"vividnotions": "VividNotions",
"vortexvanquisher": "VortexVanquisher",
"wanderingevenstar": "WanderingEvenstar",
"wastergreatsword": "WasterGreatsword",
"wavebreakersfin": "WavebreakersFin",
"waveridingwhirl": "WaveridingWhirl",
"whiteblind": "Whiteblind",
"whiteirongreatsword": "WhiteIronGreatsword",
"whitetassel": "WhiteTassel",
"windblumeode": "WindblumeOde",
"wineandsong": "WineAndSong",
"wolffang": "WolfFang",
"wolfsgravestone": "WolfsGravestone",
"xiphosmoonlight": "XiphosMoonlight"
}
+95 -22
View File
@@ -6,18 +6,27 @@ This document describes the structure, boundaries, flows, and technical rules of
Genshin Artifact Assistant is a local desktop application. Electron owns OS integration, screen capture, IPC, and overlay windows. React owns the interactive UI. Domain logic for OCR parsing, scoring, scanner state, and recommendations lives in TypeScript modules under `src/lib`. 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 ```mermaid
flowchart LR flowchart LR
User["User"] User["User"]
Genshin["Genshin Impact Window"] Genshin["Genshin Impact Window"]
Electron["Electron Main Process"] Electron["Electron Main Process"]
Native["C# Input Helper / Native Scanner"]
React["React Renderer"] React["React Renderer"]
Parser["OCR Parser and Scoring"] Parser["OCR Parser and Scoring"]
LocalData["Local Snapshot / Future SQLite"] LocalData["Local Snapshot / Future SQLite"]
User --> React User --> React
React --> Electron React --> Electron
Electron --> Genshin Electron --> Native
Native --> Genshin
Native --> Electron
Electron --> React Electron --> React
React --> Parser React --> Parser
Parser --> React Parser --> React
@@ -53,16 +62,24 @@ flowchart LR
| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery | | `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/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/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/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` | | `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/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store |
| `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/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/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/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/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 IK-style fallback paths | | `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/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports |
| `src/features/inventory/*` | Planned scanned-artifact inventory, compact result list/grid, filters, and artifact detail views | | `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/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/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/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
@@ -72,6 +89,7 @@ flowchart LR
| `src/lib/upgradeProjection.ts` | Planned best/middle/worst upgrade projection for under-leveled artifacts | | `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/scoring.ts` | Recommendation and build scoring logic |
| `src/lib/demoData.ts` | Temporary local demo snapshot | | `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 | | `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` | | `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
| `src/types/*` | Shared app, capture, and domain contracts | | `src/types/*` | Shared app, capture, and domain contracts |
@@ -117,9 +135,14 @@ sequenceDiagram
| Capture sources | Electron desktopCapturer | Electron main | Scan UI | | Capture sources | Electron desktopCapturer | Electron main | Scan UI |
| Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview | | Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview |
| OCR crops | Electron main | Electron main | Details modal, parser | | 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 | | 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 |
| Scan result entry | Scan loop and parser/evaluator | Renderer domain logic | Live scan rail, artifact inventory, summary | | 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 | | Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only | | 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 | | Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training |
@@ -147,15 +170,15 @@ 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. - 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: - 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 `/automation/probe-click?index=N` tests one read-only tile selection, and
`/scanner/start?entry=visible-inventory&limit=N` starts an auto-scan with a `/scanner/start?limit=N` starts the native capture scan with a temporary
temporary limit payload from an already visible artifact detail view. limit payload from an already visible artifact detail view.
The live known-good result on 2026-07-07 is documented in The live known-good result on 2026-07-07 is documented in
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts. - Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts.
- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read". - Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read".
- Artifact grid automation uses Inventory Kamera's 32-target full-page model - Artifact grid automation uses 32 safe click targets per full page
(`8 x 4` safe click targets). The apparent lower fifth row sits in the (`8 x 4`). The apparent lower fifth row sits in the bottom control band on
bottom control band on 16:9 captures and is not clicked automatically. 16:9 captures and is not clicked automatically.
- Guided auto-entry is state gated. The normal scan button first performs a - 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 lightweight no-OCR preflight; OCR/store/review work starts only after the
artifact inventory grid and artifact detail card are visually confirmed. artifact inventory grid and artifact detail card are visually confirmed.
@@ -167,12 +190,54 @@ sequenceDiagram
- Item verification uses the artifact OCR capture's own detail fingerprint, so - 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 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 waits remain fingerprint based and can proceed as soon as the inventory pane
changes and stabilizes, while still accepting IK-like 100 ms scroll readiness changes and stabilizes.
points.
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan). - Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them. - 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 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. 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 Auto-scan artifact captures also bypass Electron source-list enumeration and use
@@ -192,10 +257,19 @@ Architecture rules:
OCR/debug stats stay in diagnostics or detail. OCR/debug stats stay in diagnostics or detail.
- A scan result row preserves extraction status and artifact value status as - A scan result row preserves extraction status and artifact value status as
separate data even when the UI shows one compact pill. separate data even when the UI shows one compact pill.
- The artifact inventory view owns browsing, filtering, sorting, and opening - The artifact inventory view owns browsing, filtering, sorting, opening detail,
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, - The artifact detail view owns screenshot/crop inspection, OCR confidence,
parser notes, value score reasons, and upgrade projection. 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 - Upgrade projection is a local deterministic/probabilistic helper, never a
claim that an artifact will roll a specific way. claim that an artifact will roll a specific way.
@@ -228,8 +302,9 @@ Future queue refactor:
- OCR/parse/evaluation may process bounded queued screenshot/crop jobs. - OCR/parse/evaluation may process bounded queued screenshot/crop jobs.
- Queueing must preserve stop behavior, duplicate handling, review decisions, - Queueing must preserve stop behavior, duplicate handling, review decisions,
and the existing read-only safety boundary. and the existing read-only safety boundary.
- The queue refactor is secondary to content extraction and result/inventory - The native post-capture processor already consumes crop jobs with bounded
contracts while current scan speed remains acceptable. parallelism while preserving report order. Further queue work is secondary to
live throughput evidence and result quality.
## Security And Safety ## Security And Safety
@@ -251,10 +326,8 @@ Future queue refactor:
## Performance ## Performance
Current OCR is still measured against the IK target rather than assumed good. Current OCR is measured through the eval harness and live scan assessments
The app keeps a Tesseract.js worker pool, can use the Inventory-Kamera rather than assumed good. The app keeps a Tesseract.js worker pool and reports
`genshin_fast_09_04_21.traineddata` path for comparison, and reports capture, capture, OCR, card-ready, scroll-ready, active-scan, and projected-100 timings.
OCR, card-ready, scroll-ready, active-scan, and projected-100 timings. A default
engine change requires a same-capture benchmark and a qualified live soak result.
For the next product phase, performance work should not displace extraction For the next product phase, performance work should not displace extraction
quality, result clarity, or review safety unless evidence shows a regression. quality, result clarity, or review safety unless evidence shows a regression.
+101 -467
View File
@@ -9,34 +9,31 @@ Validated live with Genshin open in the artifact inventory at 1920x1080,
English UI: English UI:
- `npm run dev:admin` starts the app elevated after the user confirms UAC. - `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"`. `targetProcess: "GenshinImpact"`.
- The safe probe endpoint `/automation/probe-click?index=1` focused Genshin, - `/automation/probe-click?index=1` performs one read-only inventory tile
moved the cursor to the second visible inventory tile, clicked it, and changed selection click.
the artifact detail panel fingerprint. - The visible-inventory auto-scan path is the production baseline. It requires
- Probe result: `clicked: true`, `inputBlocked: false`, the Artifact inventory to already be open with a visible artifact detail card.
`foregroundProcess: "GenshinImpact"`, and `changed: true`. - On 2026-07-09, `/scanner/start?entry=visible-inventory&limit=100` completed
- A bounded live auto-scan via `/scanner/start?limit=2` completed with: with `100/100` attempted, verified, and parsed, `98` stored, `2` duplicates,
`clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`, `0` review samples, `0` misses, `4` pages, and `393 ms/artifact`.
`review: 2`, `misses: 0`, `status: "done"`. - On 2026-07-09, `npm run scan:native:smoke` completed against runtime
- On 2026-07-08, a visible-inventory 50-artifact run completed with `50/50` signature `2026-07-09-native-ik-visual-probe`: native visual preflight passed
parsed and stored, `0` review, `0` duplicates, and `0` misses. Throughput at 1920x1080 with `32` grid targets, guarded probe changed the detail panel,
was still slow at `61765 ms` elapsed (`1235 ms/artifact`). native capture wrote `2/2` artifact card crops in `510 ms`, and post-capture
- On 2026-07-09, the current-engine visible-inventory path completed processing parsed `2/2` with `0` review, `0` errors, `queueConcurrency: 2`,
`/scanner/start?entry=visible-inventory&limit=20&engine=current` with and `persisted: false`.
`20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and - The same native path later completed dry 20/50/100-item runs. The final run
`0` misses in `8047 ms` elapsed (`402 ms/artifact`). A same-session artifact captured `100/100` crops across 4 pages in `24,673 ms`, parsed `100/100`,
detail capture also persisted an equipped footer as `equipped: "Citlali"` and produced `0` errors, routed 3 implausible OCR values into Review, and wrote
an unlocked grey lock as `locked: false`. 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 The old alternate OCR-engine and comparison paths have been retired. The app now
movement and click input to the focused Genshin client in this environment. runs the current OCR/capture path only. Performance claims should be stated as
current-run repeatability evidence, not as external-tool parity.
Latest-source timing is not proven while `/health.appBuild.signature` differs
from the `APP_RUNTIME_SIGNATURE` in `electron/main.ts`, or after source changes
that have not been loaded by a fresh elevated runtime. Restart the elevated app
through `npm run dev:admin` and confirm UAC before collecting new 50/100
artifact evidence.
## Elevation And UAC ## Elevation And UAC
@@ -55,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 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 the Secure Desktop UAC prompt for itself.
its own runtime through the dev status endpoint.
Useful checks: Useful checks:
@@ -70,11 +66,12 @@ Expected runtime facts before automatic scan:
- `isElevated: true` - `isElevated: true`
- `genshinFound: true` - `genshinFound: true`
- `targetProcess: "GenshinImpact"` - `targetProcess: "GenshinImpact"`
- hotkeys registered - `/health.appBuild.signature` matches `APP_RUNTIME_SIGNATURE` in
`electron/main.ts`
## Mouse And Click Validation ## Mouse And Click Validation
Use the probe before broad auto-scan work: Use a probe before broad auto-scan work:
```powershell ```powershell
Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" | Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" |
@@ -86,8 +83,8 @@ feed, enhance, lock, unlock, spend, or modify game resources.
Interpretation: Interpretation:
- `click.ok: true`, `clicked: true`, `inputBlocked: false` means Windows did not - `click.ok: true`, `clicked: true`, and `inputBlocked: false` means Windows did
block SendInput/UIPI in the current configuration. not block SendInput/UIPI in the current configuration.
- `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was - `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was
sent while Genshin was foreground. sent while Genshin was foreground.
- `changed: true` means the detail panel changed after the click. - `changed: true` means the detail panel changed after the click.
@@ -95,83 +92,14 @@ Interpretation:
neighboring artifacts render identically; retry with another `index`, `row`, neighboring artifacts render identically; retry with another `index`, `row`,
or `col`. 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 ## Bounded Live Auto-Scan
For live validation, prefer a bounded scan first: Start with a tiny bounded run:
```powershell ```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2" Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2"
``` ```
The visible-inventory path is the merge-relevant safe path. It requires the
Artifact inventory to already be open with a visible artifact detail card.
The normal Auto-Scan button uses a guided start. It first takes one lightweight
preflight capture without OCR, full-frame payload, review scoring, or storing.
If an artifact detail card is already visible, it starts the visible-inventory
scan. Otherwise it blocks with an operator-facing status and asks the user to
open the Artifact inventory with a visible detail card. OCR/review/store work
starts only after the artifact-detail preflight passes.
The explicit Dev-Control entry modes below remain available for targeted
experiments only. They send read-only navigation, but they are not the
merge-ready default because live testing showed that `auto-entry` can leave the
app in the Paimon menu when the starting state is not what the choreography
expects.
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=paimon-menu&limit=2"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=auto-entry&limit=2"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?entry=visible-inventory&limit=2&engine=ik-traineddata"
```
Those paths send only read-only navigation. `ESC` is not a universal "go to
world" command: from the world it opens the Paimon menu, while from the
already-open Paimon menu it returns to the world. This is why the normal
`auto-entry` path first tries `B` directly and uses the IK-style `ESC -> B`
fallback only when direct entry did not reach an artifact detail card.
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 while tuning the entry step.
The visual preflight also classifies the Paimon menu. The Paimon profile/card
grid can look like an inventory grid if only fixed 16:9 coordinates are used, so
the scanner must reject `paimonMenu.present` before any artifact OCR, review
sample creation, store write, or grid scan starts. The guided entry may still
take lightweight skip-OCR captures while navigating, but those captures are only
state evidence.
The same guard also runs inside the scan loop. If the app is on the main game
screen, a Paimon/menu screen, a generic primary-screen capture, or any screen
without an artifact detail card, auto-scan must block instead of clicking tiles
or trying OCR.
After each click the loop now performs one fast artifact capture/OCR pass and
uses that capture's detail fingerprint to verify that the selected artifact
changed. This removes the old separate card-ready capture from the hot path. If
the detail fingerprint is unchanged, the loop retries once and then follows the
normal miss/block guards.
The outer scan start focuses Genshin once; hot-loop fingerprint/OCR captures do
not re-run the focus helper before every tile, which avoids an OS focus ping on
each artifact while still relying on click readback, foreground checks, and the
detail-card guard for safety.
After a scroll, the loop now uses the same cheap fingerprint polling model for
the inventory pane: it proceeds as soon as the next page fingerprint changed and
stabilized instead of always sleeping the old fixed 760 ms settle delay. Changed
but still animated inventory pages may proceed after 100 ms, again matching IK's
fast-scroll wait while still blocking unchanged pages.
Then poll: Then poll:
```powershell ```powershell
@@ -179,219 +107,57 @@ Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
ConvertTo-Json -Depth 12 ConvertTo-Json -Depth 12
``` ```
Before live timing, verify that the endpoint is the current app instance: 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 ```powershell
Invoke-RestMethod "http://127.0.0.1:17317/health" | npm run scan:native:smoke
ConvertTo-Json -Depth 6 npm run scan:native:smoke:5
``` ```
The response must include `appBuild.signature` and It checks `/health`, `/scanner/native/data`,
`appBuild.expectedOcrWorkerPoolSize`. If `appBuild` is missing, or `/scanner/native/preflight?category=artifacts` including the native visual
`/scanner/status` still reports the old OCR warmup start time, the local port is blank-capture guard, runs the guarded
still owned by a stale elevated Electron process. Close the old Administrator `/automation/probe-click?index=1`, starts `/scanner/start?limit=N&category=artifacts`,
window/app and restart with `npm run dev:admin` before running scanner probes. waits for the native helper to finish, runs `/scanner/native/process` with
`persist=0`, and loads `/scanner/native/results`. Evidence is written to:
Use the status `stats` timing fields for IK comparisons: `elapsedMs`, ```text
`activeScanMs`, `writeFlushMs`, `averageMsPerParsed`, outputs/native-live-smoke/<timestamp>/
`activeAverageMsPerParsed`, `averageCaptureMs`, ```
`averageCaptureRoundTripMs`, `averageCaptureRoundTripOverheadMs`,
`averageOcrMs`, `artifactsPerMinute`, and `projectedMsFor100`.
`elapsedMs` is end-to-end including queued writes; `activeScanMs` is the
click/capture/OCR loop before the final store/review flush. A run only counts as speed
evidence when `parsed`, `stored`, `review`, `duplicates`, and `misses` are read
together; raw click count alone is not scanner throughput. If `averageOcrMs`
dominates `averageMsPerParsed`, the next speed lever is an IK-style OCR worker
queue. If `averageCaptureRoundTripOverheadMs` is high, native capture encode,
Base64 transport, Electron image decode, or IPC/render scheduling is the next
bottleneck. The current 3 artifacts/second target requires `averageMsPerParsed`
at or below `333 ms` on a clean 20-artifact iteration.
Latest live timing evidence on 2026-07-08: Use this native smoke path before enabling any artifact-store promotion from
native `scan-results.json`.
- Probe: `/automation/probe-click?index=1` returned `clicked: true`, The native preflight and start endpoints both accept an explicit category:
`inputBlocked: false`, `changed: true`, and `captureTarget:
"genshin-client"`.
- Baseline after helper/hot-loop cleanup:
`/scanner/start?entry=visible-inventory&limit=50&engine=current` completed
`50/50` parsed and stored with `0` review, `0` duplicates, `0` misses,
`2` pages, `elapsedMs: 61765`, `averageMsPerParsed: 1235`,
`averageCaptureMs: 186`, `averageOcrMs: 162`, and
`averageScrollReadyMs: 844`.
- Deferred-write experiment:
the same 50-artifact run completed `50/50` with `0` misses but regressed to
`elapsedMs: 63616` because 50 single-record writes produced
`writeFlushMs: 8163`.
- Current source replaces that experiment with batch persist and quiet
auto-scan UI captures. Later direct-GDI live runs validated the batch/quiet
path at limits 20, 45, and 100 with 0 misses in the current environment.
- Direct GDI hot-path validation:
after skipping `desktopCapturer.getSources()` in auto-scan artifact captures,
the 20-artifact iteration baseline improved to `20/20` parsed, `19` stored,
`0` review, `1` duplicate, `0` misses, `7966 ms` elapsed,
`398 ms/artifact`, `averageCaptureMs: 193`, `averageOcrMs: 167`,
`averageClickMs: 2`, and `writeFlushMs: 4`. This is roughly
`2.5 artifacts/second` on the first visible page.
- Scroll-path validation with the same direct GDI hot path:
`/scanner/start?entry=visible-inventory&limit=45&engine=current` completed
`45/45` parsed, `42` stored, `0` review, `3` duplicates, `0` misses,
`2` pages, `18625 ms` elapsed, `414 ms/artifact`, `averageCaptureMs: 187`,
`averageOcrMs: 162`, and one scroll readiness wait of `173 ms`.
- 100-artifact direct-GDI validation:
`/scanner/start?entry=visible-inventory&limit=100&engine=current` completed
on runtime signature `2026-07-08-direct-gdi-hotpath` with `100/100` parsed,
`97` stored, `0` review, `3` duplicates, `0` misses, `4` pages,
`42064 ms` elapsed, `421 ms/artifact`, `averageCaptureMs: 179`,
`averageOcrMs: 154`, `averageClickMs: 2`, `writeFlushMs: 6`, and `3`
scroll readiness waits averaging `176 ms`.
- OCR/parser eval after this speed pass: `npm run eval` passed with `23/23`
exact-match cases, `100%` field accuracy, and `100%` critical fields. This is
a regression gate, not a substitute for manually checking live artifact values.
- Ownership and lock proof on 2026-07-09:
`/scanner/start?entry=visible-inventory&limit=20&engine=current` completed
`20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and
`0` misses in `8047 ms`. Smart Capture parsed equipped footers for `Citlali`
and `Linnea`, reported an unlocked artifact as `locked: false`, then reported
a visibly locked artifact as `locked: true` with `lockSignal.ratio:
0.14797913950456323` over threshold `0.06`. A follow-up bounded scan
persisted that locked artifact with `equipped: "Citlali"` and `locked: true`.
- 3 artifacts/second preparation:
auto-scan artifact captures now also omit the detail-preview payload and
expose `averageCaptureRoundTripMs` plus
`averageCaptureRoundTripOverheadMs`. The first live run exposed a false
`missing-crops-or-ocr` review trigger because the hot path intentionally omits
`detailDataUrl`; this is fixed in `getAutoReviewReason`.
- 3 artifacts/second live attempts:
after the review fix, a clean `limit=20` run completed `20/20` parsed,
`19` stored, `0` review, `1` duplicate, `0` misses, `7285 ms` elapsed,
or `364 ms/artifact` (`2.75 artifacts/second`). The stable final run on
signature `2026-07-08-direct-gdi-reviewfix` completed `20/20`, `18` stored,
`0` review, `2` duplicates, `0` misses, `7973 ms` elapsed, or
`399 ms/artifact`. 3 artifacts/second is not proven.
- Follow-up 3 artifacts/second attempts on 2026-07-09:
after the distinctive partial piece parser fix, the best clean repeatability
run reached `336 ms/artifact` with `20/20` parsed, `0` review, `0` misses,
`318 ms` average capture roundtrip, and `138 ms` roundtrip overhead. Later
runs with crop priority and image-payload cleanup stayed clean but ranged
around `346-351 ms/artifact`; the strict `333 ms/artifact` budget remains
unproven.
- Rejected speed experiments:
detail-region capture, `GAA_OCR_WORKERS=5`, DataURL-to-buffer decode, and
substat OCR `PSM.SINGLE_COLUMN` were all live/benchmark tested and were slower
than the direct-GDI baseline. Later checks also rejected skipping
`analyzePaimonMenu`, skipping lock-state as a production shortcut, reducing
the artifact-level crop scale, and `GAA_OCR_WORKERS=6` as the default.
Keep `GAA_OCR_WORKERS=4` for current runs.
- Quality-gated current-vs-IK comparison:
`npm run scan:goal:compare:validated` produced
`outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`
with `createdAt: 2026-07-08T18:41:11.6120957+02:00`.
The final validator summary passed at `limit=100` with winner `current`,
`activeAvg: 378 ms/artifact`, `projected100: 37800 ms`, `missRate: 0`, and
`reviewRate: 0`. The `current` 100-artifact run parsed `100/100`, stored `97`,
had `0` review, `0` misses, and crossed `4` pages. The `ik-traineddata`
100-artifact run parsed `97/100`, had `5` review and `3` misses, and was not
qualified because it parsed fewer artifacts than requested.
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.
Lookup and benchmark utility endpoints:
```powershell ```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/status" Invoke-RestMethod "http://127.0.0.1:17317/scanner/native/preflight?category=artifacts"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/regenerate" Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2&category=artifacts"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/ocr/warmup"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/ocr/warmup?engine=ik-traineddata"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&engine=ik-traineddata"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&engine=compare"
Invoke-RestMethod "http://127.0.0.1:17317/scanner/benchmark-ocr?limit=5&profile=full"
``` ```
The benchmark endpoint measures the current Tesseract.js engine and the Only `artifacts` is implemented as native capture today. `weapons`,
Inventory-Kamera-traineddata Tesseract.js path against the artifact crop set and `characters`, and `materials` are expected to block with a catalog-only message
returns timing/field counts, min/p50/p90/max timing, OCR p50/p90 timing, at preflight/start time until their category-specific capture flows are
20/45/100-artifact projections, skipped-OCR count, and the active OCR worker implemented and validated. They are not part of the active UI scope while those
pool size. It also returns per-field OCR timings under values are not scanned.
`ocrFieldAverages`, which is the first place to look before changing crop or Artifact preflight also blocks when the Genshin client capture is blank,
parser behavior. Individual captures also report whether the artifact was almost entirely white/black, or too visually uniform to be trusted.
detected as `sanctified`; level/substat crops are shifted in that state to match The probe-click endpoint additionally requires a detected Genshin-client
Inventory Kamera's crop model. By default it uses the auto-scan `fast` OCR profile, artifact inventory grid and visible artifact detail card before sending input.
which omits the low-value set-effect crop and the main-stat-value crop that can
be derived from slot, main-stat label, and level. The slot crop remains enabled
in the fast profile because it improved live-read quality. The fast profile also uses
Inventory Kamera's tighter substat crop height; full/manual captures keep the
larger recovery crop for debugging difficult samples. Auto-scan also omits per-crop diagnostic Base64 images from hot-loop OCR
captures while keeping the detail screenshot, OCR text, crop rect metadata, and
timings. OCR crops are passed to Tesseract as PNG buffers internally, not as
Base64 DataURLs, to avoid encode/decode overhead in batch scans. When
`skipOcrUnlessArtifactDetail` blocks OCR because no artifact detail card is
visible, OCR crop preprocessing is skipped too. Auto-scan readiness and scroll
checks use native detail/inventory fingerprints and omit preview DataURLs in
poll captures. Fast preflight/poll captures also omit crop list construction, crop images, and lock-state
detection unless a caller explicitly overrides that option; add
`profile=full` to OCR every artifact detail crop for debugging. It uses the same
artifact-detail guard as auto-scan: if the current screen is not a confirmed artifact detail view, OCR is
skipped and the response shows `skippedOcrCaptures` instead of burning time on
invalid crops.
The fast auto-scan profile now keeps the optional Equipped footer OCR on real
artifact-read captures when the footer marker is visible, so stored artifacts
can record the equipped character without requiring a separate manual capture.
Name, level, main-stat label, footer, and substats remain in the OCR hot path;
slot, set, and main-stat value are derived when the lookup/parser can validate
them. Preflight and readiness poll captures still skip OCR/crops/lock-state
work because they only prove surface and fingerprint changes.
Local store/review writes are serialized through an internal queue but no longer
block the next inventory click. The scan still flushes the queue before it
returns its final summary, so `stored` and `review` counts remain final-state
numbers.
The app warms the default OCR worker pool in the background after startup; check
`/scanner/status` -> `ocrWarmup.current` before timing the first artifact. Use
`/scanner/ocr/warmup?engine=ik-traineddata` before comparing Inventory
Kamera-traineddata timings so the benchmark is not dominated by worker creation.
`engine=ik-traineddata` uses Inventory Kamera's local
`genshin_fast_09_04_21.traineddata` through Tesseract.js when the file is found
in `data/tessdata`, `IK_TESSDATA_DIR`, `work/Inventory_Kamera`, `work/refs`,
or the local `_ik_ref*` folders.
`engine=compare` runs `current` and `ik-traineddata` against the same visible
artifact detail state. The auto-scan default must stay `current` until the IK
traineddata path wins on the same captures. For a controlled live comparison,
start the scanner with `engine=ik-traineddata`; this only changes the OCR
worker language for that run and leaves the default UI/hotkey path on
`current`.
The OCR pool defaults to four workers because the fast artifact crop set has
four useful OCR parameter groups; set `GAA_OCR_WORKERS=1..8` before startup to
benchmark a different worker count. Inventory Kamera's native engine pool is
still the reference design, but the current app path remains Tesseract.js until
native OCR is integrated and measured. Crops are scheduled across the whole
worker pool and each worker caches its last Tesseract parameter profile; this is
closer to Inventory Kamera's multi-engine field OCR than the earlier
parameter-group-serial scheduler.
## Diagnostic Evidence ## Evidence Commands
The Diagnose page contains a compact evidence timeline for scanner work. It logs
runtime pings, focus attempts, key presses, entry captures, artifact-tab clicks,
preflight failures, grid/count metadata, detail fingerprints, and detail/inventory
screenshots. The same last events are also published through:
```powershell
Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" |
ConvertTo-Json -Depth 18
```
Use this before changing scanner behavior: run the smallest failing action, read
the evidence timeline, then decide whether the failure is focus/input, entry
navigation, grid detection, capture quality, OCR, or parser validation.
If Paimon entry shows `entry key ESC` or `entry key B` with `eventsSent: 0`, the
running `InputHelper.exe` probably predates keyboard support or is blocked. Stop
the elevated app/helper, run `npm run helper:build`, then restart with
`npm run dev:admin` so the app loads the rebuilt helper.
## Soak-Test Helper
After the elevated app is running and Genshin is open on the artifact inventory, 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 the non-elevated terminal can drive the local dev-control endpoints and save a
@@ -407,130 +173,49 @@ The helper writes timestamped JSON snapshots and a transcript to:
outputs/live-soak/<timestamp>/ outputs/live-soak/<timestamp>/
``` ```
Default sequence: For short iteration:
1. `/health`
2. `/scanner/status`
3. `/capture/smart?skipOcr=1`
4. `/automation/probe-click?index=1`
5. `/automation/probe-click?index=3`
6. `/scanner/start?entry=visible-inventory&limit=2`
7. `/scanner/start?entry=visible-inventory&limit=5`
8. `/scanner/start?entry=visible-inventory&limit=10`
9. `/scanner/start?entry=visible-inventory&limit=20`
10. `/review/samples?limit=30`
For the actual Inventory-Kamera speed target, use the explicit goal run after
`/health` shows the current `appBuild`:
```powershell ```powershell
npm run scan:live:preflight npm run scan:iterate:validated
npm run scan:live:preflight:wait npm run scan:iterate:validated:wait
npm run scan:goal
npm run scan:goal:current
npm run scan:goal:ik
npm run scan:iterate:compare:validated
npm run scan:iterate:compare:validated:wait
npm run scan:goal:compare
npm run scan:goal:compare:validated
npm run scan:goal:compare:validated:wait
``` ```
`scan:live:preflight` checks `/health`, `/scanner/status`, the current For a full 2, 5, 20, 45, 100 current-run evidence chain:
`APP_RUNTIME_SIGNATURE`, elevation, and whether Genshin is visible to the helper
before a long live scan is attempted.
Use `npm run scan:live:preflight:wait` during manual startup after `npm run
dev:admin`; it waits up to 120 seconds for the elevated dev-control server and
runtime checks to become ready. The non-waiting command remains the default for
validated scan chains so automation fails fast on a missing runtime.
Use `npm run scan:iterate:compare:validated` for fast iteration while tuning OCR,
parser, capture, or readiness behavior. It runs the same preflight, compares
`current` vs. `ik-traineddata` at `limit=20`, and validates the newest assessment
with `--limit=20 --summary`. This is the preferred loop while debugging because
it gives quality-gated feedback without waiting for the full `2, 5, 20, 45, 100`
goal sequence. Use `npm run scan:iterate:compare:validated:wait` directly after
UAC if the elevated runtime may still be starting.
The goal run first warms/benchmarks `current` vs. `ik-traineddata`, then scans
limits `2, 5, 20, 45, 100` with the selected scan engine, and writes
`scan-run-summary.json` plus `scan-run-summary.csv`. `npm run scan:goal`
uses the default `current` scan engine; use `scan:goal:ik` for a native
IK-traineddata scan pass. Use `scan:goal:compare` to run both scan engines
back-to-back with the same limits and one combined CSV. The CSV is the quickest evidence for
`averageMsPerParsed`, `activeAverageMsPerParsed`, `averageCaptureMs`,
`averageCaptureRoundTripMs`, `averageCaptureRoundTripOverheadMs`,
`captureP50Ms`, `captureP90Ms`, `averageOcrMs`, `ocrP50Ms`, `ocrP90Ms`,
`averageCardReadyMs`, `averageScrollReadyMs`, `artifactsPerMinute`, and
`projectedMsFor100`.
The run also writes `scan-performance-assessment.json`, which groups results by
limit, picks the best qualified engine, and labels the dominant bottleneck as
OCR, capture-roundtrip-overhead, capture, card-ready, or scroll-ready. A
qualified winner must finish the run, parse the requested count, keep miss rate
under 2%, and keep review rate at or below 15%; review and miss rates are
penalized before active average speed is used as the tie-breaker. For IK-target
claims, check `goal100Decision`; it
must read `qualified-comparison: winner=<engine>`, and
`goal100.comparisonComplete` must be `true` so a single-engine 100-artifact run
is not mistaken for a current-vs-IK comparison.
Validate the saved assessment before using it as final evidence:
```powershell ```powershell
npm run scan:assessment:validate -- --latest npm run scan:goal:validated
npm run scan:assessment:validate -- --input=<run-dir>\scan-performance-assessment.json npm run scan:goal:validated:wait
``` ```
`--latest` searches `outputs/live-soak/` for the newest For later-session repeatability without changing OCR engines:
`scan-performance-assessment.json`. Use explicit `--input` when comparing older
or archived runs. Add `--expect-winner=current` or
`--expect-winner=ik-traineddata` when validating a specific engine claim instead
of accepting any qualified winner. Add `--limit=20` for a short iteration run
instead of the final 100-artifact proof. Add `--summary` when you want a short
report-ready PASS/FAIL output that includes the input assessment path and
assessment `createdAt` timestamp.
Optional budget flags are useful for the current speed work:
```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. Use a
separate `--max-capture-roundtrip-overhead-ms` budget when deciding whether the
next optimization belongs in native capture transport instead of OCR.
Single-engine repeatability runs may be validated with `--allow-single-engine`,
but that mode is only for repeatability evidence and must not be used for IK
parity claims.
`npm run scan:goal:compare:validated` is the preferred final command: it runs
the live preflight first, then the full comparison, and then validates the
newest assessment with `--summary`. Use
`npm run scan:goal:compare:validated:wait` for the same final flow when starting
immediately after UAC.
For later-session repeatability without changing OCR engines, use:
```powershell ```powershell
npm run scan:repeatability:wait npm run scan:repeatability:wait
``` ```
That command runs the current visible-inventory engine at 20, 45, and 100 Validate a saved assessment before using it as final evidence:
artifacts, then validates the 100-artifact result as single-engine evidence.
It uses the `live-soak.ps1 -RepeatabilityRun` switch instead of passing a
comma-separated `-Limits` value through npm/cmd, because Windows argument
parsing can collapse `20,45,100` into one unsafe number. The script also refuses
limits above 1800 as a final guard.
The assessment ranking can be verified without Genshin or the Electron app: ```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 ```powershell
npm run scan:assessment:test npm run scan:assessment:test
``` ```
This self-test rejects synthetic runs that are fast but have too many misses or
too many review samples, so the final IK comparison cannot be won by speed alone.
## Review-To-Eval Quality Loop ## Review-To-Eval Quality Loop
After any live scan that creates review samples, export candidates before adding After any live scan that creates review samples, export candidates before adding
@@ -541,13 +226,9 @@ npm run eval:review-candidates -- --limit=80
``` ```
Read `outputs/review-eval-candidates/review-eval-candidates.md`. It is a review Read `outputs/review-eval-candidates/review-eval-candidates.md`. It is a review
worklist, not ground truth. Only after the expected fields are confirmed or worklist, not ground truth. Only after expected fields are confirmed or
corrected against the real artifact should a case be moved into corrected against the real artifact should a case move into
`src/eval/corpus/confirmedReviewCorpus.ts`. This prevents the parser from `src/eval/corpus/confirmedReviewCorpus.ts`.
grading itself and keeps `npm run eval` meaningful. The exporter deduplicates
samples, puts complete modern OCR captures first, and marks missing fast-profile
fields so stale or partial captures are easier to ignore. Unconfirmed exporter
output must stay in `outputs/review-eval-candidates/`.
For a manually checked candidate, generate a paste-ready confirmed-case snippet: For a manually checked candidate, generate a paste-ready confirmed-case snippet:
@@ -555,54 +236,12 @@ For a manually checked candidate, generate a paste-ready confirmed-case snippet:
npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json npm run eval:prepare-confirmed -- --candidate=<candidate-id> --expect-file=.\path\to\expect.json
``` ```
The command requires explicit labels and writes only to the ignored outputs
folder. Review the snippet before adding it to
`src/eval/corpus/confirmedReviewCorpus.ts`.
For the current implementation summary and IK comparison rationale, see
[scanner-ik-progress-report.md](scanner-ik-progress-report.md).
Use the readiness timings to compare against Inventory Kamera's fixed waits:
IK waits about 200 ms after selecting the next inventory item and about 100 ms
after fast scrolls. If `averageCardReadyMs` or `averageScrollReadyMs` dominates
the active average while OCR is already low, tune the fingerprint gate before
touching OCR again.
The runner reads `APP_RUNTIME_SIGNATURE` from `electron/main.ts` and refuses
to run against a stale Electron process when `/health.appBuild.signature` does
not match the current source. Use `-AllowStaleBuild` only for deliberate
debugging of an older instance.
Current dev builds also expose `/dev/shutdown` on localhost. The start cleanup
script calls it before falling back to `Stop-Process`, so a previous elevated
app can shut itself down cleanly even when the caller cannot terminate an
administrator process directly. Older builds without that endpoint still need
manual close or a confirmed `npm run dev:admin` restart.
Review samples are saved as a compact summary by default so Vite does not try to
watch large Base64 payloads under `outputs/`. Full review payloads can be saved
with `-SaveFullReviewSamples` when needed.
It stops on a failed probe, blocked scan, stopped scan, or timeout unless
`-ContinueAfterBlocked` is supplied directly:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\live-soak.ps1 -Limits 2,5 -ContinueAfterBlocked
```
## Anti-Cheat And Safety Boundary ## Anti-Cheat And Safety Boundary
Do not describe the current implementation as bypassing anti-cheat. The app Do not describe the implementation as bypassing anti-cheat. The app does not
does not read memory, hook the process, inject code, modify game files, inspect read memory, hook the process, inject code, modify game files, inspect packets,
packets, or interact with kernel drivers. It uses normal Windows screen capture, or interact with kernel drivers. It uses normal Windows screen capture, focus,
focus, cursor movement, wheel, and click input. cursor movement, wheel, and click input.
The practical finding is narrower:
- A non-elevated app can be blocked by Windows integrity/UIPI when the target
process is elevated or protected.
- Running the app elevated fixed input delivery in the tested environment.
- Genshin's anti-cheat may still affect behavior on other machines, game modes,
overlays, or future versions. Re-run the probe before trusting broad scans.
Never add automation that deletes, feeds, enhances, locks/unlocks, spends Never add automation that deletes, feeds, enhances, locks/unlocks, spends
resources, reads memory, hooks, injects, or modifies Genshin. resources, reads memory, hooks, injects, or modifies Genshin.
@@ -613,12 +252,9 @@ The current 16:9 layout profile is calibrated from a 1920x1080 English
artifact-inventory capture: artifact-inventory capture:
- detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838` - detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838`
- inventory grid: `8 x 4` safe automated targets, matching Inventory Kamera's - inventory grid: `8 x 4` safe automated targets
32-artifact full-page model. The apparent lower fifth row is in the bottom
control band and is intentionally not clicked during auto-scan.
- first tile center: `x=179`, `y=254`, `row=0`, `col=0` - first tile center: `x=179`, `y=254`, `row=0`, `col=0`
- second tile center: `x=325`, `y=254`, `row=0`, `col=1` - 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 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. as higher risk and validated with Smart Capture plus the probe.
@@ -635,5 +271,3 @@ Before marking an automation change done:
6. For scan-loop changes, run 6. For scan-loop changes, run
`/scanner/start?entry=visible-inventory&limit=2` before any broader scan. `/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`. 7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
8. For IK-target claims, attach or cite `scan-performance-assessment.json` from
a non-stale `npm run scan:goal:compare:validated` run.
+19 -7
View File
@@ -13,25 +13,24 @@
- [ ] `npm run build` passes. - [ ] `npm run build` passes.
- [ ] Manual Smart Capture is tested when possible. - [ ] Manual Smart Capture is tested when possible.
## IK-Speed Or OCR-Engine Claim ## Live Scan Timing Claim
- [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`. - [ ] `/health.appBuild.signature` matches the current `APP_RUNTIME_SIGNATURE`.
- [ ] `npm run scan:live:preflight` passes, or use the validated comparison command that runs it first. - [ ] `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. - [ ] During manual UAC startup, `npm run scan:live:preflight:wait` may be used before the scan chain.
- [ ] `npm run scan:assessment:test` passes. - [ ] `npm run scan:assessment:test` passes.
- [ ] Use `npm run scan:iterate:compare:validated` or `npm run scan:iterate:compare:validated:wait` for short 20-artifact tuning loops. - [ ] Use `npm run scan:iterate:validated` or `npm run scan:iterate:validated:wait` for short 20-artifact tuning loops.
- [ ] Prefer `npm run scan:goal:compare:validated` or `npm run scan:goal:compare:validated:wait` for the final live comparison because it runs preflight, comparison, and assessment validation in sequence. - [ ] 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`. - [ ] The run includes `scan-performance-assessment.json`.
- [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=<path>\scan-performance-assessment.json` passes. - [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=<path>\scan-performance-assessment.json` passes.
- [ ] If claiming a specific winner, the validator is run with `--expect-winner=current` or `--expect-winner=ik-traineddata`. - [ ] 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. - [ ] 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 iteration claims, the 20-artifact run finishes cleanly and is validated with `--limit=20`.
- [ ] For final claims, the 100-artifact run finishes cleanly. - [ ] For final claims, the 100-artifact run finishes cleanly.
- [ ] Parsed count is at least the requested count. - [ ] Parsed count is at least the requested count.
- [ ] Miss rate is at or below 2%. - [ ] Miss rate is at or below 2%.
- [ ] Review rate is at or below 15%. - [ ] Review rate is at or below 15%.
- [ ] Speed comparison uses active scan timing plus quality, not click count alone. - [ ] Speed reporting uses active scan timing plus quality, not click count alone.
- [ ] The default OCR engine is changed only after same-capture benchmark evidence.
## UI Change ## UI Change
@@ -48,6 +47,19 @@
- [ ] The live rail shows finished artifact rows only, not intermediate OCR/debug state. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision.
- [ ] Duplicate state is represented separately from artifact quality. - [ ] Duplicate state is represented separately from artifact quality.
- [ ] The artifact inventory view can browse stored scan results without opening diagnostics. - [ ] The artifact inventory view can browse stored scan results without opening diagnostics.
+139
View File
@@ -0,0 +1,139 @@
# Current App Status
Updated: 2026-07-09
This document is the short current-state entry point. For deeper architecture
details see [ARCHITECTURE.md](ARCHITECTURE.md). For the scanner and inventory
roadmap see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
For live scanner runbooks see [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
## Summary
The app is now an Artifact-first local scanner with a native high-speed capture
path validated at 20/50/100-item scale in the current live session. The current product scope is intentionally limited to
artifacts. Weapons, materials, and character details may exist as vendored IK
catalog data, but they are not active scanner features while their values are
not scanned.
Electron/React should act as the visual control and status surface. The C#
helper owns fast Genshin focus, click, scroll, and card-crop capture for the
native path. OCR, parsing, review, promotion, and value evaluation remain
separate downstream stages so capture speed is not blocked by UI work.
## What Works Now
- Smart Capture can read the currently opened artifact detail view through
focused crops, OCR preprocessing, deterministic parsing, confidence, and
notes.
- The visible-inventory auto-scan path is the current stable production
baseline: preflight, grid detection, read-only tile selection, detail
verification, OCR, parse, store/review, scroll, and summary.
- The native IK-style artifact capture path is wired through the C# helper.
It captures visible 16:9 Artifact inventory detail card crops and writes a
self-contained run directory.
- Native runs write `manifest.json`, `capture-jobs.jsonl`, and `status.json`.
Post-capture processing can consume the job file and write
`scan-results.json` plus `processing-report.json`.
- Vendored Inventory Kamera `inventorylists` are copied under
`data/ik-inventorylists` and packaged as scanner reference data. Current
version is `6.7.0`.
- Native artifact post-processing matches parsed artifacts against IK artifact
set/piece/slot data and sends conflicts to review.
- The scan page has a compact latest-results rail. Native parsed results are
labeled as `Geparst`, persisted results as stored, uncertain results as
review, and value evaluation as `Wert offen`.
- The Inventory view can browse native scan results, stored artifacts, and
snapshot fallback rows. It shows native crop previews, field confidence,
IK/GOOD metadata, dry-run promotion status, and a per-result
`Naechster Schritt`.
- The Inventory view now shows an Artifact-only pipeline strip for scope,
native capture, OCR queue, review gate, promotion, and evidence.
- GOOD import/export, review samples, local text replacements, scanner
diagnostics, OCR eval, and local artifact storage exist.
## What Was Just Done
- The active UI scope was narrowed to Artifact scanning only.
- Weapon, material, and character-detail IK catalog coverage was removed from
the primary Inventory feature surface so it cannot look like implemented scan
support.
- Native result labels were made more honest:
`Geparst` means extracted, `Stored` means persisted, `Review` means unsafe,
and `Wert offen` means artifact value evaluation has not run yet.
- The Inventory detail panel gained a `Naechster Schritt` card:
ready for promotion, review first, already in store, blocked, or value later.
- New filters were added for native rows and promotable rows.
- Native 20/50/100-item dry runs were completed. A live OCR decimal-loss bug
and missing native result timestamp were fixed and regression-tested.
- Native Inventory results now support explicit single-result promotion with a
second confirmation, authoritative main-process revalidation, duplicate
detection, store write, scan-result update, and `promotion-log.jsonl`.
- Review-required native results now have an inline editor for identity,
main stat/value, level, substats, equipped state, lock state, and notes.
Approve requires IK identity validation, canonical main-value validation,
and legal substat rolls; Reject keeps the result blocked. Approved cases
write `review-log.jsonl` and reuse `review-samples.jsonl` for OCR eval export.
- The three real Review rows from the 100-item native run were manually
corrected and approved through that workflow. The run now has zero remaining
Review rows, all three corrections reached the eval candidate pipeline, and
the confirmed cases are permanent OCR regressions.
- The parser can now repair the confirmed dropped/extra-digit substat cases at
+20 only when one legal 5-star roll-count combination exists. Ambiguous OCR
stays reviewable.
- Project docs and roadmap were updated to state Artifact-only scope and the
next scanner/product priorities.
## Current Evidence
- `npm run lint` passed.
- `npm test` passed with `252` tests.
- `npm run build` passed.
- `git diff --check` passed; only existing CRLF warnings were reported for
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
- Native IK dry runs on 2026-07-09 completed at 20, 50, and 100 items with
complete crop/job/result counts, `0` processing errors, and `persisted: false`.
The 100-item run captured `100/100` across 4 pages in `24,673 ms`, parsed
`100/100`, and safely routed 3 OCR value losses into Review. See
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
- The older visible-inventory path has broader live evidence, including a
100-artifact run with `100/100` verified and parsed, `98` stored,
`2` duplicates, `0` review samples, `0` misses, and `393 ms/artifact`.
## Known Limits
- Native IK-style capture has current-session scale evidence, but later-session
repeatability and packaged-app evidence are still open.
- The native path is limited to visible 16:9 Artifact inventory with a visible
detail card.
- Native post-capture results do not persist by default. Clean selected results
can now be promoted explicitly after a second UI confirmation.
- Artifact value scoring and upgrade projection are not implemented for native
results yet.
- Review/edit/approve is implemented for one selected native result at a time.
Batch review is intentionally not available.
- The `333 ms/artifact` target for 3 artifacts/second is not proven.
- The native capture worker exceeded 4 artifacts/second, but the smoke runner
still captures and processes sequentially; 3 artifacts/second end to end is
therefore not proven.
- Weapons, materials, and character details are intentionally ignored until
their values are actually scanned.
## What Is Next
1. Add Artifact value evaluation after extraction is trustworthy. Keep value
reasons separate from OCR confidence.
2. Add detail-level value reasons and optional upgrade projection. Projection
must stay labeled as probabilistic.
3. Confirm packaged-app behavior for the C# helper, IK lists, preload bridge,
native crop previews, and smoke commands.
4. Repeat the native 20/50/100 evidence in a later session to prove repeatability.
5. Return to recommendations only after native artifact ingestion, promotion,
review, value scoring, and repeatability are strong enough.
## Not Next
- Do not prioritize weapons, materials, or character details yet.
- Do not auto-persist native scan results before review/promotion is safe.
- Do not claim Inventory Kamera parity until broad native Artifact runs prove
repeatable speed and quality.
- Do not merge extraction confidence and artifact value into one UI score.
+71 -33
View File
@@ -16,8 +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-008 | Replace the PowerShell input/capture helper with a C# sidecar | Accepted | 2026-07-05 |
| ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 | | ADR-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 |
| ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 | | ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 |
| ADR-011 | Quality-gated Inventory Kamera comparison before OCR default changes | 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-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 ## ADR-001: Build A Local Electron App First
@@ -47,7 +48,8 @@ Accepted
### Context ### 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 ### Decision
@@ -184,9 +186,8 @@ Accepted
The persistent PowerShell helper (ADR-006) still carries Windows PowerShell 5.1 The persistent PowerShell helper (ADR-006) still carries Windows PowerShell 5.1
quirks (the `Marshal::SizeOf` interop bug), compiles Win32 interop at startup, 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 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 - back. A C#/.NET sidecar with SendInput and direct GDI/BitBlt capture is a
uses a C#/.NET stack with InputSimulator (SendInput) and direct GDI/BitBlt cleaner fit for the validated Windows automation path.
capture.
### Decision ### Decision
@@ -215,10 +216,9 @@ Accepted
The current pipeline finds the artifact detail panel with hardcoded orange/green The current pipeline finds the artifact detail panel with hardcoded orange/green
color thresholds (`inferDetailRect`) and then crops fixed percentages of that color thresholds (`inferDetailRect`) and then crops fixed percentages of that
guessed rectangle. This is brittle against HDR, color profiles, UI scale, aspect 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 ratio, and game UI updates. A stricter borderless 16:9 profile with fixed crop
scales fixed crop coordinates from a reference resolution, then feeds Tesseract coordinates from a reference resolution is easier to validate and reproduce than
preprocessed (grayscale, upscaled, thresholded) crops - which is why general per-frame color hunting.
Tesseract is accurate enough for them.
### Decision ### Decision
@@ -289,29 +289,26 @@ Document the workflow in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
reads, hooks, injection, game-file modification, deleting, feeding, enhancing, reads, hooks, injection, game-file modification, deleting, feeding, enhancing,
locking/unlocking, or spending resources. locking/unlocking, or spending resources.
## ADR-011: Quality-Gated Inventory Kamera Comparison Before OCR Default Changes ## ADR-011: Retire Alternate OCR Comparison Paths After Current Scanner Baseline
### Status ### Status
Accepted Superseded
### Context ### Context
The product target is not merely to click through 100 artifacts quickly. It is The project previously carried alternate OCR-engine and comparison paths to
to scan the first 100 artifacts with accuracy at least as good as Inventory decide whether the current scanner should change OCR defaults. The current
Kamera and speed equal to or better than Inventory Kamera. A faster scan that visible-inventory path has now produced clean 100-artifact evidence, and the
creates too many misses, review samples, or false positives is worse than a next product phase is result clarity, artifact inventory, detail evaluation, and
slower qualified run. corpus growth. Keeping an alternate engine path increases app size and
maintenance surface without serving the current user workflow.
The app can now compare the current OCR path with Inventory Kamera's
`genshin_fast_09_04_21.traineddata` through the same visible crop set. It also
has hot-loop timing fields for capture, OCR, card readiness, scroll readiness,
active scan time, and projected 100-artifact time.
### Decision ### Decision
Use a quality-gated live soak and benchmark before changing the default OCR Retire the alternate OCR-engine and current-vs-reference comparison paths. Keep a
engine or claiming IK parity. A qualified scan result must: single current OCR/capture path and preserve the quality-gated live soak
assessment. A qualified scan result must:
- finish cleanly, - finish cleanly,
- parse at least the requested count, - parse at least the requested count,
@@ -324,22 +321,18 @@ engine or claiming IK parity. A qualified scan result must:
`scripts/live-soak.ps1` writes the evidence bundle and `scripts/live-soak.ps1` writes the evidence bundle and
`scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that `scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that
the ranking logic rejects fast but low-quality synthetic runs without needing the ranking logic rejects fast but low-quality synthetic runs without needing
Genshin. The assessment also records `goal100Decision` and Genshin. `npm run scan:assessment:validate -- --summary` prints the assessment
`goal100.comparisonComplete`; IK-target claims require a qualified 100-artifact path and `createdAt` timestamp so reports can cite the exact evidence file.
winner and a complete `current` vs. `ik-traineddata` comparison.
`npm run scan:assessment:validate -- --summary` prints the assessment path and
`createdAt` timestamp so reports can cite the exact evidence file.
### Consequences ### Consequences
- Speed claims cannot be based on click count or elapsed time alone. - Speed claims cannot be based on click count or elapsed time alone.
- A new OCR engine cannot become the default just because it is theoretically - A new OCR engine is out of scope until scanner UX, inventory, detail
closer to IK; it must win the same-capture benchmark and a qualified live run. evaluation, and corpus growth justify reopening that work.
- Stale elevated Electron instances are treated as invalid evidence, not as a - Stale elevated Electron instances are treated as invalid evidence, not as a
harmless warning. harmless warning.
- The validated `:wait` scan scripts are acceptable for manual post-UAC startup; - The validated `:wait` scan scripts are acceptable for manual post-UAC startup;
non-waiting scripts remain useful when automation should fail fast. non-waiting scripts remain useful when automation should fail fast.
- The goal remains open until the 100-artifact qualified comparison is captured.
## ADR-012: Separate Live Scan Results, Artifact Inventory, And Value Evaluation ## ADR-012: Separate Live Scan Results, Artifact Inventory, And Value Evaluation
@@ -395,5 +388,50 @@ Adopt a split scan/result/inventory model:
- Inventory and detail views become the natural place for richer analysis. - Inventory and detail views become the natural place for richer analysis.
- Recommendation work has a cleaner dependency chain: trusted scans, compact - Recommendation work has a cleaner dependency chain: trusted scans, compact
results, artifact inventory, detail evaluation, then build recommendations. results, artifact inventory, detail evaluation, then build recommendations.
- Speed work remains possible, but it is secondary unless measured timings show - Renderer-loop speed work remains secondary unless measured timings show a real
a real regression. 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.
+9 -10
View File
@@ -1,6 +1,6 @@
# Scanner Merge Evidence # Scanner Merge Evidence
Merged branch: `codex/ik-scanner-progress` Merged branch: scanner baseline branch
Target branch: `main` Target branch: `main`
Merge commit: `c025daa` Merge commit: `c025daa`
Merged on: 2026-07-09 Merged on: 2026-07-09
@@ -13,17 +13,17 @@ This document records the evidence used to merge the scanner branch into
| Area | Status | Evidence | | Area | Status | Evidence |
| --- | --- | --- | | --- | --- | --- |
| TypeScript/lint gate | Passed | `npm run lint` | | TypeScript/lint gate | Passed | `npm run lint` |
| Unit/regression suite | Passed | `npm test` with 207 tests, including equipped footer, lock detection, and PNG lock-crop regression coverage | | 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` | | Production build | Passed | `npm run build` |
| Whitespace check | Passed | `git diff --check` | | Whitespace check | Passed | `git diff --check` |
| OCR eval gate | Passed | `npm run eval` with `23/23` exact-match cases and `100%` critical fields | | 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 intentionally expects `ik-traineddata` to win its synthetic `limit=100` case while real live-winner claims stay tied to archived live assessments | | 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 | | 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&engine=current` completed `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, `0` misses, `8047 ms` elapsed | | 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` | | 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` | | 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` | | 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&engine=current` stored `A Note in Spring's Leich` with `equipped: "Citlali"` and `locked: true` | | 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 | | 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` | | 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 | | 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 |
@@ -37,7 +37,7 @@ npm run build
git diff --check git diff --check
``` ```
Latest run after the lock-state fix: passed with `207` tests. Latest run after the cleanup: passed with `209` tests.
`npm run eval` and `npm run scan:assessment:test` also passed after the `npm run eval` and `npm run scan:assessment:test` also passed after the
lock-state fix. lock-state fix.
@@ -49,10 +49,9 @@ lock-state fix.
- The optional `3 artifacts/second` target is not proven. The stable current - 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 path is closer to `2.5` to `2.75 artifacts/second` on clean 20-artifact live
runs. runs.
- Native Tesseract/IK-traineddata is not the default. The latest documented - Alternate OCR-engine comparison paths have been retired. The current scanner
qualified live 100-artifact comparison in this environment had `current` as path is the maintained baseline; future engine work should be reopened only
winner; the assessment self-test is a synthetic validator fixture, not a live with a new explicit product reason.
winner claim.
## Merge Result ## Merge Result
@@ -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.
+81 -37
View File
@@ -3,8 +3,7 @@
This document is the source of truth for project intent, scope, runtime facts, and operational expectations. 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 implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md).
For the latest Inventory-Kamera comparison work, see For the current app status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
[scanner-ik-progress-report.md](scanner-ik-progress-report.md).
For the next scan-result and artifact-inventory product phase, see For the next scan-result and artifact-inventory product phase, see
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md). [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 the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md).
@@ -15,7 +14,7 @@ For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md).
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| Project name | Genshin Artifact Assistant | | Project name | Genshin Artifact Assistant |
| Status | Scanner baseline merged to `main`; scan result rail, artifact inventory, extraction quality, and corpus growth are next | | 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 | | Platform | Windows desktop |
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup | | Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
| Runtime | Electron app with React UI and TypeScript | | Runtime | Electron app with React UI and TypeScript |
@@ -35,7 +34,8 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
- Present finished scan results as a compact artifact list instead of a debug-heavy live stats surface. - 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. - 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. - Provide a browsable local artifact inventory with detail views before promoting broader recommendations.
- Keep the app offline-first and usable without Genshin Optimizer, Inventory Kamera, Enka, or HoYoLAB. - 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. - Re-introduce recommendations only after the scanner base is trustworthy.
## Non-Goals ## Non-Goals
@@ -60,8 +60,8 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending | | 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-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-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 | Planned | | 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 | Planned | | 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 | | FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Planned |
## Non-Functional Requirements ## Non-Functional Requirements
@@ -70,7 +70,7 @@ 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 | | 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 | 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 |
| IK target | First 100 artifacts should scan with accuracy at least as good as Inventory Kamera and equal or better speed. | `npm run scan:goal:compare:validated` or `npm run scan:goal:compare:validated:wait` quality-gated report | | 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 | | 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 | | 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 | | Score integrity | Extraction confidence and artifact value are separate concepts. | Review state can block or qualify a value score |
@@ -108,10 +108,12 @@ 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`. - 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. - The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally. - Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
- The 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 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 an Inventory-Kamera comparison path: 32 safe artifact - The scanner now has a validated visible-inventory path: 32 safe artifact
targets per page, lookup-derived fields, fast OCR crop profile, current vs. targets per page, lookup-derived fields, fast OCR crop profile, and a
IK-traineddata benchmark endpoint, and a quality-gated live soak runner. quality-gated live soak runner.
- Elevated live automation is validated in the current dev environment: - Elevated live automation is validated in the current dev environment:
`/automation/probe-click?index=1` changed the selected artifact and `/automation/probe-click?index=1` changed the selected artifact and
`/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses. `/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses.
@@ -128,42 +130,71 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
- Review samples can now be exported with `npm run eval:review-candidates` into - 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 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. 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 ### What is still structurally weak
- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be. - The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be.
- Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs - Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs
with 0 misses on the current engine. The current-vs-IK-traineddata comparison with 0 misses on the current engine. The current 2026-07-09 100-artifact run
is now captured; `current` won the qualified 100-artifact comparison on completed `100/100` verified and parsed with `0` review and `0` misses.
2026-07-08.
- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be. - OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be.
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way. - Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
- The scan page is cleaner than before, but it still needs the next minimalist - The scan page now has a minimalist recent-results rail, and the Inventory view
result-rail pass so the main flow shows preview plus completed artifact can inspect native Artifact results, crop previews, IK match state, promotion
outcomes instead of live diagnostic/stat content. 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, - Recommendations and build logic exist, but artifact inventory, detail review,
and value scoring should land first so recommendations have trustworthy inputs. and value scoring should land first so recommendations have trustworthy inputs.
- The latest source has completed the final current-vs-IK-traineddata live - Repeatability and 3 artifacts/second are still open; speed work should not
comparison for this environment. Repeatability and 3 artifacts/second are outrank result clarity, inventory UX, or corpus growth while the current path
still open. is stable.
### Current product conclusion ### Current product conclusion
The app has crossed from OCR-demo/prototype into a validated scanner baseline on The app has crossed from OCR-demo/prototype into an Artifact-first scanner app.
`main`. The current merge-ready path is the visible-inventory scan flow: the 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 operator opens Artifact inventory with a visible detail card, the app verifies
the state, scans read-only, persists parsed artifacts, and keeps uncertain data the state, scans read-only, persists parsed artifacts, and keeps uncertain data
reviewable. The next product phase is not another broad speed rewrite; current reviewable. The newer native IK-style path is the intended high-speed direction:
speed is acceptable for now. The priority is better content extraction, a the helper captures Artifact card crops quickly, while OCR, parsing, review,
minimal scan-result rail, a browsable artifact inventory, detail evaluation, and promotion, and value evaluation run downstream.
corpus growth before recommendations become the core product surface.
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 ## Product Direction
- Artifact scanning is the first-class feature. - Artifact scanning is the first-class feature.
- Character optimization returns only after scan quality is trustworthy. - Character optimization returns only after scan quality is trustworthy.
- Team building stays out of the critical path until artifact ingestion is stable. - 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. - 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, - The scan workspace should be an operator surface: preview, live result rail,
Stop, status, and review access. Detailed stats and debug evidence belong in Stop, status, and review access. Detailed stats and debug evidence belong in
@@ -302,7 +333,17 @@ Outcome:
and click-through details. and click-through details.
Status: Status:
- Planned. See - 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) [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
for the implementation phases and acceptance criteria. for the implementation phases and acceptance criteria.
@@ -332,24 +373,27 @@ Status:
## Immediate Next Implementation Order ## Immediate Next Implementation Order
1. Keep the visible-inventory scanner path as the production baseline and avoid 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 promoting `auto-entry`, `direct-inventory`, or `paimon-menu` until they pass
their own low-limit live validations. their own low-limit live validations.
2. Implement the scan result and inventory data contracts from 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), [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md),
preserving separate extraction confidence and artifact value. preserving separate extraction confidence and artifact value.
3. Rework the scan page into preview plus minimal result rail; keep diagnostic 5. Finish value reasons in the existing Artifact inventory detail view before
stats out of the primary scan surface. expanding broad build recommendations.
4. Add the artifact inventory menu and detail view before expanding broad build 6. Keep the existing scan preview/result rail and Inventory workflow compact;
recommendations. move new diagnostics behind the dedicated diagnostics surface.
5. Grow the confirmed OCR corpus from review samples exported by 7. Continue growing the confirmed OCR corpus from review samples exported by
`npm run eval:review-candidates` and prepared through `npm run eval:review-candidates` and prepared through
`npm run eval:prepare-confirmed`. `npm run eval:prepare-confirmed`.
6. Repeat live scanner runs in later sessions to prove repeatability across 8. Repeat live scanner runs in later sessions to prove repeatability across
pages, locked/unlocked artifacts, equipped footers, and duplicate handling. pages, locked/unlocked artifacts, equipped footers, and duplicate handling.
7. Continue the optional `3 artifacts/second` work only if the next change can 9. Continue the optional `3 artifacts/second` work only if the next change can
reduce OCR/capture transport time without weakening quality gates. reduce OCR/capture transport time without weakening quality gates.
8. Start recommendation/product UX work only after inventory/detail evaluation, 10. Start recommendation/product UX work only after inventory/detail evaluation,
repeat scan quality, and confirmed corpus coverage are strong enough to trust repeat scan quality, and confirmed corpus coverage are strong enough to trust
stored artifacts. stored artifacts.
+9 -9
View File
@@ -2,10 +2,10 @@
Field-level accuracy measurement for the artifact OCR parser. This is the gate 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). every OCR, crop, layout, or parser change runs against (see ADR-007).
It is necessary but not sufficient for the IK target: live scan speed and It is necessary but not sufficient for live scanner proof: scan speed and
review/miss rates are measured by `npm run scan:iterate:compare:validated` for review/miss rates are measured by `npm run scan:iterate:validated` for short
short iteration and `npm run scan:goal:compare:validated` for the final iteration and `npm run scan:goal:validated` for the final 100-artifact proof.
100-artifact proof. Use the `:wait` variants directly after UAC startup. Use the `:wait` variants directly after UAC startup.
## Run it ## Run it
@@ -14,18 +14,18 @@ npm run eval # full accuracy report for the seed corpus
npm run eval:review-candidates # export unconfirmed review samples for human labeling 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 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:assessment:test # verifies quality-first scan ranking logic
npm run scan:iterate:compare:validated:wait # 20-artifact live comparison npm run scan:iterate:validated:wait # 20-artifact live scan
npm run scan:repeatability:wait # 20/45/100 current-engine repeatability npm run scan:repeatability:wait # 20/45/100 current-engine repeatability
npm run scan:goal:compare:validated:wait # final 100-artifact live comparison npm run scan:goal:validated:wait # final 100-artifact live scan
``` ```
The report prints exact-match rate, overall field accuracy, a per-field The report prints exact-match rate, overall field accuracy, a per-field
breakdown (critical fields marked with `*`), and every failing case with an breakdown (critical fields marked with `*`), and every failing case with an
`expected "..." got "..."` diff. `expected "..." got "..."` diff.
Repeatability runs are single-engine evidence. Use them to prove that the Repeatability runs prove whether the visible-inventory scanner stays stable
visible-inventory scanner stays stable across later sessions, but keep across later sessions. They are current-path evidence and should be reported
current-vs-IK claims on `scan:goal:compare:validated:*`. with review/miss rates plus timing, not just click count.
## How it works ## How it works
-387
View File
@@ -1,387 +0,0 @@
# Scanner IK Progress Report - 2026-07-07
This report summarizes the scanner/OCR work toward the current target:
scan the first 100 artifacts with accuracy at least as good as Inventory Kamera
and speed equal to or better than Inventory Kamera, without memory reads, hooks,
injection, game-file modification, or unsafe in-game actions.
## Executive Summary
The scanner has moved from a fragile OCR-first prototype toward an
Inventory-Kamera-style artifact scanner:
- Artifact scan is now the first-class path.
- Auto-scan starts only after a validated artifact inventory/detail preflight.
- Main-game, Paimon-menu, primary-screen, unsupported-layout, missing-grid, and
missing-detail states block before OCR/store/review work.
- OCR uses a fast artifact profile that skips low-value fields and derives
slot, set, and main-stat value through lookup constraints when safe.
- The OCR worker pool, field crop split, page model, scroll model, and direct
detail-change verification now mirror the relevant IK design choices more
closely.
- Diagnostics now preserve state evidence, timings, screenshots where useful,
entry events, focus/input events, preflight failures, and scan-loop reasons.
- A live soak runner now measures throughput and quality, compares current vs.
IK-traineddata engines, and refuses to run against stale Electron builds.
The current-vs-IK-traineddata comparison proof is now captured. On 2026-07-08,
`npm run scan:goal:compare:validated` passed with
`outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`.
At `limit=100`, `current` won the qualified comparison with `100/100` parsed,
`0` review, `0` misses, `378 ms/artifact` active average, and `37800 ms`
projected time for 100 artifacts. `ik-traineddata` was not qualified at
`limit=100` because it parsed `97/100`, had `5` review and `3` misses. The
separate 3 artifacts/second target is still not proven.
## What Changed
### Lookup and validation
- `scripts/generate-genshin-data.cjs` was extended into a stricter lookup
package generator.
- `src/lib/genshinLookup.ts` provides pure matching and validation for sets,
pieces, slots, stats, characters, aliases, GOOD keys, source version, and
validation summaries.
- Auto-scan preflight blocks if the lookup package is invalid.
Why this matters:
IK succeeds partly because raw OCR is not trusted by itself. The app now follows
the same principle: OCR text is normalized, matched, constrained, and derived
against a canonical package before it is accepted.
### OCR and parser pipeline
- Artifact detail crops are split into field-specific regions:
name, slot, main-stat label, main-stat value, level, substats, set effects,
equipped/footer, lock, and rarity.
- Fast auto-scan profile skips lower-value OCR work:
set effects, main-stat value crop, crop images, full-frame payloads, and
inventory preview payloads. The equipped footer remains in real artifact-read
captures when its marker is visible, because ownership now matters for phase 1
validation. Preflight and poll captures still skip OCR/crops/lock-state work.
The slot crop remains in the fast path because it materially improved
real-read quality.
- Slot, set, and main-stat value are derived when lookup, slot rules, and level
constraints make that safe.
- Field-specific Tesseract PSM/whitelist cleanup and preprocessing are used.
- OCR crops are passed as PNG buffers internally instead of Base64 DataURLs.
- Exact visual duplicate skipping is disabled in the hottest path; duplicate
handling now primarily uses parsed artifact signatures so OCR is not skipped
solely from a crop fingerprint collision.
Why this matters:
The fast path spends OCR only on fields that materially change the artifact
identity or review decision. That is closer to IK's queued crop model than a
manual-debug capture that OCRs every visible thing.
### Engine comparison and benchmark path
- `/scanner/ocr/warmup?engine=current|ik-traineddata` warms OCR workers.
- `/scanner/benchmark-ocr?engine=current|ik-traineddata|compare` benchmarks the
same visible artifact crops.
- Auto-scan accepts `ocrEngine: "current" | "ik-traineddata"`.
- `scripts/live-soak.ps1` supports:
- `npm run scan:goal`
- `npm run scan:goal:current`
- `npm run scan:goal:ik`
- `npm run scan:goal:compare`
- `npm run scan:goal:compare:validated`
- `npm run scan:goal:compare:validated:wait`
- `npm run scan:iterate:compare:validated`
- `npm run scan:iterate:compare:validated:wait`
- `npm run scan:live:preflight`
- `npm run scan:live:preflight:wait`
- `scan-performance-assessment.json` ranks runs by quality first and speed
second, and records whether the 100-artifact result is a complete
current-vs-IK comparison through `goal100Decision` and
`goal100.comparisonComplete`.
Important rule:
A fast engine cannot win if it has too many misses or too much review. A
qualified winner must finish cleanly, parse the requested count, keep miss rate
at or below 2%, and keep review rate at or below 15%.
### Auto-scan entry and safety
- The normal auto button runs a guided start:
1. focus Genshin,
2. run a lightweight no-OCR preflight,
3. if artifact detail is visible, use visible-inventory mode,
4. otherwise try direct `B -> artifact tab -> first artifact tile`,
5. if needed, fall back to the IK-style ESC/B inventory sequence,
6. start OCR only after artifact grid and detail card pass preflight.
- Entry captures are state evidence only. They do not create review samples,
store artifacts, or run artifact OCR before the detail preflight passes.
- Entry waits now poll for state readiness instead of always sleeping the full
fixed delay.
- Scan loop also rechecks the same safety boundary after each click and scroll.
Why this matters:
The previous failure mode was dangerous from a product-quality point of view:
when the game was not in artifact inventory, the scanner could still take
screenshots and try to read artifacts. The current path is explicitly blocked
outside the artifact inventory/detail state.
### Scan loop and speed
- Grid model uses IK's 32-artifact visible page concept (`8 x 4`) instead of
clicking the risky lower band.
- Last/partial page planning bottom-aligns like IK, avoiding unnecessary
duplicate reads after scroll.
- Detail-change verification now uses the artifact OCR capture itself instead
of a separate card-ready capture before OCR.
- Scroll readiness uses inventory fingerprint polling:
max 760 ms, 80 ms polls, changed pages may proceed after 100 ms.
- Store/review writes are held out of the click/capture/OCR hot path. The
latest source batches artifact store writes before the final summary instead
of issuing one save/reload cycle per artifact.
- Auto-scan artifact captures skip Electron source enumeration in the hot path
and call the GDI capture helper directly.
- Focus is done once at scan start; hot-loop captures do not refocus every tile.
Why this matters:
IK uses fixed waits around 200 ms after selecting inventory items and 100 ms
after fast scrolls. The app now keeps those as safety ceilings/acceptance points
while allowing earlier continuation when visual evidence is ready.
### Diagnostics and logging
- Scanner diagnostics now capture timeline events for runtime, focus, keypress,
entry captures, tab clicks, first-tile clicks, preflight, OCR/skips, grid,
counts, detail/page fingerprints, and failure reasons.
- `/scanner/status` publishes recent diagnostic evidence.
- Review sample output is compact by default so Vite does not watch large Base64
payloads during live soak runs.
- The live runner writes timestamped JSON snapshots, CSV summaries, transcript,
benchmark data, and performance assessment files under `outputs/live-soak/`.
Why this matters:
Future scanner bugs can be debugged from captured evidence instead of relying
only on a human description of what appeared on screen.
## Vorgehensweise
1. Read the local Inventory Kamera reference under `work/Inventory_Kamera`.
2. Copy the proven concepts, not the entire implementation:
32 artifact targets per page, fixed coordinate ratios, queued OCR work,
short item/scroll waits, read-only inventory navigation, and Tesseract
traineddata comparison.
3. Harden the app's own architecture around those concepts:
pure lookup API, parser derivation, renderer scan orchestration,
Electron capture/OCR boundary, sidecar input helper, and diagnostics.
4. Add tests before trusting behavior:
lookup validation, parser derivation, auto-entry planning/preflight,
card-ready gates, page planning, scan-loop blocking, OCR eval corpus.
5. Add live tooling before claiming performance:
bounded probes, stale-build gate, benchmark endpoint, soak runner, CSV/JSON
assessment, and quality-first comparison.
## Tests and Evidence
Latest repo validation after the recent changes:
| Check | Result |
| --- | --- |
| PowerShell parse for `scripts/live-soak.ps1` | Passed |
| `npm run scan:assessment:test` | Passed |
| `npm run lint` | Passed |
| Focused scanner tests | Passed |
| `npm test` | Passed, 171 tests |
| OCR eval seed corpus | 100% exact match, 100% field accuracy, 100% critical fields |
| `npm run build` | Passed |
| `git diff --check` | Passed |
Live evidence already collected:
- Probe click changed artifact detail successfully.
- Limit 2 live auto-scan completed with 2/2 parsed and 0 misses.
- Limit 20 live soak completed on the first visible page.
- Limit 45 live soak crossed into a scrolled page.
- On 2026-07-08, `/scanner/start?entry=visible-inventory&limit=50&engine=current`
completed `50/50` parsed and stored, `0` review, `0` duplicates, `0` misses,
`2` pages, `61765 ms` elapsed, `1235 ms/artifact`, `averageCaptureMs: 186`,
and `averageOcrMs: 162`.
- A deferred single-write flush experiment also completed `50/50`, but regressed
to `63616 ms` because `writeFlushMs` was `8163`; the source now uses batch
persist instead, pending a fresh elevated live measurement.
- After direct GDI hot-path optimization, a 20-artifact run completed
`20/20` parsed, `19` stored, `0` review, `1` duplicate, `0` misses,
`7966 ms` elapsed, `398 ms/artifact`, `averageCaptureMs: 193`, and
`averageOcrMs: 167`.
- A 45-artifact direct-GDI run completed `45/45` parsed, `42` stored,
`0` review, `3` duplicates, `0` misses, `2` pages, `18625 ms` elapsed,
`414 ms/artifact`, `averageCaptureMs: 187`, and `averageOcrMs: 162`.
- A 100-artifact direct-GDI run on signature
`2026-07-08-direct-gdi-hotpath` completed `100/100` parsed, `97` stored,
`0` review, `3` duplicates, `0` misses, `4` pages, `42064 ms` elapsed,
`421 ms/artifact`, `averageCaptureMs: 179`, and `averageOcrMs: 154`.
- `npm run eval` passed after the speed work with `23/23` exact-match cases,
`100%` field accuracy, and `100%` critical fields.
- The 3 artifacts/second target is now prepared in code but not live-proven:
artifact hot-path captures omit detail preview payloads, and stats expose
capture roundtrip/overhead timing. A qualifying 20-artifact run must finish in
`<= 6667 ms` with 0 misses and no silent OCR quality regression.
- Follow-up 3/s attempts on 2026-07-08 fixed the false review trigger caused by
omitted detail previews. The best clean `limit=20` run reached `7285 ms`
(`364 ms/artifact`, about `2.75 artifacts/second`) with `20/20` parsed,
`0` review, and `0` misses. The final stable run on
`2026-07-08-direct-gdi-reviewfix` completed `20/20` with `0` review,
`0` misses, and `7973 ms` elapsed (`399 ms/artifact`). Detail-region capture,
5 OCR workers, DataURL buffer decode, and substat `PSM.SINGLE_COLUMN` were
tested and rejected as slower than the direct-GDI baseline.
- Final current-vs-IK-traineddata comparison on 2026-07-08:
`npm run scan:goal:compare:validated` passed. Evidence file:
`outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`,
`createdAt: 2026-07-08T18:41:11.6120957+02:00`. `goal100Decision` was
`qualified-comparison: winner=current`; `goal100.comparisonComplete` was true.
`current` completed `100/100` parsed, `97` stored, `0` review, `0` misses,
`4` pages, `378 ms/artifact` active average. `ik-traineddata` completed
`97/100` parsed, `92` stored, `5` review, `3` misses and was rejected for
parsing fewer artifacts than requested.
- The review queue now has a bounded corpus-growth workflow:
`npm run eval:review-candidates` writes a deduplicated, Git-ignored worklist
to `outputs/review-eval-candidates/`. This separates complete modern OCR
samples from stale captures and prevents the parser's own guess from being
promoted to ground truth without human confirmation. After manual checking,
`npm run eval:prepare-confirmed` turns one candidate plus explicit expected
labels into a paste-ready confirmed corpus snippet.
Current live limitation:
- The fast path and current-vs-IK-traineddata 100-artifact comparison are proven
in the current live environment. 3 artifacts/second is not proven; remaining
speed work needs a larger OCR or capture-pipeline change, not more click
tuning.
- The visible-inventory ownership/lock extension is also live-proven in this
environment: equipped footer reads for `Citlali` and `Linnea`, unlocked
`locked: false`, positive locked `locked: true`, and locked/equipped
persistence all passed on 2026-07-09.
## Inventory Kamera Comparison
| Area | Inventory Kamera | Current app status |
| --- | --- | --- |
| Safe scope | Reads inventory through screen/click automation | Same safety boundary: screen capture and read-only input only |
| Entry | ESC/B inventory navigation and tab click | Direct `B` path plus IK-style fallback, with preflight guards |
| Page model | 32 artifact items per page | 32 safe targets (`8 x 4`) implemented |
| Last page | Bottom-aligned partial page after scroll | Implemented in page planner |
| Item wait | About 200 ms fixed wait | No separate wait capture; OCR capture verifies changed detail |
| Scroll wait | About 100 ms fast wait after scroll | Fingerprint polling, accepts changed page after 100 ms |
| OCR model | Native Tesseract worker queue and custom traineddata | Tesseract.js pool with current and IK-traineddata comparison path |
| Capture hot path | Direct window/screen capture without source-list scan per item | Direct GDI capture in auto-scan artifact loop |
| Field parsing | OCR plus game-data lookup | OCR plus generated lookup, GOOD keys, aliases, slot/stat constraints |
| Quality gate | Mature behavior by design and user history | Explicit benchmark/soak quality gates added |
| Diagnostics | Logs/screenshots in IK flow | Diagnostics timeline plus JSON evidence bundle |
| 100-artifact proof | Reference target | Qualified current-vs-IK comparison captured; `current` won with `100/100`, 0 review, 0 misses, 37.8s projected |
What is theoretically better than before:
- The app no longer spends OCR on invalid screens.
- It no longer treats click count as scanner success.
- It can prove whether `current` or `ik-traineddata` wins on the same capture
set instead of changing engines blindly.
- It can reject fast-but-wrong results automatically.
- It can identify whether the bottleneck is OCR, capture, card readiness, or
scroll readiness.
What is not yet proven better than IK:
- Native Tesseract speed is not integrated as the default.
- 3 artifacts/second is not proven.
- Native Inventory Kamera outside this app was not re-run in the same session;
the completed comparison is against the bundled `ik-traineddata` scan engine.
## Theoretical Runtime Flow
For short iteration while tuning:
1. Start current elevated app with `npm run dev:admin` and confirm UAC.
2. Run `npm run scan:iterate:compare:validated:wait` from a visible artifact inventory
when starting directly after UAC, or `npm run scan:iterate:compare:validated`
if preflight already passes.
3. Inspect `scan-performance-assessment.json`, review samples, and timings if the
20-artifact comparison fails quality gates.
For the intended 100-artifact comparison:
1. Start current elevated app with `npm run dev:admin` and confirm UAC.
2. Verify `/health.appBuild.signature` matches `electron/main.ts`.
3. Warm current and IK-traineddata OCR workers.
4. Run a small bounded probe from the artifact inventory.
5. Run `npm run scan:goal:compare:validated:wait` directly after UAC, or
`npm run scan:goal:compare:validated` if preflight already passes.
6. For each engine and limit (`2, 5, 20, 45, 100`):
- focus Genshin once,
- verify lookup and layout,
- verify artifact grid and detail card,
- click one safe grid target,
- poll detail fingerprint,
- skip duplicate visuals,
- OCR only the fast artifact crop set,
- parse through lookup constraints,
- queue store/review writes,
- scroll with inventory fingerprint polling,
- stop on repeated pages, invalid surfaces, blocked input, OCR timeout, or
repeated misses.
7. Write CSV, JSON snapshots, transcript, benchmark report, and performance
assessment.
8. Declare a winner only if the 100-artifact run is qualified by quality.
Expected bottleneck sequence:
- If OCR dominates, compare `current` vs `ik-traineddata`, crop count, and
worker pool size.
- If capture dominates, reduce payload construction and preview/crop image work.
- If card-ready dominates, tune the detail fingerprint gate.
- If scroll-ready dominates, tune page fingerprint polling and scroll notches.
## What Is Better Than Before
- Auto-scan is artifact-detail gated; no more blind OCR from main gameplay or
menu screens.
- Paimon/menu detection blocks before scan-loop OCR or writes.
- The normal button is one coherent guided flow instead of a separate "get to
inventory first, then scan" workflow.
- The app has a real lookup layer instead of raw OCR plus scattered hardcoded
assumptions.
- The scanner can compare OCR engines without changing the default blindly.
- Performance reports now include quality decisions, not just elapsed time.
- Diagnostics are concrete enough for later self-troubleshooting.
- Stale elevated runtime is detected before live soak, avoiding false evidence.
## Risks and Remaining Work
1. Keep `current` as the default OCR engine for now; it won the qualified
current-vs-IK-traineddata live comparison.
2. If pursuing 3 artifacts/second, focus on capture/OCR pipeline changes rather
than click timing.
3. Grow the eval corpus with confirmed real review samples before tightening
parser thresholds further.
4. Repeat equipped/locked live samples in later sessions if confidence or UI
behavior changes, but the first positive locked proof has passed.
5. Keep recommendations secondary until scanner quality remains stable across
repeated live sessions and the result rail, artifact inventory, and detail
evaluation flow are implemented.
6. Treat current speed as acceptable for the next product phase; prioritize
artifact content extraction and review-safe value scoring before another
broad speed pass.
## Definition of Done for the IK Target
The goal is complete only when current evidence proves all of these:
- The app is running the latest runtime signature.
- The 100-artifact scan finishes cleanly.
- Parsed count is at least 100.
- Miss rate is at or below 2%.
- Review rate is at or below 15%.
- The run is equal to or faster than the recorded IK reference or the selected
IK-traineddata/native baseline on the same machine and inventory setup.
- The evidence bundle is saved under `outputs/live-soak/`.
- Any chosen default OCR engine is backed by the same-capture benchmark.
+99 -16
View File
@@ -1,14 +1,22 @@
# Scanner Results And Artifact Inventory Roadmap # Scanner Results And Artifact Inventory Roadmap
This document defines the next product phase after the validated visible-inventory This document defines the next product phase after the validated visible-inventory
scanner baseline. The scanner is already fast enough for the current milestone; scanner baseline and the new native IK-style capture direction. The next capture
the next work should improve artifact content extraction, review safety, and a milestone optimizes speed by moving game-control and card-crop capture into the
minimal inventory experience that makes scanned artifacts useful. 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 ## Product Stance
- Keep the scan workspace focused on operation, not analysis. - Keep the scan workspace focused on operation, not analysis.
- Keep the live preview as the dominant surface. - 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, - Move debug metrics, OCR internals, and detailed evaluation behind details,
diagnostics, or the inventory view. diagnostics, or the inventory view.
- Do not merge scan confidence and artifact value into one ambiguous score. - Do not merge scan confidence and artifact value into one ambiguous score.
@@ -64,29 +72,31 @@ reasons in the detail view before it becomes a recommendation source.
## Planned Pipeline Shape ## Planned Pipeline Shape
The current scan loop can keep shipping while the UI and data contracts are The current TypeScript scan loop can keep shipping as fallback while the native
built. A fuller producer/consumer pipeline is a later implementation step: pipeline takes over high-speed capture. The target producer/consumer pipeline is:
```mermaid ```mermaid
flowchart LR flowchart LR
Capture["Single capture and game-control worker"] Native["C# native click, scroll, capture worker"]
Queue["Bounded screenshot/crop queue"] Queue["Bounded card-crop queue"]
OCR["OCR and parse workers"] OCR["OCR and parse workers"]
Eval["Artifact evaluation"] Match["IK inventorylists matching"]
Eval["Artifact evaluation (deferred)"]
Aggregate["Aggregator and store"] Aggregate["Aggregator and store"]
UI["Live rail and inventory"] UI["Live rail and inventory"]
Capture --> Queue Native --> Queue
Queue --> OCR Queue --> OCR
OCR --> Eval OCR --> Match
Match --> Eval
Eval --> Aggregate Eval --> Aggregate
Aggregate --> UI Aggregate --> UI
``` ```
Constraints: Constraints:
- Only one worker may control Genshin input, focus, click, scroll, or failsafe - Only the native helper may control Genshin input, focus, click, scroll, card
polling. capture, or failsafe polling in the fast path.
- OCR/parse/evaluation workers may run concurrently on already captured - OCR/parse/evaluation workers may run concurrently on already captured
screenshot/crop jobs. screenshot/crop jobs.
- The queue must be bounded, initially around `4-8` jobs, so the scanner does - The queue must be bounded, initially around `4-8` jobs, so the scanner does
@@ -94,28 +104,42 @@ Constraints:
- The pipeline must preserve current safety rules: no memory reads, hooks, - The pipeline must preserve current safety rules: no memory reads, hooks,
injection, game-file changes, deleting, feeding, enhancing, locking/unlocking, injection, game-file changes, deleting, feeding, enhancing, locking/unlocking,
or spending resources. or spending resources.
- Do not implement the queue refactor before the result/inventory contracts are - Evaluation may be omitted until after capture speed and crop quality are
stable, unless timing evidence shows the current loop has become the blocker. validated.
## Implementation Phases ## Implementation Phases
### Phase 0 - Documentation and contracts ### Phase 0 - Documentation and contracts
Status: prepared by this document. Status: prepared by this document and ADR-013.
Outcome: Outcome:
- Product direction is documented. - Product direction is documented.
- Main docs point to this roadmap. - Main docs point to this roadmap.
- Acceptance criteria and checklists exist before code changes. - 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 ### 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: Outcome:
- Add a durable scan result entry model with sequence number, capture metadata, - Add a durable scan result entry model with sequence number, capture metadata,
parsed artifact identity, extraction status, artifact value score, value parsed artifact identity, extraction status, artifact value score, value
status, duplicate/review flags, and timestamps. 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. - Keep existing stored artifact records compatible.
- Add tests for status derivation so low-confidence OCR cannot become a normal - Add tests for status derivation so low-confidence OCR cannot become a normal
`Good` or `Strong` result. `Good` or `Strong` result.
@@ -130,6 +154,12 @@ Likely files:
### Phase 2 - Minimal live result rail ### 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: Outcome:
- Rework the scan main section into preview plus right-side result rail. - Rework the scan main section into preview plus right-side result rail.
@@ -148,6 +178,33 @@ Likely files:
### Phase 3 - Artifact inventory view ### 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: Outcome:
- Add a menu item for scanned artifact inventory. - Add a menu item for scanned artifact inventory.
@@ -167,6 +224,14 @@ Likely files:
### Phase 4 - Artifact detail view ### 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: Outcome:
- Clicking a live row or inventory item opens detail. - Clicking a live row or inventory item opens detail.
@@ -185,6 +250,9 @@ Likely files:
### Phase 5 - Artifact value evaluation ### 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: Outcome:
- Add a deterministic artifact value evaluator before promoting build - Add a deterministic artifact value evaluator before promoting build
@@ -204,6 +272,8 @@ Likely files:
### Phase 6 - Upgrade projection ### Phase 6 - Upgrade projection
Status: later detail-level feature after artifact value evaluation.
Outcome: Outcome:
- For artifacts below max level, show optional projection only in detail. - For artifacts below max level, show optional projection only in detail.
@@ -230,6 +300,11 @@ Outcome:
- Preserve stop/failsafe behavior and review decisions. - Preserve stop/failsafe behavior and review decisions.
- Compare throughput against the current baseline without weakening accuracy. - 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: Likely files:
- `src/lib/autoScanLoop.ts` - `src/lib/autoScanLoop.ts`
@@ -239,6 +314,9 @@ Likely files:
### Phase 8 - Recommendation promotion ### Phase 8 - Recommendation promotion
Status: intentionally delayed until native artifact ingestion, review,
promotion, value scoring, and repeatability are trustworthy.
Outcome: Outcome:
- Promote account-level recommendations only after scan result quality, - Promote account-level recommendations only after scan result quality,
@@ -263,6 +341,11 @@ Outcome:
- `npm run lint` - `npm run lint`
- `npm test` - `npm test`
- `npm run build` - `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. - Add unit tests for score/status derivation and upgrade projection.
- For scanner-facing changes, run a low-limit visible-inventory live scan before - For scanner-facing changes, run a low-limit visible-inventory live scan before
wider validation. wider validation.
+155 -282
View File
@@ -1,300 +1,173 @@
# Scanner rework status # Scanner Rework Status
Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in Updated: 2026-07-09
[DECISIONS.md](DECISIONS.md) for the decisions behind these. For the current
live automation runbook, see
[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
For the next result/inventory product phase, see
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
## Current Scanner Status Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
See [scanner-ik-progress-report.md](scanner-ik-progress-report.md) for the full ## Was Es Kann
report.
Current status after the 2026-07-09 merge to `main`: - 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`.
- The scanner architecture now follows the relevant Inventory Kamera model: ## Was Zuletzt Gemacht Wurde
32 artifact targets per page, lookup-derived fields, fast artifact OCR profile,
direct detail-fingerprint verification from the OCR capture, page-overlap
planning, and batched store work.
- The live runner can compare `current` and `ik-traineddata` engines and rejects
runs that are fast but fail miss/review quality thresholds.
- The final current-vs-IK-traineddata 100-artifact comparison is now proven for
the current live environment. On 2026-07-08,
`npm run scan:goal:compare:validated` passed with evidence at
`outputs/live-soak/2026-07-08T18-38-35/scan-performance-assessment.json`.
`current` won with `100/100` parsed, `0` review, `0` misses, and
`378 ms/artifact` active average. `ik-traineddata` was rejected at 100 because
it parsed `97/100`, had `5` review and `3` misses. The next optional speed
target remains `3 artifacts/second`, which means `333 ms/artifact` or faster
on clean 20-artifact iterations.
- The merge-ready default is the visible-inventory path. The app blocks normal
guided Auto-Scan unless Artifact inventory and a visible detail card are
detected. `auto-entry`, `direct-inventory`, and `paimon-menu` remain explicit
Dev-Control experiments.
- Ownership and lock-state proof is no longer theoretical: live captures parsed
equipped characters (`Citlali`, `Linnea`), unlocked artifacts reported
`locked: false`, a visibly locked artifact reported `locked: true`, and a
bounded auto-scan persisted the locked/equipped state.
- Current speed is acceptable for the next product phase. The next work should
prioritize correct artifact content extraction, a minimal live result rail,
scanned artifact inventory, detail evaluation, and review-safe value scoring
before another broad speed pass.
## Done (implemented, unit-tested, build green) - 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.
- **OCR eval harness**`src/eval/`, `npm run eval`, gate in `npm test`. See ## Was Noch Nicht
[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.
- **Auto-scan OCR performance pass** - auto-scan captures now use an artifact
OCR mode that skips inventory-count OCR on each tile, keeps equipped-character
OCR on the real artifact-read captures, raises the substat crop to catch
artifact level, stores automatic review samples without full-screen/inventory screenshots, reads only the tail of large
JSONL files, avoids review noise when only level/equipped is missing, starts
the scan with an OCR-free preflight capture, prevents repeated startup review
reprocessing, omits full-frame and inventory-preview Base64 payloads from tile
captures, and applies crop-specific Tesseract page-segmentation/whitelist
parameters.
- **Visible-page live soak helper** - `scripts/live-soak.ps1` now drives the
dev-control health/status, smart-capture, probe-click, bounded scan, and
review-tail endpoints and writes evidence to `outputs/live-soak/`. On
2026-07-07 it completed probes at indices 1 and 3 plus scan limits 2, 5, 10,
and 20 against the elevated running app. The limit 20 run finished `done` with
20 attempted, 20 verified, 18 parsed, 18 stored, 1 review, 1 duplicate, 1
miss, and 1 page.
- **Scroll/page-transition live soak** - after the helper and loop fixes,
`scripts/live-soak.ps1 -Limits 45 -ProbeIndices 1 -SkipSmartCapture`
completed `done` on 2026-07-07 with 45 attempted, 45 verified, 35 parsed, 35
stored, 9 review, 1 duplicate, 9 misses, and 2 pages. This validates that the
scanner can cross from the first visible page into a scrolled page in the live
1920x1080 setup.
- **Lookup package layer** - `scripts/generate-genshin-data.cjs` now emits
normalized lookup keys, GOOD keys, piece/set/slot links, aliases, source
version metadata, generated time, and validation summary. `src/lib/genshinLookup.ts`
provides pure matching and validation APIs, and the scanner status/dev-control
path exposes lookup validity. Auto-scan preflight blocks when the lookup package
is invalid.
- **Inventory-Kamera-style field split** - artifact detail crops now separate
name, slot, main-stat label, main-stat value, level, substats, set effects, and
footer. OCR uses field-specific PSM/whitelist cleanup, and the parser derives
slot/set/main-stat through lookup constraints before falling back to review.
- **Paimon-menu auto-entry scaffold** - auto-scan supports
`scanEntryMode: "paimon-menu"` and `/scanner/start?entry=paimon-menu&limit=N`.
The entry sends only read-only navigation (`ESC`, `B`, artifact-tab click),
then requires a valid lookup, supported layout, and detected artifact grid
before the scan loop starts. The existing visible-inventory start remains the
fallback/debug path.
- **OCR benchmark endpoint scaffold** - `/scanner/benchmark-ocr?limit=N` captures
identical artifact crops with the current engine and returns timing/field counts.
`/scanner/benchmark-ocr?engine=compare` can also compare the local
Inventory-Kamera-traineddata Tesseract.js path when
`genshin_fast_09_04_21.traineddata` is present in `data/tessdata`, `work/`, or
`IK_TESSDATA_DIR`. The OCR worker pool defaults to four workers and can be
tuned with `GAA_OCR_WORKERS=1..8`. Native Tesseract is still not the default
and should only replace `tesseract.js` after the benchmark proves it faster
and more accurate on the same crops.
- **Quality-gated live comparison** - `scripts/live-soak.ps1` now supports
goal runs for `current`, `ik-traineddata`, and `compare`, writes CSV/JSON
summaries, groups results by limit, identifies timing bottlenecks, and rejects
winners that miss the requested count, exceed 2% misses, or exceed 15% review.
`npm run scan:assessment:test` verifies this ranking logic without Genshin.
The assessment also reports `goal100Decision` and
`goal100.comparisonComplete`, so a single-engine 100-artifact run cannot be
misread as the final IK comparison. Use
`npm run scan:iterate:compare:validated:wait` for the 20-artifact live
iteration and `npm run scan:goal:compare:validated:wait` for the final proof
when starting directly after UAC. The validator `--summary` output includes
the assessment path and timestamp for reporting.
- **State-polled guided entry** - the guided auto-entry waits for Inventory,
artifact grid, and first detail card evidence instead of sleeping the full
fixed delay every time. OCR/review/store work still starts only after artifact
detail preflight passes.
- **Hot-loop speed pass (2026-07-08)** - the scan loop no longer performs a
separate card-ready capture before OCR; the artifact OCR capture itself
verifies detail-fingerprint change. Routine click diagnostics and scan stat
publishes are throttled. Auto-scan artifact captures no longer update the
full preview/topbar UI on every tile. Store writes can be batched so the scan
path avoids per-artifact save/reload churn. Auto-scan artifact captures now
use a direct GDI hot path and skip Electron `desktopCapturer.getSources()` in
the per-artifact loop.
- **3/s instrumentation pass (2026-07-08)** - artifact hot-path captures omit
the detail-preview payload, and scan stats now split inner capture time from
end-to-end capture roundtrip time. Use `averageCaptureRoundTripMs` and
`averageCaptureRoundTripOverheadMs` in the next `limit=20` live iteration to
decide whether the next cut belongs in native capture transport or OCR.
- **Repeatability and capture-overhead guardrails (2026-07-09)** -
`scripts/live-soak.ps1` now writes capture roundtrip and roundtrip-overhead
timing into `scan-performance-assessment.json`. The assessment validator can
enforce optional speed budgets with `--max-active-average-ms` and
`--max-capture-roundtrip-overhead-ms`, and `npm run scan:repeatability:wait`
runs 20/45/100 current-engine passes as repeatability evidence without
presenting them as an IK comparison. `-RepeatabilityRun` now sets those limits
inside PowerShell, and the script refuses unsafe limits above 1800 so npm/cmd
argument parsing cannot accidentally turn `20,45,100` into one oversized run.
- **Distinctive partial piece recovery (2026-07-09)** - a live repeatability run
exposed four identical OCR misses where the piece name was read as
`Wontiroms Creation pan`. The parser now derives a piece only when a long OCR
fragment uniquely matches exactly one known artifact piece. This recovered the
local case as `Sharpness That Ceased Upon Wondrous Creation` /
`Disenchantment in Deep Shadow` without adding a broad fuzzy exception.
- **Repeatability live pass after parser fix (2026-07-09)** -
`outputs/live-soak/2026-07-09T09-29-11/scan-performance-assessment.json`
captured a clean current-engine 20-artifact run: `20/20` parsed, `0` review,
`0` misses, `336 ms/artifact` active average, `318 ms` average capture
roundtrip, and `138 ms` average roundtrip overhead. The strict 3 artifacts per
second budget still failed by 3 ms (`336 ms` vs `333 ms`).
- **3/s follow-up experiments (2026-07-09)** - tested and rejected several
shortcut-style optimizations because live runs got slower or added risk:
skipping Paimon-menu analysis, skipping lock-state as a production shortcut,
reducing the artifact-level crop scale, and raising the OCR worker pool to 6.
The kept low-risk changes are fast-profile OCR crop priority and avoiding a
duplicate DataURL string when the native helper already returns Base64. A
follow-up clean 20-artifact run after payload cleanup reached `351 ms/artifact`,
`331 ms` capture roundtrip, and `146 ms` roundtrip overhead, so the next
credible 3/s work is native capture transport/roundtrip reduction, not UI
recommendation work.
- **3/s live attempt (2026-07-08)** - the missing-detail-preview review trigger
was fixed and tested. The best clean 20-artifact run reached `7285 ms`
(`364 ms/artifact`, about `2.75 artifacts/second`) with 0 review and 0 misses.
The final stable run on `2026-07-08-direct-gdi-reviewfix` completed `20/20`
with 0 review, 0 misses, and `7973 ms` elapsed (`399 ms/artifact`). Detail
region capture, 5 OCR workers, DataURL buffer decode, and substat
`PSM.SINGLE_COLUMN` were tested and rejected as slower.
- **Review-to-eval loop (2026-07-08)** - `npm run eval:review-candidates`
exports the local review queue into `outputs/review-eval-candidates/` as a
human-labeling worklist. The exporter deduplicates samples, surfaces complete
fast-field captures first, marks stale captures, and now surfaces equipped
footer OCR plus `locked=true/false` payload counts for the next ownership/lock
validation pass. Its output is deliberately
ignored by Git and must not be treated as ground truth until fields are
confirmed against the real artifact. Confirmed review labels now have a
dedicated corpus file, `src/eval/corpus/confirmedReviewCorpus.ts`, with tests
that reject duplicate ids, empty labels, and unconfirmed entries. The helper
`npm run eval:prepare-confirmed` generates a paste-ready confirmed-case
snippet only when explicit expected labels are provided.
- **Prepared ownership/learning loop (2026-07-08)** - fast auto-scan no longer
drops the artifact footer by profile alone; it omits footer OCR only when the
capture option explicitly requests that or when the footer marker is absent.
Parser tests cover noisy equipped names, split `Equipped:`/name footers, and
one-letter OCR fragments that must stay `Not detected`. Scanner learning now
persists text replacements, field aliases, constrained fixes, crop adjustment
proposals, and UI-profile adjustment proposals instead of truncating everything
back to text replacements.
- **Visible-inventory merge guard (2026-07-09)** - the normal guided Auto-Scan
start no longer falls back into `auto-entry` when the artifact detail card is
missing. It now blocks and asks the operator to open the Artifact inventory
with a visible detail card. The explicit `auto-entry`, `direct-inventory`, and
`paimon-menu` Dev-Control modes remain available for targeted experiments, but
they are not the merge-ready default path.
- **Ownership live smoke (2026-07-09)** - live artifact detail capture parsed
and stored an equipped footer as `equipped: "Citlali"` and the grey lock state
as `locked: false`. A same-session visible-inventory run with
`/scanner/start?entry=visible-inventory&limit=20&engine=current` completed
`20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and
`0` misses in `8047 ms` elapsed (`402 ms/artifact`).
- **Locked artifact live proof (2026-07-09)** - a visibly locked artifact was
selected through a read-only inventory tile click. Smart Capture reported
`locked: true` with `lockSignal.ratio: 0.14797913950456323` over threshold
`0.06`, and `/scanner/start?entry=visible-inventory&limit=1&engine=current`
persisted the same artifact with `equipped: "Citlali"` and `locked: true`.
Lock detection now decodes the lock crop PNG before measuring active lock
pixels because Electron's native bitmap channel order was ambiguous in live
captures.
## Next product phase - result rail and inventory - 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.
The next implementation pass is planned in ## Wo Es Noch Probleme Macht
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
Summary:
1. Add result data contracts that preserve extraction confidence separately from - Die native Pipeline hat jetzt 20/50/100-Skalenevidenz aus einer Live-Session.
artifact value score. Spaetere Session-Repeatability und Packaged-App-Evidenz stehen noch aus.
2. Rework the scan view into screenshot/preview plus a compact right-side live - Capture roundtrip and OCR remain the main timing costs in the 100-artifact
result rail. path.
3. Add a scanned artifact inventory menu with minimal list/grid rows and - Auto-scan still requires a visible artifact inventory detail card for the
score/review pills. production path; broader auto-entry modes remain dev experiments.
4. Add click-through artifact detail with screenshot/crops, parsed fields, OCR - OCR/parser quality is good on the confirmed corpus, but review samples must be
confidence, value reasons, and review state. manually labeled before they can become permanent eval ground truth.
5. Add upgrade projection later as a detail-only feature with worst/middle/best - Non-16:9 or unusual game layouts are intentionally higher risk and should
cases and explicit uncertainty. block or fall into review instead of silently scanning.
6. Defer the full screenshot queue/worker pipeline until the result/inventory - The UI now labels these risks in the Artifact Inventory pipeline. Same-session
contracts are stable or timing evidence shows the current loop is the blocker. scale is proven, but external-tool parity and later-session repeatability are
intentionally not claimed.
## Remaining - needs the live environment or a UI pass ## Was Noch Verbesserungsfaehig Ist
These cannot be finished/validated without Genshin running at the user's - Repeat the bounded 20/50/100 native runs in a later session and in a packaged
resolution or without UI work best tested live: 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.
1. **Validate/tune OCR preprocessing** on more real captures — confirm invert + ## Was Als Naechstes Ansteht
threshold + upscale factor help (not hurt) actual Tesseract reads. The
text-level eval harness cannot measure image preprocessing.
2. **Wire and benchmark native IK-traineddata OCR** against the same crop set.
The current benchmark can use IK-traineddata through Tesseract.js; native
Tesseract integration remains the next implementation step before any engine
default changes.
3. **Validate explicit entry modes separately** from world, direct inventory,
and Paimon/menu states with low limits only. These are now Dev-Control
experiments, not the normal merge path; the normal Auto-Scan button blocks
unless the visible artifact detail card is already present.
4. **Repeat locked=true on another page/session** if lock behavior changes.
The first positive live proof passed on 2026-07-09, including store
persistence. Further repeats are useful for confidence but no longer block
the merge.
5. **3 artifacts/second iteration** - not reached yet. The next credible path is 1. Artifact Value Evaluation ergaenzen, aber erst nach sauberer Extraktion und
either native Tesseract/IK-traineddata integration that materially reduces weiterhin getrennt von OCR/Parser-Confidence.
substat OCR time, or a larger capture pipeline change that avoids full-frame 2. Detail-Ansicht um Value-Gruende und optionale Upgrade-Projektion erweitern.
PNG/Base64 transport without hurting safety checks. The target remains 3. Packaged-App-Verhalten fuer Helper, IK-Listen, Preload-Bridge,
`<= 6667 ms` elapsed for 20 parsed artifacts with 0 misses and no silent OCR Crop-Preview und Smoke-Kommandos pruefen.
review regression. The latest clean 20-artifact repeatability run reached 4. Empfehlungen und Build-UX erst danach wieder nach vorne ziehen.
`336 ms/artifact`, so 3/s remains close but unproven.
6. **Broader scan soak test** — direct-GDI current-engine runs now passed at ## Current Validation Commands
`20/20`, `45/45`, and `100/100` with 0 misses. Continue with
`npm run scan:repeatability:wait` in later sessions to check duplicate rate,
scroll behavior, and capture roundtrip timing without changing defaults.
7. **Repeatability pass** — repeat the qualified current-vs-IK-traineddata run
in a later live session before making major OCR-engine defaults or speed
claims beyond this environment. Current-engine-only repeatability is useful
evidence, but it is not an IK parity claim.
Visible-page limits up to 20, scroll/page-transition limit 45, the final ```powershell
100-artifact current-vs-IK-traineddata comparison, equipped footer live smokes, npm run scan:live:preflight
and one positive locked-artifact persistence proof have passed for the current npm run scan:iterate:validated
environment. Remaining soak work is repeatability, OCR corpus growth, additional npm run scan:goal:validated
equipped/locked repeats, and optional 3 artifacts/second speed work. npm run scan:repeatability:wait
npm run scan:assessment:validate -- --latest --summary
npm run eval
npm test
npm run build
```
## Grow the eval corpus Latest static validation after the Artifact-only Inventory UI update:
Every low-confidence review sample already stores its crops + OCR. Confirm/correct ```powershell
those via `reviewSampleToEvalCase` and commit them into `src/eval/corpus/` so the npm run lint # passed
harness keeps measuring real-world accuracy across patches. See npm test # passed, 252 tests
[ocr-eval.md](ocr-eval.md). 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`.
+33
View File
@@ -19,6 +19,17 @@ import type {
SaveSnapshotResult, SaveSnapshotResult,
GoodDatabase, GoodDatabase,
GoodImportFileResult, GoodImportFileResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
ScannerStatusPayload, ScannerStatusPayload,
} from "../../src/types/global.js"; } from "../../src/types/global.js";
import type { import type {
@@ -33,6 +44,17 @@ interface AppHandlersDependencies {
moveMainWindowOffGenshin: () => Promise<void>; moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>; focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>; 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>; readRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>; loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>; saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
@@ -73,6 +95,17 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin, moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
focusGenshinForScanStart: dependencies.focusGenshinForScanStart, focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
publishScannerStatus: dependencies.publishScannerStatus, 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, getRuntimeInfo: dependencies.readRuntimeInfo,
loadSnapshot: dependencies.loadSnapshotFromDisk, loadSnapshot: dependencies.loadSnapshotFromDisk,
saveSnapshot: dependencies.saveSnapshotToDisk, saveSnapshot: dependencies.saveSnapshotToDisk,
+104 -32
View File
@@ -9,6 +9,13 @@ import type {
CaptureSourceInfo, CaptureSourceInfo,
ClickResult, ClickResult,
ReviewSampleListResult, ReviewSampleListResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
ScannerCommand, ScannerCommand,
ScannerStatusPayload, ScannerStatusPayload,
AppRuntimeInfo, AppRuntimeInfo,
@@ -24,7 +31,16 @@ interface DevControlServerDependencies {
sendScannerCommand: (command: ScannerCommand | "probe-click") => void; sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
scannerStatus: () => ScannerStatusPayload; scannerStatus: () => ScannerStatusPayload;
warmOcr: (engine: "current" | "ik-traineddata") => Promise<unknown>; 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>; loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
listCaptureSources: () => Promise<CaptureSourceInfo[]>; listCaptureSources: () => Promise<CaptureSourceInfo[]>;
captureSource: ( captureSource: (
@@ -161,23 +177,17 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
} }
if (url.pathname === "/scanner/start") { if (url.pathname === "/scanner/start") {
const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
const entry = url.searchParams.get("entry");
const engine = url.searchParams.get("engine");
const scanEntryMode = entry === "paimon-menu" || entry === "visible-inventory" || entry === "direct-inventory" || entry === "auto-entry"
? entry
: undefined;
const ocrEngine = engine === "ik-traineddata" ? "ik-traineddata" : engine === "current" ? "current" : undefined;
const hasLimit = Number.isFinite(limit) && limit > 0; const hasLimit = Number.isFinite(limit) && limit > 0;
const command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine const category = url.searchParams.get("category") ?? undefined;
? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine } deps.nativeScannerStart({ ...(hasLimit ? { limit } : {}), ...(category ? { category } : {}) })
: "start-auto"; .then((scanner) => writeDevJson(res, 200, { ok: true, scanner }))
deps.sendScannerCommand(command); .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
writeDevJson(res, 200, { ok: true, command });
return; return;
} }
if (url.pathname === "/scanner/stop") { if (url.pathname === "/scanner/stop") {
deps.sendScannerCommand("stop"); deps.nativeScannerStop()
writeDevJson(res, 200, { ok: true, command: "stop" }); .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; return;
} }
if (url.pathname === "/scanner/probe") { if (url.pathname === "/scanner/probe") {
@@ -198,13 +208,68 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
return; return;
} }
if (url.pathname === "/scanner/status") { 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; return;
} }
if (url.pathname === "/scanner/ocr/warmup") { if (url.pathname === "/scanner/ocr/warmup") {
const engineParam = url.searchParams.get("engine"); deps.warmOcr("current")
const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current";
deps.warmOcr(engine)
.then((status) => writeDevJson(res, 200, { ok: true, status })) .then((status) => writeDevJson(res, 200, { ok: true, status }))
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
return; return;
@@ -226,14 +291,8 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
if (url.pathname === "/scanner/benchmark-ocr") { if (url.pathname === "/scanner/benchmark-ocr") {
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1)); const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
const sourceId = url.searchParams.get("sourceId"); const sourceId = url.searchParams.get("sourceId");
const engineParam = url.searchParams.get("engine");
const profileParam = url.searchParams.get("profile"); const profileParam = url.searchParams.get("profile");
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast"; const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
const engines: Array<"current" | "ik-traineddata"> = engineParam === "compare"
? ["current", "ik-traineddata"]
: engineParam === "ik-traineddata"
? ["ik-traineddata"]
: ["current"];
deps.listCaptureSources() deps.listCaptureSources()
.then(async (sources) => { .then(async (sources) => {
const source = findGenshinSource(sources, sourceId); const source = findGenshinSource(sources, sourceId);
@@ -243,7 +302,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
} }
const benchmarkSource = source; const benchmarkSource = source;
async function runEngineBenchmark(engine: "current" | "ik-traineddata") { async function runCurrentBenchmark() {
const startedAt = Date.now(); const startedAt = Date.now();
const captures: Array<{ const captures: Array<{
index: number; index: number;
@@ -265,7 +324,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, { const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
ocrMode: "artifact", ocrMode: "artifact",
ocrProfile, ocrProfile,
ocrEngine: engine, ocrEngine: "current",
omitFullFrame: true, omitFullFrame: true,
omitInventoryPreview: true, omitInventoryPreview: true,
skipOcrUnlessArtifactDetail: true, skipOcrUnlessArtifactDetail: true,
@@ -316,7 +375,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
]), ]),
); );
return { return {
engine, engine: "current",
nativeTesseract: "not-enabled", nativeTesseract: "not-enabled",
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null, workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
ocrProfile, ocrProfile,
@@ -340,13 +399,10 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
captures, captures,
}; };
} }
const summaries = []; const summary = await runCurrentBenchmark();
for (const engine of engines) {
summaries.push(await runEngineBenchmark(engine));
}
writeDevJson(res, 200, { writeDevJson(res, 200, {
ok: true, ok: true,
summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries }, summary,
}); });
}) })
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
@@ -390,6 +446,22 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
} }
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true }); 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 centers = before.inventoryGrid?.centers ?? [];
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol) const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
? centers.find((center) => center.row === requestedRow && center.col === requestedCol) ? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
+44
View File
@@ -2,6 +2,17 @@ import { ipcMain } from "electron";
import type { import type {
BooleanResult, BooleanResult,
FocusGenshinResult, FocusGenshinResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
RuntimeInfo, RuntimeInfo,
SaveSnapshotResult, SaveSnapshotResult,
ScannerStatusPayload, ScannerStatusPayload,
@@ -13,6 +24,17 @@ interface AppCommandDependencies {
moveMainWindowOffGenshin: () => Promise<void>; moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>; focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>; 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>; getRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshot: () => Promise<AppSnapshot | null>; loadSnapshot: () => Promise<AppSnapshot | null>;
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>; saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
@@ -26,6 +48,17 @@ export function registerAppHandlers({
moveMainWindowOffGenshin, moveMainWindowOffGenshin,
focusGenshinForScanStart, focusGenshinForScanStart,
publishScannerStatus, publishScannerStatus,
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
nativeScannerProcessRun,
nativeScannerLoadResults,
nativeScannerPromoteResults,
nativeScannerReviewResult,
nativeScannerLoadImage,
getRuntimeInfo, getRuntimeInfo,
loadSnapshot, loadSnapshot,
saveSnapshot, saveSnapshot,
@@ -42,6 +75,17 @@ export function registerAppHandlers({
await publishScannerStatus(status); await publishScannerStatus(status);
return { ok: true }; 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("app:getRuntimeInfo", async () => getRuntimeInfo());
ipcMain.handle("snapshot:load", async () => loadSnapshot()); ipcMain.handle("snapshot:load", async () => loadSnapshot());
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot)); ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
+355 -45
View File
@@ -6,17 +6,33 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createWorker, PSM } from "tesseract.js"; import { createWorker, PSM } from "tesseract.js";
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js"; import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
import {
createNativeScannerProcessingService,
nativeScannerProcessStats,
type NativeCaptureJobPayload,
} from "./services/nativeScannerProcessingService.js";
import { pngBufferToBitmap } from "./services/pngBitmap.js"; import { pngBufferToBitmap } from "./services/pngBitmap.js";
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
import { createDevControlServer } from "./devControlServer.js"; import { createDevControlServer } from "./devControlServer.js";
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js"; import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js"; import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
import type { AppSnapshot } from "../src/types/domain.js"; import type { AppSnapshot } from "../src/types/domain.js";
import type { import type {
CaptureOptions, CaptureOptions,
CaptureResult, CaptureResult,
GoodDatabase, GoodDatabase,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
OcrResult, OcrResult,
AppRuntimeInfo, AppRuntimeInfo,
ScannerCommand, ScannerCommand,
@@ -54,7 +70,7 @@ app.commandLine.appendSwitch("disable-gpu-sandbox");
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
const APP_RUNTIME_STARTED_AT = new Date().toISOString(); const APP_RUNTIME_STARTED_AT = new Date().toISOString();
const APP_RUNTIME_SIGNATURE = "2026-07-08-direct-gdi-reviewfix"; const APP_RUNTIME_SIGNATURE = "2026-07-09-native-artifact-pipeline";
let registeredHotkeys: Record<string, boolean> = {}; let registeredHotkeys: Record<string, boolean> = {};
let devControlServer: Server | null = null; let devControlServer: Server | null = null;
@@ -214,6 +230,305 @@ async function publishScannerStatus(status: ScannerStatusPayload) {
return { ok: true }; return { ok: true };
} }
function nativeScannerDataDir() {
const candidates = [
process.env.IK_INVENTORYLISTS_DIR,
path.join(process.cwd(), "data", "ik-inventorylists"),
path.join(app.getAppPath(), "data", "ik-inventorylists"),
path.join(process.resourcesPath, "ik-inventorylists"),
].filter((candidate): candidate is string => Boolean(candidate));
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
}
function nativeScannerOutputRoot() {
return path.join(app.getPath("userData"), "native-scans");
}
function nativeScannerStats(status: NativeScannerRunStatus): Record<string, number> {
return {
clicked: Number(status.clicked ?? 0),
attempted: Number(status.captured ?? 0),
verified: Number(status.captured ?? 0),
parsed: 0,
stored: 0,
review: 0,
duplicates: 0,
misses: 0,
pages: Number(status.pages ?? 0),
elapsedMs: Number(status.activeMs ?? 0),
activeScanMs: Number(status.activeMs ?? 0),
queued: Number(status.queued ?? 0),
captured: Number(status.captured ?? 0),
};
}
function publishNativeScannerStatus(status: NativeScannerRunStatus, extra: Partial<ScannerStatusPayload> = {}) {
const summary = {
mode: "Native IK Scanner",
status: status.status,
...nativeScannerStats(status),
targetCount: status.target,
gridLabel: status.message,
runId: status.runId,
outputRoot: status.outputRoot,
runDir: status.runDir,
manifestPath: status.manifestPath,
jobsPath: status.jobsPath,
lastArtifactPath: status.lastArtifactPath,
};
scannerDevStatus = {
...scannerDevStatus,
running: Boolean(status.running),
reviewStatus: status.message || "Native scanner ready.",
captureStatus: status.status,
stats: nativeScannerStats(status),
summary,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
status.message || `native scanner ${status.status}`,
],
nativeScanner: status,
...extra,
updatedAt: new Date().toISOString(),
};
return scannerDevStatus;
}
async function nativeScannerDataStatus(): Promise<NativeScannerDataStatus> {
return getInputHelperService().nativeScannerDataStatus(nativeScannerDataDir());
}
async function nativeScannerCatalog(): Promise<NativeScannerCatalogStatus> {
return getInputHelperService().nativeScannerCatalog(nativeScannerDataDir());
}
async function loadNativeIkArtifactCatalog(): Promise<IkArtifactCatalog | null> {
const catalog = await nativeScannerCatalog();
return catalog.data.valid ? { artifacts: catalog.artifacts } : null;
}
async function nativeScannerPreflight(options: { category?: string } = {}): Promise<NativeScannerPreflightStatus> {
const preflight = await getInputHelperService().nativeScannerPreflight(nativeScannerDataDir(), options.category ?? "artifacts");
scannerDevStatus = {
...scannerDevStatus,
nativeScannerPreflight: preflight,
lookupStatus: preflight.data,
updatedAt: new Date().toISOString(),
};
return preflight;
}
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStart({
dataDir: nativeScannerDataDir(),
outputRoot: nativeScannerOutputRoot(),
limit: options.limit ?? 100,
category: options.category ?? "artifacts",
});
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
return status;
}
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStop();
publishNativeScannerStatus(status);
return status;
}
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStatus();
publishNativeScannerStatus(status);
return status;
}
function resolveNativeProcessRunDir(runDir?: string) {
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
const candidate = runDir?.trim() || native?.runDir || "";
if (!candidate) return "";
const root = path.resolve(nativeScannerOutputRoot());
const resolved = path.resolve(candidate);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "";
return resolved;
}
async function buildNativeCardCaptureResult(
imagePath: string,
job: NativeCaptureJobPayload,
): Promise<CaptureResult> {
const sourceImage = nativeImage.createFromPath(imagePath);
const size = sourceImage.getSize();
if (!size.width || !size.height) throw new Error(`Native card crop is empty: ${imagePath}`);
const detailRect = { x: 0, y: 0, width: size.width, height: size.height };
const inventoryRect = { x: 0, y: 0, width: 1, height: 1 };
const crops = createCrops(
sourceImage,
size,
detailRect,
inventoryRect,
{
ocrMode: "artifact",
ocrProfile: "full",
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitLockState: true,
},
undefined,
{ skipOcr: false },
);
const croppedPayload = crops
.filter((crop) => crop.ocrEnabled !== false)
.map((crop) => crop.ocrImage
? {
id: crop.id,
label: crop.label,
image: crop.ocrImage,
}
: null)
.filter((crop): crop is OcrCropPayload => Boolean(crop));
const recognized = await runOcrOnCropsWithTimeout(croppedPayload, "current", 6500);
return {
id: `native-card-${job.sequence}`,
name: path.basename(imagePath),
width: size.width,
height: size.height,
dataUrl: "",
capturedAt: new Date().toISOString(),
captureTarget: "genshin-client",
detailFingerprint: `${path.basename(imagePath)}:${size.width}x${size.height}`,
ocr: recognized.ocr,
ocrTimedOut: recognized.timedOut,
ocrSkipped: false,
crops: crops.map((crop) => ({
id: crop.id,
label: crop.label,
rect: {
x: crop.rect.x,
y: crop.rect.y,
width: crop.rect.width,
height: crop.rect.height,
},
dataUrl: crop.dataUrl,
})),
artifactDetail: {
present: true,
confidence: 70,
orangeHits: 0,
greenHits: 0,
textHits: 0,
},
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: false,
warning: "Native card-crop processing uses the detail card as its own coordinate space.",
},
timings: {
prepareMs: 0,
ocrMs: 0,
totalMs: 0,
ocrProfile: "full",
ocrEngine: "current",
ocrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
cropCount: croppedPayload.length,
ocrSkipped: false,
},
};
}
function createNativeScannerService() {
return createNativeScannerProcessingService({
resolveRunDir: resolveNativeProcessRunDir,
buildCaptureResult: buildNativeCardCaptureResult,
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
});
}
async function nativeScannerProcessRun(options: { runDir?: string; persist?: boolean; limit?: number } = {}): Promise<NativeScannerProcessStatus> {
const service = createNativeScannerService();
const status = await service.processRun(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: `Native post-processing: ${status.parsed}/${status.processed} parsed, ${status.review} review, ${status.stored} stored.`,
stats: {
...(scannerDevStatus.stats ?? {}),
...nativeScannerProcessStats(status),
},
nativeScannerProcessing: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native process: ${status.parsed}/${status.processed} parsed, report ${status.reportPath}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerLoadResults(options: { runDir?: string; limit?: number } = {}): Promise<NativeScannerResultsLoadStatus> {
const service = createNativeScannerService();
const loaded = await service.loadResults(options);
scannerDevStatus = {
...scannerDevStatus,
nativeScannerResults: loaded,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native results: ${loaded.results.length}/${loaded.total} loaded from ${loaded.path || "scan-results.json"}`,
],
updatedAt: new Date().toISOString(),
};
return loaded;
}
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
const service = createNativeScannerService();
const status = await service.promoteResults(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Native promotion: ${status.promoted} promoted, ${status.alreadyStored} already stored.`
: `Native promotion blocked: ${status.error ?? "unknown error"}`,
nativeScannerPromotion: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native promotion: ${status.promoted}/${status.requested}, log ${status.logPath || "unavailable"}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerReviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus> {
const service = createNativeScannerService();
const status = await service.reviewResult(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Native review ${status.action}: ${status.resultId}`
: `Native review blocked: ${status.error ?? "unknown error"}`,
nativeScannerReview: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native review ${status.action}: ${status.ok ? "ok" : status.error}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
const service = createNativeScannerService();
return service.loadImage(options);
}
async function readRuntimeInfo() { async function readRuntimeInfo() {
try { try {
const result = await getInputHelperService().getRuntimeInfo(); const result = await getInputHelperService().getRuntimeInfo();
@@ -446,6 +761,9 @@ function focusMainWindow() {
} }
function sendScannerCommand(command: ScannerCommand | "probe-click") { function sendScannerCommand(command: ScannerCommand | "probe-click") {
if (command === "stop") {
void nativeScannerStop().catch(() => undefined);
}
getAppWindowManager().sendScannerCommand(command); getAppWindowManager().sendScannerCommand(command);
} }
@@ -468,6 +786,15 @@ function startDevControlServer() {
sendScannerCommand, sendScannerCommand,
clickScreen: clickScreenCommand, clickScreen: clickScreenCommand,
scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }), scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }),
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
nativeScannerProcessRun,
nativeScannerLoadResults,
nativeScannerLoadImage,
warmOcr: (engine) => warmOcrWorkerPool(engine), warmOcr: (engine) => warmOcrWorkerPool(engine),
loadReviewSamples, loadReviewSamples,
listCaptureSources, listCaptureSources,
@@ -483,15 +810,13 @@ function createOverlayWindow() {
getAppWindowManager().createOverlayWindow(); getAppWindowManager().createOverlayWindow();
} }
// Inventory Kamera keeps a pool of native Tesseract engines and scans artifact // The fast artifact profile has four useful OCR parameter groups, so the
// fields concurrently. Our fast artifact profile has four useful OCR parameter // default pool is four workers unless the machine is smaller or GAA_OCR_WORKERS
// groups, so the default pool is four workers unless the machine is smaller or // explicitly overrides it.
// GAA_OCR_WORKERS explicitly overrides it.
const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize(); const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize();
const IK_TRAINEDDATA_LANG = "genshin_fast_09_04_21";
type OcrWorker = Awaited<ReturnType<typeof createWorker>>; type OcrWorker = Awaited<ReturnType<typeof createWorker>>;
type OcrWorkerEngine = "current" | "ik-traineddata"; type OcrWorkerEngine = "current";
type OcrCropPayload = { id: string; label: string; image: Buffer }; type OcrCropPayload = { id: string; label: string; image: Buffer };
type OcrWarmupStatus = { type OcrWarmupStatus = {
engine: OcrWorkerEngine; engine: OcrWorkerEngine;
@@ -513,11 +838,9 @@ type OcrWorkerPoolState = {
const ocrWorkerPools: Record<OcrWorkerEngine, OcrWorkerPoolState> = { const ocrWorkerPools: Record<OcrWorkerEngine, OcrWorkerPoolState> = {
current: { poolPromise: null, runQueue: Promise.resolve() }, current: { poolPromise: null, runQueue: Promise.resolve() },
"ik-traineddata": { poolPromise: null, runQueue: Promise.resolve() },
}; };
const ocrWarmupStatuses: Record<OcrWorkerEngine, OcrWarmupStatus> = { const ocrWarmupStatuses: Record<OcrWorkerEngine, OcrWarmupStatus> = {
current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE }, current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
"ik-traineddata": { engine: "ik-traineddata", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
}; };
function resolveOcrWorkerPoolSize() { function resolveOcrWorkerPoolSize() {
@@ -541,42 +864,13 @@ function appRuntimeInfo(): AppRuntimeInfo {
} }
function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine { function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine {
return options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current"; void options;
} return "current";
function ikTessdataCandidates() {
const envDir = process.env.IK_TESSDATA_DIR;
return [
envDir,
path.resolve(process.cwd(), "data", "tessdata"),
path.resolve(process.cwd(), "work", "Inventory_Kamera", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "work", "refs", "Inventory_Kamera", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "..", "_ik_ref_fork", "InventoryKamera", "tessdata"),
path.resolve(process.cwd(), "..", "_ik_ref", "InventoryKamera", "tessdata"),
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref_fork", "InventoryKamera", "tessdata"),
path.resolve(process.env.USERPROFILE ?? "", "Desktop", "_ik_ref", "InventoryKamera", "tessdata"),
process.resourcesPath ? path.resolve(process.resourcesPath, "tessdata") : "",
].filter(Boolean) as string[];
}
function findIkTessdataDir() {
return ikTessdataCandidates().find((candidate) => existsSync(path.join(candidate, `${IK_TRAINEDDATA_LANG}.traineddata`))) ?? "";
} }
function getOcrWorkerOptions(engine: OcrWorkerEngine) { function getOcrWorkerOptions(engine: OcrWorkerEngine) {
if (engine !== "ik-traineddata") return { lang: "eng", options: undefined }; void engine;
const langPath = findIkTessdataDir(); return { lang: "eng", options: undefined };
if (!langPath) {
throw new Error(`IK traineddata not found. Set IK_TESSDATA_DIR or place ${IK_TRAINEDDATA_LANG}.traineddata in data/tessdata.`);
}
return {
lang: IK_TRAINEDDATA_LANG,
options: {
langPath,
gzip: false,
cachePath: app.isReady() ? path.join(app.getPath("userData"), "tessdata-cache") : path.resolve(process.cwd(), "outputs", "tessdata-cache"),
},
};
} }
function getOcrWorkerPool(engine: OcrWorkerEngine) { function getOcrWorkerPool(engine: OcrWorkerEngine) {
@@ -594,7 +888,7 @@ function getOcrWorkerPool(engine: OcrWorkerEngine) {
} }
async function resetOcrWorker(engine?: OcrWorkerEngine) { async function resetOcrWorker(engine?: OcrWorkerEngine) {
const engines: OcrWorkerEngine[] = engine ? [engine] : ["current", "ik-traineddata"]; const engines: OcrWorkerEngine[] = engine ? [engine] : ["current"];
await Promise.all(engines.map(async (engineId) => { await Promise.all(engines.map(async (engineId) => {
const state = ocrWorkerPools[engineId]; const state = ocrWorkerPools[engineId];
const broken = state.poolPromise; const broken = state.poolPromise;
@@ -710,7 +1004,6 @@ function warmOcrWorkerPool(engine: OcrWorkerEngine = "current") {
function getOcrWarmupStatus() { function getOcrWarmupStatus() {
return { return {
current: ocrWarmupStatuses.current, current: ocrWarmupStatuses.current,
"ik-traineddata": ocrWarmupStatuses["ik-traineddata"],
}; };
} }
@@ -1334,7 +1627,7 @@ async function buildCaptureResult(
: await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine); : await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine);
const ocrMs = Date.now() - ocrStartedAt; const ocrMs = Date.now() - ocrStartedAt;
const totalMs = Date.now() - buildStartedAt; const totalMs = Date.now() - buildStartedAt;
const captureOcrEngine: CaptureOptions["ocrEngine"] = ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current"; const captureOcrEngine: CaptureOptions["ocrEngine"] = "current";
const count = parseInventoryCount(recognized.ocr); const count = parseInventoryCount(recognized.ocr);
return { return {
@@ -1458,6 +1751,23 @@ function initializeAppLifecycle() {
moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(), moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(),
focusGenshinForScanStart: () => focusGenshinForScanStart(), focusGenshinForScanStart: () => focusGenshinForScanStart(),
publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status), publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status),
nativeScannerDataStatus: () => nativeScannerDataStatus(),
nativeScannerCatalog: () => nativeScannerCatalog(),
nativeScannerPreflight: (options?: { category?: string }) => nativeScannerPreflight(options),
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
nativeScannerStop: () => nativeScannerStop(),
nativeScannerStatus: () => nativeScannerStatus(),
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options),
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options),
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
nativeScannerReviewResult: (options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}) => nativeScannerReviewResult(options),
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
readRuntimeInfo: () => readRuntimeInfo(), readRuntimeInfo: () => readRuntimeInfo(),
loadSnapshotFromDisk: () => loadSnapshotFromDisk(), loadSnapshotFromDisk: () => loadSnapshotFromDisk(),
saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot), saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot),
+11
View File
@@ -23,6 +23,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
exportGood: (payload) => ipcRenderer.invoke("good:export", payload), exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
importGoodFile: () => ipcRenderer.invoke("good:importFile"), importGoodFile: () => ipcRenderer.invoke("good:importFile"),
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status), 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"), showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"), hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback) => { onScannerCommand: (callback) => {
+12 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"; 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 { StoredArtifactRecord } from "../src/types/storage.js";
import type { AppSnapshot } from "../src/types/domain.js"; import type { AppSnapshot } from "../src/types/domain.js";
@@ -26,6 +26,17 @@ contextBridge.exposeInMainWorld("assistantApi", {
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload), exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
importGoodFile: () => ipcRenderer.invoke("good:importFile"), importGoodFile: () => ipcRenderer.invoke("good:importFile"),
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status), 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"), showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"), hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback: (command: ScannerCommand) => void) => { onScannerCommand: (callback: (command: ScannerCommand) => void) => {
+49
View File
@@ -8,6 +8,10 @@ import type {
GdiCaptureResult, GdiCaptureResult,
HelperOperationResponse, HelperOperationResponse,
KeyPressResult, KeyPressResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerPreflightStatus,
NativeScannerRunStatus,
WindowBounds, WindowBounds,
RuntimeInfo, RuntimeInfo,
ScrollResult, ScrollResult,
@@ -153,6 +157,12 @@ export interface InputHelperService {
keyPress(key: string): Promise<KeyPressResult>; keyPress(key: string): Promise<KeyPressResult>;
getAutomationGuard(): Promise<AutomationGuard>; getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>; 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; dispose(): void;
} }
@@ -303,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 { return {
getRuntimeInfo, getRuntimeInfo,
focusGenshinWindow, focusGenshinWindow,
@@ -313,6 +356,12 @@ export function createInputHelperService(options: { userDataPath: string; exePat
keyPress, keyPress,
getAutomationGuard, getAutomationGuard,
capturePrimaryScreenViaGdi, capturePrimaryScreenViaGdi,
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
dispose: () => inputHelper.dispose(), dispose: () => inputHelper.dispose(),
}; };
} }
@@ -87,11 +87,9 @@ function Send-MouseInput {
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
} }
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves # Uses bare SetCursorPos, then sends button-down and button-up as ONE SendInput
# with bare SetCursorPos, then clicks via the InputSimulator library's # call (two INPUT structs in the same array) - back-to-back with no artificial
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE # delay between them, unlike two separate SendInput calls with a
# 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). # Start-Sleep in between. Returns the number of injected events (2 = ok).
function Send-MouseClickBatch { function Send-MouseClickBatch {
$down = New-Object Native.InputHelper+INPUT $down = New-Object Native.InputHelper+INPUT
@@ -208,7 +206,7 @@ function Find-GenshinWindow {
# Plain SetForegroundWindow from this background helper process is silently # Plain SetForegroundWindow from this background helper process is silently
# refused by Windows' foreground lock. Attach our thread's input queue to the # 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 # target (and current foreground) window thread and clear the lock timeout, so
# the foreground change is honored - the same technique Inventory Kamera uses. # the foreground change is honored.
function Force-Foreground { function Force-Foreground {
param([IntPtr]$hwnd) param([IntPtr]$hwnd)
$current = [Native.InputHelper]::GetCurrentThreadId() $current = [Native.InputHelper]::GetCurrentThreadId()
@@ -313,13 +311,10 @@ while ($true) {
} }
$targetX = [int]$cmd.x $targetX = [int]$cmd.x
$targetY = [int]$cmd.y $targetY = [int]$cmd.y
# Matches Inventory Kamera's verified-working sequence exactly: bare # Bare SetCursorPos immediately followed by a click, with NO extra move
# SetCursorPos immediately followed by a click, with NO extra move # event and NO artificial delay between moving and clicking. Settling
# event and NO artificial delay between moving and clicking - IK's # delays only happen after the click, in the scan loop. Down+up are sent
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back, # as one SendInput call (see Send-MouseClickBatch).
# 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 [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
$point = Get-CursorPoint $point = Get-CursorPoint
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
@@ -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);
}
+274
View File
@@ -0,0 +1,274 @@
using System.Text.Json;
namespace GenshinAssistant.InputHelper;
internal static class IkInventoryLists
{
public static IkInventoryListStatus Load(string dataDir)
{
var dir = ResolveDirectory(dataDir);
var required = new[] { "artifacts.json", "weapons.json", "characters.json", "materials.json", "version.txt" };
var missing = required.Where(file => !File.Exists(Path.Combine(dir, file))).ToArray();
var status = new IkInventoryListStatus { Directory = dir, Missing = missing };
if (missing.Length > 0) return status;
status.Version = File.ReadAllText(Path.Combine(dir, "version.txt")).Trim();
status.ArtifactSets = CountJsonObjectProperties(Path.Combine(dir, "artifacts.json"));
status.ArtifactPieces = CountArtifactPieces(Path.Combine(dir, "artifacts.json"));
status.Weapons = CountJsonObjectProperties(Path.Combine(dir, "weapons.json"));
status.Characters = CountJsonObjectProperties(Path.Combine(dir, "characters.json"));
status.Materials = CountJsonObjectProperties(Path.Combine(dir, "materials.json"));
return status;
}
private static string ResolveDirectory(string dataDir)
{
if (!string.IsNullOrWhiteSpace(dataDir)) return dataDir;
var candidates = new List<string>();
var envDir = Environment.GetEnvironmentVariable("IK_INVENTORYLISTS_DIR");
if (!string.IsNullOrWhiteSpace(envDir)) candidates.Add(envDir);
AddDirectoryCandidates(candidates, AppContext.BaseDirectory);
AddDirectoryCandidates(candidates, Environment.CurrentDirectory);
var baseParent = Directory.GetParent(AppContext.BaseDirectory);
for (var depth = 0; depth < 6 && baseParent != null; depth++)
{
AddDirectoryCandidates(candidates, baseParent.FullName);
baseParent = baseParent.Parent;
}
return candidates.FirstOrDefault(IsCompleteInventoryListDirectory)
?? Path.Combine(AppContext.BaseDirectory, "inventorylists");
}
private static void AddDirectoryCandidates(List<string> candidates, string root)
{
if (string.IsNullOrWhiteSpace(root)) return;
candidates.Add(Path.Combine(root, "inventorylists"));
candidates.Add(Path.Combine(root, "ik-inventorylists"));
candidates.Add(Path.Combine(root, "data", "ik-inventorylists"));
}
private static bool IsCompleteInventoryListDirectory(string dir)
{
return File.Exists(Path.Combine(dir, "artifacts.json"))
&& File.Exists(Path.Combine(dir, "weapons.json"))
&& File.Exists(Path.Combine(dir, "characters.json"))
&& File.Exists(Path.Combine(dir, "materials.json"))
&& File.Exists(Path.Combine(dir, "version.txt"));
}
public static object CatalogPayload(string dataDir)
{
var status = Load(dataDir);
if (!status.Valid)
{
return new
{
data = status.ToPayload(),
artifacts = Array.Empty<object>(),
weapons = Array.Empty<object>(),
characters = Array.Empty<object>(),
materials = Array.Empty<object>(),
};
}
return new
{
data = status.ToPayload(),
artifacts = LoadArtifactCatalog(Path.Combine(status.Directory, "artifacts.json")),
weapons = LoadStringMapCatalog(Path.Combine(status.Directory, "weapons.json")),
characters = LoadCharacterCatalog(Path.Combine(status.Directory, "characters.json")),
materials = LoadStringMapCatalog(Path.Combine(status.Directory, "materials.json")),
};
}
private static int CountJsonObjectProperties(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.ValueKind == JsonValueKind.Object
? doc.RootElement.EnumerateObject().Count()
: 0;
}
private static int CountArtifactPieces(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return 0;
var count = 0;
foreach (var set in doc.RootElement.EnumerateObject())
{
if (!set.Value.TryGetProperty("artifacts", out var artifacts)) continue;
if (artifacts.ValueKind == JsonValueKind.Object) count += artifacts.EnumerateObject().Count();
}
return count;
}
private static List<object> LoadStringMapCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var entry in doc.RootElement.EnumerateObject())
{
entries.Add(new
{
normalizedName = entry.Name,
good = entry.Value.ValueKind == JsonValueKind.String ? entry.Value.GetString() ?? "" : "",
});
}
return entries;
}
private static List<object> LoadCharacterCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var entry in doc.RootElement.EnumerateObject())
{
var value = entry.Value;
entries.Add(new
{
normalizedName = entry.Name,
good = JsonString(value, "GOOD"),
element = JsonFirstString(value, "Element"),
weaponType = JsonFirstInt(value, "WeaponType"),
constellationName = JsonFirstString(value, "ConstellationName"),
});
}
return entries;
}
private static List<object> LoadArtifactCatalog(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var entries = new List<object>();
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
foreach (var set in doc.RootElement.EnumerateObject())
{
var pieces = new List<object>();
if (set.Value.TryGetProperty("artifacts", out var artifacts) && artifacts.ValueKind == JsonValueKind.Object)
{
foreach (var piece in artifacts.EnumerateObject())
{
pieces.Add(new
{
slot = piece.Name,
artifactName = JsonString(piece.Value, "artifactName"),
good = JsonString(piece.Value, "GOOD"),
normalizedName = JsonString(piece.Value, "normalizedName"),
});
}
}
entries.Add(new
{
normalizedName = set.Name,
setName = JsonString(set.Value, "setName"),
good = JsonString(set.Value, "GOOD"),
pieces,
});
}
return entries;
}
private static string JsonString(JsonElement value, string propertyName)
=> value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString() ?? ""
: "";
private static string JsonFirstString(JsonElement value, string propertyName)
{
if (!value.TryGetProperty(propertyName, out var property)) return "";
if (property.ValueKind == JsonValueKind.String) return property.GetString() ?? "";
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
{
var first = property[0];
return first.ValueKind == JsonValueKind.String ? first.GetString() ?? "" : "";
}
return "";
}
private static int JsonFirstInt(JsonElement value, string propertyName)
{
if (!value.TryGetProperty(propertyName, out var property)) return -1;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number)) return number;
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
{
var first = property[0];
return first.ValueKind == JsonValueKind.Number && first.TryGetInt32(out number) ? number : -1;
}
return -1;
}
}
internal sealed class IkInventoryListStatus
{
public string Directory { get; init; } = "";
public string Version { get; set; } = "";
public int ArtifactSets { get; set; }
public int ArtifactPieces { get; set; }
public int Weapons { get; set; }
public int Characters { get; set; }
public int Materials { get; set; }
public string[] Missing { get; init; } = Array.Empty<string>();
public bool Valid => Missing.Length == 0;
public object SupportedCategoriesPayload() => new
{
artifacts = ArtifactCategoryPayload("artifacts.json", ArtifactSets, ArtifactPieces),
weapons = SimpleCategoryPayload("weapons.json", Weapons),
characters = SimpleCategoryPayload("characters.json", Characters),
materials = SimpleCategoryPayload("materials.json", Materials),
};
private object ArtifactCategoryPayload(string file, int setCount, int pieceCount)
{
var catalogAvailable = Valid;
var nativeCaptureSupported = Valid;
return new
{
file,
setCount,
pieceCount,
supported = nativeCaptureSupported,
catalogAvailable,
nativeCaptureSupported,
scanStatus = nativeCaptureSupported ? "native_capture" : "missing_data",
};
}
private object SimpleCategoryPayload(string file, int count)
{
var catalogAvailable = Valid;
const bool nativeCaptureSupported = false;
return new
{
file,
count,
supported = nativeCaptureSupported,
catalogAvailable,
nativeCaptureSupported,
scanStatus = catalogAvailable ? "catalog_only" : "missing_data",
};
}
public object ToPayload() => new
{
directory = Directory,
version = Version,
artifactSets = ArtifactSets,
artifactPieces = ArtifactPieces,
weapons = Weapons,
characters = Characters,
materials = Materials,
totalEntries = ArtifactPieces + Weapons + Characters + Materials,
categories = SupportedCategoriesPayload(),
missing = Missing,
valid = Valid,
source = "InventoryKamera inventorylists",
};
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
namespace GenshinAssistant.InputHelper;
internal static class NativeScannerFiles
{
private static readonly JsonSerializerOptions PrettyJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
private static readonly JsonSerializerOptions LineJson = new(JsonSerializerDefaults.Web);
public static void WriteJson(string path, object payload)
{
File.WriteAllText(path, JsonSerializer.Serialize(payload, PrettyJson));
}
public static void AppendJsonLine(StreamWriter writer, object payload)
{
writer.WriteLine(JsonSerializer.Serialize(payload, LineJson));
writer.Flush();
}
}
+749 -7
View File
@@ -19,6 +19,7 @@ namespace GenshinAssistant.InputHelper;
internal static class Program internal static class Program
{ {
private static IntPtr _genshinHwnd = IntPtr.Zero; private static IntPtr _genshinHwnd = IntPtr.Zero;
private static readonly NativeScannerService Scanner = new();
private static int Main() private static int Main()
{ {
@@ -109,9 +110,8 @@ internal static class Program
var targetX = GetInt(root, "x"); var targetX = GetInt(root, "x");
var targetY = GetInt(root, "y"); var targetY = GetInt(root, "y");
// Bare SetCursorPos then a batched down+up click, matching the // Bare SetCursorPos then a batched down+up click: no extra move
// verified Inventory Kamera sequence: no extra move event, no // event, no gap between move and click.
// gap between move and click.
Native.SetCursorPos(targetX, targetY); Native.SetCursorPos(targetX, targetY);
Native.GetCursorPos(out var pt); Native.GetCursorPos(out var pt);
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2; var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
@@ -238,6 +238,36 @@ internal static class Program
break; 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: default:
response["ok"] = false; response["ok"] = false;
response["error"] = "unknown op"; response["error"] = "unknown op";
@@ -262,6 +292,24 @@ internal static class Program
public bool F9; 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 private struct FocusInfo
{ {
public IntPtr Hwnd; public IntPtr Hwnd;
@@ -308,10 +356,9 @@ internal static class Program
} }
// Plain SetForegroundWindow from a background process is silently refused by // Plain SetForegroundWindow from a background process is silently refused by
// Windows' foreground lock. Inventory Kamera and other reliable automation // Windows' foreground lock. Attach the calling thread's input queue to the
// tools bypass it by attaching the calling thread's input queue to the target // target (and current-foreground) window thread and clear the lock timeout,
// (and current-foreground) window thread and clearing the lock timeout, so the // so the foreground change is honored. Without this the auto-scan aborts with
// foreground change is honored. Without this the auto-scan aborts with
// "Genshin konnte nicht in den Vordergrund geholt werden". // "Genshin konnte nicht in den Vordergrund geholt werden".
private static bool ForceForeground(IntPtr hwnd) private static bool ForceForeground(IntPtr hwnd)
{ {
@@ -478,6 +525,701 @@ internal static class Program
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true; if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
return false; 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 internal static class Native
+15 -9
View File
@@ -20,20 +20,19 @@
"eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs", "eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs",
"scan:soak": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1", "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": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun",
"scan:goal:current": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine current", "scan:goal:validated": "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
"scan:goal:ik": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine ik-traineddata", "scan:goal:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
"scan:goal:compare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine compare", "scan:iterate": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
"scan:goal:compare:validated": "npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", "scan:iterate:validated": "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
"scan:goal:compare:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", "scan:iterate:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
"scan:iterate:compare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr",
"scan:iterate:compare:validated": "npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20",
"scan:iterate:compare:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate:compare && 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": "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 --allow-single-engine", "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": "node scripts/live-preflight.cjs",
"scan:live:preflight:wait": "node scripts/live-preflight.cjs --wait=120", "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:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment",
"scan:assessment:validate": "node scripts/validate-scan-assessment.cjs", "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", "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" "data:genshin": "node scripts/generate-genshin-data.cjs"
}, },
@@ -75,6 +74,13 @@
"filter": [ "filter": [
"**/*" "**/*"
] ]
},
{
"from": "data/ik-inventorylists",
"to": "ik-inventorylists",
"filter": [
"**/*"
]
} }
], ],
"win": { "win": {
+47 -93
View File
@@ -7,12 +7,12 @@ param(
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"), [string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"),
[switch]$GoalRun, [switch]$GoalRun,
[switch]$RepeatabilityRun, [switch]$RepeatabilityRun,
[ValidateSet("current", "ik-traineddata", "compare")] [ValidateSet("current")]
[string]$ScanEngine = "current", [string]$ScanEngine = "current",
[switch]$BenchmarkOcr, [switch]$BenchmarkOcr,
[int]$BenchmarkLimit = 5, [int]$BenchmarkLimit = 5,
[ValidateSet("current", "ik-traineddata", "compare")] [ValidateSet("current")]
[string]$BenchmarkEngine = "compare", [string]$BenchmarkEngine = "current",
[ValidateSet("fast", "full")] [ValidateSet("fast", "full")]
[string]$BenchmarkProfile = "fast", [string]$BenchmarkProfile = "fast",
[switch]$SkipSmartCapture, [switch]$SkipSmartCapture,
@@ -205,11 +205,11 @@ function Get-TimingBottleneck([object]$Timing) {
function Get-TimingRecommendation([object]$Timing) { function Get-TimingRecommendation([object]$Timing) {
$bottleneck = Get-TimingBottleneck $Timing $bottleneck = Get-TimingBottleneck $Timing
switch ($bottleneck) { switch ($bottleneck) {
"ocr" { return "OCR dominates; compare engine, crop count, worker pool, and parser-derived fields first." } "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-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." } "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 against IK's 200ms item wait." } "card-ready" { return "Card-ready dominates; tune detail fingerprint polling before OCR changes." }
"scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling against IK's 100ms fast-scroll wait." } "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." } default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." }
} }
} }
@@ -296,7 +296,7 @@ function New-PerformanceAssessment([object[]]$Summaries) {
limit = [int]$group.Name limit = [int]$group.Name
engineCount = $entries.Count engineCount = $entries.Count
enginesCompared = @($entries | ForEach-Object { $_.engine }) enginesCompared = @($entries | ForEach-Object { $_.engine })
comparisonComplete = (@($entries | Where-Object { $_.engine -eq "current" }).Count -gt 0 -and @($entries | Where-Object { $_.engine -eq "ik-traineddata" }).Count -gt 0) singleEngine = $true
winnerEngine = $winner.engine winnerEngine = $winner.engine
winnerQualified = $winner.qualified winnerQualified = $winner.qualified
winnerMissRate = $winner.missRate winnerMissRate = $winner.missRate
@@ -312,18 +312,16 @@ function New-PerformanceAssessment([object[]]$Summaries) {
$goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1) $goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
$goal100Decision = "not-run: missing 100-artifact assessment" $goal100Decision = "not-run: missing 100-artifact assessment"
if ($goal100.Count -gt 0) { if ($goal100.Count -gt 0) {
if (-not $goal100[0].comparisonComplete) { if (-not $goal100[0].winnerQualified) {
$goal100Decision = "not-comparable: current and ik-traineddata were not both run"
} elseif (-not $goal100[0].winnerQualified) {
$goal100Decision = "not-qualified: 100-artifact winner failed quality gates" $goal100Decision = "not-qualified: 100-artifact winner failed quality gates"
} else { } else {
$goal100Decision = "qualified-comparison: winner=$($goal100[0].winnerEngine)" $goal100Decision = "qualified: winner=$($goal100[0].winnerEngine)"
} }
} }
return [pscustomobject]@{ return [pscustomobject]@{
createdAt = (Get-Date).ToString("o") createdAt = (Get-Date).ToString("o")
goalLimit = 100 goalLimit = 100
goalEngines = @("current", "ik-traineddata") goalEngines = @("current")
goal100Decision = $goal100Decision goal100Decision = $goal100Decision
goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null } goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
limits = $limitReports limits = $limitReports
@@ -335,11 +333,10 @@ function Write-PerformanceAssessment([object]$Assessment) {
Write-Host "assessment goal100: $($Assessment.goal100Decision)" Write-Host "assessment goal100: $($Assessment.goal100Decision)"
} }
foreach ($limit in @($Assessment.limits)) { foreach ($limit in @($Assessment.limits)) {
Write-Host ("assessment limit={0}: winner={1} qualified={2} completeCompare={3} engines={4} missRate={5:P1} reviewRate={6:P1} activeAvg={7}ms projected100={8}ms roundtripOverheadAvg={9}ms" -f ` 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.limit,
$limit.winnerEngine, $limit.winnerEngine,
$limit.winnerQualified, $limit.winnerQualified,
$limit.comparisonComplete,
($limit.enginesCompared -join ","), ($limit.enginesCompared -join ","),
$limit.winnerMissRate, $limit.winnerMissRate,
$limit.winnerReviewRate, $limit.winnerReviewRate,
@@ -372,34 +369,34 @@ function Invoke-AssessmentSelfTest {
limit = 100 limit = 100
status = "done" status = "done"
parsed = 100 parsed = 100
review = 22 review = 0
misses = 0 misses = 0
activeAverageMsPerParsed = 700 activeAverageMsPerParsed = 500
averageMsPerParsed = 760 averageMsPerParsed = 520
activeProjectedMsFor100 = 70000 activeProjectedMsFor100 = 50000
averageOcrMs = 260 averageOcrMs = 180
averageCaptureMs = 160 averageCaptureMs = 120
averageCaptureRoundTripMs = 340 averageCaptureRoundTripMs = 260
averageCaptureRoundTripOverheadMs = 180 averageCaptureRoundTripOverheadMs = 140
averageCardReadyMs = 190 averageCardReadyMs = 100
averageScrollReadyMs = 60 averageScrollReadyMs = 50
}, },
[pscustomobject]@{ [pscustomobject]@{
engine = "ik-traineddata" engine = "high-review"
limit = 100 limit = 100
status = "done" status = "done"
parsed = 100 parsed = 100
review = 4 review = 22
misses = 0 misses = 0
activeAverageMsPerParsed = 820 activeAverageMsPerParsed = 300
averageMsPerParsed = 870 averageMsPerParsed = 330
activeProjectedMsFor100 = 82000 activeProjectedMsFor100 = 30000
averageOcrMs = 210 averageOcrMs = 100
averageCaptureMs = 170 averageCaptureMs = 90
averageCaptureRoundTripMs = 300 averageCaptureRoundTripMs = 190
averageCaptureRoundTripOverheadMs = 130 averageCaptureRoundTripOverheadMs = 100
averageCardReadyMs = 205 averageCardReadyMs = 40
averageScrollReadyMs = 80 averageScrollReadyMs = 20
}, },
[pscustomobject]@{ [pscustomobject]@{
engine = "current" engine = "current"
@@ -418,23 +415,6 @@ function Invoke-AssessmentSelfTest {
averageCardReadyMs = 80 averageCardReadyMs = 80
averageScrollReadyMs = 0 averageScrollReadyMs = 0
}, },
[pscustomobject]@{
engine = "ik-traineddata"
limit = 20
status = "done"
parsed = 20
review = 2
misses = 0
activeAverageMsPerParsed = 460
averageMsPerParsed = 475
activeProjectedMsFor100 = 46000
averageOcrMs = 190
averageCaptureMs = 130
averageCaptureRoundTripMs = 310
averageCaptureRoundTripOverheadMs = 180
averageCardReadyMs = 80
averageScrollReadyMs = 0
},
[pscustomobject]@{ [pscustomobject]@{
engine = "broken-fast" engine = "broken-fast"
limit = 45 limit = 45
@@ -476,43 +456,37 @@ function Invoke-AssessmentSelfTest {
$limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0] $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] $limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]
if ($goal100.winnerEngine -ne "ik-traineddata") { if ($goal100.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected ik-traineddata to win limit=100, got '$($goal100.winnerEngine)'." throw "Assessment self-test failed: expected current to win limit=100, got '$($goal100.winnerEngine)'."
} }
if (-not $goal100.winnerQualified) { if (-not $goal100.winnerQualified) {
throw "Assessment self-test failed: expected limit=100 winner to be qualified." throw "Assessment self-test failed: expected limit=100 winner to be qualified."
} }
if (-not $goal100.comparisonComplete) { if ($assessment.goal100Decision -ne "qualified: winner=current") {
throw "Assessment self-test failed: expected limit=100 to be a complete current vs ik-traineddata comparison."
}
if ($assessment.goal100Decision -ne "qualified-comparison: winner=ik-traineddata") {
throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'." throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'."
} }
if ($limit20.winnerEngine -ne "current") { if ($limit20.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'." throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'."
} }
if (-not $limit20.comparisonComplete) {
throw "Assessment self-test failed: expected limit=20 to be a complete current vs ik-traineddata comparison."
}
if (-not $limit20.winnerQualified) { if (-not $limit20.winnerQualified) {
throw "Assessment self-test failed: expected limit=20 winner to be qualified." throw "Assessment self-test failed: expected limit=20 winner to be qualified."
} }
if ($limit45.winnerEngine -ne "current") { if ($limit45.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'." throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
} }
if (@($goal100.engines | Where-Object { $_.engine -eq "current" })[0].qualityDecision -ne "not-qualified: review rate above 15%") { 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 current run to be rejected." 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%") { 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." throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
} }
$singleEngineAssessment = New-PerformanceAssessment -Summaries @( $missingGoalAssessment = New-PerformanceAssessment -Summaries @(
[pscustomobject]@{ [pscustomobject]@{
engine = "current" engine = "current"
limit = 100 limit = 20
status = "done" status = "done"
parsed = 100 parsed = 20
review = 0 review = 0
misses = 0 misses = 0
activeAverageMsPerParsed = 500 activeAverageMsPerParsed = 500
@@ -526,8 +500,8 @@ function Invoke-AssessmentSelfTest {
averageScrollReadyMs = 50 averageScrollReadyMs = 50
} }
) )
if ($singleEngineAssessment.goal100Decision -ne "not-comparable: current and ik-traineddata were not both run") { if ($missingGoalAssessment.goal100Decision -ne "not-run: missing 100-artifact assessment") {
throw "Assessment self-test failed: expected single-engine 100 run to be not-comparable, got '$($singleEngineAssessment.goal100Decision)'." throw "Assessment self-test failed: expected missing 100 run to be not-run, got '$($missingGoalAssessment.goal100Decision)'."
} }
Write-PerformanceAssessment $assessment Write-PerformanceAssessment $assessment
@@ -543,32 +517,13 @@ function Invoke-OcrBenchmark {
) )
Write-Host "Warming current OCR workers..." Write-Host "Warming current OCR workers..."
$warmCurrent = Invoke-DevJson "/scanner/ocr/warmup?engine=current" $warmCurrent = Invoke-DevJson "/scanner/ocr/warmup"
Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null
if ($Engine -eq "compare" -or $Engine -eq "ik-traineddata") {
Write-Host "Warming IK-traineddata OCR workers..."
$warmIk = Invoke-DevJson "/scanner/ocr/warmup?engine=ik-traineddata"
Save-Json "benchmark-warmup-ik-traineddata" $warmIk | Out-Null
}
Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit" Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit"
$benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&engine=$Engine&profile=$Profile" $benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&profile=$Profile"
Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null
if ($benchmark.summary.mode -eq "compare") {
foreach ($engineSummary in @($benchmark.summary.engines)) {
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$engineSummary.engine,
$engineSummary.averageMs,
$engineSummary.averageOcrMs,
$engineSummary.p50Ms,
$engineSummary.p90Ms,
$engineSummary.projectedMs.artifacts100,
$engineSummary.skippedOcrCaptures,
$engineSummary.workerPoolSize)
}
} else {
$summary = $benchmark.summary $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 ` 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.engine,
@@ -579,7 +534,6 @@ function Invoke-OcrBenchmark {
$summary.projectedMs.artifacts100, $summary.projectedMs.artifacts100,
$summary.skippedOcrCaptures, $summary.skippedOcrCaptures,
$summary.workerPoolSize) $summary.workerPoolSize)
}
return $benchmark return $benchmark
} }
@@ -707,7 +661,7 @@ $Limits = @($Limits | ForEach-Object {
$limit $limit
}) })
$ScanEngines = if ($ScanEngine -eq "compare") { @("current", "ik-traineddata") } else { @($ScanEngine) } $ScanEngines = @($ScanEngine)
$RunSummaries = @() $RunSummaries = @()
$transcriptPath = Join-Path $RunDir "transcript.log" $transcriptPath = Join-Path $RunDir "transcript.log"
@@ -728,7 +682,7 @@ try {
if ($health.appBuild) { if ($health.appBuild) {
Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)" 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) { 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 IK-speed comparison." -ForegroundColor Yellow Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for scanner timing." -ForegroundColor Yellow
} }
} }
@@ -772,7 +726,7 @@ try {
foreach ($limit in $Limits) { foreach ($limit in $Limits) {
if ($limit -lt 1) { continue } if ($limit -lt 1) { continue }
Write-Host "Starting bounded scanner run limit=$limit engine=$engine" Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
$start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit&engine=$engine" $start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit"
Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null
$finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine $finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
+265
View File
@@ -0,0 +1,265 @@
param(
[string]$BaseUrl = "http://127.0.0.1:17317",
[int]$Limit = 2,
[ValidateSet("artifacts", "weapons", "characters", "materials")]
[string]$Category = "artifacts",
[int]$PollIntervalSeconds = 1,
[int]$TimeoutSeconds = 180,
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\native-live-smoke"),
[switch]$SkipProbe,
[switch]$Persist
)
$ErrorActionPreference = "Stop"
function ConvertTo-SafeFilePart([string]$Value) {
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
$safe = $safe.Trim("-")
if ($safe.Length -eq 0) { return "item" }
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
return $safe
}
function Invoke-DevJson([string]$Path) {
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
try {
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
} catch {
$response = $_.Exception.Response
if ($response) {
$stream = $response.GetResponseStream()
if ($stream) {
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if (-not [string]::IsNullOrWhiteSpace($body)) {
try {
return $body | ConvertFrom-Json
} catch {
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
}
}
}
}
throw
}
}
function Save-Json([string]$Name, [object]$Payload) {
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
$Payload | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Test-ProbeSucceeded([object]$ProbePayload) {
if ($ProbePayload.ok) { return $true }
if ($ProbePayload.changed) { return $true }
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
return $false
}
function Get-NativeScanner([object]$StatusPayload) {
if ($StatusPayload.nativeScanner) { return $StatusPayload.nativeScanner }
if ($StatusPayload.scanner) { return $StatusPayload.scanner }
return $null
}
function Get-ExpectedAppSignature() {
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
$mainSource = Get-Content -LiteralPath $mainPath -Raw
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
if (-not $match.Success) {
throw "APP_RUNTIME_SIGNATURE not found in electron/main.ts."
}
return $match.Groups[1].Value
}
function UriEscape([string]$Value) {
return [System.Uri]::EscapeDataString($Value)
}
$safeLimit = [Math]::Max(1, [Math]::Min(100, $Limit))
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$RunDir = Join-Path $OutputRoot $timestamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
$summary = [ordered]@{
ok = $false
createdAt = (Get-Date).ToString("o")
limit = $safeLimit
category = $Category
expectedSignature = ""
outputDir = $RunDir
persistRequested = [bool]$Persist
health = $null
data = $null
preflight = $null
probe = $null
finalStatus = $null
process = $null
results = $null
errors = @()
}
try {
$expectedSignature = Get-ExpectedAppSignature
$summary.expectedSignature = $expectedSignature
$health = Invoke-DevJson "/health"
$summary.health = @{
ok = [bool]$health.ok
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
}
Save-Json "01-health" $health | Out-Null
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
}
$data = Invoke-DevJson "/scanner/native/data"
$summary.data = @{
ok = [bool]$data.ok
version = [string]$data.status.version
totalEntries = [int]$data.status.totalEntries
valid = [bool]$data.status.valid
}
Save-Json "02-native-data" $data | Out-Null
if (-not $data.ok) {
throw "Native IK data check failed."
}
$preflight = Invoke-DevJson "/scanner/native/preflight?category=$Category"
$summary.preflight = @{
ok = [bool]$preflight.ok
ready = [bool]$preflight.status.ready
category = [string]$preflight.status.category
categoryReady = [bool]$preflight.status.categoryReady
genshinFound = [bool]$preflight.status.genshinFound
isSixteenNine = [bool]$preflight.status.isSixteenNine
gridCount = [int]$preflight.status.grid.count
visualReady = if ($preflight.status.visual) { [bool]$preflight.status.visual.ready } else { $null }
visualWhitePct = if ($preflight.status.visual) { [double]$preflight.status.visual.whitePct } else { $null }
visualDarkPct = if ($preflight.status.visual) { [double]$preflight.status.visual.darkPct } else { $null }
visualColorPct = if ($preflight.status.visual) { [double]$preflight.status.visual.colorPct } else { $null }
visualLumaStdDev = if ($preflight.status.visual) { [double]$preflight.status.visual.lumaStdDev } else { $null }
blockReason = [string]$preflight.status.blockReason
}
Save-Json "03-native-preflight" $preflight | Out-Null
if (-not $preflight.ok) {
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
}
if (-not $SkipProbe) {
$probe = Invoke-DevJson "/automation/probe-click?index=1"
$summary.probe = @{
ok = [bool](Test-ProbeSucceeded $probe)
endpointOk = [bool]$probe.ok
changed = [bool]$probe.changed
}
Save-Json "04-probe-click" $probe | Out-Null
if (-not (Test-ProbeSucceeded $probe)) {
throw "Probe click did not prove safe input delivery."
}
}
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
Save-Json "05-native-start" $start | Out-Null
$startedScanner = Get-NativeScanner $start
if ($null -eq $startedScanner) {
throw "Native scanner start returned no scanner payload."
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$polls = @()
do {
Start-Sleep -Seconds $PollIntervalSeconds
$status = Invoke-DevJson "/scanner/status"
$scanner = Get-NativeScanner $status
$polls += [pscustomobject]@{
at = (Get-Date).ToString("o")
status = if ($scanner) { [string]$scanner.status } else { "missing" }
running = if ($scanner) { [bool]$scanner.running } else { $false }
captured = if ($scanner) { [int]$scanner.captured } else { 0 }
activeMs = if ($scanner) { [int]$scanner.activeMs } else { 0 }
message = if ($scanner) { [string]$scanner.message } else { "missing native scanner payload" }
}
if ($null -ne $scanner -and -not $scanner.running) {
break
}
} while ((Get-Date) -lt $deadline)
Save-Json "06-native-polls" $polls | Out-Null
$final = Invoke-DevJson "/scanner/status"
Save-Json "07-native-final-status" $final | Out-Null
$finalScanner = Get-NativeScanner $final
if ($null -eq $finalScanner) {
throw "Native scanner final status returned no scanner payload."
}
$summary.finalStatus = @{
status = [string]$finalScanner.status
running = [bool]$finalScanner.running
runDir = [string]$finalScanner.runDir
captured = [int]$finalScanner.captured
clicked = [int]$finalScanner.clicked
pages = [int]$finalScanner.pages
activeMs = [int]$finalScanner.activeMs
message = [string]$finalScanner.message
}
if ($finalScanner.running) {
throw "Native scanner did not finish before timeout."
}
if ([string]$finalScanner.status -ne "done") {
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
}
if ([int]$finalScanner.captured -lt $safeLimit) {
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
}
$runDirParam = UriEscape([string]$finalScanner.runDir)
$persistParam = if ($Persist) { "1" } else { "0" }
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
Save-Json "08-native-process" $process | Out-Null
$summary.process = @{
ok = [bool]$process.ok
processed = [int]$process.status.processed
parsed = [int]$process.status.parsed
review = [int]$process.status.review
errors = [int]$process.status.errors
stored = [int]$process.status.stored
persisted = [bool]$process.status.persisted
queueConcurrency = [int]$process.status.queueConcurrency
elapsedMs = [int]$process.status.elapsedMs
}
if (-not $process.ok) {
throw "Native post-capture processing failed."
}
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
Save-Json "09-native-results" $results | Out-Null
$summary.results = @{
ok = [bool]$results.ok
total = [int]$results.status.total
loaded = [int]$results.status.results.Count
}
if (-not $results.ok) {
throw "Native scan results could not be loaded."
}
$summary.ok = $true
} catch {
$summary.errors += [string]$_.Exception.Message
throw
} finally {
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
Write-Host "Native live smoke summary: $summaryPath"
if ($summary.ok) {
Write-Host ("ok limit={0} captured={1} parsed={2} review={3} errors={4} stored={5} persisted={6}" -f `
$summary.limit,
$summary.finalStatus.captured,
$summary.process.parsed,
$summary.process.review,
$summary.process.errors,
$summary.process.stored,
$summary.process.persisted)
}
}
+5 -13
View File
@@ -51,7 +51,6 @@ function validateAssessment(assessment, options = {}) {
const errors = []; const errors = [];
const expectedWinner = options.expectedWinner || "any"; const expectedWinner = options.expectedWinner || "any";
const expectedLimit = options.limit === undefined ? 100 : Number(options.limit); const expectedLimit = options.limit === undefined ? 100 : Number(options.limit);
const allowSingleEngine = options.allowSingleEngine === true;
const maxActiveAverageMsPerParsed = options.maxActiveAverageMsPerParsed === undefined const maxActiveAverageMsPerParsed = options.maxActiveAverageMsPerParsed === undefined
? null ? null
: Number(options.maxActiveAverageMsPerParsed); : Number(options.maxActiveAverageMsPerParsed);
@@ -77,26 +76,20 @@ function validateAssessment(assessment, options = {}) {
errors.push(`Missing limit=${expectedLimit} assessment.`); errors.push(`Missing limit=${expectedLimit} assessment.`);
} }
if (!allowSingleEngine && expectedLimit === 100 && assessment.goal100Decision !== `qualified-comparison: winner=${limitAssessment?.winnerEngine}`) { if (expectedLimit === 100 && assessment.goal100Decision !== `qualified: winner=${limitAssessment?.winnerEngine}`) {
errors.push(`goal100Decision is not a qualified comparison: ${assessment.goal100Decision || "<missing>"}`); errors.push(`goal100Decision is not a qualified 100-artifact run: ${assessment.goal100Decision || "<missing>"}`);
} }
if (limitAssessment?.limit !== expectedLimit) { if (limitAssessment?.limit !== expectedLimit) {
errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? "<missing>"}.`); errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? "<missing>"}.`);
} }
if (!allowSingleEngine && limitAssessment?.comparisonComplete !== true) errors.push(`limit=${expectedLimit}.comparisonComplete must be true.`);
if (limitAssessment?.winnerQualified !== true) errors.push(`limit=${expectedLimit}.winnerQualified must be true.`); if (limitAssessment?.winnerQualified !== true) errors.push(`limit=${expectedLimit}.winnerQualified must be true.`);
if (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) { if (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) {
errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? "<missing>"}'.`); errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? "<missing>"}'.`);
} }
const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : []; const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : [];
const engineNames = new Set(engines.map((entry) => entry?.engine)); if (engines.length < 1) {
if (!allowSingleEngine) {
for (const required of ["current", "ik-traineddata"]) {
if (!engineNames.has(required)) errors.push(`limit=${expectedLimit} is missing engine result: ${required}.`);
}
} else if (engines.length < 1) {
errors.push(`limit=${expectedLimit} must include at least one engine result.`); errors.push(`limit=${expectedLimit} must include at least one engine result.`);
} }
@@ -191,8 +184,8 @@ function main() {
? findLatestAssessment(argValue("root", defaultAssessmentRoot())) ? findLatestAssessment(argValue("root", defaultAssessmentRoot()))
: argValue("input", process.argv[2] || ""); : argValue("input", process.argv[2] || "");
const expectedWinner = argValue("expect-winner", "any"); const expectedWinner = argValue("expect-winner", "any");
if (!["any", "current", "ik-traineddata"].includes(expectedWinner)) { if (!["any", "current"].includes(expectedWinner)) {
throw new Error("--expect-winner must be one of: any, current, ik-traineddata."); throw new Error("--expect-winner must be one of: any, current.");
} }
const limit = Number(argValue("limit", "100")); const limit = Number(argValue("limit", "100"));
const assessment = loadAssessment(inputPath); const assessment = loadAssessment(inputPath);
@@ -201,7 +194,6 @@ function main() {
const result = validateAssessment(assessment, { const result = validateAssessment(assessment, {
expectedWinner, expectedWinner,
limit, limit,
allowSingleEngine: hasFlag("allow-single-engine"),
maxActiveAverageMsPerParsed: maxActiveAverageMsPerParsed === "" ? undefined : maxActiveAverageMsPerParsed, maxActiveAverageMsPerParsed: maxActiveAverageMsPerParsed === "" ? undefined : maxActiveAverageMsPerParsed,
maxCaptureRoundTripOverheadMs: maxCaptureRoundTripOverheadMs === "" ? undefined : maxCaptureRoundTripOverheadMs, maxCaptureRoundTripOverheadMs: maxCaptureRoundTripOverheadMs === "" ? undefined : maxCaptureRoundTripOverheadMs,
}); });
+89 -1
View File
@@ -11,4 +11,92 @@ export interface ConfirmedReviewEvalCase extends OcrEvalCase {
// Human-confirmed review samples belong here after their `expect` values were // Human-confirmed review samples belong here after their `expect` values were
// checked against the real artifact. Do not paste unconfirmed exporter output // checked against the real artifact. Do not paste unconfirmed exporter output
// directly from outputs/review-eval-candidates/. // directly from outputs/review-eval-candidates/.
export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = []; 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,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);
});
});
+42 -103
View File
@@ -6,28 +6,27 @@ import { describe, expect, it } from "vitest";
function validAssessment() { function validAssessment() {
return { return {
createdAt: "2026-07-08T12:00:00.000Z", createdAt: "2026-07-09T12:00:00.000Z",
goal100Decision: "qualified-comparison: winner=ik-traineddata", goal100Decision: "qualified: winner=current",
goal100: { goal100: {
limit: 100, limit: 100,
comparisonComplete: true, singleEngine: true,
winnerEngine: "ik-traineddata", winnerEngine: "current",
winnerQualified: true, winnerQualified: true,
winnerActiveAverageMsPerParsed: 820, winnerActiveAverageMsPerParsed: 393,
winnerActiveProjectedMsFor100: 82000, winnerActiveProjectedMsFor100: 39300,
winnerAverageCaptureRoundTripMs: 420, winnerAverageCaptureRoundTripMs: 333,
winnerAverageCaptureRoundTripOverheadMs: 160, winnerAverageCaptureRoundTripOverheadMs: 148,
winnerMissRate: 0, winnerMissRate: 0,
winnerReviewRate: 0.04, winnerReviewRate: 0,
engines: [ engines: [
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04, averageCaptureRoundTripMs: 420, averageCaptureRoundTripOverheadMs: 160 }, { engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 333, averageCaptureRoundTripOverheadMs: 148 },
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.06, averageCaptureRoundTripMs: 460, averageCaptureRoundTripOverheadMs: 190 },
], ],
}, },
limits: [ limits: [
{ {
limit: 20, limit: 20,
comparisonComplete: true, singleEngine: true,
winnerEngine: "current", winnerEngine: "current",
winnerQualified: true, winnerQualified: true,
winnerActiveAverageMsPerParsed: 390, winnerActiveAverageMsPerParsed: 390,
@@ -38,7 +37,6 @@ function validAssessment() {
winnerReviewRate: 0.05, winnerReviewRate: 0.05,
engines: [ engines: [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0.05, averageCaptureRoundTripMs: 364, averageCaptureRoundTripOverheadMs: 152 }, { engine: "current", qualified: true, missRate: 0, reviewRate: 0.05, averageCaptureRoundTripMs: 364, averageCaptureRoundTripOverheadMs: 152 },
{ engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.1, averageCaptureRoundTripMs: 410, averageCaptureRoundTripOverheadMs: 180 },
], ],
}, },
], ],
@@ -52,7 +50,7 @@ function writeAssessment(dir: string, payload: unknown) {
} }
describe("scan assessment validator", () => { describe("scan assessment validator", () => {
it("accepts a qualified complete 100-artifact comparison", () => { it("accepts a qualified 100-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const inputPath = writeAssessment(dir, validAssessment()); const inputPath = writeAssessment(dir, validAssessment());
@@ -70,14 +68,10 @@ describe("scan assessment validator", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const inputPath = writeAssessment(dir, validAssessment()); const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync( const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], {
"node",
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=ik-traineddata"],
{
cwd: process.cwd(), cwd: process.cwd(),
encoding: "utf8", encoding: "utf8",
}, });
);
expect(JSON.parse(output).ok).toBe(true); expect(JSON.parse(output).ok).toBe(true);
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
@@ -94,28 +88,24 @@ describe("scan assessment validator", () => {
}); });
expect(output).toContain("scan assessment: PASS"); expect(output).toContain("scan assessment: PASS");
expect(output).toContain(`input: ${inputPath}`); expect(output).toContain(`input: ${inputPath}`);
expect(output).toContain("createdAt: 2026-07-08T12:00:00.000Z"); expect(output).toContain("createdAt: 2026-07-09T12:00:00.000Z");
expect(output).toContain("limit: 100"); expect(output).toContain("limit: 100");
expect(output).toContain("winner: ik-traineddata"); expect(output).toContain("winner: current");
expect(output).toContain("activeAvg: 820ms/artifact"); expect(output).toContain("activeAvg: 393ms/artifact");
expect(output).toContain("captureRoundTripOverhead: 160ms"); expect(output).toContain("captureRoundTripOverhead: 148ms");
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
}); });
it("accepts a qualified complete 20-artifact comparison", () => { it("accepts a qualified 20-artifact current run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const inputPath = writeAssessment(dir, validAssessment()); const inputPath = writeAssessment(dir, validAssessment());
const output = execFileSync( const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], {
"node",
["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"],
{
cwd: process.cwd(), cwd: process.cwd(),
encoding: "utf8", encoding: "utf8",
}, });
);
expect(output).toContain("scan assessment: PASS"); expect(output).toContain("scan assessment: PASS");
expect(output).toContain("limit: 20"); expect(output).toContain("limit: 20");
expect(output).toContain("winner: current"); expect(output).toContain("winner: current");
@@ -228,35 +218,35 @@ describe("scan assessment validator", () => {
it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => { 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")); const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
expect(packageJson.scripts["scan:goal:compare:validated"]).toBe( expect(packageJson.scripts["scan:goal:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
); );
expect(packageJson.scripts["scan:goal:compare:validated:wait"]).toBe( expect(packageJson.scripts["scan:goal:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary",
); );
expect(packageJson.scripts["scan:iterate:compare"]).toBe( expect(packageJson.scripts["scan:iterate"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr", "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr",
); );
expect(packageJson.scripts["scan:iterate:compare:validated"]).toBe( expect(packageJson.scripts["scan:iterate:validated"]).toBe(
"npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20",
); );
expect(packageJson.scripts["scan:iterate:compare:validated:wait"]).toBe( expect(packageJson.scripts["scan:iterate:validated:wait"]).toBe(
"npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", "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( expect(packageJson.scripts["scan:repeatability"]).toBe(
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current", "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current",
); );
expect(packageJson.scripts["scan:repeatability:wait"]).toBe( 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 --allow-single-engine", "npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current",
); );
}); });
it("rejects a mismatched expected winner", () => { it("rejects an invalid expected winner", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const inputPath = writeAssessment(dir, validAssessment()); const inputPath = writeAssessment(dir, validAssessment());
expect(() => expect(() =>
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], { execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=old-engine"], {
cwd: process.cwd(), cwd: process.cwd(),
stdio: "pipe", stdio: "pipe",
}), }),
@@ -269,10 +259,12 @@ describe("scan assessment validator", () => {
it("prints errors in summary mode for rejected assessments", () => { it("prints errors in summary mode for rejected assessments", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const inputPath = writeAssessment(dir, validAssessment()); const payload = validAssessment();
payload.goal100Decision = "not-qualified: 100-artifact winner failed quality gates";
const inputPath = writeAssessment(dir, payload);
let stdout = ""; let stdout = "";
try { try {
execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current", "--summary"], { execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary"], {
cwd: process.cwd(), cwd: process.cwd(),
encoding: "utf8", encoding: "utf8",
stdio: "pipe", stdio: "pipe",
@@ -281,60 +273,7 @@ describe("scan assessment validator", () => {
stdout = String((error as { stdout?: string }).stdout || ""); stdout = String((error as { stdout?: string }).stdout || "");
} }
expect(stdout).toContain("scan assessment: FAIL"); expect(stdout).toContain("scan assessment: FAIL");
expect(stdout).toContain("Expected winner"); expect(stdout).toContain("goal100Decision is not a qualified 100-artifact run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("rejects a single-engine 100-artifact run", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run";
payload.goal100.comparisonComplete = false;
payload.goal100.engines = [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 360, averageCaptureRoundTripOverheadMs: 150 },
];
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("accepts a single-engine 100-artifact repeatability run only with the explicit flag", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try {
const payload = validAssessment();
payload.goal100Decision = "not-comparable: current and ik-traineddata were not both run";
payload.goal100.comparisonComplete = false;
payload.goal100.winnerEngine = "current";
payload.goal100.engines = [
{ engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 360, averageCaptureRoundTripOverheadMs: 150 },
];
const inputPath = writeAssessment(dir, payload);
const output = execFileSync(
"node",
[
"scripts/validate-scan-assessment.cjs",
`--input=${inputPath}`,
"--summary",
"--expect-winner=current",
"--allow-single-engine",
],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(output).toContain("scan assessment: PASS");
expect(output).toContain("winner: current");
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
@@ -438,8 +377,8 @@ describe("scan assessment validator", () => {
it("validates the latest assessment under a live-soak root", () => { it("validates the latest assessment under a live-soak root", () => {
const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-"));
try { try {
const older = path.join(dir, "2026-07-08T10-00-00"); const older = path.join(dir, "2026-07-09T10-00-00");
const newer = path.join(dir, "2026-07-08T11-00-00"); const newer = path.join(dir, "2026-07-09T11-00-00");
mkdirSync(older); mkdirSync(older);
mkdirSync(newer); mkdirSync(newer);
writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" }); writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" });
@@ -451,7 +390,7 @@ describe("scan assessment validator", () => {
}); });
const parsed = JSON.parse(output); const parsed = JSON.parse(output);
expect(parsed.ok).toBe(true); expect(parsed.ok).toBe(true);
expect(parsed.inputPath).toContain("2026-07-08T11-00-00"); expect(parsed.inputPath).toContain("2026-07-09T11-00-00");
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
+267
View File
@@ -0,0 +1,267 @@
import { RefreshCw } from "lucide-react";
import { useInventoryViewModel } from "./hooks/useInventoryViewModel";
import type { InventoryViewProps } from "./types";
export function InventoryView({ snapshot }: InventoryViewProps) {
const {
rows,
selectedRow,
selectedPreview,
previewStatus,
filter,
sort,
loading,
statusText,
promotionStatus,
promotionPending,
promotionConfirming,
reviewEditing,
reviewPending,
reviewStatus,
reviewDraft,
totalRows,
nativeCount,
storedCount,
reviewCount,
catalogSummary,
promotionSummary,
pipelineSummary,
filterOptions,
sortOptions,
setFilter,
setSort,
setSelectedId,
refresh,
requestSelectedPromotion,
cancelSelectedPromotion,
confirmSelectedPromotion,
startSelectedReview,
cancelSelectedReview,
setReviewDraft,
submitSelectedReview,
} = useInventoryViewModel({ snapshot });
return (
<section className="inventory-view">
<div className="inventory-toolbar">
<div>
<p className="eyebrow">Native Artifact Inventory</p>
<h2>Artifact Scan-Ergebnisse</h2>
</div>
<div className="inventory-toolbar-actions">
<div className="inventory-stats">
<span>{totalRows} gesamt</span>
<span>{nativeCount} native</span>
<span>{storedCount} store</span>
<span>{reviewCount} review</span>
</div>
<button className="ghost-button icon-button" onClick={() => void refresh()} disabled={loading} title="Inventory aktualisieren">
<RefreshCw size={16} />
</button>
</div>
</div>
<div className={`inventory-catalog-strip ${catalogSummary.className}`}>
<div>
<span>{catalogSummary.ok ? "Artifact IK bereit" : "IK Listen pruefen"}</span>
<strong>{catalogSummary.title}</strong>
<small>{catalogSummary.detail}</small>
</div>
{catalogSummary.counts.length > 0 && (
<div className="inventory-catalog-counts">
{catalogSummary.counts.map((entry) => (
<span key={entry.label}>
<strong>{entry.value}</strong>
<small>{entry.label}</small>
{entry.sample && <em>{entry.sample}</em>}
</span>
))}
</div>
)}
</div>
<div className="inventory-pipeline-strip" aria-label="Native artifact pipeline status">
{pipelineSummary.cards.map((card) => (
<span key={card.id} className={card.className} title={card.detail}>
<strong>{card.title}</strong>
<small>{card.value}</small>
<em>{card.detail}</em>
</span>
))}
</div>
<p className="inventory-pipeline-caption">{pipelineSummary.caption}</p>
<div className={`inventory-promotion-strip ${promotionSummary.className}`}>
<div>
<span>{promotionSummary.title}</span>
<strong>{promotionSummary.detail}</strong>
<small>Analyse aus scan-results.json und lokalem Artifact-Store. Store-Schreibzugriff bleibt ein expliziter naechster Schritt.</small>
</div>
<div className="inventory-promotion-counts">
<span><strong>{promotionSummary.ready}</strong><small>Speicherbar</small></span>
<span><strong>{promotionSummary.alreadyStored + promotionSummary.persisted}</strong><small>Im Store</small></span>
<span><strong>{promotionSummary.review}</strong><small>Review</small></span>
<span><strong>{promotionSummary.blocked}</strong><small>Blockiert</small></span>
</div>
</div>
<div className="inventory-controls">
<div className="segmented-control">
{filterOptions.map((option) => (
<button
key={option.id}
className={filter === option.id ? "active" : ""}
onClick={() => setFilter(option.id)}
>
{option.label} <span>{option.count}</span>
</button>
))}
</div>
<label className="inventory-sort">
<span>Sort</span>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
{sortOptions.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
</div>
<div className="inventory-browser">
<div className="inventory-list" aria-label="Artifact inventory results">
{rows.length > 0 ? rows.map((row) => (
<button
key={row.id}
className={`inventory-row ${selectedRow?.id === row.id ? "active" : ""}`}
onClick={() => setSelectedId(row.id)}
>
<span className="inventory-row-sequence">{row.sequenceLabel}</span>
<span className="inventory-row-main">
<strong>{row.title}</strong>
<span>{row.slot} - {row.mainStat} {row.mainValue} - {row.levelLabel}</span>
</span>
<span className="inventory-row-source">{row.sourceLabel}</span>
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
</button>
)) : (
<p className="inventory-empty">Keine Scan-Ergebnisse geladen.</p>
)}
</div>
<aside className="inventory-detail">
{selectedRow ? (
<>
<div className="inventory-detail-heading">
<p className="eyebrow">{selectedRow.sourceLabel} {selectedRow.sequenceLabel}</p>
<h3>{selectedRow.title}</h3>
<span className={`result-rail-status ${selectedRow.statusClassName}`}>{selectedRow.statusLabel}</span>
</div>
<div className={`inventory-action-card ${selectedRow.nextActionClassName}`}>
<span>Naechster Schritt</span>
<strong>{selectedRow.nextActionLabel}</strong>
<small>{selectedRow.nextActionDetail}</small>
{selectedRow.canPromote && !promotionConfirming && (
<button className="primary-button inventory-promote-button" onClick={requestSelectedPromotion} disabled={promotionPending}>
Ausgewaehltes Ergebnis promoten
</button>
)}
{selectedRow.canPromote && promotionConfirming && (
<div className="inventory-promotion-confirm">
<strong>Dieses gepruefte Ergebnis jetzt in den lokalen Store schreiben?</strong>
<div>
<button className="primary-button" onClick={() => void confirmSelectedPromotion()} disabled={promotionPending}>Bestaetigen</button>
<button className="ghost-button" onClick={cancelSelectedPromotion} disabled={promotionPending}>Abbrechen</button>
</div>
</div>
)}
{selectedRow.source === "native" && selectedRow.needsReview && !reviewEditing && (
<button className="primary-button inventory-promote-button" onClick={startSelectedReview} disabled={reviewPending}>
Review bearbeiten
</button>
)}
</div>
{reviewEditing && reviewDraft && (
<div className="inventory-review-editor">
<div className="inventory-review-heading">
<div>
<span>Manuelle Korrektur</span>
<strong>Crop pruefen, Felder korrigieren, dann freigeben oder ablehnen.</strong>
</div>
<button className="ghost-button" onClick={cancelSelectedReview} disabled={reviewPending}>Schliessen</button>
</div>
<div className="inventory-review-grid">
<label><span>Name</span><input value={reviewDraft.name} onChange={(event) => setReviewDraft({ ...reviewDraft, name: event.target.value })} /></label>
<label><span>Set</span><input value={reviewDraft.setName} onChange={(event) => setReviewDraft({ ...reviewDraft, setName: event.target.value })} /></label>
<label><span>Slot</span><select value={reviewDraft.slot} onChange={(event) => setReviewDraft({ ...reviewDraft, slot: event.target.value })}>
{["Flower of Life", "Plume of Death", "Sands of Eon", "Goblet of Eonothem", "Circlet of Logos"].map((slot) => <option key={slot}>{slot}</option>)}
</select></label>
<label><span>Level</span><input type="number" min="0" max="20" value={reviewDraft.level} onChange={(event) => setReviewDraft({ ...reviewDraft, level: event.target.value })} /></label>
<label><span>Main Stat</span><input value={reviewDraft.mainStat} onChange={(event) => setReviewDraft({ ...reviewDraft, mainStat: event.target.value })} /></label>
<label><span>Main Value</span><input value={reviewDraft.mainValue} onChange={(event) => setReviewDraft({ ...reviewDraft, mainValue: event.target.value })} /></label>
<label><span>Equipped</span><input value={reviewDraft.equipped} onChange={(event) => setReviewDraft({ ...reviewDraft, equipped: event.target.value })} /></label>
<label className="inventory-review-lock"><input type="checkbox" checked={reviewDraft.locked} onChange={(event) => setReviewDraft({ ...reviewDraft, locked: event.target.checked })} /><span>Locked</span></label>
<label className="inventory-review-wide"><span>Substats, eine Zeile pro Wert</span><textarea rows={4} value={reviewDraft.substats} onChange={(event) => setReviewDraft({ ...reviewDraft, substats: event.target.value })} /></label>
<label className="inventory-review-wide"><span>Review-Notiz</span><input value={reviewDraft.note} onChange={(event) => setReviewDraft({ ...reviewDraft, note: event.target.value })} /></label>
</div>
<div className="inventory-review-actions">
<button className="primary-button" onClick={() => void submitSelectedReview("approve")} disabled={reviewPending}>Korrektur freigeben</button>
<button className="ghost-button danger" onClick={() => void submitSelectedReview("reject")} disabled={reviewPending}>Ergebnis ablehnen</button>
</div>
</div>
)}
<div className={`inventory-preview ${selectedPreview ? "has-preview" : "is-empty"}`}>
{selectedPreview ? (
<img src={selectedPreview.dataUrl} alt={`Preview ${selectedRow.title}`} />
) : (
<span>{previewStatus || "Kein Crop-Preview geladen"}</span>
)}
</div>
{previewStatus && selectedPreview && <p className="inventory-preview-status">{previewStatus}</p>}
<div className="inventory-detail-grid">
<span>Set</span><strong>{selectedRow.setName}</strong>
<span>Slot</span><strong>{selectedRow.slot}</strong>
<span>Main</span><strong>{selectedRow.mainStat} {selectedRow.mainValue}</strong>
<span>Level</span><strong>{selectedRow.levelLabel}</strong>
<span>Value</span><strong>{selectedRow.valueLabel}</strong>
<span>IK</span><strong>{selectedRow.ikLabel}</strong>
<span>GOOD</span><strong>{selectedRow.ikGoodLabel}</strong>
<span>Promotion</span><strong>{selectedRow.promotionLabel}</strong>
<span>Confidence</span><strong>{selectedRow.confidence}%</strong>
<span>Equipped</span><strong>{selectedRow.equipped}</strong>
</div>
<div className="inventory-substats">
{selectedRow.substats.length > 0 ? selectedRow.substats.map((substat) => (
<span key={`${selectedRow.id}-${substat}`}>{substat}</span>
)) : <span>Keine Substats gelesen</span>}
</div>
{selectedRow.fieldConfidences.length > 0 && (
<div className="inventory-field-confidence">
{selectedRow.fieldConfidences.map((field) => (
<span key={`${selectedRow.id}-${field.key}`} className={field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"}>
<strong>{field.confidence}%</strong>
<small>{field.label} · {field.source}</small>
</span>
))}
</div>
)}
{(selectedRow.notes.length > 0 || selectedRow.promotionReasons.length > 0 || selectedRow.error || selectedRow.imagePath || selectedRow.promotionRecordId) && (
<div className="inventory-notes">
{selectedRow.imagePath && <span>{selectedRow.imagePath}</span>}
{selectedRow.promotionRecordId && <span>{selectedRow.promotionRecordId}</span>}
{selectedRow.error && <strong>{selectedRow.error}</strong>}
{selectedRow.promotionReasons.map((reason) => <span key={`${selectedRow.id}-promotion-${reason}`}>{reason}</span>)}
{selectedRow.notes.map((note) => <span key={`${selectedRow.id}-${note}`}>{note}</span>)}
</div>
)}
</>
) : (
<p className="inventory-empty">Waehle ein Ergebnis aus.</p>
)}
</aside>
</div>
<p className="inventory-status">{statusText}</p>
{promotionStatus && <p className="inventory-status">{promotionStatus}</p>}
{reviewStatus && <p className="inventory-status">{reviewStatus}</p>}
</section>
);
}
@@ -0,0 +1,353 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
buildArtifactPipelineSummary,
buildInventoryCatalogSummary,
buildInventoryRows,
filterInventoryRows,
sortInventoryRows,
type InventoryCatalogSummary,
type InventoryBrowserFilter,
type InventoryBrowserRow,
type InventoryBrowserSort,
type InventoryPipelineSummary,
} from "../../../lib/inventoryBrowser";
import { buildScanResultPromotionSummary, type ScanResultPromotionSummary } from "../../../lib/scanResultPromotion";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
import type { NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerReviewArtifactInput } from "../../../types/global";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
import type { InventoryViewProps } from "../types";
export interface InventoryViewModel {
rows: InventoryBrowserRow[];
selectedRow: InventoryBrowserRow | null;
selectedPreview: { dataUrl: string; width: number; height: number; path: string } | null;
previewStatus: string;
filter: InventoryBrowserFilter;
sort: InventoryBrowserSort;
loading: boolean;
statusText: string;
promotionStatus: string;
promotionPending: boolean;
promotionConfirming: boolean;
reviewEditing: boolean;
reviewPending: boolean;
reviewStatus: string;
reviewDraft: InventoryReviewDraft | null;
totalRows: number;
nativeCount: number;
storedCount: number;
reviewCount: number;
catalogSummary: InventoryCatalogSummary;
promotionSummary: ScanResultPromotionSummary;
pipelineSummary: InventoryPipelineSummary;
filterOptions: Array<{ id: InventoryBrowserFilter; label: string; count: number }>;
sortOptions: Array<{ id: InventoryBrowserSort; label: string }>;
setFilter: (filter: InventoryBrowserFilter) => void;
setSort: (sort: InventoryBrowserSort) => void;
setSelectedId: (id: string) => void;
refresh: () => Promise<void>;
requestSelectedPromotion: () => void;
cancelSelectedPromotion: () => void;
confirmSelectedPromotion: () => Promise<void>;
startSelectedReview: () => void;
cancelSelectedReview: () => void;
setReviewDraft: (draft: InventoryReviewDraft) => void;
submitSelectedReview: (action: "approve" | "reject") => Promise<void>;
}
export interface InventoryReviewDraft {
name: string;
slot: string;
level: string;
setName: string;
mainStat: string;
mainValue: string;
substats: string;
equipped: string;
locked: boolean;
note: string;
}
export function useInventoryViewModel({ snapshot }: InventoryViewProps): InventoryViewModel {
const repositories = useMemo(() => createRendererRepositories(), []);
const [storedArtifacts, setStoredArtifacts] = useState<StoredArtifactRecord[]>([]);
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
const [nativeRunDir, setNativeRunDir] = useState("");
const [nativeDataStatus, setNativeDataStatus] = useState<NativeScannerDataStatus | null>(null);
const [nativeCatalog, setNativeCatalog] = useState<NativeScannerCatalogStatus | null>(null);
const [selectedPreview, setSelectedPreview] = useState<{ dataUrl: string; width: number; height: number; path: string } | null>(null);
const [previewStatus, setPreviewStatus] = useState("");
const [filter, setFilter] = useState<InventoryBrowserFilter>("all");
const [sort, setSort] = useState<InventoryBrowserSort>("newest");
const [selectedId, setSelectedId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [promotionStatus, setPromotionStatus] = useState("");
const [promotionPending, setPromotionPending] = useState(false);
const [promotionConfirmingId, setPromotionConfirmingId] = useState("");
const [reviewEditingId, setReviewEditingId] = useState("");
const [reviewDraft, setReviewDraft] = useState<InventoryReviewDraft | null>(null);
const [reviewPending, setReviewPending] = useState(false);
const [reviewStatus, setReviewStatus] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [artifactResult, nativeResult, dataResult, catalogResult] = await Promise.allSettled([
repositories?.artifacts.loadAll(),
repositories?.automation.nativeScannerLoadResults({ limit: 500 }),
repositories?.automation.nativeScannerDataStatus(),
repositories?.automation.nativeScannerCatalog(),
]);
if (artifactResult.status === "fulfilled" && artifactResult.value?.ok) {
setStoredArtifacts(artifactResult.value.artifacts);
}
if (nativeResult.status === "fulfilled" && nativeResult.value?.ok) {
setNativeResults(nativeResult.value.results);
setNativeRunDir(nativeResult.value.runDir);
}
if (dataResult.status === "fulfilled" && dataResult.value?.valid !== undefined) {
setNativeDataStatus(dataResult.value);
}
if (catalogResult.status === "fulfilled" && catalogResult.value?.data?.valid !== undefined) {
setNativeCatalog(catalogResult.value);
setNativeDataStatus(catalogResult.value.data);
}
if (artifactResult.status === "rejected") {
setError(errorMessage(artifactResult.reason));
}
} finally {
setLoading(false);
}
}, [repositories]);
useEffect(() => {
void refresh();
}, [refresh]);
const allRows = useMemo(
() =>
buildInventoryRows({
nativeRunDir,
nativeResults,
storedArtifacts,
snapshotArtifacts: snapshot.artifacts,
}),
[nativeResults, nativeRunDir, snapshot.artifacts, storedArtifacts],
);
const rows = useMemo(
() => sortInventoryRows(filterInventoryRows(allRows, filter), sort),
[allRows, filter, sort],
);
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null;
const promotionConfirming = Boolean(selectedRow && promotionConfirmingId === selectedRow.id);
const reviewEditing = Boolean(selectedRow && reviewEditingId === selectedRow.id && reviewDraft);
const requestSelectedPromotion = useCallback(() => {
if (selectedRow?.canPromote) setPromotionConfirmingId(selectedRow.id);
}, [selectedRow]);
const cancelSelectedPromotion = useCallback(() => setPromotionConfirmingId(""), []);
const confirmSelectedPromotion = useCallback(async () => {
if (!selectedRow?.canPromote || !selectedRow.runDir || !selectedRow.scanResultId || !repositories?.automation) return;
setPromotionPending(true);
setPromotionStatus("Promotion wird geprueft und geschrieben...");
try {
const result = await repositories.automation.nativeScannerPromoteResults({
runDir: selectedRow.runDir,
resultIds: [selectedRow.scanResultId],
});
if (!result.ok) {
setPromotionStatus(result.error ?? "Promotion wurde blockiert.");
return;
}
setPromotionStatus(`${result.promoted} Ergebnis promotet, ${result.added} neu, ${result.updated} aktualisiert. Log: ${result.logPath}`);
setPromotionConfirmingId("");
await refresh();
} catch (promotionError) {
setPromotionStatus(errorMessage(promotionError));
} finally {
setPromotionPending(false);
}
}, [refresh, repositories, selectedRow]);
const startSelectedReview = useCallback(() => {
if (!selectedRow || selectedRow.source !== "native" || !selectedRow.needsReview) return;
setReviewEditingId(selectedRow.id);
setReviewDraft({
name: selectedRow.title,
slot: selectedRow.slot,
level: selectedRow.levelLabel.replace("+", ""),
setName: selectedRow.setName,
mainStat: selectedRow.mainStat,
mainValue: selectedRow.mainValue,
substats: selectedRow.substats.join("\n"),
equipped: selectedRow.equipped,
locked: Boolean(selectedRow.locked),
note: "",
});
}, [selectedRow]);
const cancelSelectedReview = useCallback(() => {
setReviewEditingId("");
setReviewDraft(null);
}, []);
const submitSelectedReview = useCallback(async (action: "approve" | "reject") => {
if (!selectedRow?.runDir || !selectedRow.scanResultId || !reviewDraft || !repositories?.automation) return;
setReviewPending(true);
setReviewStatus(action === "approve" ? "Korrektur wird validiert..." : "Ergebnis wird abgelehnt...");
try {
const artifact: NativeScannerReviewArtifactInput = {
name: reviewDraft.name,
slot: reviewDraft.slot,
level: Number(reviewDraft.level),
setName: reviewDraft.setName,
mainStat: reviewDraft.mainStat,
mainValue: reviewDraft.mainValue,
substats: reviewDraft.substats.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean),
equipped: reviewDraft.equipped,
locked: reviewDraft.locked,
};
const result = await repositories.automation.nativeScannerReviewResult({
runDir: selectedRow.runDir,
resultId: selectedRow.scanResultId,
action,
artifact: action === "approve" ? artifact : undefined,
note: reviewDraft.note,
});
if (!result.ok) {
setReviewStatus(result.error ?? "Review wurde blockiert.");
return;
}
setReviewStatus(action === "approve"
? `Review freigegeben. ${result.correctedFields.length} Feld(er) korrigiert. Eval-Sample: ${result.evalSampleSaved ? "gespeichert" : "ohne OCR-Payload"}.`
: "Ergebnis wurde abgelehnt und bleibt von Promotion ausgeschlossen.");
setReviewEditingId("");
setReviewDraft(null);
await refresh();
} catch (reviewError) {
setReviewStatus(errorMessage(reviewError));
} finally {
setReviewPending(false);
}
}, [refresh, repositories, reviewDraft, selectedRow]);
useEffect(() => {
let canceled = false;
setSelectedPreview(null);
const automation = repositories?.automation;
if (!automation) {
setPreviewStatus("Electron bridge nicht verfuegbar.");
return () => {
canceled = true;
};
}
if (!selectedRow?.runDir || !selectedRow.imagePath || selectedRow.source !== "native") {
setPreviewStatus(selectedRow?.imagePath ? "Preview nur fuer native Run-Crops verfuegbar." : "");
return () => {
canceled = true;
};
}
setPreviewStatus("Preview wird geladen...");
void automation.nativeScannerLoadImage({
runDir: selectedRow.runDir,
imagePath: selectedRow.imagePath,
}).then((result) => {
if (canceled) return;
if (result?.ok) {
setSelectedPreview({
dataUrl: result.dataUrl,
width: result.width,
height: result.height,
path: result.path,
});
setPreviewStatus(`${result.width}x${result.height}`);
} else {
setPreviewStatus(result?.error ?? "Preview konnte nicht geladen werden.");
}
}).catch((error: unknown) => {
if (!canceled) setPreviewStatus(errorMessage(error));
});
return () => {
canceled = true;
};
}, [repositories, selectedRow?.id, selectedRow?.imagePath, selectedRow?.runDir, selectedRow?.source]);
const reviewCount = allRows.filter((row) => row.needsReview).length;
const nativeCount = allRows.filter((row) => row.source === "native").length;
const storedCount = allRows.filter((row) => row.source === "store").length;
const catalogSummary = useMemo(
() => buildInventoryCatalogSummary(nativeDataStatus, nativeCatalog),
[nativeCatalog, nativeDataStatus],
);
const promotionSummary = useMemo(
() => buildScanResultPromotionSummary(nativeResults, storedArtifacts),
[nativeResults, storedArtifacts],
);
const pipelineSummary = useMemo(
() => buildArtifactPipelineSummary(nativeDataStatus, nativeResults, promotionSummary),
[nativeDataStatus, nativeResults, promotionSummary],
);
const statusText = loading
? "Inventory wird geladen..."
: error || `${rows.length}/${allRows.length} Eintraege sichtbar`;
return {
rows,
selectedRow,
selectedPreview,
previewStatus,
filter,
sort,
loading,
statusText,
promotionStatus,
promotionPending,
promotionConfirming,
reviewEditing,
reviewPending,
reviewStatus,
reviewDraft,
totalRows: allRows.length,
nativeCount,
storedCount,
reviewCount,
catalogSummary,
promotionSummary,
pipelineSummary,
filterOptions: [
{ id: "all", label: "Alle", count: allRows.length },
{ id: "native", label: "Native", count: nativeCount },
{ id: "parsed", label: "Gelesen", count: allRows.length - reviewCount },
{ id: "review", label: "Review", count: reviewCount },
{ id: "promotable", label: "Speicherbar", count: promotionSummary.ready },
{ id: "stored", label: "Store", count: storedCount },
],
sortOptions: [
{ id: "newest", label: "Neueste" },
{ id: "name", label: "Name" },
{ id: "status", label: "Status" },
],
setFilter,
setSort,
setSelectedId,
refresh,
requestSelectedPromotion,
cancelSelectedPromotion,
confirmSelectedPromotion,
startSelectedReview,
cancelSelectedReview,
setReviewDraft,
submitSelectedReview,
};
}
function errorMessage(value: unknown) {
return value instanceof Error ? value.message : String(value);
}
+5
View File
@@ -0,0 +1,5 @@
import type { AppSnapshot } from "../../types/domain";
export interface InventoryViewProps {
snapshot: AppSnapshot;
}
+2 -2
View File
@@ -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"; import type { AppNavigationItem } from "./types";
export const appNavigationItems: AppNavigationItem[] = [ export const appNavigationItems: AppNavigationItem[] = [
{ id: "scan", label: "Scan", icon: Radar }, { id: "scan", label: "Scan", icon: Radar },
{ id: "inventory", label: "Inventory", icon: Archive },
{ id: "triage", label: "Triage", icon: Layers3 }, { id: "triage", label: "Triage", icon: Layers3 },
{ id: "builds", label: "Builds", icon: Wand2 }, { id: "builds", label: "Builds", icon: Wand2 },
{ id: "overlay", label: "Overlay", icon: Eye }, { id: "overlay", label: "Overlay", icon: Eye },
{ id: "diagnose", label: "Diagnose", icon: Wrench }, { id: "diagnose", label: "Diagnose", icon: Wrench },
]; ];
+1 -1
View File
@@ -1,7 +1,7 @@
import { type ComponentType, type ReactNode } from "react"; import { type ComponentType, type ReactNode } from "react";
import type { LucideProps } from "lucide-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 { export interface AppNavigationItem {
id: NavigationId; id: NavigationId;
@@ -53,7 +53,7 @@ const appDiagnosisSections = [
tone: "next", tone: "next",
icon: Target, icon: Target,
items: [ items: [
"Mehr Live-Wiederholungen: 20/45/100 Runs in spaeteren Sessions, sauber als Repeatability statt IK-Claim markieren.", "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.", "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.", "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.", "Capture-Roundtrip-Overhead als eigenen Optimierungspunkt pruefen, bevor weitere OCR-Engine-Wechsel priorisiert werden.",
@@ -260,7 +260,7 @@ export function DiagnosticsView({ controller, latestCapture, captureStatus, onDe
</div> </div>
</div> </div>
<p className="scanner-subcopy"> <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> </p>
{interopStatus && <p className="review-status">{interopStatus}</p>} {interopStatus && <p className="review-status">{interopStatus}</p>}
</div> </div>
@@ -7,6 +7,8 @@ export function ScanMainSection({
latestCapture, latestCapture,
captureStatus, captureStatus,
parsedArtifact, parsedArtifact,
recentArtifacts,
nativeScanResults,
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
@@ -14,10 +16,12 @@ export function ScanMainSection({
autoScanRunning, autoScanRunning,
canOpenReviewQueue, canOpenReviewQueue,
openReviewQueue, openReviewQueue,
openInventory,
}: ScanMainSectionProps) { }: ScanMainSectionProps) {
const { const {
canOpenDetails, canOpenDetails,
handleOpenDetails, handleOpenDetails,
handleOpenInventory,
handleOpenReviewQueue, handleOpenReviewQueue,
captureImageSrc, captureImageSrc,
captureImageAlt, captureImageAlt,
@@ -28,14 +32,20 @@ export function ScanMainSection({
targetLabel, targetLabel,
dbLabel, dbLabel,
reviewLabel, reviewLabel,
recentRows,
recentEmptyText,
canOpenInventory,
} = useScanMainSectionModel({ } = useScanMainSectionModel({
latestCapture, latestCapture,
parsedArtifact, parsedArtifact,
recentArtifacts,
nativeScanResults,
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
setDetailsOpen, setDetailsOpen,
openReviewQueue, openReviewQueue,
openInventory,
}); });
return ( return (
@@ -62,6 +72,35 @@ export function ScanMainSection({
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : ( {parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
<p className="result-empty">{noArtifactText}</p> <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"> <div className="scanner-result-brief">
<span>{targetLabel}</span> <span>{targetLabel}</span>
<span>{dbLabel}</span> <span>{dbLabel}</span>
@@ -14,6 +14,7 @@ export function ScanViewLayout({
bridgeReady, bridgeReady,
controller, controller,
isScanning, isScanning,
onOpenInventory,
}: ScanViewLayoutProps) { }: ScanViewLayoutProps) {
const { const {
setDetailsOpen, setDetailsOpen,
@@ -24,6 +25,8 @@ export function ScanViewLayout({
reviewSampleTotal, reviewSampleTotal,
autoScanRunning, autoScanRunning,
storedTotal, storedTotal,
recentArtifacts,
nativeScanResults,
learningRulesLoaded, learningRulesLoaded,
parsedArtifact, parsedArtifact,
learningRuleCount, learningRuleCount,
@@ -52,6 +55,8 @@ export function ScanViewLayout({
latestCapture={latestCapture} latestCapture={latestCapture}
captureStatus={captureStatus} captureStatus={captureStatus}
parsedArtifact={parsedArtifact} parsedArtifact={parsedArtifact}
recentArtifacts={recentArtifacts}
nativeScanResults={nativeScanResults}
sourceLabel={sourceLabel} sourceLabel={sourceLabel}
gridLabel={gridLabel} gridLabel={gridLabel}
inventoryLabel={inventoryLabel} inventoryLabel={inventoryLabel}
@@ -64,6 +69,7 @@ export function ScanViewLayout({
openReviewQueue={openReviewQueue} openReviewQueue={openReviewQueue}
autoScanRunning={autoScanRunning} autoScanRunning={autoScanRunning}
setDetailsOpen={setDetailsOpen} setDetailsOpen={setDetailsOpen}
openInventory={onOpenInventory}
/> />
<ScanModalsSection <ScanModalsSection
@@ -1,9 +1,21 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { ScanMainSectionProps } from "../types"; 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 { export interface ScanMainSectionModel {
canOpenDetails: boolean; canOpenDetails: boolean;
handleOpenDetails: () => void; handleOpenDetails: () => void;
handleOpenInventory: () => void;
handleOpenReviewQueue: () => void; handleOpenReviewQueue: () => void;
captureImageSrc: string; captureImageSrc: string;
captureImageAlt: string; captureImageAlt: string;
@@ -14,31 +26,43 @@ export interface ScanMainSectionModel {
targetLabel: string; targetLabel: string;
dbLabel: string; dbLabel: string;
reviewLabel: string; reviewLabel: string;
recentRows: ScanResultRailRow[];
recentEmptyText: string;
canOpenInventory: boolean;
} }
type UseScanMainSectionModelProps = Pick< type UseScanMainSectionModelProps = Pick<
ScanMainSectionProps, ScanMainSectionProps,
| "latestCapture" | "latestCapture"
| "parsedArtifact" | "parsedArtifact"
| "recentArtifacts"
| "nativeScanResults"
| "activeTargetCount" | "activeTargetCount"
| "storedTotal" | "storedTotal"
| "reviewSampleTotal" | "reviewSampleTotal"
| "setDetailsOpen" | "setDetailsOpen"
| "openReviewQueue" | "openReviewQueue"
| "openInventory"
>; >;
export function useScanMainSectionModel({ export function useScanMainSectionModel({
latestCapture, latestCapture,
parsedArtifact, parsedArtifact,
recentArtifacts,
nativeScanResults,
activeTargetCount, activeTargetCount,
storedTotal, storedTotal,
reviewSampleTotal, reviewSampleTotal,
setDetailsOpen, setDetailsOpen,
openReviewQueue, openReviewQueue,
openInventory,
}: UseScanMainSectionModelProps): ScanMainSectionModel { }: UseScanMainSectionModelProps): ScanMainSectionModel {
const handleOpenDetails = useCallback(() => { const handleOpenDetails = useCallback(() => {
setDetailsOpen(true); setDetailsOpen(true);
}, [setDetailsOpen]); }, [setDetailsOpen]);
const handleOpenInventory = useCallback(() => {
openInventory?.();
}, [openInventory]);
const handleOpenReviewQueue = useCallback(() => { const handleOpenReviewQueue = useCallback(() => {
void openReviewQueue(); void openReviewQueue();
}, [openReviewQueue]); }, [openReviewQueue]);
@@ -53,10 +77,20 @@ export function useScanMainSectionModel({
const targetLabel = `Ziel ${activeTargetCount}`; const targetLabel = `Ziel ${activeTargetCount}`;
const dbLabel = `DB ${storedTotal ?? "-"}`; const dbLabel = `DB ${storedTotal ?? "-"}`;
const reviewLabel = `Review ${reviewSampleTotal}`; const reviewLabel = `Review ${reviewSampleTotal}`;
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 { return {
canOpenDetails, canOpenDetails,
handleOpenDetails, handleOpenDetails,
handleOpenInventory,
handleOpenReviewQueue, handleOpenReviewQueue,
captureImageSrc, captureImageSrc,
captureImageAlt, captureImageAlt,
@@ -67,5 +101,55 @@ export function useScanMainSectionModel({
targetLabel, targetLabel,
dbLabel, dbLabel,
reviewLabel, reviewLabel,
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,
}; };
} }
@@ -90,7 +90,7 @@ export function useScanTopControlsModel({
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten."); : "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
const autoScanButtonTitle = requiresAdminForAutoScan const autoScanButtonTitle = requiresAdminForAutoScan
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten." ? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
: "Prueft zuerst ohne OCR den Screen, oeffnet bei Bedarf per Inventory-Kamera-Sequenz das Artifact-Inventar und scannt erst mit sichtbarer Detailkarte."; : "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 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 manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig."; const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
+4
View File
@@ -33,12 +33,15 @@ export interface ScanViewLayoutProps {
bridgeReady: boolean; bridgeReady: boolean;
controller: ScanViewControllerResult; controller: ScanViewControllerResult;
isScanning: boolean; isScanning: boolean;
onOpenInventory?: ScanViewProps["onOpenInventory"];
} }
export interface ScanMainSectionProps { export interface ScanMainSectionProps {
latestCapture: ScanViewProps["latestCapture"]; latestCapture: ScanViewProps["latestCapture"];
captureStatus: string; captureStatus: string;
parsedArtifact: ScanViewControllerResult["parsedArtifact"]; parsedArtifact: ScanViewControllerResult["parsedArtifact"];
recentArtifacts: ScanViewControllerResult["recentArtifacts"];
nativeScanResults: ScanViewControllerResult["nativeScanResults"];
sourceLabel: string; sourceLabel: string;
gridLabel: string; gridLabel: string;
inventoryLabel: string; inventoryLabel: string;
@@ -51,6 +54,7 @@ export interface ScanMainSectionProps {
autoScanRunning: boolean; autoScanRunning: boolean;
openReviewQueue: () => void; openReviewQueue: () => void;
setDetailsOpen: (value: boolean) => void; setDetailsOpen: (value: boolean) => void;
openInventory?: ScanViewProps["onOpenInventory"];
} }
export interface ScanTopControlsSectionProps { export interface ScanTopControlsSectionProps {
@@ -71,7 +71,7 @@ export async function prepareAutoScanEntry({
appendDiagnosticEvent({ appendDiagnosticEvent({
phase: "entry-fallback", phase: "entry-fallback",
severity: "info", severity: "info",
message: `Direct inventory entry did not reach an artifact detail card. Trying IK fallback. ${directPreflight.reason}`, message: `Direct inventory entry did not reach an artifact detail card. Trying ESC/B fallback. ${directPreflight.reason}`,
capture: directCapture, capture: directCapture,
}); });
} }
@@ -53,7 +53,6 @@ export interface VisibleGridScanOptions {
scanLimit?: number; scanLimit?: number;
scanEntryMode?: ScanEntryMode; scanEntryMode?: ScanEntryMode;
processInitialSelection?: boolean; processInitialSelection?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
} }
function buildScanSignature(parsed: ParsedArtifactCandidate) { function buildScanSignature(parsed: ParsedArtifactCandidate) {
@@ -195,7 +194,6 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
} = context; } = context;
const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit; const scanLimit = typeof options.scanLimit === "number" ? clampScanLimit(options.scanLimit) : configuredScanLimit;
const scanEntryMode = options.scanEntryMode ?? "visible-inventory"; const scanEntryMode = options.scanEntryMode ?? "visible-inventory";
const ocrEngine = options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "current";
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return; if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.focusGenshinForScanStart || !automationRepo?.focusGenshin || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) { if (scanEntryMode !== "visible-inventory" && !automationRepo.keyPress) {
@@ -226,8 +224,8 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
appendDiagnosticEvent({ appendDiagnosticEvent({
phase: "scan-start", phase: "scan-start",
severity: "info", severity: "info",
message: `Auto-scan start requested (${scanEntryMode}, ${ocrEngine})`, message: `Auto-scan start requested (${scanEntryMode})`,
details: { scanLimit, skipRows, detectedInventoryCount, ocrEngine }, details: { scanLimit, skipRows, detectedInventoryCount },
}); });
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null); const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
@@ -412,7 +410,6 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
detectedInventoryCount, detectedInventoryCount,
processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory", processInitialSelection: options.processInitialSelection ?? scanEntryMode !== "visible-inventory",
skipInitialGridTarget: scanEntryMode !== "visible-inventory", skipInitialGridTarget: scanEntryMode !== "visible-inventory",
ocrEngine,
}, },
); );
@@ -421,7 +418,7 @@ export async function runVisibleGridScan(context: ScanActionContext, options: Vi
await focusDashboard(); 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}` : ""}`); 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({ setScanSummary({
mode: scanEntryMode === "visible-inventory" ? `Automatischer Scan [${ocrEngine}]` : `Automatischer Scan (${scanEntryMode}) [${ocrEngine}]`, mode: scanEntryMode === "visible-inventory" ? "Automatischer Scan" : `Automatischer Scan (${scanEntryMode})`,
status: result.status, status: result.status,
...result.stats, ...result.stats,
targetCount: result.targetCount, targetCount: result.targetCount,
@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import type { CaptureOptions, ScannerCommand } from "../../../types/global"; import type { ScannerCommand } from "../../../types/global";
import type { VisibleGridScanOptions } from "./scanViewScanActions"; import type { VisibleGridScanOptions } from "./scanViewScanActions";
interface ScanCommandListenerInput { interface ScanCommandListenerInput {
@@ -9,7 +9,7 @@ interface ScanCommandListenerInput {
isScanning: boolean; isScanning: boolean;
selectedSourceId: string; selectedSourceId: string;
requestScanStop: (reason: string) => void; requestScanStop: (reason: string) => void;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>; runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
} }
@@ -30,12 +30,13 @@ export function useScanCommandListener({
return; return;
} }
const commandType = typeof command === "string" ? command : command.type; const commandType = typeof command === "string" ? command : command.type;
if (commandType === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) { const canStartNativeScan = Boolean(automationRepo.nativeScannerStart);
if (commandType === "start-auto" && !autoScanRunning && !isScanning && (selectedSourceId || canStartNativeScan)) {
if (typeof command === "string" || !command.scanEntryMode) { if (typeof command === "string" || !command.scanEntryMode) {
void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit, ocrEngine: command.ocrEngine }); void runGuidedAutoScan(typeof command === "string" ? undefined : { scanLimit: command.scanLimit });
return; return;
} }
void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode, ocrEngine: command.ocrEngine }); void runVisibleGridScan({ scanLimit: command.scanLimit, scanEntryMode: command.scanEntryMode });
} }
}); });
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]); }, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runGuidedAutoScan, runVisibleGridScan]);
@@ -75,7 +75,7 @@ export function useScanSnapshotPublisher({
storedTotal, storedTotal,
learningRuleCount, learningRuleCount,
lookupStatus: validateLookupPackage(), lookupStatus: validateLookupPackage(),
ocrEngine: scanSummary?.mode.includes("ik-traineddata") ? "ik-traineddata" : "current", ocrEngine: "current",
entryMode: scanSummary?.mode.includes("auto-entry") entryMode: scanSummary?.mode.includes("auto-entry")
? "auto-entry" ? "auto-entry"
: scanSummary?.mode.includes("direct-inventory") : scanSummary?.mode.includes("direct-inventory")
+203 -7
View File
@@ -12,7 +12,7 @@ import { createReviewContext, createScanActionContext } from "./scanViewControll
import { useScanCommandListener } from "./useScanCommandListener"; import { useScanCommandListener } from "./useScanCommandListener";
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
import type { ScannerLearningRules } from "../../../lib/scannerLearning"; 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 { import type {
ArtifactRepositoryPort, ArtifactRepositoryPort,
ReviewSampleRepositoryPort, ReviewSampleRepositoryPort,
@@ -20,10 +20,12 @@ import type {
RuntimeRepositoryPort, RuntimeRepositoryPort,
AutomationRepositoryPort, AutomationRepositoryPort,
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; } 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 { RuntimeInfo } from "../../../types/global";
import type { StoredScanResultEntry } from "../../../types/storage";
import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; import type { createScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry"; import { validateAutoScanEntryPreflight } from "../../../lib/autoScanEntry";
import { wait } from "../../../lib/scanReviewUtils";
type BooleanSetter = Dispatch<SetStateAction<boolean>>; type BooleanSetter = Dispatch<SetStateAction<boolean>>;
type NumberSetter = Dispatch<SetStateAction<number>>; type NumberSetter = Dispatch<SetStateAction<number>>;
@@ -33,6 +35,64 @@ type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>; type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>; type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>; 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 { interface ScanViewActionInput {
autoScanRunning: boolean; autoScanRunning: boolean;
@@ -63,6 +123,7 @@ interface ScanViewActionInput {
setLearningRulesLoaded: BooleanSetter; setLearningRulesLoaded: BooleanSetter;
setScannerLearningRules: ScannerRulesSetter; setScannerLearningRules: ScannerRulesSetter;
setStoredTotal: NumberOrNullSetter; setStoredTotal: NumberOrNullSetter;
setNativeScanResults: NativeScanResultsSetter;
scannerLearningRules: ScannerLearningRules; scannerLearningRules: ScannerLearningRules;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
latestCapture: CaptureResult | null; latestCapture: CaptureResult | null;
@@ -81,7 +142,7 @@ export interface ScanViewActionResult {
loadReviewQueue: () => Promise<void>; loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>; openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>; runAutoReviewScan: () => Promise<void>;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>; runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>; runVisibleGridScan: (options?: VisibleGridScanOptions) => Promise<void>;
} }
@@ -115,6 +176,7 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
setLearningRulesLoaded, setLearningRulesLoaded,
setScannerLearningRules, setScannerLearningRules,
setStoredTotal, setStoredTotal,
setNativeScanResults,
scannerLearningRules, scannerLearningRules,
captureSelectedSource, captureSelectedSource,
latestCapture, latestCapture,
@@ -126,9 +188,16 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
const requestScanStop = useCallback((reason = "Stop angefordert.") => { const requestScanStop = useCallback((reason = "Stop angefordert.") => {
stopVisibleScanRef.current = true; 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}`); appendAutomationLog(`stop requested: ${reason}`);
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`); setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]); }, [appendAutomationLog, automationRepo, setReviewStatus, stopVisibleScanRef]);
const reviewContext = useMemo( const reviewContext = useMemo(
() => () =>
@@ -311,8 +380,121 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
scanActionContext, scanActionContext,
]); ]);
const runGuidedAutoScan = useCallback(async (options: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] } = {}) => { const runGuidedAutoScan = useCallback(async (options: { scanLimit?: number } = {}) => {
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) { 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; return;
} }
setReviewStatus("Auto-Scan prueft den Startzustand ohne OCR..."); setReviewStatus("Auto-Scan prueft den Startzustand ohne OCR...");
@@ -340,7 +522,6 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
scanLimit: options.scanLimit, scanLimit: options.scanLimit,
scanEntryMode: "visible-inventory", scanEntryMode: "visible-inventory",
processInitialSelection: true, processInitialSelection: true,
ocrEngine: options.ocrEngine,
}); });
}, [ }, [
autoScanRunning, autoScanRunning,
@@ -348,9 +529,24 @@ export function useScanViewActions(input: ScanViewActionInput): ScanViewActionRe
selectedSourceId, selectedSourceId,
automationRepo?.clickScreen, automationRepo?.clickScreen,
automationRepo?.scrollScreen, automationRepo?.scrollScreen,
automationRepo?.nativeScannerPreflight,
automationRepo?.nativeScannerStart,
automationRepo?.nativeScannerStatus,
automationRepo?.nativeScannerStop,
automationRepo?.nativeScannerProcessRun,
automationRepo?.nativeScannerLoadResults,
setAutoScanRunning,
setAutoScanStats,
setScanSummary,
setReviewStatus, setReviewStatus,
setNativeScanResults,
scanLimit,
detectedInventoryCount,
stopVisibleScanRef,
appendAutomationLog,
captureSelectedSource, captureSelectedSource,
appendDiagnosticEvent, appendDiagnosticEvent,
focusDashboard,
scanActionContext, scanActionContext,
]); ]);
@@ -18,6 +18,7 @@ import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type Sc
import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog"; import { createScanDiagnosticEvent, summarizeClickResult, type ScanDiagnosticEvent } from "../../../lib/scanDiagnosticsLog";
import type { ScanViewProps, ScanViewControllerResult } from "../types"; import type { ScanViewProps, ScanViewControllerResult } from "../types";
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories"; import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
export function useScanViewController({ export function useScanViewController({
@@ -52,6 +53,8 @@ export function useScanViewController({
const stopVisibleScanRef = useRef(false); const stopVisibleScanRef = useRef(false);
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats); const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null); const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
const [recentArtifacts, setRecentArtifacts] = useState<StoredArtifactRecord[]>([]);
const [nativeScanResults, setNativeScanResults] = useState<StoredScanResultEntry[]>([]);
const [scanLimit, setScanLimit] = useState(16); const [scanLimit, setScanLimit] = useState(16);
const [scanLimitTouched, setScanLimitTouched] = useState(false); const [scanLimitTouched, setScanLimitTouched] = useState(false);
const [skipRows, setSkipRows] = useState(0); const [skipRows, setSkipRows] = useState(0);
@@ -166,6 +169,7 @@ export function useScanViewController({
setLearningRulesLoaded, setLearningRulesLoaded,
setScannerLearningRules, setScannerLearningRules,
setStoredTotal, setStoredTotal,
setNativeScanResults,
scannerLearningRules, scannerLearningRules,
captureSelectedSource, captureSelectedSource,
latestCapture, latestCapture,
@@ -191,8 +195,10 @@ export function useScanViewController({
artifactRepo, artifactRepo,
latestCapture, latestCapture,
scanLimitTouched, scanLimitTouched,
storedTotal,
setScanLimit, setScanLimit,
setStoredTotal, setStoredTotal,
setRecentArtifacts,
}); });
useScanSnapshotPublisher({ useScanSnapshotPublisher({
@@ -229,6 +235,8 @@ export function useScanViewController({
automationLog, automationLog,
diagnosticEvents, diagnosticEvents,
storedTotal, storedTotal,
recentArtifacts,
nativeScanResults,
devMode, devMode,
scannerLearningRules, scannerLearningRules,
learningRulesLoaded, learningRulesLoaded,
@@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import type { CaptureResult } from "../../../types/global"; import type { CaptureResult } from "../../../types/global";
import type { StoredArtifactRecord } from "../../../types/storage";
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
import { clampScanLimit } from "../../../lib/scannerSession"; import { clampScanLimit } from "../../../lib/scannerSession";
@@ -8,16 +9,20 @@ interface ScanViewStateSyncInput {
artifactRepo?: ArtifactRepositoryPort; artifactRepo?: ArtifactRepositoryPort;
latestCapture: CaptureResult | null; latestCapture: CaptureResult | null;
scanLimitTouched: boolean; scanLimitTouched: boolean;
storedTotal: number | null;
setScanLimit: Dispatch<SetStateAction<number>>; setScanLimit: Dispatch<SetStateAction<number>>;
setStoredTotal: Dispatch<SetStateAction<number | null>>; setStoredTotal: Dispatch<SetStateAction<number | null>>;
setRecentArtifacts: Dispatch<SetStateAction<StoredArtifactRecord[]>>;
} }
export function useScanViewStateSync({ export function useScanViewStateSync({
artifactRepo, artifactRepo,
latestCapture, latestCapture,
scanLimitTouched, scanLimitTouched,
storedTotal,
setScanLimit, setScanLimit,
setStoredTotal, setStoredTotal,
setRecentArtifacts,
}: ScanViewStateSyncInput) { }: ScanViewStateSyncInput) {
useEffect(() => { useEffect(() => {
const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0; const detectedCount = latestCapture?.inventoryCount?.total ?? latestCapture?.inventoryCount?.current ?? 0;
@@ -28,7 +33,17 @@ export function useScanViewStateSync({
useEffect(() => { useEffect(() => {
artifactRepo?.loadAll().then((result) => { 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); }).catch(() => undefined);
}, [artifactRepo, setStoredTotal]); }, [artifactRepo, setRecentArtifacts, setStoredTotal, storedTotal]);
} }
+5 -2
View File
@@ -1,6 +1,6 @@
import type { AppSnapshot } from "../../types/domain"; import type { AppSnapshot } from "../../types/domain";
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global"; 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 { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser"; import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog"; import type { ScanDiagnosticEvent } from "../../lib/scanDiagnosticsLog";
@@ -20,6 +20,7 @@ export interface ScanViewProps {
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
bridgeReady: boolean; bridgeReady: boolean;
onStoredArtifactsChanged?: () => Promise<void>; onStoredArtifactsChanged?: () => Promise<void>;
onOpenInventory?: () => void;
} }
export interface ScanViewControllerResult { export interface ScanViewControllerResult {
@@ -33,6 +34,8 @@ export interface ScanViewControllerResult {
autoScanRunning: boolean; autoScanRunning: boolean;
autoScanStats: AutoScanStats; autoScanStats: AutoScanStats;
scanSummary: ScanSummary | null; scanSummary: ScanSummary | null;
recentArtifacts: StoredArtifactRecord[];
nativeScanResults: StoredScanResultEntry[];
scanLimit: number; scanLimit: number;
scanLimitTouched: boolean; scanLimitTouched: boolean;
skipRows: number; skipRows: number;
@@ -81,7 +84,7 @@ export interface ScanViewControllerResult {
loadReviewQueue: () => Promise<void>; loadReviewQueue: () => Promise<void>;
openReviewQueue: () => Promise<void>; openReviewQueue: () => Promise<void>;
runAutoReviewScan: () => Promise<void>; runAutoReviewScan: () => Promise<void>;
runGuidedAutoScan: (options?: { scanLimit?: number; ocrEngine?: CaptureOptions["ocrEngine"] }) => Promise<void>; runGuidedAutoScan: (options?: { scanLimit?: number }) => Promise<void>;
runVisibleGridScan: () => Promise<void>; runVisibleGridScan: () => Promise<void>;
canGoodInterop: boolean; canGoodInterop: boolean;
exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>; exportGoodFromStore: () => Promise<{ ok: boolean; path?: string; count: number }>;
@@ -28,6 +28,15 @@ import type {
ReviewSampleListResult, ReviewSampleListResult,
SaveScannerLearningRulesResult, SaveScannerLearningRulesResult,
GoodImportFileResult, GoodImportFileResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
} from "../../types/global"; } from "../../types/global";
const EMPTY_SNAPSHOT: AppSnapshot | null = null; const EMPTY_SNAPSHOT: AppSnapshot | null = null;
@@ -75,6 +84,162 @@ const EMPTY_GOOD_IMPORT_FILE_RESULT: GoodImportFileResult = {
path: "", path: "",
error: "Electron bridge unavailable.", 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>( async function createBridgeSafeCall<TResult>(
callback: () => Promise<TResult> | TResult | null | undefined, callback: () => Promise<TResult> | TResult | null | undefined,
@@ -209,6 +374,28 @@ export function createRendererRepositories(): RendererRepositories | null {
scrollScreen: (notches, anchorX, anchorY) => scrollScreen: (notches, anchorX, anchorY) =>
createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()), createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()),
keyPress: (key) => createBridgeSafeCall(() => bridge.keyPress(key), emptyKeyPressResult(key)), 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, onCommand: bridge.onScannerCommand,
}; };
@@ -10,6 +10,17 @@ import type {
GoodDatabase, GoodDatabase,
GoodImportFileResult, GoodImportFileResult,
FocusGenshinResult, FocusGenshinResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
RuntimeInfo, RuntimeInfo,
ScannerCommand, ScannerCommand,
LoadScannerLearningRulesResult, LoadScannerLearningRulesResult,
@@ -66,6 +77,17 @@ export interface AutomationRepositoryPort {
clickScreen(x: number, y: number): Promise<ClickResult>; clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
keyPress(key: string): Promise<KeyPressResult>; 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; onCommand(callback: (command: ScannerCommand) => void): () => void;
} }
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseArtifactCandidate } from "./artifactOcrParser"; import { parseArtifactCandidate, reviewedMainValueError } from "./artifactOcrParser";
import type { CaptureResult } from "../types/global"; import type { CaptureResult } from "../types/global";
function captureFromOcr(textById: Record<string, string>): CaptureResult { function captureFromOcr(textById: Record<string, string>): CaptureResult {
@@ -20,6 +20,11 @@ function captureFromOcr(textById: Record<string, string>): CaptureResult {
} }
describe("parseArtifactCandidate", () => { 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", () => { it("matches artifact data from the generated Genshin data package", () => {
const parsed = parseArtifactCandidate(captureFromOcr({ const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "A Note in Spring's Lei\n| Sands of Eon Vi", "artifact-title": "A Note in Spring's Lei\n| Sands of Eon Vi",
@@ -247,6 +252,22 @@ describe("parseArtifactCandidate", () => {
expect(parsed?.mainValue).toBe("31.1%"); 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", () => { it("keeps percent substats distinct from flat ATK HP and DEF", () => {
const parsed = parseArtifactCandidate(captureFromOcr({ const parsed = parseArtifactCandidate(captureFromOcr({
"artifact-title": "Beast Tamer's Talisman\nFlower of Life", "artifact-title": "Beast Tamer's Talisman\nFlower of Life",
+116 -7
View File
@@ -20,7 +20,12 @@ import {
} from "./genshinData.js"; } from "./genshinData.js";
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js"; import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js"; import { matchCharacter, matchPiece, matchSet, matchSlot, matchStat } from "./genshinLookup.js";
import { implausibleSubstats } from "./substatRolls.js"; import {
implausibleSubstats,
isPlausibleSubstat,
parseSubstatEntry,
possibleSubstatRollCounts,
} from "./substatRolls.js";
type MainStatValueReference = { stat: string; base: number; max: number }; type MainStatValueReference = { stat: string; base: number; max: number };
@@ -56,6 +61,24 @@ export interface ParsedArtifactCandidate {
const mainStatNames = sortLongestFirst(globalMainStats); const mainStatNames = sortLongestFirst(globalMainStats);
const substatNames = sortLongestFirst(globalSubstats); 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 { export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArtifactCandidate | null {
if (!capture?.ocr?.length) return null; if (!capture?.ocr?.length) return null;
@@ -107,8 +130,13 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
mainValueField = noisyReferenceMatch.mainValue; mainValueField = noisyReferenceMatch.mainValue;
} }
} }
const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n")); const parsedSubstats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n"), parsedLevel);
const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing"); 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 setField = parseSetName(setText, nameField);
const equippedField = parseEquippedCharacter(footerText + "\n" + allText); const equippedField = parseEquippedCharacter(footerText + "\n" + allText);
const notes: string[] = []; const notes: string[] = [];
@@ -120,7 +148,10 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
if (!mainStatField.value) notes.push("Main stat not confidently parsed."); if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
if (!mainValueField.value) notes.push("Main stat value 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."); 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 (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."); if (!setField.value) notes.push("Set name not confidently parsed.");
@@ -324,6 +355,29 @@ function findMainValue(text: string, mainStat: string, slot: string, level: numb
if (!ocrField.value) return derivedField; if (!ocrField.value) return derivedField;
if (!derivedField.value) return ocrField; 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)) { if (["HP", "ATK", "DEF", "Elemental Mastery"].includes(mainStat)) {
const ocrNumeric = parseNumericValue(ocrField.value); const ocrNumeric = parseNumericValue(ocrField.value);
const derivedNumeric = parseNumericValue(derivedField.value); const derivedNumeric = parseNumericValue(derivedField.value);
@@ -482,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) const normalized = normalizeText(text)
.replace(/CRIT\s*DMG/gi, "CRIT DMG") .replace(/CRIT\s*DMG/gi, "CRIT DMG")
.replace(/CRIT\s*Rate/gi, "CRIT Rate") .replace(/CRIT\s*Rate/gi, "CRIT Rate")
@@ -490,14 +544,69 @@ function parseSubstats(text: string) {
.replace(/Elemental\s*Mastery/gi, "Elemental Mastery") .replace(/Elemental\s*Mastery/gi, "Elemental Mastery")
.replace(/([A-Z]{2,4})\s*\+/g, "$1+"); .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[] = []; const results: string[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = statPattern.exec(normalized))) { while ((match = statPattern.exec(normalized))) {
const stat = canonicalSubstatName(match[1], match[2]); const stat = canonicalSubstatName(match[1], match[2]);
results.push(`${stat}+${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) { function leadingSetEffectText(text: string) {
+6 -6
View File
@@ -27,21 +27,21 @@ function capture(overrides: Partial<CaptureResult> = {}): CaptureResult {
describe("autoScanEntry", () => { describe("autoScanEntry", () => {
it("plans only read-only keys/clicks for the Paimon entry", () => { it("plans only read-only keys/clicks for the Paimon entry", () => {
expect(buildAutoScanEntryPlan("paimon-menu")).toEqual([ expect(buildAutoScanEntryPlan("paimon-menu")).toEqual([
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" }, { 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: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" }, { type: "key", key: "B", label: "Open inventory from world" },
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" }, { 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: "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" }, { type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
]); ]);
}); });
it("plans a guided auto entry with direct world path before IK fallback", () => { it("plans a guided auto entry with direct world path before ESC/B fallback", () => {
expect(buildAutoScanEntryPlan("auto-entry")).toEqual([ expect(buildAutoScanEntryPlan("auto-entry")).toEqual([
{ type: "key", key: "B", label: "Try direct inventory entry from world" }, { 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-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: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" }, { 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: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
{ type: "key", key: "B", label: "Fallback: open inventory from world" }, { type: "key", key: "B", label: "Fallback: open inventory from world" },
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" }, { type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
@@ -50,7 +50,7 @@ describe("autoScanEntry", () => {
]); ]);
}); });
it("uses Inventory Kamera's artifact-tab coordinate ratio", () => { it("uses the artifact-tab coordinate ratio", () => {
expect(artifactTabClickTarget(capture())).toEqual({ x: 672, y: 47 }); expect(artifactTabClickTarget(capture())).toEqual({ x: 672, y: 47 });
}); });
+4 -4
View File
@@ -31,7 +31,7 @@ export function buildAutoScanEntryPlan(mode: ScanEntryMode): AutoScanEntryAction
{ type: "key", key: "B", label: "Try direct inventory entry from world" }, { 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-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: "click-first-artifact", label: "Select first visible artifact so the detail card is open" },
{ type: "key", key: "ESC", label: "Fallback: Inventory Kamera step from already-open Paimon menu" }, { 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: "ESC", label: "Fallback: close Paimon menu if ESC opened or left it visible" },
{ type: "key", key: "B", label: "Fallback: open inventory from world" }, { type: "key", key: "B", label: "Fallback: open inventory from world" },
{ type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" }, { type: "click-artifact-tab", label: "Fallback: select artifact inventory tab" },
@@ -40,10 +40,10 @@ export function buildAutoScanEntryPlan(mode: ScanEntryMode): AutoScanEntryAction
]; ];
} }
return [ return [
{ type: "key", key: "ESC", label: "Inventory Kamera step: leave the already-open Paimon menu" }, { 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: "ESC", label: "If Paimon is still visible, close it before opening inventory" },
{ type: "key", key: "B", label: "Inventory Kamera step: open inventory from world" }, { type: "key", key: "B", label: "Open inventory from world" },
{ type: "click-artifact-tab", label: "Inventory Kamera step: select artifact inventory tab" }, { 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: "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" }, { type: "capture-preflight", label: "Verify artifact inventory grid and detail card" },
]; ];
+2 -3
View File
@@ -184,7 +184,7 @@ describe("autoScanLoop fingerprints", () => {
shouldStop: () => false, shouldStop: () => false,
}; };
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null, ocrEngine: "ik-traineddata" }); const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
expect(result.blockedReason).toContain("Keine Artifact-Detailansicht"); expect(result.blockedReason).toContain("Keine Artifact-Detailansicht");
expect(result.status).toBe("blocked"); expect(result.status).toBe("blocked");
expect(clicked).toBe(0); expect(clicked).toBe(0);
@@ -252,7 +252,7 @@ describe("autoScanLoop fingerprints", () => {
shouldStop: () => false, shouldStop: () => false,
}; };
const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null, ocrEngine: "ik-traineddata" }); const result = await runAutoScanLoop(deps, { scanLimit: 1, skipRows: 0, detectedInventoryCount: null });
expect(result.blockedReason).toBe(""); expect(result.blockedReason).toBe("");
expect(result.status).toBe("done"); expect(result.status).toBe("done");
expect(clicked).toBe(1); expect(clicked).toBe(1);
@@ -263,7 +263,6 @@ describe("autoScanLoop fingerprints", () => {
expect(selectedCaptureOptions).toContainEqual({ expect(selectedCaptureOptions).toContainEqual({
ocrMode: "artifact", ocrMode: "artifact",
ocrProfile: "fast", ocrProfile: "fast",
ocrEngine: "ik-traineddata",
omitFullFrame: true, omitFullFrame: true,
omitDetailPreview: true, omitDetailPreview: true,
omitInventoryPreview: true, omitInventoryPreview: true,
+3 -8
View File
@@ -9,11 +9,9 @@ import type { AutoScanStats, ScanSummary } from "./scannerSession";
import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession"; import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
import { validateAutoScanEntryPreflight } from "./autoScanEntry"; import { validateAutoScanEntryPreflight } from "./autoScanEntry";
// Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md // One click per tile, fingerprint verification, one retry if the detail view did
// ADR-007): one click per tile, a fixed settle delay, one retry if the detail // not change, then move on. Extra click profiles made failures harder to
// view did not change, then move on. No click-profile matrix, no offset // diagnose and did not improve the validated visible-inventory path.
// retries, no double-read conflict resolution - those never fixed a single
// click and only made failures harder to diagnose.
type AutoScanApi = { type AutoScanApi = {
clickScreen: (x: number, y: number) => Promise<ClickResult>; clickScreen: (x: number, y: number) => Promise<ClickResult>;
@@ -60,7 +58,6 @@ export type AutoScanLoopOptions = {
detectedInventoryCount?: number | null; detectedInventoryCount?: number | null;
processInitialSelection?: boolean; processInitialSelection?: boolean;
skipInitialGridTarget?: boolean; skipInitialGridTarget?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
}; };
export type AutoScanLoopResult = { export type AutoScanLoopResult = {
@@ -287,7 +284,6 @@ export async function runAutoScanLoop(
const initialCapture = await captureSelectedSource(0, false, { const initialCapture = await captureSelectedSource(0, false, {
ocrMode: "artifact", ocrMode: "artifact",
ocrProfile: "fast", ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true, omitFullFrame: true,
omitDetailPreview: true, omitDetailPreview: true,
omitInventoryPreview: true, omitInventoryPreview: true,
@@ -422,7 +418,6 @@ export async function runAutoScanLoop(
const capture = await captureSelectedSource(0, false, { const capture = await captureSelectedSource(0, false, {
ocrMode: "artifact", ocrMode: "artifact",
ocrProfile: "fast", ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true, omitFullFrame: true,
omitDetailPreview: true, omitDetailPreview: true,
omitInventoryPreview: true, omitInventoryPreview: true,
+1 -1
View File
@@ -41,7 +41,7 @@ describe("automationPlanner", () => {
expect(grid?.targets.find((target) => target.row === 1 && target.col === 2)).toMatchObject({ x: 340, y: 350 }); expect(grid?.targets.find((target) => target.row === 1 && target.col === 2)).toMatchObject({ x: 340, y: 350 });
}); });
it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => { it("plans overlapping inventory pages for a partial final page", () => {
const targets = Array.from({ length: 32 }, (_, index) => ({ const targets = Array.from({ length: 32 }, (_, index) => ({
row: Math.floor(index / 8), row: Math.floor(index / 8),
col: index % 8, col: index % 8,
+3 -4
View File
@@ -26,10 +26,9 @@ export type InventoryPagePlan = {
scrollRowsAfterPage: number; scrollRowsAfterPage: number;
}; };
// Matches Inventory Kamera's model: the whole app simply always runs // Auto-scan runs from the app's own elevated process, so it only needs to check
// elevated (see docs/DECISIONS.md ADR-007), so auto-scan only needs to check // our process elevation - no per-process target-elevation probing or separate
// our own process elevation - no per-process target-elevation probing or // broker process required.
// separate broker process required.
export function requiresAdminForAutomation(runtime: RuntimeInfo | null | undefined) { export function requiresAdminForAutomation(runtime: RuntimeInfo | null | undefined) {
return runtime?.ok === true && runtime.isElevated !== true; return runtime?.ok === true && runtime.isElevated !== true;
} }
+2 -2
View File
@@ -5,8 +5,8 @@ import { simplifyForMatch } from "./fuzzyMatch";
import { inferRarity } from "./substatRolls"; import { inferRarity } from "./substatRolls";
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the // GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
// local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha // local store can round-trip with GOOD-compatible tools (ADR-003). Export is
// (ADR-003). Export is lossless for the fields GOOD carries; import is // lossless for the fields GOOD carries; import is
// best-effort because GOOD does not store piece names or main-stat values. // best-effort because GOOD does not store piece names or main-stat values.
export interface GoodImportArtifact { export interface GoodImportArtifact {
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import type { IkArtifactCatalog } from "./ikArtifactMatcher";
import { matchParsedArtifactToIk } from "./ikArtifactMatcher";
const catalog: IkArtifactCatalog = {
artifacts: [
{
normalizedName: "silkenmoonsserenade",
setName: "Silken Moon's Serenade",
good: "SilkenMoonsSerenade",
pieces: [
{
slot: "plume",
artifactName: "Pristine Plume of the Blessed",
good: "PristinePlumeOfTheBlessed",
normalizedName: "pristineplumeoftheblessed",
},
{
slot: "flower",
artifactName: "Blooming Warmth of the Blessed",
good: "BloomingWarmthOfTheBlessed",
normalizedName: "bloomingwarmthoftheblessed",
},
],
},
],
};
function parsedArtifact(overrides: Partial<ParsedArtifactCandidate> = {}): ParsedArtifactCandidate {
const field = { value: "", confidence: 92, source: "ocr" as const };
return {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%"],
setName: "Silken Moon's Serenade",
equipped: "Aino",
confidence: 93,
notes: [],
fields: {
name: field,
slot: field,
level: field,
mainStat: field,
mainValue: field,
setName: field,
equipped: field,
substats: field,
},
...overrides,
};
}
describe("IK artifact matcher", () => {
it("matches parsed artifact identity to IK GOOD keys", () => {
const match = matchParsedArtifactToIk(parsedArtifact(), catalog);
expect(match).toMatchObject({
matched: true,
confidence: 100,
source: "ik-inventorylists",
setKey: "silkenmoonsserenade",
setGood: "SilkenMoonsSerenade",
artifactKey: "pristineplumeoftheblessed",
artifactGood: "PristinePlumeOfTheBlessed",
slotKey: "plume",
notes: [],
});
});
it("flags IK slot mismatches for review", () => {
const match = matchParsedArtifactToIk(parsedArtifact({ slot: "Flower of Life" }), catalog);
expect(match?.matched).toBe(false);
expect(match?.notes.join(" ")).toContain("slot mismatch");
});
it("keeps missing IK catalog data unsafe", () => {
const match = matchParsedArtifactToIk(parsedArtifact(), { artifacts: [] });
expect(match).toMatchObject({
matched: false,
confidence: 0,
notes: ["IK artifact catalog unavailable."],
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { ParsedArtifactCandidate } from "./artifactOcrParser.js";
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
import type { ScanResultIkMatch } from "../types/storage.js";
export interface IkArtifactPieceCatalogEntry {
slot: string;
artifactName: string;
good: string;
normalizedName: string;
}
export interface IkArtifactSetCatalogEntry {
normalizedName: string;
setName: string;
good: string;
pieces: IkArtifactPieceCatalogEntry[];
}
export interface IkArtifactCatalog {
artifacts: readonly IkArtifactSetCatalogEntry[];
}
const SLOT_TO_IK: Record<string, string> = {
"Flower of Life": "flower",
"Plume of Death": "plume",
"Sands of Eon": "sands",
"Goblet of Eonothem": "goblet",
"Circlet of Logos": "circlet",
};
export function matchParsedArtifactToIk(
parsed: ParsedArtifactCandidate | null,
catalog: IkArtifactCatalog | null | undefined,
): ScanResultIkMatch | null {
if (!parsed) return null;
if (!catalog?.artifacts?.length) {
return reviewMatch("IK artifact catalog unavailable.");
}
const setMatch = findSetMatch(parsed, catalog.artifacts);
const pieceMatch = findPieceMatch(parsed, setMatch?.set ? [setMatch.set] : catalog.artifacts);
const set = setMatch?.set ?? pieceMatch?.set;
const piece = pieceMatch?.piece;
const notes: string[] = [];
if (!set) notes.push(`IK set not matched: ${parsed.setName}.`);
if (!piece) notes.push(`IK artifact piece not matched: ${parsed.name}.`);
if (setMatch?.set && pieceMatch?.set && setMatch.set.normalizedName !== pieceMatch.set.normalizedName) {
notes.push(`IK set/piece mismatch: ${setMatch.set.setName} vs ${pieceMatch.set.setName}.`);
}
const parsedSlotKey = SLOT_TO_IK[parsed.slot] ?? "";
if (!parsedSlotKey) notes.push(`IK slot not mapped: ${parsed.slot}.`);
if (piece && parsedSlotKey && piece.slot !== parsedSlotKey) {
notes.push(`IK slot mismatch: parsed ${parsed.slot}, piece is ${piece.slot}.`);
}
const matched = Boolean(set && piece && parsedSlotKey && piece.slot === parsedSlotKey && notes.length === 0);
return {
matched,
confidence: matched
? Math.round(Math.min(setMatch?.confidence ?? 100, pieceMatch?.confidence ?? 100, 100))
: Math.round(Math.min(setMatch?.confidence ?? 0, pieceMatch?.confidence ?? 0, 65)),
source: "ik-inventorylists",
setKey: set?.normalizedName,
setGood: set?.good,
setName: set?.setName,
artifactKey: piece?.normalizedName,
artifactGood: piece?.good,
artifactName: piece?.artifactName,
slotKey: piece?.slot ?? (parsedSlotKey || undefined),
notes,
};
}
function findSetMatch(parsed: ParsedArtifactCandidate, sets: readonly IkArtifactSetCatalogEntry[]) {
const normalized = simplifyForMatch(parsed.setName);
const exact = sets.find((set) =>
set.normalizedName === normalized || simplifyForMatch(set.setName) === normalized || set.good === parsed.setName,
);
if (exact) return { set: exact, confidence: 100 };
const fuzzy = fuzzyFindKnown(parsed.setName, sets.map((set) => set.setName), 0.78);
if (!fuzzy) return null;
const set = sets.find((entry) => entry.setName === fuzzy.value);
return set ? { set, confidence: Math.round(fuzzy.score * 100) } : null;
}
function findPieceMatch(parsed: ParsedArtifactCandidate, sets: readonly IkArtifactSetCatalogEntry[]) {
const normalized = simplifyForMatch(parsed.name);
const pieces = sets.flatMap((set) => set.pieces.map((piece) => ({ set, piece })));
const exact = pieces.find(({ piece }) =>
piece.normalizedName === normalized || simplifyForMatch(piece.artifactName) === normalized || piece.good === parsed.name,
);
if (exact) return { ...exact, confidence: 100 };
const fuzzy = fuzzyFindKnown(parsed.name, pieces.map(({ piece }) => piece.artifactName), 0.78);
if (!fuzzy) return null;
const match = pieces.find(({ piece }) => piece.artifactName === fuzzy.value);
return match ? { ...match, confidence: Math.round(fuzzy.score * 100) } : null;
}
function reviewMatch(note: string): ScanResultIkMatch {
return {
matched: false,
confidence: 0,
source: "ik-inventorylists",
notes: [note],
};
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { catalogSampleLabels, matchIkCatalogItem } from "./ikCatalogMatcher";
const weapons = [
{ normalizedName: "favoniussword", good: "FavoniusSword" },
{ normalizedName: "skywardblade", good: "SkywardBlade" },
];
const characters = [
{ normalizedName: "furina", good: "Furina" },
{ normalizedName: "traveleranemo", good: "TravelerAnemo" },
];
const materials = [
{ normalizedName: "mora", good: "Mora" },
{ normalizedName: "heroswit", good: "HerosWit" },
];
describe("IK simple catalog matcher", () => {
it("matches weapon names against IK GOOD keys", () => {
const match = matchIkCatalogItem("Favonius Sword", weapons, "weapon");
expect(match).toMatchObject({
matched: true,
category: "weapon",
confidence: 100,
good: "FavoniusSword",
source: "ik-inventorylists",
});
});
it("fuzzy-matches character names without inventing a category", () => {
const match = matchIkCatalogItem("traveler anemo", characters, "character");
expect(match.matched).toBe(true);
expect(match.good).toBe("TravelerAnemo");
expect(match.confidence).toBeGreaterThanOrEqual(78);
});
it("keeps unknown materials in review state", () => {
const match = matchIkCatalogItem("not a real material", materials, "material");
expect(match).toMatchObject({
matched: false,
category: "material",
confidence: 0,
});
expect(match.notes[0]).toContain("not matched");
});
it("creates compact GOOD-key samples for UI catalog summaries", () => {
expect(catalogSampleLabels([...weapons, ...characters], 3)).toEqual([
"FavoniusSword",
"SkywardBlade",
"Furina",
]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
export type IkSimpleCatalogCategory = "weapon" | "character" | "material";
export interface IkSimpleCatalogEntry {
normalizedName: string;
good: string;
}
export interface IkCatalogItemMatch {
matched: boolean;
category: IkSimpleCatalogCategory;
confidence: number;
source: "ik-inventorylists";
normalizedName?: string;
good?: string;
notes: string[];
}
export function matchIkCatalogItem(
text: string,
entries: readonly IkSimpleCatalogEntry[],
category: IkSimpleCatalogCategory,
): IkCatalogItemMatch {
const candidate = text.trim();
if (!candidate) return reviewMatch(category, "No IK match text provided.");
if (!entries.length) return reviewMatch(category, `IK ${category} catalog unavailable.`);
const normalized = simplifyForMatch(candidate);
const exact = entries.find((entry) =>
entry.normalizedName === normalized || simplifyForMatch(entry.good) === normalized,
);
if (exact) return matched(category, exact, 100);
const labels = entries.flatMap((entry) => [entry.normalizedName, entry.good]);
const fuzzy = fuzzyFindKnown(candidate, labels, 0.78);
if (!fuzzy) return reviewMatch(category, `IK ${category} not matched: ${candidate}.`);
const entry = entries.find((item) => item.normalizedName === fuzzy.value || item.good === fuzzy.value);
return entry
? matched(category, entry, Math.round(fuzzy.score * 100))
: reviewMatch(category, `IK ${category} not matched: ${candidate}.`);
}
export function catalogSampleLabels(entries: readonly IkSimpleCatalogEntry[], count = 3) {
return entries
.slice(0, Math.max(0, count))
.map((entry) => entry.good || entry.normalizedName)
.filter(Boolean);
}
function matched(category: IkSimpleCatalogCategory, entry: IkSimpleCatalogEntry, confidence: number): IkCatalogItemMatch {
return {
matched: true,
category,
confidence,
source: "ik-inventorylists",
normalizedName: entry.normalizedName,
good: entry.good,
notes: [],
};
}
function reviewMatch(category: IkSimpleCatalogCategory, note: string): IkCatalogItemMatch {
return {
matched: false,
category,
confidence: 0,
source: "ik-inventorylists",
notes: [note],
};
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { buildIkScanCapabilityRows } from "./ikScanCapabilities";
import type { NativeScannerDataStatus } from "../types/global";
function validData(overrides: Partial<NativeScannerDataStatus> = {}): NativeScannerDataStatus {
return {
directory: "C:/app/data/ik-inventorylists",
version: "6.7.0",
artifactSets: 61,
artifactPieces: 289,
weapons: 247,
characters: 119,
materials: 715,
totalEntries: 1370,
categories: {
artifacts: {
file: "artifacts.json",
setCount: 61,
pieceCount: 289,
supported: true,
catalogAvailable: true,
nativeCaptureSupported: true,
scanStatus: "native_capture",
},
weapons: {
file: "weapons.json",
count: 247,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
characters: {
file: "characters.json",
count: 119,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
materials: {
file: "materials.json",
count: 715,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
},
missing: [],
valid: true,
source: "InventoryKamera inventorylists",
...overrides,
};
}
describe("IK scan capabilities", () => {
it("marks artifacts as native capture and other IK categories as catalog-only", () => {
const rows = buildIkScanCapabilityRows(validData());
expect(rows).toEqual([
expect.objectContaining({
id: "artifacts",
countLabel: "61/289",
status: "native_capture",
statusLabel: "Native Capture",
className: "ok",
}),
expect.objectContaining({
id: "weapons",
countLabel: "247",
status: "catalog_only",
statusLabel: "Katalog bereit",
className: "muted",
}),
expect.objectContaining({
id: "characters",
countLabel: "119",
status: "catalog_only",
}),
expect.objectContaining({
id: "materials",
countLabel: "715",
status: "catalog_only",
}),
]);
});
it("keeps every category unsafe when the IK inventorylists are incomplete", () => {
const rows = buildIkScanCapabilityRows(validData({ valid: false, missing: ["materials.json"] }));
expect(rows).toHaveLength(4);
expect(rows.every((row) => row.status === "missing_data")).toBe(true);
expect(rows.every((row) => row.className === "warn")).toBe(true);
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { NativeScannerDataStatus } from "../types/global";
export type IkScanCapabilityId = "artifacts" | "weapons" | "characters" | "materials";
export type IkScanCapabilityStatus = "native_capture" | "catalog_only" | "missing_data";
export interface IkScanCapabilityRow {
id: IkScanCapabilityId;
label: string;
countLabel: string;
status: IkScanCapabilityStatus;
statusLabel: string;
className: "ok" | "warn" | "muted";
detail: string;
}
export function buildIkScanCapabilityRows(data: NativeScannerDataStatus | null | undefined): IkScanCapabilityRow[] {
const valid = Boolean(data?.valid);
return [
capability({
id: "artifacts",
label: "Artifacts",
countLabel: data ? `${data.artifactSets}/${data.artifactPieces}` : "0/0",
valid,
catalogAvailable: Boolean(data?.categories.artifacts.catalogAvailable ?? data?.valid),
nativeCapture: Boolean(data?.categories.artifacts.nativeCaptureSupported ?? data?.categories.artifacts.supported),
detail: "Native 16:9 visible-inventory capture path is implemented for artifact card crops.",
}),
capability({
id: "weapons",
label: "Weapons",
countLabel: String(data?.weapons ?? 0),
valid,
catalogAvailable: Boolean(data?.categories.weapons.catalogAvailable ?? data?.valid),
nativeCapture: Boolean(data?.categories.weapons.nativeCaptureSupported ?? false),
detail: "IK weapon names and GOOD keys are loaded; weapon capture flow still needs category-specific evidence.",
}),
capability({
id: "characters",
label: "Characters",
countLabel: String(data?.characters ?? 0),
valid,
catalogAvailable: Boolean(data?.categories.characters.catalogAvailable ?? data?.valid),
nativeCapture: Boolean(data?.categories.characters.nativeCaptureSupported ?? false),
detail: "IK character names and GOOD keys are loaded; character capture flow still needs category-specific evidence.",
}),
capability({
id: "materials",
label: "Materials",
countLabel: String(data?.materials ?? 0),
valid,
catalogAvailable: Boolean(data?.categories.materials.catalogAvailable ?? data?.valid),
nativeCapture: Boolean(data?.categories.materials.nativeCaptureSupported ?? false),
detail: "IK material names and GOOD keys are loaded; material capture flow still needs category-specific evidence.",
}),
];
}
function capability(input: {
id: IkScanCapabilityId;
label: string;
countLabel: string;
valid: boolean;
catalogAvailable: boolean;
nativeCapture: boolean;
detail: string;
}): IkScanCapabilityRow {
if (!input.valid || !input.catalogAvailable) {
return {
id: input.id,
label: input.label,
countLabel: input.countLabel,
status: "missing_data",
statusLabel: "Daten fehlen",
className: "warn",
detail: "IK inventorylists are incomplete or unavailable.",
};
}
if (input.nativeCapture) {
return {
id: input.id,
label: input.label,
countLabel: input.countLabel,
status: "native_capture",
statusLabel: "Native Capture",
className: "ok",
detail: input.detail,
};
}
return {
id: input.id,
label: input.label,
countLabel: input.countLabel,
status: "catalog_only",
statusLabel: "Katalog bereit",
className: "muted",
detail: input.detail,
};
}
+345
View File
@@ -0,0 +1,345 @@
import { describe, expect, it } from "vitest";
import {
buildArtifactPipelineSummary,
buildInventoryCatalogSummary,
buildInventoryRows,
filterInventoryRows,
sortInventoryRows,
type InventoryBrowserRow,
} from "./inventoryBrowser";
import type { NativeScannerCatalogStatus, NativeScannerDataStatus } from "../types/global";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
function nativeEntry(overrides: Partial<StoredScanResultEntry> = {}): StoredScanResultEntry {
return {
id: "native-1",
runId: "run-1",
sequence: 3,
category: "artifact",
source: "native-ik-scan",
imagePath: "artifact-0003.png",
capturedAt: "2026-07-09T10:00:03.000Z",
extractionStatus: "parsed",
extractionConfidence: 93,
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%"],
equipped: "Aino",
},
...overrides,
};
}
function storedRecord(overrides: Partial<StoredArtifactRecord> = {}): StoredArtifactRecord {
return {
id: "store-1",
name: "Gladiator's Nostalgia",
slot: "Flower of Life",
level: 20,
setName: "Gladiator's Finale",
mainStat: "HP",
mainValue: "4,780",
substats: ["CRIT Rate+3.9%"],
equipped: "Hu Tao",
confidence: 91,
needsReview: false,
source: "auto-scan",
firstSeenAt: "2026-07-09T09:00:00.000Z",
lastSeenAt: "2026-07-09T09:30:00.000Z",
timesSeen: 2,
...overrides,
};
}
function validIkData(): NativeScannerDataStatus {
return {
directory: "C:/app/data/ik-inventorylists",
version: "6.7.0",
artifactSets: 61,
artifactPieces: 289,
weapons: 247,
characters: 119,
materials: 715,
totalEntries: 1370,
categories: {
artifacts: {
file: "artifacts.json",
setCount: 61,
pieceCount: 289,
supported: true,
catalogAvailable: true,
nativeCaptureSupported: true,
scanStatus: "native_capture",
},
weapons: {
file: "weapons.json",
count: 247,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
characters: {
file: "characters.json",
count: 119,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
materials: {
file: "materials.json",
count: 715,
supported: false,
catalogAvailable: true,
nativeCaptureSupported: false,
scanStatus: "catalog_only",
},
},
missing: [],
valid: true,
source: "InventoryKamera inventorylists",
};
}
describe("inventory browser rows", () => {
it("builds rows from native scan results and stored artifacts", () => {
const rows = buildInventoryRows({
nativeRunDir: "C:/runs/run-1",
nativeResults: [nativeEntry({
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
fieldConfidences: [
{ key: "name", label: "Name", value: "Pristine Plume of the Blessed", confidence: 96, source: "database" },
{ key: "mainStat", label: "Main stat", value: "ATK", confidence: 94, source: "ocr" },
],
})],
storedArtifacts: [storedRecord()],
});
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
source: "native",
title: "Pristine Plume of the Blessed",
statusLabel: "Geparst",
valueLabel: "Wert offen",
ikLabel: "Matched",
ikGoodLabel: "SilkenMoonsSerenade / PristinePlumeOfTheBlessed",
promotionLabel: "Speicherbar",
promotionClassName: "ok",
canPromote: true,
nextActionLabel: "Bereit zur Promotion",
fieldConfidences: [
{ key: "name", label: "Name", value: "Pristine Plume of the Blessed", confidence: 96, source: "database" },
{ key: "mainStat", label: "Main stat", value: "ATK", confidence: 94, source: "ocr" },
],
sourceLabel: "Native Artifact",
sequenceLabel: "#3",
runDir: "C:/runs/run-1",
});
expect(rows[1]).toMatchObject({
source: "store",
title: "Gladiator's Nostalgia",
statusLabel: "Duplicate",
promotionLabel: "Im Store",
sequenceLabel: "2x",
});
});
it("filters review rows without treating weak value as review", () => {
const rows = buildInventoryRows({
nativeResults: [
nativeEntry({
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
}),
nativeEntry({
id: "native-review",
sequence: 4,
extractionStatus: "review",
extractionConfidence: 55,
needsReview: true,
valueStatus: "review",
}),
],
});
const reviewRows = filterInventoryRows(rows, "review");
const parsedRows = filterInventoryRows(rows, "parsed");
expect(reviewRows).toHaveLength(1);
expect(reviewRows[0].statusLabel).toBe("Review");
expect(parsedRows).toHaveLength(1);
expect(parsedRows[0].valueLabel).toBe("Wert offen");
expect(filterInventoryRows(rows, "native")).toHaveLength(2);
expect(filterInventoryRows(rows, "promotable")).toHaveLength(1);
});
it("shows catalog-only native categories without artifact promotion", () => {
const rows = buildInventoryRows({
nativeResults: [
nativeEntry({
id: "native-weapon",
sequence: 5,
category: "weapon",
artifact: undefined,
extractionStatus: "error",
extractionConfidence: 0,
needsReview: true,
valueStatus: "review",
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
}),
],
});
expect(rows[0]).toMatchObject({
title: "Weapon #5",
setName: "IK catalog-only",
slot: "Weapon",
mainStat: "Not parsed",
sourceLabel: "Native Weapon",
promotionLabel: "Blockiert",
canPromote: false,
});
});
it("sorts newest rows first by timestamp", () => {
const rows: InventoryBrowserRow[] = [
buildInventoryRows({ storedArtifacts: [storedRecord()] })[0],
buildInventoryRows({ nativeResults: [nativeEntry()] })[0],
];
const sorted = sortInventoryRows(rows, "newest");
expect(sorted[0].source).toBe("native");
expect(sorted[1].source).toBe("store");
});
});
describe("inventory catalog summary", () => {
const data = validIkData();
const validIkCatalog: NativeScannerCatalogStatus = {
data,
artifacts: [
{
normalizedName: "silkenmoonsserenade",
setName: "Silken Moon's Serenade",
good: "SilkenMoonsSerenade",
pieces: [],
},
],
weapons: [{ normalizedName: "favoniussword", good: "FavoniusSword" }],
characters: [{ normalizedName: "furina", good: "Furina", element: "Hydro", weaponType: 1, constellationName: "" }],
materials: [{ normalizedName: "mora", good: "Mora" }],
};
it("summarizes the vendored IK category coverage", () => {
const summary = buildInventoryCatalogSummary(data, validIkCatalog);
expect(summary).toMatchObject({
ok: true,
title: "IK 6.7.0",
detail: "Artifact-Scanner nutzt IK-Artifactdaten aus InventoryKamera inventorylists. Andere IK-Listen sind vorerst nicht im aktiven UI-Scope.",
className: "ok",
});
expect(summary.counts).toEqual([
{ label: "Artifact-Sets", value: "61", sample: "SilkenMoonsSerenade" },
{ label: "Artifact-Pieces", value: "289" },
{ label: "Aktiver Scope", value: "Artifacts", sample: "Waffen, Materialien und Charakterdetails ausgeblendet" },
]);
});
it("keeps missing IK data visibly unsafe", () => {
const summary = buildInventoryCatalogSummary({
...data,
valid: false,
missing: ["materials.json"],
});
expect(summary.ok).toBe(false);
expect(summary.className).toBe("warn");
expect(summary.detail).toContain("materials.json");
expect(summary.counts).toEqual([]);
});
});
describe("artifact pipeline summary", () => {
it("keeps native artifact evidence separate from value and promotion", () => {
const promotionSummary = buildInventoryRows({
nativeResults: [
nativeEntry({
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
}),
],
});
const summary = buildArtifactPipelineSummary(
validIkData(),
[nativeEntry({
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
})],
{
total: 1,
ready: promotionSummary.filter((row) => row.canPromote).length,
alreadyStored: 0,
persisted: 0,
review: 0,
blocked: 0,
title: "Native Promotion",
detail: "1 speicherbar - 0 im Store - 0 Review - 0 blockiert",
className: "ok",
decisions: [],
},
);
expect(summary.cards.map((card) => card.id)).toEqual(["scope", "capture", "processing", "review", "promotion", "evidence"]);
expect(summary.cards.find((card) => card.id === "scope")).toMatchObject({
value: "Artifacts only",
className: "ok",
});
expect(summary.cards.find((card) => card.id === "processing")).toMatchObject({
value: "1/1 geparst",
className: "ok",
});
expect(summary.cards.find((card) => card.id === "evidence")).toMatchObject({
value: "Kleiner Run",
className: "warn",
});
expect(summary.caption).toContain("keine Value-Auswertung");
});
});
+401
View File
@@ -0,0 +1,401 @@
import type { Artifact } from "../types/domain";
import {
buildScanResultPromotionSummary,
type ScanResultPromotionDecision,
type ScanResultPromotionSummary,
} from "./scanResultPromotion";
import type { NativeScannerCatalogStatus, NativeScannerDataStatus } from "../types/global";
import type { ScanResultFieldConfidence, StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
export type InventoryBrowserFilter = "all" | "review" | "parsed" | "stored" | "native" | "promotable";
export type InventoryBrowserSort = "newest" | "name" | "status";
export type InventoryBrowserSource = "native" | "store" | "snapshot";
export interface InventoryBrowserRow {
id: string;
source: InventoryBrowserSource;
title: string;
setName: string;
slot: string;
mainStat: string;
mainValue: string;
levelLabel: string;
substats: string[];
equipped: string;
locked?: boolean;
reviewDecision?: "approved" | "rejected";
confidence: number;
needsReview: boolean;
statusLabel: string;
statusClassName: string;
valueLabel: string;
ikLabel: string;
ikGoodLabel: string;
promotionLabel: string;
promotionClassName: "ok" | "warn" | "muted";
promotionReasons: string[];
promotionRecordId?: string;
canPromote: boolean;
nextActionLabel: string;
nextActionDetail: string;
nextActionClassName: "ok" | "warn" | "muted";
fieldConfidences: ScanResultFieldConfidence[];
sourceLabel: string;
sequenceLabel: string;
timestamp: string;
imagePath?: string;
runDir?: string;
scanResultId?: string;
notes: string[];
error?: string;
}
export interface InventoryCatalogSummary {
ok: boolean;
title: string;
detail: string;
className: "ok" | "warn";
counts: Array<{ label: string; value: string; sample?: string }>;
}
export interface InventoryPipelineCard {
id: "scope" | "capture" | "processing" | "review" | "promotion" | "evidence";
title: string;
value: string;
detail: string;
className: "ok" | "warn" | "muted";
}
export interface InventoryPipelineSummary {
cards: InventoryPipelineCard[];
caption: string;
}
interface BuildInventoryRowsInput {
nativeRunDir?: string;
nativeResults?: readonly StoredScanResultEntry[];
storedArtifacts?: readonly StoredArtifactRecord[];
snapshotArtifacts?: readonly Artifact[];
}
export function buildInventoryRows({
nativeRunDir = "",
nativeResults = [],
storedArtifacts = [],
snapshotArtifacts = [],
}: BuildInventoryRowsInput): InventoryBrowserRow[] {
const promotionSummary = buildScanResultPromotionSummary(nativeResults, storedArtifacts);
const promotionByResultId = new Map(promotionSummary.decisions.map((decision) => [decision.resultId, decision]));
const nativeRows = nativeResults.map((entry) => nativeResultToInventoryRow(entry, nativeRunDir, promotionByResultId.get(entry.id)));
const storedRows = storedArtifacts.map(storedArtifactToInventoryRow);
const seenStored = new Set(storedRows.map((row) => row.id));
const snapshotRows = snapshotArtifacts
.filter((artifact) => !seenStored.has(`store:${artifact.id}`))
.map(snapshotArtifactToInventoryRow);
return [...nativeRows, ...storedRows, ...snapshotRows];
}
export function filterInventoryRows(rows: readonly InventoryBrowserRow[], filter: InventoryBrowserFilter) {
if (filter === "review") return rows.filter((row) => row.needsReview);
if (filter === "parsed") return rows.filter((row) => !row.needsReview);
if (filter === "stored") return rows.filter((row) => row.source === "store");
if (filter === "native") return rows.filter((row) => row.source === "native");
if (filter === "promotable") return rows.filter((row) => row.canPromote);
return [...rows];
}
export function sortInventoryRows(rows: readonly InventoryBrowserRow[], sort: InventoryBrowserSort) {
const sorted = [...rows];
if (sort === "name") {
return sorted.sort((left, right) => left.title.localeCompare(right.title) || right.timestamp.localeCompare(left.timestamp));
}
if (sort === "status") {
return sorted.sort((left, right) => left.statusLabel.localeCompare(right.statusLabel) || right.timestamp.localeCompare(left.timestamp));
}
return sorted.sort((left, right) => right.timestamp.localeCompare(left.timestamp));
}
export function buildInventoryCatalogSummary(
data: NativeScannerDataStatus | null | undefined,
catalog?: NativeScannerCatalogStatus | null,
): InventoryCatalogSummary {
if (!data) {
return {
ok: false,
title: "IK inventorylists",
detail: "Katalogstatus noch nicht geladen.",
className: "warn",
counts: [],
};
}
if (!data.valid) {
return {
ok: false,
title: data.version ? `IK ${data.version}` : "IK inventorylists",
detail: `Unvollstaendig: ${data.missing.join(", ") || "unbekannte Dateien fehlen"}.`,
className: "warn",
counts: [],
};
}
return {
ok: true,
title: `IK ${data.version || "?"}`,
detail: `Artifact-Scanner nutzt IK-Artifactdaten aus ${data.source || "InventoryKamera inventorylists"}. Andere IK-Listen sind vorerst nicht im aktiven UI-Scope.`,
className: "ok",
counts: [
catalogCount("Artifact-Sets", String(data.artifactSets), catalog?.artifacts.slice(0, 3).map((entry) => entry.good).filter(Boolean)),
catalogCount("Artifact-Pieces", String(data.artifactPieces)),
catalogCount("Aktiver Scope", "Artifacts", ["Waffen, Materialien und Charakterdetails ausgeblendet"]),
],
};
}
export function buildArtifactPipelineSummary(
data: NativeScannerDataStatus | null | undefined,
nativeResults: readonly StoredScanResultEntry[] = [],
promotionSummary: ScanResultPromotionSummary,
): InventoryPipelineSummary {
const artifactResults = nativeResults.filter((entry) => entry.category === "artifact");
const parsed = artifactResults.filter((entry) => entry.extractionStatus === "parsed" && !entry.needsReview).length;
const review = artifactResults.filter((entry) => entry.needsReview).length;
const errors = artifactResults.filter((entry) => entry.extractionStatus === "error" || entry.extractionStatus === "missing_crop" || entry.extractionStatus === "parse_error").length;
const deferredValue = artifactResults.filter((entry) => entry.valueStatus === "deferred").length;
const evidenceClassName = artifactResults.length === 0 ? "muted" : artifactResults.length < 20 ? "warn" : "ok";
const processingClassName = artifactResults.length === 0 ? "muted" : parsed === artifactResults.length ? "ok" : "warn";
const reviewClassName = artifactResults.length === 0 ? "muted" : review + errors === 0 ? "ok" : "warn";
return {
cards: [
{
id: "scope",
title: "Scope",
value: "Artifacts only",
detail: data?.valid
? `${data.artifactSets}/${data.artifactPieces} IK Artifactdaten aktiv.`
: "IK Artifactdaten noch nicht geladen.",
className: data?.valid ? "ok" : "warn",
},
{
id: "capture",
title: "Native Capture",
value: artifactResults.length > 0 ? `${artifactResults.length} Crops` : "Kein Run",
detail: "C# Helper erfasst Karten-Crops; 16:9 Artifact-Inventar mit Detailkarte bleibt Pflicht.",
className: artifactResults.length > 0 ? "ok" : "muted",
},
{
id: "processing",
title: "OCR Queue",
value: artifactResults.length > 0 ? `${parsed}/${artifactResults.length} geparst` : "Wartet",
detail: "OCR und Parser laufen nach der schnellen Erfassung als separater Nachgang.",
className: processingClassName,
},
{
id: "review",
title: "Review Gate",
value: `${review} Review`,
detail: errors > 0 ? `${errors} blockierte oder fehlende Ergebnisse.` : "Unsichere OCR bleibt sichtbar statt als Wertung zu erscheinen.",
className: reviewClassName,
},
{
id: "promotion",
title: "Promotion",
value: `${promotionSummary.ready} speicherbar`,
detail: `Store-Schreibzugriff bleibt explizit; ${promotionSummary.alreadyStored + promotionSummary.persisted} bereits im Store.`,
className: promotionSummary.ready > 0 ? "ok" : promotionSummary.review + promotionSummary.blocked > 0 ? "warn" : "muted",
},
{
id: "evidence",
title: "Evidenz",
value: artifactResults.length >= 20 ? "Run brauchbar" : artifactResults.length > 0 ? "Kleiner Run" : "Keine Daten",
detail: artifactResults.length >= 20
? "Fuer Speed-Claims trotzdem 50/100-Item-Runs verwenden."
: "Native-Paritaet erst nach groesseren 20/50/100-Artifact-Runs behaupten.",
className: evidenceClassName,
},
],
caption: `${deferredValue} native Artifact-Ergebnisse haben noch keine Value-Auswertung; Extraktion und Artifact-Wert bleiben getrennt.`,
};
}
function catalogCount(label: string, value: string, sampleValues: string[] = []) {
const sample = sampleValues.filter(Boolean).join(", ");
return sample ? { label, value, sample } : { label, value };
}
function nativeResultToInventoryRow(
entry: StoredScanResultEntry,
runDir: string,
promotion?: ScanResultPromotionDecision,
): InventoryBrowserRow {
const artifact = entry.artifact;
const categoryLabel = scanResultCategoryLabel(entry.category);
const clean = entry.extractionStatus === "parsed" && !entry.needsReview;
const statusLabel = clean ? (entry.persistedArtifact ? "Stored" : "Geparst") : entry.extractionStatus === "missing_crop" ? "Crop fehlt" : "Review";
const nextAction = nativeNextAction(entry, promotion);
return {
id: `native:${entry.id}`,
source: "native",
title: artifact?.name || `${categoryLabel} #${entry.sequence}`,
setName: artifact?.setName || (entry.category === "artifact" ? "Unknown set" : "IK catalog-only"),
slot: artifact?.slot || (entry.category === "artifact" ? "Unknown slot" : categoryLabel),
mainStat: artifact?.mainStat || (entry.category === "artifact" ? "Unknown main stat" : "Not parsed"),
mainValue: artifact?.mainValue || "?",
levelLabel: typeof artifact?.level === "number" ? `+${artifact.level}` : "+?",
substats: artifact?.substats ?? [],
equipped: artifact?.equipped || "Not detected",
locked: artifact?.locked,
reviewDecision: entry.review?.status,
confidence: entry.extractionConfidence,
needsReview: entry.needsReview,
statusLabel,
statusClassName: clean ? entry.persistedArtifact ? "stored" : "parsed" : "review",
valueLabel: entry.valueStatus === "deferred" ? "Wert offen" : "Review",
ikLabel: entry.ikMatch ? (entry.ikMatch.matched ? "Matched" : "Review") : "Not checked",
ikGoodLabel: entry.ikMatch?.setGood && entry.ikMatch?.artifactGood
? `${entry.ikMatch.setGood} / ${entry.ikMatch.artifactGood}`
: "-",
promotionLabel: promotion?.label ?? "Ungeprueft",
promotionClassName: promotion?.className ?? "muted",
promotionReasons: promotion?.reasons ?? [],
promotionRecordId: promotion?.artifactRecordId,
canPromote: Boolean(promotion?.canPersist),
nextActionLabel: nextAction.label,
nextActionDetail: nextAction.detail,
nextActionClassName: nextAction.className,
fieldConfidences: entry.fieldConfidences ?? [],
sourceLabel: entry.category === "artifact" ? "Native Artifact" : `Native ${categoryLabel}`,
sequenceLabel: `#${entry.sequence}`,
timestamp: entry.capturedAt,
imagePath: entry.imagePath,
runDir,
scanResultId: entry.id,
notes: entry.notes,
error: entry.error,
};
}
function scanResultCategoryLabel(category: StoredScanResultEntry["category"]) {
if (category === "weapon") return "Weapon";
if (category === "character") return "Character";
if (category === "material") return "Material";
if (category === "unknown") return "Unknown";
return "Artifact";
}
function storedArtifactToInventoryRow(record: StoredArtifactRecord): InventoryBrowserRow {
const timestamp = record.lastSeenAt ?? record.firstSeenAt ?? "";
return {
id: `store:${record.id}`,
source: "store",
title: record.name,
setName: record.setName,
slot: record.slot,
mainStat: record.mainStat,
mainValue: record.mainValue,
levelLabel: typeof record.level === "number" ? `+${record.level}` : "+?",
substats: record.substats,
equipped: record.equipped || "Not detected",
locked: record.locked,
confidence: record.confidence,
needsReview: record.needsReview,
statusLabel: record.needsReview ? "Review" : (record.timesSeen ?? 1) > 1 ? "Duplicate" : "Stored",
statusClassName: record.needsReview ? "review" : (record.timesSeen ?? 1) > 1 ? "duplicate" : "stored",
valueLabel: record.needsReview ? "Review" : "Wert offen",
ikLabel: "Not checked",
ikGoodLabel: "-",
promotionLabel: "Im Store",
promotionClassName: "muted",
promotionReasons: ["Artifact is already in the local store."],
promotionRecordId: record.id,
canPromote: false,
nextActionLabel: record.needsReview ? "Review pruefen" : "Bereits im Store",
nextActionDetail: record.needsReview
? "Dieser Store-Eintrag bleibt unsicher, bis die OCR-Felder bestaetigt sind."
: "Keine Promotion noetig; Value-Auswertung kann spaeter auf Store-Daten laufen.",
nextActionClassName: record.needsReview ? "warn" : "muted",
fieldConfidences: [],
sourceLabel: "Store",
sequenceLabel: record.timesSeen ? `${record.timesSeen}x` : "1x",
timestamp,
notes: [],
};
}
function snapshotArtifactToInventoryRow(artifact: Artifact): InventoryBrowserRow {
return {
id: `snapshot:${artifact.id}`,
source: "snapshot",
title: artifact.setName,
setName: artifact.setName,
slot: artifact.slot,
mainStat: artifact.mainStat,
mainValue: "",
levelLabel: `+${artifact.level}`,
substats: artifact.substats.map((substat) => `${substat.key}+${substat.value}${substat.unit === "%" ? "%" : ""}`),
equipped: artifact.equipped || "Not detected",
confidence: artifact.confidence,
needsReview: artifact.confidence < 80,
statusLabel: artifact.confidence < 80 ? "Review" : "Snapshot",
statusClassName: artifact.confidence < 80 ? "review" : "stored",
valueLabel: "Wert offen",
ikLabel: "Not checked",
ikGoodLabel: "-",
promotionLabel: "Snapshot",
promotionClassName: "muted",
promotionReasons: ["Snapshot rows are display-only until imported into the store."],
canPromote: false,
nextActionLabel: "Nur Anzeige",
nextActionDetail: "Snapshot-Daten sind Fallback-Anzeige und kein nativer Scan-Resultat-Nachgang.",
nextActionClassName: "muted",
fieldConfidences: [],
sourceLabel: "Snapshot",
sequenceLabel: "local",
timestamp: artifact.lastSeenAt,
notes: [],
};
}
function nativeNextAction(
entry: StoredScanResultEntry,
promotion?: ScanResultPromotionDecision,
): { label: string; detail: string; className: "ok" | "warn" | "muted" } {
if (promotion?.canPersist) {
return {
label: "Bereit zur Promotion",
detail: "Parsed, IK-gematcht und vollstaendig genug. Store-Schreibzugriff bleibt ein expliziter naechster Schritt.",
className: "ok",
};
}
if (promotion?.status === "already_stored" || promotion?.status === "persisted" || entry.persistedArtifact) {
return {
label: "Bereits im Store",
detail: "Dieses Ergebnis ist Store-Evidenz und braucht keine erneute Promotion.",
className: "muted",
};
}
if (entry.needsReview || promotion?.status === "review") {
return {
label: "Review zuerst",
detail: "OCR, IK-Match oder Feldconfidence ist nicht sauber genug fuer Store-Promotion.",
className: "warn",
};
}
if (promotion?.status === "blocked") {
return {
label: "Blockiert",
detail: "Es fehlt eine geparste Artifact-Identitaet oder ein sicherer IK-Abgleich.",
className: "warn",
};
}
return {
label: entry.valueStatus === "deferred" ? "Wert spaeter auswerten" : "Pruefen",
detail: "Die Extraktion ist vom Artifact-Wert getrennt; Bewertung wird nachgelagert ergaenzt.",
className: "muted",
};
}
+3 -3
View File
@@ -69,7 +69,7 @@ describe("layoutProfile", () => {
} }
}); });
it("matches Inventory Kamera-like 1080p artifact field crops", () => { it("matches 1080p artifact field crops", () => {
const detail = profileDetailRect(HD); const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD).map((crop) => [crop.id, crop.rect])); const crops = Object.fromEntries(detailCropRects(detail, HD).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-slot"]).toEqual({ x: 1328, y: 185, width: 234, height: 40 }); expect(crops["artifact-slot"]).toEqual({ x: 1328, y: 185, width: 234, height: 40 });
@@ -78,13 +78,13 @@ describe("layoutProfile", () => {
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 193 }); expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 193 });
}); });
it("uses Inventory Kamera's tighter substat crop in the fast auto-scan profile", () => { it("uses a tighter substat crop in the fast auto-scan profile", () => {
const detail = profileDetailRect(HD); const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD, { fastProfile: true }).map((crop) => [crop.id, crop.rect])); const crops = Object.fromEntries(detailCropRects(detail, HD, { fastProfile: true }).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 154 }); expect(crops["artifact-substats"]).toEqual({ x: 1338, y: 473, width: 408, height: 154 });
}); });
it("shifts level and substat crops for sanctified artifacts like Inventory Kamera", () => { it("shifts level and substat crops for sanctified artifacts", () => {
const detail = profileDetailRect(HD); const detail = profileDetailRect(HD);
const crops = Object.fromEntries(detailCropRects(detail, HD, { sanctified: true }).map((crop) => [crop.id, crop.rect])); const crops = Object.fromEntries(detailCropRects(detail, HD, { sanctified: true }).map((crop) => [crop.id, crop.rect]));
expect(crops["artifact-level"]).toEqual({ x: 1333, y: 468, width: 70, height: 35 }); expect(crops["artifact-level"]).toEqual({ x: 1333, y: 468, width: 70, height: 35 });
+13 -13
View File
@@ -1,13 +1,13 @@
// Resolution-anchored layout geometry for the artifact inventory screen // Resolution-anchored layout geometry for the artifact inventory screen
// (ADR-009). Inventory Kamera's proven approach is to require borderless 16:9 and // (ADR-009). The auto scanner requires borderless 16:9 and derives crop/grid
// derive crop/grid coordinates from the client rectangle instead of detecting the // coordinates from the client rectangle instead of detecting the panel by colour
// panel by colour each frame. This module is the single, pure, unit-tested source // each frame. This module is the single, pure, unit-tested source of that
// of that geometry; electron/main.ts consumes it for cropping and keeps a // geometry; electron/main.ts consumes it for cropping and keeps a colour-based
// colour-based detail-rect detector only as a fallback for off-profile setups. // detail-rect detector only as a fallback for off-profile setups.
// //
// Calibrated from a 1920x1080 English artifact-inventory screenshot and scaled // Calibrated from a 1920x1080 English artifact-inventory screenshot and scaled
// by client size. This follows Inventory Kamera's stable approach: fixed // by client size: fixed 16:9-relative UI regions first, visual detection only as
// 16:9-relative UI regions first, visual detection only as a fallback. // a fallback.
export interface LayoutRect { export interface LayoutRect {
x: number; x: number;
@@ -91,9 +91,9 @@ export function profileDetailRect(imageSize: { width: number; height: number }):
} }
// OCR crops inside the detail panel, as fractions of the detail rect. These are // OCR crops inside the detail panel, as fractions of the detail rect. These are
// deliberately closer to Inventory Kamera's split-card model than the older // deliberately split-card oriented instead of using the older broad crops:
// broad crops: small fields get small OCR profiles, while the legacy parser // small fields get small OCR profiles, while the legacy parser still accepts
// still accepts old review samples with artifact-title/artifact-main-stat. // old review samples with artifact-title/artifact-main-stat.
export function detailCropRects( export function detailCropRects(
detailRect: LayoutRect, detailRect: LayoutRect,
imageSize: { width: number; height: number }, imageSize: { width: number; height: number },
@@ -222,9 +222,9 @@ export function inventoryGrid(imageSize: { width: number; height: number }, deta
const stepX = Math.round(imageSize.width * 0.076); const stepX = Math.round(imageSize.width * 0.076);
const stepY = Math.round(imageSize.height * 0.163); const stepY = Math.round(imageSize.height * 0.163);
// Inventory Kamera treats the artifact inventory as 32 safe click targets per // The artifact inventory has 32 safe click targets per full page. The
// full page. The apparent fifth row sits in the bottom control band on 16:9 // apparent fifth row sits in the bottom control band on 16:9 captures and is
// captures and is not a reliable target during automated scrolling. // not a reliable target during automated scrolling.
const visibleRows = 4; const visibleRows = 4;
const startX = Math.round(imageSize.width * 0.093); const startX = Math.round(imageSize.width * 0.093);
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import {
createStoredScanResultEntry,
deriveArtifactValueStatus,
deriveScanResultExtractionStatus,
} from "./scanResultEntry";
function parsedArtifact(overrides: Partial<ParsedArtifactCandidate> = {}): ParsedArtifactCandidate {
const field = { value: "", confidence: 92, source: "ocr" as const };
return {
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
level: 20,
mainStat: "ATK",
mainValue: "311",
substats: ["CRIT DMG+7.0%", "DEF%+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
setName: "Silken Moon's Serenade",
equipped: "Aino",
confidence: 93,
notes: [],
fields: {
name: field,
slot: field,
level: field,
mainStat: field,
mainValue: field,
setName: field,
equipped: field,
substats: field,
},
...overrides,
};
}
describe("scan result entries", () => {
it("keeps clean extraction separate from deferred artifact value evaluation", () => {
const entry = createStoredScanResultEntry({
runId: "run-1",
sequence: 1,
page: 0,
row: 1,
col: 2,
source: "native-ik-scan",
imagePath: "artifact-0001.png",
parsed: parsedArtifact(),
needsReview: false,
confidence: 93,
artifactRecordId: "artifact-id",
persistedArtifact: true,
locked: true,
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setKey: "silkenmoonsserenade",
setGood: "SilkenMoonsSerenade",
setName: "Silken Moon's Serenade",
artifactKey: "pristineplumeoftheblessed",
artifactGood: "PristinePlumeOfTheBlessed",
artifactName: "Pristine Plume of the Blessed",
slotKey: "plume",
notes: [],
},
});
expect(entry.extractionStatus).toBe("parsed");
expect(entry.needsReview).toBe(false);
expect(entry.valueStatus).toBe("deferred");
expect(entry.valueScore).toBeNull();
expect(entry.artifactRecordId).toBe("artifact-id");
expect(entry.persistedArtifact).toBe(true);
expect(entry.artifact).toMatchObject({
name: "Pristine Plume of the Blessed",
slot: "Plume of Death",
setName: "Silken Moon's Serenade",
mainStat: "ATK",
mainValue: "311",
locked: true,
});
expect(entry.ikMatch).toMatchObject({
matched: true,
source: "ik-inventorylists",
artifactGood: "PristinePlumeOfTheBlessed",
});
expect(entry.fieldConfidences).toEqual([
{ key: "name", label: "Name", value: "", confidence: 92, source: "ocr" },
{ key: "slot", label: "Slot", value: "", confidence: 92, source: "ocr" },
{ key: "level", label: "Level", value: "", confidence: 92, source: "ocr" },
{ key: "mainStat", label: "Main stat", value: "", confidence: 92, source: "ocr" },
{ key: "mainValue", label: "Main value", value: "", confidence: 92, source: "ocr" },
{ key: "setName", label: "Set", value: "", confidence: 92, source: "ocr" },
{ key: "equipped", label: "Equipped", value: "", confidence: 92, source: "ocr" },
{ key: "substats", label: "Substats", value: "", confidence: 92, source: "ocr" },
]);
});
it("turns low-confidence or incomplete parses into review results without value scores", () => {
const entry = createStoredScanResultEntry({
runId: "run-1",
sequence: 2,
source: "native-ik-scan",
imagePath: "artifact-0002.png",
parsed: parsedArtifact({
confidence: 65,
notes: ["main stat not confidently parsed"],
}),
needsReview: true,
confidence: 65,
});
expect(entry.extractionStatus).toBe("review");
expect(entry.needsReview).toBe(true);
expect(entry.valueStatus).toBe("review");
expect(entry.valueScore).toBeNull();
expect(entry.persistedArtifact).toBe(false);
});
it("keeps non-artifact categories as review-only scan evidence", () => {
const entry = createStoredScanResultEntry({
runId: "run-1",
sequence: 3,
category: "weapon",
source: "native-ik-scan",
imagePath: "weapon-0003.png",
parsed: null,
needsReview: true,
confidence: 0,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
notes: ["catalog-only"],
});
expect(entry.category).toBe("weapon");
expect(entry.extractionStatus).toBe("error");
expect(entry.valueStatus).toBe("review");
expect(entry.artifact).toBeUndefined();
expect(entry.persistedArtifact).toBe(false);
});
it("maps missing crops and parse failures to explicit extraction states", () => {
expect(deriveScanResultExtractionStatus({
parsed: false,
needsReview: true,
error: "Card crop image missing.",
})).toBe("missing_crop");
expect(deriveScanResultExtractionStatus({
parsed: false,
needsReview: true,
})).toBe("parse_error");
expect(deriveArtifactValueStatus("missing_crop")).toBe("review");
expect(deriveArtifactValueStatus("parsed")).toBe("deferred");
});
});
+130
View File
@@ -0,0 +1,130 @@
import { hashId } from "./artifactStore.js";
import type { ParsedArtifactCandidate } from "./artifactOcrParser.js";
import type {
ArtifactValueStatus,
ScanResultFieldConfidence,
ScanResultIkMatch,
ScanResultExtractionStatus,
ScanResultCategory,
StoredScanResultEntry,
} from "../types/storage.js";
export interface ScanResultEntryInput {
runId: string;
sequence: number;
page?: number;
row?: number;
col?: number;
category?: ScanResultCategory;
source: string;
imagePath: string;
parsed: ParsedArtifactCandidate | null;
needsReview: boolean;
confidence: number;
capturedAt?: string;
artifactRecordId?: string;
persistedArtifact?: boolean;
notes?: string[];
error?: string;
locked?: boolean;
ikMatch?: ScanResultIkMatch;
}
export function deriveScanResultExtractionStatus(input: {
parsed: boolean;
needsReview: boolean;
error?: string;
}): ScanResultExtractionStatus {
if (input.error === "Card crop image missing.") return "missing_crop";
if (input.error) return input.parsed ? "review" : "error";
if (!input.parsed) return "parse_error";
if (input.needsReview) return "review";
return "parsed";
}
export function deriveArtifactValueStatus(extractionStatus: ScanResultExtractionStatus): ArtifactValueStatus {
return extractionStatus === "parsed" ? "deferred" : "review";
}
export function createStoredScanResultEntry(input: ScanResultEntryInput): StoredScanResultEntry {
const category = input.category ?? "artifact";
const extractionStatus = deriveScanResultExtractionStatus({
parsed: Boolean(input.parsed),
needsReview: input.needsReview,
error: input.error,
});
const id = hashId([
input.runId,
input.sequence,
category,
input.imagePath,
input.parsed?.name ?? "",
input.parsed?.slot ?? "",
input.error ?? "",
].join("::"));
return {
id,
runId: input.runId,
sequence: input.sequence,
page: input.page,
row: input.row,
col: input.col,
category,
source: input.source,
imagePath: input.imagePath,
capturedAt: input.capturedAt ?? new Date(0).toISOString(),
extractionStatus,
extractionConfidence: Math.max(0, Math.round(input.confidence || 0)),
needsReview: extractionStatus !== "parsed",
valueStatus: deriveArtifactValueStatus(extractionStatus),
valueScore: null,
artifact: input.parsed
? {
name: input.parsed.name,
slot: input.parsed.slot,
level: input.parsed.level,
setName: input.parsed.setName,
mainStat: input.parsed.mainStat,
mainValue: input.parsed.mainValue,
substats: [...input.parsed.substats],
equipped: input.parsed.equipped,
locked: input.locked,
}
: undefined,
ikMatch: input.ikMatch,
fieldConfidences: input.parsed ? parsedFieldConfidences(input.parsed) : undefined,
artifactRecordId: input.artifactRecordId,
persistedArtifact: Boolean(input.persistedArtifact),
notes: [...(input.notes ?? input.parsed?.notes ?? [])],
error: input.error,
};
}
function parsedFieldConfidences(parsed: ParsedArtifactCandidate): ScanResultFieldConfidence[] {
return [
fieldConfidence("name", "Name", parsed.fields.name),
fieldConfidence("slot", "Slot", parsed.fields.slot),
fieldConfidence("level", "Level", parsed.fields.level),
fieldConfidence("mainStat", "Main stat", parsed.fields.mainStat),
fieldConfidence("mainValue", "Main value", parsed.fields.mainValue),
fieldConfidence("setName", "Set", parsed.fields.setName),
fieldConfidence("equipped", "Equipped", parsed.fields.equipped),
fieldConfidence("substats", "Substats", parsed.fields.substats),
].filter((entry): entry is ScanResultFieldConfidence => Boolean(entry));
}
function fieldConfidence(
key: string,
label: string,
parsedField: ParsedArtifactCandidate["fields"][keyof ParsedArtifactCandidate["fields"]] | undefined,
): ScanResultFieldConfidence | null {
if (!parsedField) return null;
return {
key,
label,
value: parsedField.value,
confidence: Math.max(0, Math.round(parsedField.confidence || 0)),
source: parsedField.source,
};
}
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import {
buildScanResultPromotionSummary,
classifyScanResultPromotion,
deriveStoreIdFromScanResult,
scanResultToStoredArtifact,
} from "./scanResultPromotion";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
function nativeEntry(overrides: Partial<StoredScanResultEntry> = {}): StoredScanResultEntry {
return {
id: "native-1",
runId: "run-1",
sequence: 1,
category: "artifact",
source: "native-ik-scan",
imagePath: "cards/artifact-0001.png",
capturedAt: "2026-07-09T10:00:00.000Z",
extractionStatus: "parsed",
extractionConfidence: 94,
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%", "Elemental Mastery+40"],
equipped: "Aino",
},
ikMatch: {
matched: true,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: [],
},
fieldConfidences: [
{ key: "name", label: "Name", value: "Pristine Plume of the Blessed", confidence: 96, source: "database" },
{ key: "slot", label: "Slot", value: "Plume of Death", confidence: 95, source: "database" },
{ key: "mainStat", label: "Main stat", value: "ATK", confidence: 92, source: "ocr" },
],
...overrides,
};
}
function storedRecord(id: string): StoredArtifactRecord {
return {
id,
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"],
equipped: "Aino",
confidence: 94,
needsReview: false,
source: "native-ik-scan",
};
}
describe("scan result promotion", () => {
it("classifies clean IK-matched native results as explicitly promotable", () => {
const entry = nativeEntry();
const decision = classifyScanResultPromotion(entry);
expect(decision).toMatchObject({
status: "ready",
label: "Speicherbar",
className: "ok",
canPersist: true,
artifactRecordId: deriveStoreIdFromScanResult(entry),
});
});
it("separates already stored artifacts from new promotable results", () => {
const entry = nativeEntry();
const storeId = deriveStoreIdFromScanResult(entry);
const summary = buildScanResultPromotionSummary([entry], [storedRecord(storeId ?? "")]);
expect(summary).toMatchObject({
total: 1,
ready: 0,
alreadyStored: 1,
persisted: 0,
review: 0,
blocked: 0,
className: "muted",
});
expect(summary.decisions[0]).toMatchObject({
status: "already_stored",
canPersist: false,
});
});
it("treats persisted scan results as read-only evidence instead of new work", () => {
const entry = nativeEntry({ artifactRecordId: "artifact-id", persistedArtifact: true });
const decision = classifyScanResultPromotion(entry, new Set(["artifact-id"]));
expect(decision).toMatchObject({
status: "persisted",
label: "Persistiert",
canPersist: false,
});
});
it("requires IK match and field confidence before promotion", () => {
const entry = nativeEntry({
ikMatch: {
matched: false,
confidence: 100,
source: "ik-inventorylists",
setGood: "SilkenMoonsSerenade",
artifactGood: "PristinePlumeOfTheBlessed",
notes: ["IK slot mismatch"],
},
fieldConfidences: [
{ key: "mainValue", label: "Main value", value: "?", confidence: 42, source: "ocr" },
],
});
const decision = classifyScanResultPromotion(entry);
expect(decision.status).toBe("review");
expect(decision.canPersist).toBe(false);
expect(decision.reasons.join(" ")).toContain("IK inventorylist match");
expect(decision.reasons.join(" ")).toContain("Low field confidence: Main value");
});
it("blocks missing or unparsed scan results", () => {
const entry = nativeEntry({
artifact: undefined,
extractionStatus: "missing_crop",
extractionConfidence: 0,
needsReview: true,
ikMatch: undefined,
fieldConfidences: undefined,
error: "Card crop image missing.",
});
const decision = classifyScanResultPromotion(entry);
expect(decision).toMatchObject({
status: "blocked",
label: "Blockiert",
canPersist: false,
});
expect(decision.reasons).toContain("No parsed artifact identity is available.");
});
it("never derives artifact store identities for non-artifact scan results", () => {
const entry = nativeEntry({
category: "weapon",
artifact: undefined,
extractionStatus: "error",
extractionConfidence: 0,
needsReview: true,
ikMatch: undefined,
fieldConfidences: undefined,
error: "Native post-capture processing for category 'weapons' is not implemented yet; IK catalog is available only.",
});
const decision = classifyScanResultPromotion(entry);
expect(deriveStoreIdFromScanResult(entry)).toBeUndefined();
expect(decision).toMatchObject({
status: "blocked",
canPersist: false,
});
expect(decision.reasons).toContain("Only artifact scan results can be promoted into the artifact store.");
});
it("converts an approved native result into the canonical store record", () => {
const entry = nativeEntry({ artifact: { ...nativeEntry().artifact!, locked: true } });
const record = scanResultToStoredArtifact(entry);
expect(record).toMatchObject({
id: deriveStoreIdFromScanResult(entry),
name: "Pristine Plume of the Blessed",
confidence: 94,
needsReview: false,
locked: true,
source: "native-ik-scan",
});
});
});
+204
View File
@@ -0,0 +1,204 @@
import { hashId } from "./artifactStore.js";
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage.js";
export type ScanResultPromotionStatus =
| "ready"
| "already_stored"
| "persisted"
| "review"
| "blocked";
export interface ScanResultPromotionDecision {
resultId: string;
sequence: number;
status: ScanResultPromotionStatus;
label: string;
className: "ok" | "warn" | "muted";
canPersist: boolean;
artifactRecordId?: string;
reasons: string[];
}
export interface ScanResultPromotionSummary {
total: number;
ready: number;
alreadyStored: number;
persisted: number;
review: number;
blocked: number;
title: string;
detail: string;
className: "ok" | "warn" | "muted";
decisions: ScanResultPromotionDecision[];
}
export function buildScanResultPromotionSummary(
nativeResults: readonly StoredScanResultEntry[] = [],
storedArtifacts: readonly StoredArtifactRecord[] = [],
): ScanResultPromotionSummary {
const storedIds = new Set(storedArtifacts.map((artifact) => artifact.id));
const decisions = nativeResults.map((entry) => classifyScanResultPromotion(entry, storedIds));
const ready = decisions.filter((decision) => decision.status === "ready").length;
const alreadyStored = decisions.filter((decision) => decision.status === "already_stored").length;
const persisted = decisions.filter((decision) => decision.status === "persisted").length;
const review = decisions.filter((decision) => decision.status === "review").length;
const blocked = decisions.filter((decision) => decision.status === "blocked").length;
const className = ready > 0 ? "ok" : review + blocked > 0 ? "warn" : "muted";
return {
total: nativeResults.length,
ready,
alreadyStored,
persisted,
review,
blocked,
title: "Native Promotion",
detail: promotionSummaryDetail({ total: nativeResults.length, ready, alreadyStored, persisted, review, blocked }),
className,
decisions,
};
}
export function classifyScanResultPromotion(
entry: StoredScanResultEntry,
storedIds: ReadonlySet<string> = new Set(),
): ScanResultPromotionDecision {
const artifactRecordId = entry.artifactRecordId || deriveStoreIdFromScanResult(entry);
const base = {
resultId: entry.id,
sequence: entry.sequence,
artifactRecordId,
};
if (entry.persistedArtifact || (entry.artifactRecordId && storedIds.has(entry.artifactRecordId))) {
return {
...base,
status: "persisted",
label: "Persistiert",
className: "muted",
canPersist: false,
reasons: ["Native result is already persisted in the artifact store."],
};
}
const blockingReasons = scanResultBlockingReasons(entry);
if (blockingReasons.length > 0) {
const hasParsedArtifact = Boolean(entry.artifact);
return {
...base,
status: hasParsedArtifact ? "review" : "blocked",
label: hasParsedArtifact ? "Review" : "Blockiert",
className: "warn",
canPersist: false,
reasons: blockingReasons,
};
}
if (artifactRecordId && storedIds.has(artifactRecordId)) {
return {
...base,
status: "already_stored",
label: "Schon im Store",
className: "muted",
canPersist: false,
reasons: ["Same artifact store identity already exists."],
};
}
return {
...base,
status: "ready",
label: "Speicherbar",
className: "ok",
canPersist: true,
reasons: ["Parsed, IK-matched, and complete enough for explicit promotion."],
};
}
export function deriveStoreIdFromScanResult(entry: StoredScanResultEntry) {
if (entry.category !== "artifact") return undefined;
const artifact = entry.artifact;
if (!artifact) return undefined;
return hashId([
artifact.name,
artifact.slot,
artifact.level,
artifact.mainStat,
artifact.mainValue,
artifact.setName,
artifact.substats.join("|"),
].join("::"));
}
export function scanResultToStoredArtifact(entry: StoredScanResultEntry): StoredArtifactRecord | null {
const artifactRecordId = deriveStoreIdFromScanResult(entry);
if (!artifactRecordId || !entry.artifact) return null;
return {
id: artifactRecordId,
name: entry.artifact.name,
slot: entry.artifact.slot,
level: entry.artifact.level,
setName: entry.artifact.setName,
mainStat: entry.artifact.mainStat,
mainValue: entry.artifact.mainValue,
substats: [...entry.artifact.substats],
equipped: entry.artifact.equipped,
confidence: entry.extractionConfidence,
needsReview: false,
locked: entry.artifact.locked,
source: "native-ik-scan",
};
}
function scanResultBlockingReasons(entry: StoredScanResultEntry) {
const reasons: string[] = [];
if (entry.category !== "artifact") reasons.push("Only artifact scan results can be promoted into the artifact store.");
if (!entry.artifact) reasons.push("No parsed artifact identity is available.");
if (entry.extractionStatus !== "parsed" || entry.needsReview) {
reasons.push(`Extraction status is ${entry.extractionStatus}.`);
}
if (entry.error) reasons.push(entry.error);
const artifact = entry.artifact;
if (artifact) {
if (artifact.name === "Unknown artifact") reasons.push("Artifact name is unknown.");
if (artifact.slot === "Unknown slot") reasons.push("Artifact slot is unknown.");
if (artifact.setName === "Unknown set") reasons.push("Artifact set is unknown.");
if (artifact.mainStat === "Unknown main stat") reasons.push("Main stat is unknown.");
if (artifact.mainValue === "?") reasons.push("Main stat value is missing.");
if (artifact.substats.length === 0) reasons.push("No substats were parsed.");
}
if (!entry.ikMatch) {
reasons.push("IK inventorylist match is missing.");
} else if (!entry.ikMatch.matched) {
reasons.push("IK inventorylist match has a set, piece, or slot conflict.");
}
const lowConfidenceFields = (entry.fieldConfidences ?? [])
.filter((field) => field.confidence < 70)
.map((field) => field.label);
if (lowConfidenceFields.length > 0) {
reasons.push(`Low field confidence: ${lowConfidenceFields.join(", ")}.`);
}
return [...new Set(reasons)];
}
function promotionSummaryDetail(input: {
total: number;
ready: number;
alreadyStored: number;
persisted: number;
review: number;
blocked: number;
}) {
if (input.total === 0) return "Keine nativen Scan-Ergebnisse geladen.";
const parts = [
`${input.ready} speicherbar`,
`${input.alreadyStored + input.persisted} im Store`,
`${input.review} Review`,
`${input.blocked} blockiert`,
];
return parts.join(" - ");
}
+2 -2
View File
@@ -71,7 +71,7 @@ export function shouldSaveReviewSample(parsed: { confidence: number; notes: stri
} }
export function shouldFlagArtifactForReview( export function shouldFlagArtifactForReview(
parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }>; substats?: string[] } | null, parsed: { confidence: number; notes: string[]; fields: Record<string, { confidence: number }>; level?: number; substats?: string[] } | null,
) { ) {
if (!parsed) return true; if (!parsed) return true;
if (reviewRelevantConfidence(parsed.fields) < 78) return true; if (reviewRelevantConfidence(parsed.fields) < 78) return true;
@@ -90,7 +90,7 @@ export function shouldFlagArtifactForReview(
// A substat value that matches no legal roll combination is a guaranteed OCR // A substat value that matches no legal roll combination is a guaranteed OCR
// misread - never store it as fact, always review. // misread - never store it as fact, always review.
if (implausibleSubstats(parsed.substats ?? []).length > 0) return true; if (implausibleSubstats(parsed.substats ?? [], (parsed.level ?? 0) > 16 ? 5 : undefined).length > 0) return true;
return false; return false;
} }
+11
View File
@@ -5,6 +5,7 @@ import {
isPlausibleSubstat, isPlausibleSubstat,
isPlausibleSubstatValue, isPlausibleSubstatValue,
parseSubstatEntry, parseSubstatEntry,
possibleSubstatRollCounts,
} from "./substatRolls"; } from "./substatRolls";
describe("substatRolls parsing", () => { describe("substatRolls parsing", () => {
@@ -16,6 +17,13 @@ describe("substatRolls parsing", () => {
}); });
describe("substatRolls plausibility", () => { describe("substatRolls plausibility", () => {
it("reports possible roll counts for artifact-level consistency checks", () => {
expect(possibleSubstatRollCounts("DEF%+11.7%")).toContain(2);
expect(possibleSubstatRollCounts("DEF%+31.7%")).toContain(6);
expect(possibleSubstatRollCounts("HP+1,165")).toContain(4);
expect(possibleSubstatRollCounts("CRIT Rate+2.3%", 5)).toEqual([]);
expect(possibleSubstatRollCounts("CRIT Rate+2.3%", 4)).toEqual([1]);
});
it("accepts real single-roll values (5-star)", () => { it("accepts real single-roll values (5-star)", () => {
expect(isPlausibleSubstatValue("CRIT DMG", 7.8)).toBe(true); // 7.77 high roll expect(isPlausibleSubstatValue("CRIT DMG", 7.8)).toBe(true); // 7.77 high roll
expect(isPlausibleSubstatValue("CRIT DMG", 5.4)).toBe(true); // 5.44 low roll expect(isPlausibleSubstatValue("CRIT DMG", 5.4)).toBe(true); // 5.44 low roll
@@ -28,6 +36,8 @@ describe("substatRolls plausibility", () => {
expect(isPlausibleSubstat("CRIT DMG+13.2%")).toBe(true); // 5.44+7.77 or 6.22+6.99 expect(isPlausibleSubstat("CRIT DMG+13.2%")).toBe(true); // 5.44+7.77 or 6.22+6.99
expect(isPlausibleSubstat("Elemental Mastery+68")).toBe(true); expect(isPlausibleSubstat("Elemental Mastery+68")).toBe(true);
expect(isPlausibleSubstat("Energy Recharge+11.7%")).toBe(true); expect(isPlausibleSubstat("Energy Recharge+11.7%")).toBe(true);
expect(isPlausibleSubstat("CRIT Rate+2.3%", 5)).toBe(false);
expect(isPlausibleSubstat("CRIT Rate+2.3%", 4)).toBe(true);
}); });
it("rejects values that fit no roll combination at either rarity (OCR misreads)", () => { it("rejects values that fit no roll combination at either rarity (OCR misreads)", () => {
@@ -47,6 +57,7 @@ describe("substatRolls plausibility", () => {
it("collects the implausible entries from a substat list", () => { it("collects the implausible entries from a substat list", () => {
const bad = implausibleSubstats(["CRIT DMG+13.2%", "CRIT DMG+8.1%", "ATK+19"]); const bad = implausibleSubstats(["CRIT DMG+13.2%", "CRIT DMG+8.1%", "ATK+19"]);
expect(bad).toEqual(["CRIT DMG+8.1%"]); expect(bad).toEqual(["CRIT DMG+8.1%"]);
expect(implausibleSubstats(["CRIT Rate+2.3%"], 5)).toEqual(["CRIT Rate+2.3%"]);
}); });
}); });
+52 -5
View File
@@ -68,6 +68,7 @@ function buildValidSet(rolls: number[], percent: boolean): Set<number> {
} }
const VALID_CACHE = new Map<string, Set<number>>(); const VALID_CACHE = new Map<string, Set<number>>();
const ROLL_COUNT_CACHE = new Map<string, Map<number, Set<number>>>();
function validSet(stat: string, table: Record<string, number[]>, tag: string): Set<number> | null { function validSet(stat: string, table: Record<string, number[]>, tag: string): Set<number> | null {
const rolls = table[stat]; const rolls = table[stat];
@@ -81,6 +82,31 @@ function validSet(stat: string, table: Record<string, number[]>, tag: string): S
return cached; return cached;
} }
function rollCountMap(stat: string, table: Record<string, number[]>, tag: string): Map<number, Set<number>> | null {
const rolls = table[stat];
if (!rolls) return null;
const key = `${tag}:${stat}`;
let cached = ROLL_COUNT_CACHE.get(key);
if (cached) return cached;
cached = new Map();
let sums = new Set<number>([0]);
for (let count = 1; count <= MAX_ROLLS; count++) {
const next = new Set<number>();
for (const sum of sums) {
for (const roll of rolls) next.add(Math.round((sum + roll) * 1000) / 1000);
}
sums = next;
for (const sum of sums) {
const displayed = displayRound(sum, isPercent(stat));
const counts = cached.get(displayed) ?? new Set<number>();
counts.add(count);
cached.set(displayed, counts);
}
}
ROLL_COUNT_CACHE.set(key, cached);
return cached;
}
export function parseSubstatEntry(entry: string): { stat: string; value: number; percent: boolean } | null { export function parseSubstatEntry(entry: string): { stat: string; value: number; percent: boolean } | null {
const plusIndex = entry.indexOf("+"); const plusIndex = entry.indexOf("+");
if (plusIndex <= 0) return null; if (plusIndex <= 0) return null;
@@ -92,24 +118,45 @@ export function parseSubstatEntry(entry: string): { stat: string; value: number;
} }
/** A displayed value is plausible if it matches a roll sum for 5-star or 4-star. */ /** A displayed value is plausible if it matches a roll sum for 5-star or 4-star. */
export function isPlausibleSubstatValue(stat: string, value: number): boolean { export function isPlausibleSubstatValue(stat: string, value: number, rarity?: 4 | 5): boolean {
const rounded = displayRound(value, isPercent(stat)); const rounded = displayRound(value, isPercent(stat));
const five = validSet(stat, ROLLS_5STAR, "5"); const five = validSet(stat, ROLLS_5STAR, "5");
const four = validSet(stat, ROLLS_4STAR, "4"); const four = validSet(stat, ROLLS_4STAR, "4");
// Unknown stat (no table) -> do not claim it is implausible. // Unknown stat (no table) -> do not claim it is implausible.
if (!five && !four) return true; if (!five && !four) return true;
if (rarity === 5) return Boolean(five?.has(rounded));
if (rarity === 4) return Boolean(four?.has(rounded));
return Boolean(five?.has(rounded)) || Boolean(four?.has(rounded)); return Boolean(five?.has(rounded)) || Boolean(four?.has(rounded));
} }
export function isPlausibleSubstat(entry: string): boolean { export function isPlausibleSubstat(entry: string, rarity?: 4 | 5): boolean {
const parsed = parseSubstatEntry(entry); const parsed = parseSubstatEntry(entry);
if (!parsed) return true; if (!parsed) return true;
return isPlausibleSubstatValue(parsed.stat, parsed.value); return isPlausibleSubstatValue(parsed.stat, parsed.value, rarity);
}
export function possibleSubstatRollCounts(entry: string, rarity?: 4 | 5): number[] {
const parsed = parseSubstatEntry(entry);
if (!parsed) return [];
const rounded = displayRound(parsed.value, isPercent(parsed.stat));
const counts = new Set<number>();
const maps = rarity === 5
? [rollCountMap(parsed.stat, ROLLS_5STAR, "5")]
: rarity === 4
? [rollCountMap(parsed.stat, ROLLS_4STAR, "4")]
: [
rollCountMap(parsed.stat, ROLLS_5STAR, "5"),
rollCountMap(parsed.stat, ROLLS_4STAR, "4"),
];
for (const map of maps) {
for (const count of map?.get(rounded) ?? []) counts.add(count);
}
return [...counts].sort((left, right) => left - right);
} }
/** The substat entries whose value fits no legal roll combination. */ /** The substat entries whose value fits no legal roll combination. */
export function implausibleSubstats(substats: readonly string[]): string[] { export function implausibleSubstats(substats: readonly string[], rarity?: 4 | 5): string[] {
return substats.filter((entry) => !isPlausibleSubstat(entry)); return substats.filter((entry) => !isPlausibleSubstat(entry, rarity));
} }
/** /**
+3
View File
@@ -1,5 +1,6 @@
import { Eye, Play, Save } from "lucide-react"; import { Eye, Play, Save } from "lucide-react";
import { BuildsView } from "../../features/builds/BuildsView"; import { BuildsView } from "../../features/builds/BuildsView";
import { InventoryView } from "../../features/inventory/InventoryView";
import { OverlayPreview, OverlaySettings } from "../../features/overlay/OverlayViews"; import { OverlayPreview, OverlaySettings } from "../../features/overlay/OverlayViews";
import { ScanView } from "../../features/scan/ScanView"; import { ScanView } from "../../features/scan/ScanView";
import { TriageView } from "../../features/triage/TriageView"; import { TriageView } from "../../features/triage/TriageView";
@@ -70,8 +71,10 @@ export function AppPageLayout({ controller }: AppPageLayoutProps) {
captureSelectedSource={captureSelectedSource} captureSelectedSource={captureSelectedSource}
bridgeReady={bridgeReady} bridgeReady={bridgeReady}
onStoredArtifactsChanged={loadStoredArtifactSnapshot} onStoredArtifactsChanged={loadStoredArtifactSnapshot}
onOpenInventory={() => setActiveView("inventory")}
/> />
)} )}
{activeView === "inventory" && <InventoryView snapshot={snapshot} />}
{activeView === "triage" && <TriageView snapshot={snapshot} />} {activeView === "triage" && <TriageView snapshot={snapshot} />}
{activeView === "builds" && <BuildsView snapshot={snapshot} />} {activeView === "builds" && <BuildsView snapshot={snapshot} />}
{activeView === "overlay" && <OverlaySettings canShowOverlay={canShowOverlay} onShowOverlay={showOverlay} />} {activeView === "overlay" && <OverlaySettings canShowOverlay={canShowOverlay} onShowOverlay={showOverlay} />}
+35 -1
View File
@@ -16,6 +16,17 @@ import type {
SaveSnapshotResult, SaveSnapshotResult,
GoodDatabase, GoodDatabase,
GoodImportFileResult, GoodImportFileResult,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
NativeScannerStartCategory,
ScannerCommand, ScannerCommand,
ScannerStatusPayload, ScannerStatusPayload,
ScannerLearningRulePayload, ScannerLearningRulePayload,
@@ -53,6 +64,17 @@ export interface AssistantBridge {
getRuntimeInfo: () => Promise<RuntimeInfo>; getRuntimeInfo: () => Promise<RuntimeInfo>;
getAutomationGuard: () => Promise<AutomationGuard>; getAutomationGuard: () => Promise<AutomationGuard>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>; publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
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>;
focusMainWindow: () => Promise<BooleanResult>; focusMainWindow: () => Promise<BooleanResult>;
focusGenshin: () => Promise<FocusGenshinResult>; focusGenshin: () => Promise<FocusGenshinResult>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>; focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
@@ -70,7 +92,8 @@ export function getAssistantBridge(): AssistantBridge | null {
const api = window.assistantApi; const api = window.assistantApi;
if (!api) return null; if (!api) return null;
const apiRecord = api as unknown as Record<string, unknown>; const apiRecord = api as unknown as Record<string, unknown>;
const canAutoScan = hasFunction(apiRecord, "clickScreen") && hasFunction(apiRecord, "scrollScreen"); const hasNativeScanner = hasFunction(apiRecord, "nativeScannerStart") && hasFunction(apiRecord, "nativeScannerStatus") && hasFunction(apiRecord, "nativeScannerStop");
const canAutoScan = hasNativeScanner || (hasFunction(apiRecord, "clickScreen") && hasFunction(apiRecord, "scrollScreen"));
const canReviewSamples = hasFunction(apiRecord, "loadReviewSamples") && hasFunction(apiRecord, "saveReviewSample"); const canReviewSamples = hasFunction(apiRecord, "loadReviewSamples") && hasFunction(apiRecord, "saveReviewSample");
const hasFocusGenshinForScanStart = hasFunction(apiRecord, "focusGenshinForScanStart"); const hasFocusGenshinForScanStart = hasFunction(apiRecord, "focusGenshinForScanStart");
@@ -96,6 +119,17 @@ export function getAssistantBridge(): AssistantBridge | null {
getRuntimeInfo: () => api.getRuntimeInfo(), getRuntimeInfo: () => api.getRuntimeInfo(),
getAutomationGuard: () => api.getAutomationGuard(), getAutomationGuard: () => api.getAutomationGuard(),
publishScannerStatus: (status) => api.publishScannerStatus(status), publishScannerStatus: (status) => api.publishScannerStatus(status),
nativeScannerDataStatus: () => api.nativeScannerDataStatus(),
nativeScannerCatalog: () => api.nativeScannerCatalog(),
nativeScannerPreflight: (options) => api.nativeScannerPreflight(options),
nativeScannerStart: (options) => api.nativeScannerStart(options),
nativeScannerStop: () => api.nativeScannerStop(),
nativeScannerStatus: () => api.nativeScannerStatus(),
nativeScannerProcessRun: (options) => api.nativeScannerProcessRun(options),
nativeScannerLoadResults: (options) => api.nativeScannerLoadResults(options),
nativeScannerPromoteResults: (options) => api.nativeScannerPromoteResults(options),
nativeScannerReviewResult: (options) => api.nativeScannerReviewResult(options),
nativeScannerLoadImage: (options) => api.nativeScannerLoadImage(options),
focusMainWindow: () => api.focusMainWindow(), focusMainWindow: () => api.focusMainWindow(),
focusGenshin: () => api.focusGenshin(), focusGenshin: () => api.focusGenshin(),
focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()), focusGenshinForScanStart: () => (hasFocusGenshinForScanStart ? api.focusGenshinForScanStart() : api.focusGenshin()),
+818
View File
@@ -2072,6 +2072,804 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
line-height: 1.5; line-height: 1.5;
} }
.result-empty.compact {
font-size: 12px;
}
.scanner-result-rail {
display: grid;
gap: 8px;
min-width: 0;
}
.result-rail-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.result-rail-heading span {
display: inline-grid;
min-width: 26px;
height: 22px;
place-items: center;
border: 1px solid rgba(126, 231, 242, 0.24);
border-radius: 999px;
color: var(--cyan);
font-size: 11px;
font-weight: 900;
}
.result-rail-list {
display: grid;
gap: 6px;
max-height: 198px;
overflow: auto;
padding-right: 2px;
}
.result-rail-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) minmax(54px, auto) minmax(68px, auto);
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid rgba(188, 154, 255, 0.12);
border-radius: 8px;
background: rgba(8, 6, 18, 0.3);
color: inherit;
padding: 7px 8px;
text-align: left;
}
.result-rail-row:disabled {
cursor: default;
opacity: 0.72;
}
.result-rail-index {
color: var(--text-muted);
font-size: 11px;
font-weight: 900;
}
.result-rail-main {
display: grid;
min-width: 0;
gap: 2px;
}
.result-rail-main strong,
.result-rail-main span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-rail-main strong {
color: #f4efff;
font-size: 12px;
}
.result-rail-main span {
color: var(--text-soft);
font-size: 11px;
}
.result-rail-score {
color: var(--text-soft);
font-size: 11px;
font-weight: 900;
text-align: right;
white-space: nowrap;
}
.result-rail-status {
display: inline-grid;
min-height: 24px;
place-items: center;
border: 1px solid rgba(126, 231, 242, 0.24);
border-radius: 999px;
color: var(--cyan);
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
white-space: nowrap;
}
.result-rail-status.review {
border-color: rgba(255, 196, 120, 0.35);
color: var(--amber);
}
.result-rail-status.duplicate {
border-color: rgba(188, 154, 255, 0.32);
color: #d8c6ff;
}
.result-rail-status.stored {
border-color: rgba(116, 241, 188, 0.3);
color: #8ff6c9;
}
.result-rail-status.parsed {
border-color: rgba(126, 231, 242, 0.28);
color: var(--cyan);
}
.inventory-view {
display: grid;
gap: 14px;
min-height: 0;
}
.inventory-toolbar,
.inventory-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.inventory-toolbar h2 {
margin: 2px 0 0;
font-size: 22px;
}
.inventory-toolbar-actions,
.inventory-stats,
.segmented-control {
display: flex;
align-items: center;
gap: 8px;
}
.inventory-stats span,
.inventory-status {
color: var(--text-soft);
font-size: 12px;
}
.inventory-catalog-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 58px;
border: 1px solid rgba(126, 231, 242, 0.18);
border-radius: 8px;
background: rgba(126, 231, 242, 0.06);
padding: 10px 12px;
}
.inventory-catalog-strip.warn {
border-color: rgba(255, 196, 120, 0.26);
background: rgba(255, 196, 120, 0.07);
}
.inventory-catalog-strip > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.inventory-catalog-strip span,
.inventory-catalog-strip small {
color: var(--text-soft);
font-size: 11px;
}
.inventory-catalog-strip span {
font-weight: 900;
text-transform: uppercase;
}
.inventory-catalog-strip strong {
min-width: 0;
overflow: hidden;
color: #f4efff;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-catalog-strip.ok > div:first-child > strong {
color: var(--cyan);
}
.inventory-catalog-counts {
display: grid;
grid-template-columns: repeat(3, minmax(88px, auto));
gap: 8px;
}
.inventory-catalog-counts span {
display: grid;
min-width: 0;
gap: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
padding: 7px 9px;
text-transform: none;
}
.inventory-catalog-counts strong {
color: #f4efff;
font-size: 14px;
}
.inventory-catalog-counts small,
.inventory-catalog-counts em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-catalog-counts em {
color: var(--text-soft);
font-size: 10px;
font-style: normal;
text-transform: none;
}
.inventory-promotion-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 54px;
border: 1px solid rgba(116, 241, 188, 0.2);
border-radius: 8px;
background: rgba(116, 241, 188, 0.055);
padding: 10px 12px;
}
.inventory-promotion-strip.warn {
border-color: rgba(255, 196, 120, 0.26);
background: rgba(255, 196, 120, 0.065);
}
.inventory-promotion-strip.muted {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.025);
}
.inventory-promotion-strip > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.inventory-promotion-strip span,
.inventory-promotion-strip small {
color: var(--text-soft);
font-size: 11px;
}
.inventory-promotion-strip span {
font-weight: 900;
text-transform: uppercase;
}
.inventory-promotion-strip strong {
min-width: 0;
overflow: hidden;
color: #f4efff;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-promotion-strip.ok > div:first-child > strong {
color: #9df0d4;
}
.inventory-promotion-counts {
display: grid;
grid-template-columns: repeat(4, minmax(76px, auto));
gap: 8px;
}
.inventory-promotion-counts span {
display: grid;
min-width: 0;
gap: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
padding: 7px 9px;
text-transform: none;
}
.inventory-promotion-counts strong {
color: #f4efff;
font-size: 14px;
}
.inventory-pipeline-strip,
.inventory-capability-strip {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.inventory-pipeline-strip span,
.inventory-capability-strip span {
display: grid;
min-width: 0;
gap: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(8, 6, 18, 0.28);
padding: 8px 10px;
}
.inventory-pipeline-strip span.ok,
.inventory-capability-strip span.ok {
border-color: rgba(116, 241, 188, 0.28);
background: rgba(116, 241, 188, 0.06);
}
.inventory-pipeline-strip span.warn,
.inventory-capability-strip span.warn {
border-color: rgba(255, 196, 120, 0.3);
background: rgba(255, 196, 120, 0.065);
}
.inventory-pipeline-strip span.muted,
.inventory-capability-strip span.muted {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.025);
}
.inventory-pipeline-strip strong,
.inventory-pipeline-strip small,
.inventory-pipeline-strip em,
.inventory-capability-strip strong,
.inventory-capability-strip small,
.inventory-capability-strip em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-pipeline-strip strong,
.inventory-capability-strip strong {
color: #f4efff;
font-size: 12px;
}
.inventory-pipeline-strip small,
.inventory-capability-strip small {
color: var(--text-soft);
font-size: 11px;
}
.inventory-pipeline-strip em,
.inventory-capability-strip em {
color: var(--text-muted);
font-size: 10px;
font-style: normal;
font-weight: 900;
text-transform: uppercase;
}
.inventory-pipeline-strip em {
text-transform: none;
}
.inventory-pipeline-strip span.ok em,
.inventory-capability-strip span.ok em {
color: #9df0d4;
}
.inventory-pipeline-caption {
margin: -8px 0 0;
color: var(--text-soft);
font-size: 11px;
line-height: 1.4;
}
.icon-button {
width: 36px;
min-width: 36px;
padding: 0;
}
.segmented-control {
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
}
.segmented-control button {
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-soft);
font: inherit;
font-size: 12px;
font-weight: 800;
padding: 7px 10px;
}
.segmented-control button.active {
background: rgba(126, 231, 242, 0.12);
color: var(--cyan);
}
.segmented-control button span {
color: var(--text-muted);
margin-left: 4px;
}
.inventory-sort {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-soft);
font-size: 12px;
font-weight: 800;
}
.inventory-sort select {
min-height: 34px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(12, 10, 24, 0.92);
color: var(--text);
padding: 0 10px;
}
.inventory-browser {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(320px, 0.72fr);
gap: 12px;
min-height: 0;
}
.inventory-list,
.inventory-detail {
min-height: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(8, 6, 18, 0.26);
}
.inventory-list {
display: grid;
align-content: start;
gap: 6px;
max-height: min(58vh, 560px);
overflow: auto;
padding: 8px;
}
.inventory-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 64px 92px;
align-items: center;
gap: 10px;
min-height: 48px;
width: 100%;
border: 1px solid rgba(188, 154, 255, 0.11);
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
color: inherit;
padding: 8px 10px;
text-align: left;
}
.inventory-row.active {
border-color: rgba(126, 231, 242, 0.36);
background: rgba(126, 231, 242, 0.08);
}
.inventory-row-sequence,
.inventory-row-source {
color: var(--text-muted);
font-size: 11px;
font-weight: 900;
}
.inventory-row-main {
display: grid;
min-width: 0;
gap: 2px;
}
.inventory-row-main strong,
.inventory-row-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-row-main strong {
font-size: 13px;
}
.inventory-row-main span {
color: var(--text-soft);
font-size: 11px;
}
.inventory-detail {
display: grid;
align-content: start;
gap: 12px;
padding: 14px;
}
.inventory-detail-heading {
display: grid;
gap: 5px;
}
.inventory-detail-heading h3 {
margin: 0;
font-size: 18px;
}
.inventory-action-card {
display: grid;
gap: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
padding: 10px 12px;
}
.inventory-action-card.ok {
border-color: rgba(116, 241, 188, 0.28);
background: rgba(116, 241, 188, 0.055);
}
.inventory-action-card.warn {
border-color: rgba(255, 196, 120, 0.32);
background: rgba(255, 196, 120, 0.06);
}
.inventory-action-card span,
.inventory-action-card small {
color: var(--text-soft);
font-size: 11px;
}
.inventory-action-card span {
font-weight: 900;
text-transform: uppercase;
}
.inventory-action-card strong {
color: #f4efff;
font-size: 13px;
}
.inventory-promote-button {
justify-self: start;
margin-top: 7px;
}
.inventory-promotion-confirm {
display: grid;
gap: 8px;
margin-top: 7px;
padding-top: 8px;
border-top: 1px solid rgba(116, 241, 188, 0.18);
}
.inventory-promotion-confirm > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inventory-review-editor {
display: grid;
gap: 10px;
border: 1px solid rgba(255, 196, 120, 0.28);
border-radius: 12px;
background: rgba(255, 196, 120, 0.055);
padding: 12px;
}
.inventory-review-heading,
.inventory-review-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.inventory-review-heading > div {
display: grid;
gap: 2px;
}
.inventory-review-heading span,
.inventory-review-grid label > span {
color: var(--text-soft);
font-size: 11px;
}
.inventory-review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.inventory-review-grid label {
display: grid;
gap: 4px;
}
.inventory-review-grid input,
.inventory-review-grid select,
.inventory-review-grid textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(8, 6, 18, 0.68);
color: var(--text);
padding: 7px 9px;
font: inherit;
}
.inventory-review-wide {
grid-column: 1 / -1;
}
.inventory-review-grid .inventory-review-lock {
display: flex;
align-items: center;
align-self: end;
gap: 7px;
min-height: 34px;
}
.inventory-review-grid .inventory-review-lock input {
width: auto;
}
.inventory-review-actions {
justify-content: flex-start;
}
.inventory-preview {
display: grid;
min-height: 156px;
max-height: 220px;
place-items: center;
overflow: hidden;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px;
background:
radial-gradient(circle at 50% 20%, rgba(126, 231, 242, 0.08), transparent 44%),
rgba(5, 4, 12, 0.66);
}
.inventory-preview img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 220px;
object-fit: contain;
}
.inventory-preview.is-empty {
padding: 16px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.inventory-preview-status {
margin: -4px 0 0;
color: var(--text-soft);
font-size: 11px;
}
.inventory-detail-grid {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px 12px;
font-size: 12px;
}
.inventory-detail-grid span {
color: var(--text-muted);
}
.inventory-detail-grid strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-substats,
.inventory-notes {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.inventory-field-confidence {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.inventory-field-confidence span {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 4px 6px;
align-items: center;
border: 1px solid rgba(188, 154, 255, 0.14);
border-radius: 8px;
background: rgba(8, 6, 18, 0.34);
padding: 7px 8px;
}
.inventory-field-confidence strong {
color: var(--text-muted);
font-size: 12px;
}
.inventory-field-confidence small {
min-width: 0;
overflow: hidden;
color: var(--text-soft);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.inventory-field-confidence .high strong {
color: #9df0d4;
}
.inventory-field-confidence .medium strong {
color: var(--gold);
}
.inventory-field-confidence .low {
border-color: rgba(255, 140, 157, 0.3);
}
.inventory-field-confidence .low strong {
color: var(--danger);
}
.inventory-substats span,
.inventory-notes span,
.inventory-notes strong {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
color: var(--text-soft);
font-size: 11px;
padding: 5px 8px;
}
.inventory-notes strong {
border-color: rgba(255, 196, 120, 0.35);
color: var(--amber);
}
.inventory-empty {
color: var(--text-muted);
margin: 0;
padding: 14px;
}
.scanner-result-brief { .scanner-result-brief {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2324,6 +3122,26 @@ button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="t
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.inventory-catalog-strip,
.inventory-promotion-strip {
align-items: stretch;
flex-direction: column;
}
.inventory-catalog-counts,
.inventory-promotion-counts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inventory-pipeline-strip,
.inventory-capability-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inventory-browser {
grid-template-columns: 1fr;
}
.scanner-main-grid { .scanner-main-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+250 -3
View File
@@ -1,12 +1,12 @@
import type { AppSnapshot } from "./domain"; import type { AppSnapshot } from "./domain";
import type { StoredArtifactRecord } from "./storage"; import type { ScanResultCategory, ScanResultIkMatch, StoredArtifactRecord, StoredScanResultEntry } from "./storage";
export interface CaptureOptions { export interface CaptureOptions {
skipOcr?: boolean; skipOcr?: boolean;
skipOcrUnlessArtifactDetail?: boolean; skipOcrUnlessArtifactDetail?: boolean;
ocrMode?: "full" | "artifact"; ocrMode?: "full" | "artifact";
ocrProfile?: "full" | "fast"; ocrProfile?: "full" | "fast";
ocrEngine?: "current" | "native-tesseract" | "benchmark" | "ik-traineddata"; ocrEngine?: "current";
scanEntryMode?: "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry"; scanEntryMode?: "visible-inventory" | "direct-inventory" | "paimon-menu" | "auto-entry";
omitFullFrame?: boolean; omitFullFrame?: boolean;
omitDetailPreview?: boolean; omitDetailPreview?: boolean;
@@ -198,7 +198,6 @@ export type ScannerCommand =
type: "start-auto"; type: "start-auto";
scanLimit?: number; scanLimit?: number;
scanEntryMode?: CaptureOptions["scanEntryMode"]; scanEntryMode?: CaptureOptions["scanEntryMode"];
ocrEngine?: CaptureOptions["ocrEngine"];
}; };
export interface ScannerLearningRulePayload { export interface ScannerLearningRulePayload {
@@ -224,6 +223,237 @@ export interface ScannerLearningRulePayload {
}>; }>;
} }
export interface NativeScannerDataStatus {
directory: string;
version: string;
artifactSets: number;
artifactPieces: number;
weapons: number;
characters: number;
materials: number;
totalEntries: number;
categories: {
artifacts: NativeScannerArtifactCategoryStatus;
weapons: NativeScannerSimpleCategoryStatus;
characters: NativeScannerSimpleCategoryStatus;
materials: NativeScannerSimpleCategoryStatus;
};
missing: string[];
valid: boolean;
source: string;
}
export type NativeScannerCategoryScanStatus = "native_capture" | "catalog_only" | "missing_data";
export type NativeScannerStartCategory = "artifacts" | "weapons" | "characters" | "materials";
export interface NativeScannerCategoryStatusBase {
file: string;
supported: boolean;
catalogAvailable?: boolean;
nativeCaptureSupported?: boolean;
scanStatus?: NativeScannerCategoryScanStatus;
}
export interface NativeScannerArtifactCategoryStatus extends NativeScannerCategoryStatusBase {
setCount: number;
pieceCount: number;
}
export interface NativeScannerSimpleCategoryStatus extends NativeScannerCategoryStatusBase {
count: number;
}
export interface NativeScannerGridStatus {
rows: number;
cols: number;
count: number;
anchorX: number;
anchorY: number;
first?: { x: number; y: number; row: number; col: number };
last?: { x: number; y: number; row: number; col: number };
}
export interface NativeScannerCatalogStatus {
data: NativeScannerDataStatus;
artifacts: Array<{
normalizedName: string;
setName: string;
good: string;
pieces: Array<{ slot: string; artifactName: string; good: string; normalizedName: string }>;
}>;
weapons: Array<{ normalizedName: string; good: string }>;
characters: Array<{
normalizedName: string;
good: string;
element: string;
weaponType: number;
constellationName: string;
}>;
materials: Array<{ normalizedName: string; good: string }>;
}
export interface NativeScannerRunStatus {
running: boolean;
status: "idle" | "running" | "done" | "blocked" | "stopped" | string;
runId: string;
category?: NativeScannerStartCategory | string;
target: number;
captured: number;
queued: number;
clicked: number;
pages: number;
activeMs: number;
message: string;
outputRoot: string;
runDir: string;
manifestPath: string;
jobsPath: string;
statusPath: string;
dataVersion: string;
lastArtifactPath: string;
supportedCategories?: NativeScannerDataStatus["categories"];
lastJob?: {
sequence: number;
category?: NativeScannerStartCategory | string;
page: number;
row: number;
col: number;
clientX: number;
clientY: number;
screenX: number;
screenY: number;
clickEventsSent: number;
relativePath: string;
absolutePath: string;
capturedAt: string;
detail: { width: number; height: number };
kind: string;
downstream: string;
};
}
export interface NativeScannerPreflightStatus {
data: NativeScannerDataStatus;
category?: NativeScannerStartCategory | string;
supportedCategories?: NativeScannerDataStatus["categories"];
categoryReady?: boolean;
genshinFound: boolean;
bounds: { left: number; top: number; width: number; height: number } | null;
isSixteenNine: boolean;
grid: NativeScannerGridStatus;
visual?: {
ready: boolean;
samples: number;
whitePct: number;
darkPct: number;
colorPct: number;
lumaStdDev: number;
blockReason?: string;
};
ready: boolean;
blockReason?: string;
}
export interface NativeScannerProcessStatus {
ok: boolean;
runDir: string;
jobsPath: string;
reportPath: string;
scanResultsPath: string;
processed: number;
parsed: number;
review: number;
stored: number;
errors: number;
elapsedMs: number;
queueConcurrency: number;
persisted: boolean;
error?: string;
results: Array<{
sequence: number;
category?: ScanResultCategory | string;
page?: number;
row?: number;
col?: number;
imagePath: string;
parsed: boolean;
artifactName?: string;
setName?: string;
slot?: string;
confidence: number;
needsReview: boolean;
persisted?: boolean;
ikMatch?: ScanResultIkMatch;
notes?: string[];
ocr: OcrResult[];
error?: string;
}>;
}
export interface NativeScannerResultsLoadStatus {
ok: boolean;
runDir: string;
path: string;
total: number;
results: StoredScanResultEntry[];
error?: string;
}
export interface NativeScannerPromotionStatus {
ok: boolean;
runDir: string;
logPath: string;
requested: number;
selected: number;
promoted: number;
alreadyStored: number;
review: number;
blocked: number;
added: number;
updated: number;
total: number;
promotedResultIds: string[];
error?: string;
}
export interface NativeScannerReviewArtifactInput {
name: string;
slot: string;
level: number;
setName: string;
mainStat: string;
mainValue: string;
substats: string[];
equipped: string;
locked?: boolean;
}
export interface NativeScannerReviewStatus {
ok: boolean;
runDir: string;
logPath: string;
resultId: string;
action: "approve" | "reject";
evalSampleSaved: boolean;
correctedFields: string[];
error?: string;
}
export interface NativeScannerImageLoadStatus {
ok: boolean;
runDir: string;
path: string;
dataUrl: string;
width: number;
height: number;
error?: string;
}
export interface NativeScannerResult {
ok: boolean;
scanner: NativeScannerRunStatus | NativeScannerPreflightStatus | NativeScannerDataStatus | NativeScannerCatalogStatus | NativeScannerProcessStatus | NativeScannerResultsLoadStatus | NativeScannerImageLoadStatus;
}
export interface LoadScannerLearningRulesResult { export interface LoadScannerLearningRulesResult {
ok: boolean; ok: boolean;
path: string; path: string;
@@ -429,6 +659,23 @@ declare global {
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>; exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
importGoodFile: () => Promise<GoodImportFileResult>; importGoodFile: () => Promise<GoodImportFileResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>; publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
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>;
showOverlay: () => Promise<BooleanResult>; showOverlay: () => Promise<BooleanResult>;
hideOverlay: () => Promise<BooleanResult>; hideOverlay: () => Promise<BooleanResult>;
onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void; onScannerCommand: (callback: (command: ScannerCommand) => void) => () => void;
+78
View File
@@ -16,3 +16,81 @@ export interface StoredArtifactRecord {
lastSeenAt?: string; lastSeenAt?: string;
timesSeen?: number; timesSeen?: number;
} }
export type ScanResultCategory = "artifact" | "weapon" | "character" | "material" | "unknown";
export type ScanResultExtractionStatus =
| "parsed"
| "review"
| "missing_crop"
| "parse_error"
| "error";
export type ArtifactValueStatus =
| "deferred"
| "review";
export interface ScanResultArtifactIdentity {
name: string;
slot: string;
level?: number;
setName: string;
mainStat: string;
mainValue: string;
substats: string[];
equipped: string;
locked?: boolean;
}
export interface ScanResultIkMatch {
matched: boolean;
confidence: number;
source: "ik-inventorylists";
setKey?: string;
setGood?: string;
setName?: string;
artifactKey?: string;
artifactGood?: string;
artifactName?: string;
slotKey?: string;
notes: string[];
}
export interface ScanResultFieldConfidence {
key: string;
label: string;
value: string;
confidence: number;
source: "ocr" | "database" | "derived" | "fallback" | "missing";
}
export interface StoredScanResultEntry {
id: string;
runId: string;
sequence: number;
page?: number;
row?: number;
col?: number;
category: ScanResultCategory;
source: string;
imagePath: string;
capturedAt: string;
extractionStatus: ScanResultExtractionStatus;
extractionConfidence: number;
needsReview: boolean;
valueStatus: ArtifactValueStatus;
valueScore: number | null;
artifact?: ScanResultArtifactIdentity;
ikMatch?: ScanResultIkMatch;
fieldConfidences?: ScanResultFieldConfidence[];
artifactRecordId?: string;
persistedArtifact: boolean;
notes: string[];
error?: string;
review?: {
status: "approved" | "rejected";
reviewedAt: string;
note?: string;
correctedFields: string[];
};
}