From 639b0b7f590000dc4507cfeadf27fafee685b1a3 Mon Sep 17 00:00:00 2001 From: AzuTear Date: Thu, 9 Jul 2026 23:30:42 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + README.md | 19 +- data/ik-inventorylists/SOURCE.md | 20 + data/ik-inventorylists/artifacts.json | 1874 +++++++++++++++++ data/ik-inventorylists/characters.json | 1677 +++++++++++++++ data/ik-inventorylists/materials.json | 717 +++++++ data/ik-inventorylists/version.txt | 1 + data/ik-inventorylists/weapons.json | 249 +++ docs/ARCHITECTURE.md | 117 +- docs/AUTOMATION_LIVE_SCAN.md | 568 +---- docs/CHECKLISTS.md | 26 +- docs/CURRENT_STATUS.md | 139 ++ docs/DECISIONS.md | 104 +- docs/MERGE_READINESS.md | 19 +- docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md | 84 + docs/PROJECT.md | 118 +- docs/ocr-eval.md | 18 +- docs/scanner-ik-progress-report.md | 387 ---- docs/scanner-results-inventory-roadmap.md | 115 +- docs/scanner-rework-status.md | 437 ++-- electron/bootstrap/ipcBootstrap.ts | 33 + electron/devControlServer.ts | 136 +- electron/ipc/appHandlers.ts | 44 + electron/main.ts | 400 +++- electron/preload.cjs | 11 + electron/preload.ts | 13 +- electron/services/inputHelper.ts | 49 + .../services/inputHelperPowerShellFallback.ts | 21 +- .../nativeScannerProcessingService.ts | 496 +++++ .../nativeScannerResultWorkflowService.ts | 357 ++++ native/input-helper/IkInventoryLists.cs | 274 +++ native/input-helper/NativeScannerFiles.cs | 24 + native/input-helper/Program.cs | 756 ++++++- package.json | 24 +- scripts/live-soak.ps1 | 160 +- scripts/native-live-smoke.ps1 | 265 +++ scripts/validate-scan-assessment.cjs | 18 +- src/eval/corpus/confirmedReviewCorpus.ts | 90 +- .../nativeScannerProcessingService.test.ts | 613 ++++++ .../scanAssessmentValidatorScript.test.ts | 153 +- src/features/inventory/InventoryView.tsx | 267 +++ .../inventory/hooks/useInventoryViewModel.ts | 353 ++++ src/features/inventory/types.ts | 5 + src/features/layout/navigation.ts | 4 +- src/features/layout/types.ts | 2 +- .../scan/components/DiagnosticsView.tsx | 4 +- .../scan/components/ScanMainSection.tsx | 39 + .../scan/components/ScanViewLayout.tsx | 6 + .../hooks/useScanMainSectionModel.ts | 84 + .../hooks/useScanTopControlsModel.ts | 2 +- src/features/scan/components/types.ts | 4 + .../scan/hooks/scanViewEntryActions.ts | 2 +- .../scan/hooks/scanViewScanActions.ts | 9 +- .../scan/hooks/useScanCommandListener.ts | 11 +- .../scan/hooks/useScanSnapshotPublisher.ts | 2 +- src/features/scan/hooks/useScanViewActions.ts | 210 +- .../scan/hooks/useScanViewController.ts | 8 + .../scan/hooks/useScanViewStateSync.ts | 19 +- src/features/scan/types.ts | 7 +- .../rendererBridgeRepositoryFactory.ts | 187 ++ .../rendererBridgeRepositoryTypes.ts | 22 + src/lib/artifactOcrParser.test.ts | 23 +- src/lib/artifactOcrParser.ts | 123 +- src/lib/autoScanEntry.test.ts | 12 +- src/lib/autoScanEntry.ts | 8 +- src/lib/autoScanLoop.test.ts | 5 +- src/lib/autoScanLoop.ts | 11 +- src/lib/automationPlanner.test.ts | 2 +- src/lib/automationPlanner.ts | 7 +- src/lib/goodInterop.ts | 4 +- src/lib/ikArtifactMatcher.test.ts | 90 + src/lib/ikArtifactMatcher.ts | 110 + src/lib/ikCatalogMatcher.test.ts | 56 + src/lib/ikCatalogMatcher.ts | 72 + src/lib/ikScanCapabilities.test.ts | 96 + src/lib/ikScanCapabilities.ts | 100 + src/lib/inventoryBrowser.test.ts | 345 +++ src/lib/inventoryBrowser.ts | 401 ++++ src/lib/layoutProfile.test.ts | 6 +- src/lib/layoutProfile.ts | 26 +- src/lib/scanResultEntry.test.ts | 153 ++ src/lib/scanResultEntry.ts | 130 ++ src/lib/scanResultPromotion.test.ts | 191 ++ src/lib/scanResultPromotion.ts | 204 ++ src/lib/scannerLearning.ts | 4 +- src/lib/substatRolls.test.ts | 11 + src/lib/substatRolls.ts | 57 +- src/pages/app/AppPageLayout.tsx | 3 + src/services/assistantBridge.ts | 36 +- src/styles/base.css | 818 +++++++ src/types/global.d.ts | 253 ++- src/types/storage.ts | 78 + 92 files changed, 13606 insertions(+), 1703 deletions(-) create mode 100644 data/ik-inventorylists/SOURCE.md create mode 100644 data/ik-inventorylists/artifacts.json create mode 100644 data/ik-inventorylists/characters.json create mode 100644 data/ik-inventorylists/materials.json create mode 100644 data/ik-inventorylists/version.txt create mode 100644 data/ik-inventorylists/weapons.json create mode 100644 docs/CURRENT_STATUS.md create mode 100644 docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md delete mode 100644 docs/scanner-ik-progress-report.md create mode 100644 electron/services/nativeScannerProcessingService.ts create mode 100644 electron/services/nativeScannerResultWorkflowService.ts create mode 100644 native/input-helper/IkInventoryLists.cs create mode 100644 native/input-helper/NativeScannerFiles.cs create mode 100644 scripts/native-live-smoke.ps1 create mode 100644 src/eval/nativeScannerProcessingService.test.ts create mode 100644 src/features/inventory/InventoryView.tsx create mode 100644 src/features/inventory/hooks/useInventoryViewModel.ts create mode 100644 src/features/inventory/types.ts create mode 100644 src/lib/ikArtifactMatcher.test.ts create mode 100644 src/lib/ikArtifactMatcher.ts create mode 100644 src/lib/ikCatalogMatcher.test.ts create mode 100644 src/lib/ikCatalogMatcher.ts create mode 100644 src/lib/ikScanCapabilities.test.ts create mode 100644 src/lib/ikScanCapabilities.ts create mode 100644 src/lib/inventoryBrowser.test.ts create mode 100644 src/lib/inventoryBrowser.ts create mode 100644 src/lib/scanResultEntry.test.ts create mode 100644 src/lib/scanResultEntry.ts create mode 100644 src/lib/scanResultPromotion.test.ts create mode 100644 src/lib/scanResultPromotion.ts diff --git a/.gitignore b/.gitignore index 9378661..3a33b56 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ outputs/dist/ outputs/admin-start/ outputs/live-capture/ outputs/live-soak/ +outputs/native-live-smoke/ outputs/review-eval-candidates/ # Logs diff --git a/README.md b/README.md index af7da9a..479a1df 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,24 @@ # Genshin Artifact Assistant -Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on Inventory Kamera or Genshin Optimizer as the main workflow. +Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on external scanner or optimizer tools as the main workflow. ## Current MVP -- Electron + React + TypeScript app shell. -- Dark purple fintech/glassmorphism UI direction. -- Genshin capture source discovery. -- Smart Capture that focuses Genshin, hides the app, captures the primary screen, and restores the app. -- Artifact detail-panel crop detection with focused OCR regions. -- OCR parser for artifact name, slot, main stat, substats, set, equipped character, confidence, and review notes. -- Demo triage and build suggestion views. -- Read-only overlay preview shell. +- Electron + React + TypeScript app shell with a C# sidecar for fast read-only scan input/capture. +- Artifact-first scanner scope. Weapons, materials, and character details are intentionally not active scanner features yet. +- Smart Capture for the currently visible artifact detail view with focused OCR crops, parser confidence, and review notes. +- Visible-inventory auto-scan baseline with read-only tile selection, detail verification, OCR, parse, store/review, scroll, and summary. +- Native IK-style artifact capture path that writes card crops and run artifacts for post-capture processing. +- Vendored Inventory Kamera `inventorylists` under `data/ik-inventorylists` as artifact reference data. +- Scan result rail plus Inventory view for native results, stored artifacts, crop previews, IK/GOOD match state, explicit promotion, single-result review/edit/approve, and Artifact-only pipeline state. +- GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and read-only overlay preview shell. ## Documentation This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app: - [AGENTS.md](AGENTS.md) +- [docs/CURRENT_STATUS.md](docs/CURRENT_STATUS.md) - [docs/PROJECT.md](docs/PROJECT.md) - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - [docs/CONVENTIONS.md](docs/CONVENTIONS.md) diff --git a/data/ik-inventorylists/SOURCE.md b/data/ik-inventorylists/SOURCE.md new file mode 100644 index 0000000..6c247be --- /dev/null +++ b/data/ik-inventorylists/SOURCE.md @@ -0,0 +1,20 @@ +# IK inventorylists + +Quelle: lokales Inventory-Kamera-1.4.4-Paket, Unterordner `inventorylists` + +Kopiert am: 2026-07-09 + +Version laut `version.txt`: `6.7.0` + +Geladene Kategorien laut Helper: + +- artifacts: `61` Sets / `289` Pieces +- weapons: `247` +- characters: `119` +- materials: `715` +- totalEntries: `1370` + +Die Dateien wurden 1:1 uebernommen, damit der native Scanner dieselben Listen +fuer Artefakte, Waffen, Charaktere und Materialien wie Inventory Kamera nutzen +kann. Die App soll diese Daten nur laden und abgleichen, nicht manuell +nachmodellieren. diff --git a/data/ik-inventorylists/artifacts.json b/data/ik-inventorylists/artifacts.json new file mode 100644 index 0000000..6634ae4 --- /dev/null +++ b/data/ik-inventorylists/artifacts.json @@ -0,0 +1,1874 @@ +{ + "adaycarvedfromrisingwinds": { + "setName": "A Day Carved From Rising Winds", + "GOOD": "ADayCarvedFromRisingWinds", + "normalizedName": "adaycarvedfromrisingwinds", + "artifacts": { + "goblet": { + "artifactName": "Heldenepos's Unspoken Tale", + "GOOD": "HeldenepossUnspokenTale", + "normalizedName": "heldenepossunspokentale" + }, + "plume": { + "artifactName": "Dawn's Brilliant Oath", + "GOOD": "DawnsBrilliantOath", + "normalizedName": "dawnsbrilliantoath" + }, + "circlet": { + "artifactName": "Minnesang of Love and Lament", + "GOOD": "MinnesangOfLoveAndLament", + "normalizedName": "minnesangofloveandlament" + }, + "flower": { + "artifactName": "Windborne Flower's Spruchdichtung", + "GOOD": "WindborneFlowersSpruchdichtung", + "normalizedName": "windborneflowersspruchdichtung" + }, + "sands": { + "artifactName": "A Note in Spring's Leich", + "GOOD": "ANoteInSpringsLeich", + "normalizedName": "anoteinspringsleich" + } + } + }, + "adventurer": { + "setName": "Adventurer", + "GOOD": "Adventurer", + "normalizedName": "adventurer", + "artifacts": { + "goblet": { + "artifactName": "Adventurer's Golden Goblet", + "GOOD": "AdventurersGoldenGoblet", + "normalizedName": "adventurersgoldengoblet" + }, + "plume": { + "artifactName": "Adventurer's Tail Feather", + "GOOD": "AdventurersTailFeather", + "normalizedName": "adventurerstailfeather" + }, + "circlet": { + "artifactName": "Adventurer's Bandana", + "GOOD": "AdventurersBandana", + "normalizedName": "adventurersbandana" + }, + "flower": { + "artifactName": "Adventurer's Flower", + "GOOD": "AdventurersFlower", + "normalizedName": "adventurersflower" + }, + "sands": { + "artifactName": "Adventurer's Pocket Watch", + "GOOD": "AdventurersPocketWatch", + "normalizedName": "adventurerspocketwatch" + } + } + }, + "archaicpetra": { + "setName": "Archaic Petra", + "GOOD": "ArchaicPetra", + "normalizedName": "archaicpetra", + "artifacts": { + "goblet": { + "artifactName": "Goblet of Chiseled Crag", + "GOOD": "GobletOfChiseledCrag", + "normalizedName": "gobletofchiseledcrag" + }, + "plume": { + "artifactName": "Feather of Jagged Peaks", + "GOOD": "FeatherOfJaggedPeaks", + "normalizedName": "featherofjaggedpeaks" + }, + "circlet": { + "artifactName": "Mask of Solitude Basalt", + "GOOD": "MaskOfSolitudeBasalt", + "normalizedName": "maskofsolitudebasalt" + }, + "flower": { + "artifactName": "Flower of Creviced Cliff", + "GOOD": "FlowerOfCrevicedCliff", + "normalizedName": "flowerofcrevicedcliff" + }, + "sands": { + "artifactName": "Sundial of Enduring Jade", + "GOOD": "SundialOfEnduringJade", + "normalizedName": "sundialofenduringjade" + } + } + }, + "aubadeofmorningstarandmoon": { + "setName": "Aubade of Morningstar and Moon", + "GOOD": "AubadeOfMorningstarAndMoon", + "normalizedName": "aubadeofmorningstarandmoon", + "artifacts": { + "goblet": { + "artifactName": "Moonlit Offering's Libation", + "GOOD": "MoonlitOfferingsLibation", + "normalizedName": "moonlitofferingslibation" + }, + "plume": { + "artifactName": "Moonlit Offering's Parting Light", + "GOOD": "MoonlitOfferingsPartingLight", + "normalizedName": "moonlitofferingspartinglight" + }, + "circlet": { + "artifactName": "Moonlit Offering's Silver Crown", + "GOOD": "MoonlitOfferingsSilverCrown", + "normalizedName": "moonlitofferingssilvercrown" + }, + "flower": { + "artifactName": "Moonlit Offering's Opulent Dream", + "GOOD": "MoonlitOfferingsOpulentDream", + "normalizedName": "moonlitofferingsopulentdream" + }, + "sands": { + "artifactName": "Moonlit Offering's Final Hour", + "GOOD": "MoonlitOfferingsFinalHour", + "normalizedName": "moonlitofferingsfinalhour" + } + } + }, + "berserker": { + "setName": "Berserker", + "GOOD": "Berserker", + "normalizedName": "berserker", + "artifacts": { + "goblet": { + "artifactName": "Berserker's Bone Goblet", + "GOOD": "BerserkersBoneGoblet", + "normalizedName": "berserkersbonegoblet" + }, + "plume": { + "artifactName": "Berserker's Indigo Feather", + "GOOD": "BerserkersIndigoFeather", + "normalizedName": "berserkersindigofeather" + }, + "circlet": { + "artifactName": "Berserker's Battle Mask", + "GOOD": "BerserkersBattleMask", + "normalizedName": "berserkersbattlemask" + }, + "flower": { + "artifactName": "Berserker's Rose", + "GOOD": "BerserkersRose", + "normalizedName": "berserkersrose" + }, + "sands": { + "artifactName": "Berserker's Timepiece", + "GOOD": "BerserkersTimepiece", + "normalizedName": "berserkerstimepiece" + } + } + }, + "blizzardstrayer": { + "setName": "Blizzard Strayer", + "GOOD": "BlizzardStrayer", + "normalizedName": "blizzardstrayer", + "artifacts": { + "goblet": { + "artifactName": "Frost-Weaved Dignity", + "GOOD": "FrostWeavedDignity", + "normalizedName": "frostweaveddignity" + }, + "plume": { + "artifactName": "Icebreaker's Resolve", + "GOOD": "IcebreakersResolve", + "normalizedName": "icebreakersresolve" + }, + "circlet": { + "artifactName": "Broken Rime's Echo", + "GOOD": "BrokenRimesEcho", + "normalizedName": "brokenrimesecho" + }, + "flower": { + "artifactName": "Snowswept Memory", + "GOOD": "SnowsweptMemory", + "normalizedName": "snowsweptmemory" + }, + "sands": { + "artifactName": "Frozen Homeland's Demise", + "GOOD": "FrozenHomelandsDemise", + "normalizedName": "frozenhomelandsdemise" + } + } + }, + "bloodstainedchivalry": { + "setName": "Bloodstained Chivalry", + "GOOD": "BloodstainedChivalry", + "normalizedName": "bloodstainedchivalry", + "artifacts": { + "goblet": { + "artifactName": "Bloodstained Chevalier's Goblet", + "GOOD": "BloodstainedChevaliersGoblet", + "normalizedName": "bloodstainedchevaliersgoblet" + }, + "plume": { + "artifactName": "Bloodstained Black Plume", + "GOOD": "BloodstainedBlackPlume", + "normalizedName": "bloodstainedblackplume" + }, + "circlet": { + "artifactName": "Bloodstained Iron Mask", + "GOOD": "BloodstainedIronMask", + "normalizedName": "bloodstainedironmask" + }, + "flower": { + "artifactName": "Bloodstained Flower of Iron", + "GOOD": "BloodstainedFlowerOfIron", + "normalizedName": "bloodstainedflowerofiron" + }, + "sands": { + "artifactName": "Bloodstained Final Hour", + "GOOD": "BloodstainedFinalHour", + "normalizedName": "bloodstainedfinalhour" + } + } + }, + "braveheart": { + "setName": "Brave Heart", + "GOOD": "BraveHeart", + "normalizedName": "braveheart", + "artifacts": { + "goblet": { + "artifactName": "Outset of the Brave", + "GOOD": "OutsetOfTheBrave", + "normalizedName": "outsetofthebrave" + }, + "plume": { + "artifactName": "Prospect of the Brave", + "GOOD": "ProspectOfTheBrave", + "normalizedName": "prospectofthebrave" + }, + "circlet": { + "artifactName": "Crown of the Brave", + "GOOD": "CrownOfTheBrave", + "normalizedName": "crownofthebrave" + }, + "flower": { + "artifactName": "Medal of the Brave", + "GOOD": "MedalOfTheBrave", + "normalizedName": "medalofthebrave" + }, + "sands": { + "artifactName": "Fortitude of the Brave", + "GOOD": "FortitudeOfTheBrave", + "normalizedName": "fortitudeofthebrave" + } + } + }, + "celestialgift": { + "setName": "Celestial Gift", + "GOOD": "CelestialGift", + "normalizedName": "celestialgift", + "artifacts": { + "goblet": { + "artifactName": "Heavensent Reward", + "GOOD": "HeavensentReward", + "normalizedName": "heavensentreward" + }, + "plume": { + "artifactName": "Heavensent Demise", + "GOOD": "HeavensentDemise", + "normalizedName": "heavensentdemise" + }, + "circlet": { + "artifactName": "Heavensent Crown", + "GOOD": "HeavensentCrown", + "normalizedName": "heavensentcrown" + }, + "flower": { + "artifactName": "Heavensent Fragrance", + "GOOD": "HeavensentFragrance", + "normalizedName": "heavensentfragrance" + }, + "sands": { + "artifactName": "Heavensent Decree", + "GOOD": "HeavensentDecree", + "normalizedName": "heavensentdecree" + } + } + }, + "crimsonwitchofflames": { + "setName": "Crimson Witch of Flames", + "GOOD": "CrimsonWitchOfFlames", + "normalizedName": "crimsonwitchofflames", + "artifacts": { + "goblet": { + "artifactName": "Witch's Heart Flames", + "GOOD": "WitchsHeartFlames", + "normalizedName": "witchsheartflames" + }, + "plume": { + "artifactName": "Witch's Ever-Burning Plume", + "GOOD": "WitchsEverBurningPlume", + "normalizedName": "witchseverburningplume" + }, + "circlet": { + "artifactName": "Witch's Scorching Hat", + "GOOD": "WitchsScorchingHat", + "normalizedName": "witchsscorchinghat" + }, + "flower": { + "artifactName": "Witch's Flower of Blaze", + "GOOD": "WitchsFlowerOfBlaze", + "normalizedName": "witchsflowerofblaze" + }, + "sands": { + "artifactName": "Witch's End Time", + "GOOD": "WitchsEndTime", + "normalizedName": "witchsendtime" + } + } + }, + "deepwoodmemories": { + "setName": "Deepwood Memories", + "GOOD": "DeepwoodMemories", + "normalizedName": "deepwoodmemories", + "artifacts": { + "goblet": { + "artifactName": "Lamp of the Lost", + "GOOD": "LampOfTheLost", + "normalizedName": "lampofthelost" + }, + "plume": { + "artifactName": "Scholar of Vines", + "GOOD": "ScholarOfVines", + "normalizedName": "scholarofvines" + }, + "circlet": { + "artifactName": "Laurel Coronet", + "GOOD": "LaurelCoronet", + "normalizedName": "laurelcoronet" + }, + "flower": { + "artifactName": "Labyrinth Wayfarer", + "GOOD": "LabyrinthWayfarer", + "normalizedName": "labyrinthwayfarer" + }, + "sands": { + "artifactName": "A Time of Insight", + "GOOD": "ATimeOfInsight", + "normalizedName": "atimeofinsight" + } + } + }, + "defenderswill": { + "setName": "Defender's Will", + "GOOD": "DefendersWill", + "normalizedName": "defenderswill", + "artifacts": { + "goblet": { + "artifactName": "Guardian's Vessel", + "GOOD": "GuardiansVessel", + "normalizedName": "guardiansvessel" + }, + "plume": { + "artifactName": "Guardian's Sigil", + "GOOD": "GuardiansSigil", + "normalizedName": "guardianssigil" + }, + "circlet": { + "artifactName": "Guardian's Band", + "GOOD": "GuardiansBand", + "normalizedName": "guardiansband" + }, + "flower": { + "artifactName": "Guardian's Flower", + "GOOD": "GuardiansFlower", + "normalizedName": "guardiansflower" + }, + "sands": { + "artifactName": "Guardian's Clock", + "GOOD": "GuardiansClock", + "normalizedName": "guardiansclock" + } + } + }, + "desertpavilionchronicle": { + "setName": "Desert Pavilion Chronicle", + "GOOD": "DesertPavilionChronicle", + "normalizedName": "desertpavilionchronicle", + "artifacts": { + "goblet": { + "artifactName": "Defender of the Enchanting Dream", + "GOOD": "DefenderOfTheEnchantingDream", + "normalizedName": "defenderoftheenchantingdream" + }, + "plume": { + "artifactName": "End of the Golden Realm", + "GOOD": "EndOfTheGoldenRealm", + "normalizedName": "endofthegoldenrealm" + }, + "circlet": { + "artifactName": "Legacy of the Desert High-Born", + "GOOD": "LegacyOfTheDesertHighBorn", + "normalizedName": "legacyofthedeserthighborn" + }, + "flower": { + "artifactName": "The First Days of the City of Kings", + "GOOD": "TheFirstDaysOfTheCityOfKings", + "normalizedName": "thefirstdaysofthecityofkings" + }, + "sands": { + "artifactName": "Timepiece of the Lost Path", + "GOOD": "TimepieceOfTheLostPath", + "normalizedName": "timepieceofthelostpath" + } + } + }, + "disenchantmentindeepshadow": { + "setName": "Disenchantment in Deep Shadow", + "GOOD": "DisenchantmentInDeepShadow", + "normalizedName": "disenchantmentindeepshadow", + "artifacts": { + "goblet": { + "artifactName": "Ovations That Ceased Upon Festivity", + "GOOD": "OvationsThatCeasedUponFestivity", + "normalizedName": "ovationsthatceaseduponfestivity" + }, + "plume": { + "artifactName": "Sharpness That Ceased Upon Wondrous Creation", + "GOOD": "SharpnessThatCeasedUponWondrousCreation", + "normalizedName": "sharpnessthatceaseduponwondrouscreation" + }, + "circlet": { + "artifactName": "Pendulum That Ceased Amidst a Great Fall", + "GOOD": "PendulumThatCeasedAmidstAGreatFall", + "normalizedName": "pendulumthatceasedamidstagreatfall" + }, + "flower": { + "artifactName": "Iridescence That Ceased Amidst Glory", + "GOOD": "IridescenceThatCeasedAmidstGlory", + "normalizedName": "iridescencethatceasedamidstglory" + }, + "sands": { + "artifactName": "Moment That Ceased Upon Waking From Grand Dreams", + "GOOD": "MomentThatCeasedUponWakingFromGrandDreams", + "normalizedName": "momentthatceaseduponwakingfromgranddreams" + } + } + }, + "echoesofanoffering": { + "setName": "Echoes of an Offering", + "GOOD": "EchoesOfAnOffering", + "normalizedName": "echoesofanoffering", + "artifacts": { + "goblet": { + "artifactName": "Chalice of the Font", + "GOOD": "ChaliceOfTheFont", + "normalizedName": "chaliceofthefont" + }, + "plume": { + "artifactName": "Jade Leaf", + "GOOD": "JadeLeaf", + "normalizedName": "jadeleaf" + }, + "circlet": { + "artifactName": "Flowing Rings", + "GOOD": "FlowingRings", + "normalizedName": "flowingrings" + }, + "flower": { + "artifactName": "Soulscent Bloom", + "GOOD": "SoulscentBloom", + "normalizedName": "soulscentbloom" + }, + "sands": { + "artifactName": "Symbol of Felicitation", + "GOOD": "SymbolOfFelicitation", + "normalizedName": "symboloffelicitation" + } + } + }, + "emblemofseveredfate": { + "setName": "Emblem of Severed Fate", + "GOOD": "EmblemOfSeveredFate", + "normalizedName": "emblemofseveredfate", + "artifacts": { + "goblet": { + "artifactName": "Scarlet Vessel", + "GOOD": "ScarletVessel", + "normalizedName": "scarletvessel" + }, + "plume": { + "artifactName": "Sundered Feather", + "GOOD": "SunderedFeather", + "normalizedName": "sunderedfeather" + }, + "circlet": { + "artifactName": "Ornate Kabuto", + "GOOD": "OrnateKabuto", + "normalizedName": "ornatekabuto" + }, + "flower": { + "artifactName": "Magnificent Tsuba", + "GOOD": "MagnificentTsuba", + "normalizedName": "magnificenttsuba" + }, + "sands": { + "artifactName": "Storm Cage", + "GOOD": "StormCage", + "normalizedName": "stormcage" + } + } + }, + "finaleofthedeepgalleries": { + "setName": "Finale of the Deep Galleries", + "GOOD": "FinaleOfTheDeepGalleries", + "normalizedName": "finaleofthedeepgalleries", + "artifacts": { + "goblet": { + "artifactName": "Deep Gallery's Bestowed Banquet", + "GOOD": "DeepGallerysBestowedBanquet", + "normalizedName": "deepgallerysbestowedbanquet" + }, + "plume": { + "artifactName": "Deep Gallery's Distant Pact", + "GOOD": "DeepGallerysDistantPact", + "normalizedName": "deepgallerysdistantpact" + }, + "circlet": { + "artifactName": "Deep Gallery's Lost Crown", + "GOOD": "DeepGallerysLostCrown", + "normalizedName": "deepgalleryslostcrown" + }, + "flower": { + "artifactName": "Deep Gallery's Echoing Song", + "GOOD": "DeepGallerysEchoingSong", + "normalizedName": "deepgallerysechoingsong" + }, + "sands": { + "artifactName": "Deep Gallery's Moment of Oblivion", + "GOOD": "DeepGallerysMomentOfOblivion", + "normalizedName": "deepgallerysmomentofoblivion" + } + } + }, + "flowerofparadiselost": { + "setName": "Flower of Paradise Lost", + "GOOD": "FlowerOfParadiseLost", + "normalizedName": "flowerofparadiselost", + "artifacts": { + "goblet": { + "artifactName": "Secret-Keeper's Magic Bottle", + "GOOD": "SecretKeepersMagicBottle", + "normalizedName": "secretkeepersmagicbottle" + }, + "plume": { + "artifactName": "Wilting Feast", + "GOOD": "WiltingFeast", + "normalizedName": "wiltingfeast" + }, + "circlet": { + "artifactName": "Amethyst Crown", + "GOOD": "AmethystCrown", + "normalizedName": "amethystcrown" + }, + "flower": { + "artifactName": "Ay-Khanoum's Myriad", + "GOOD": "AyKhanoumsMyriad", + "normalizedName": "aykhanoumsmyriad" + }, + "sands": { + "artifactName": "A Moment Congealed", + "GOOD": "AMomentCongealed", + "normalizedName": "amomentcongealed" + } + } + }, + "fragmentofharmonicwhimsy": { + "setName": "Fragment of Harmonic Whimsy", + "GOOD": "FragmentOfHarmonicWhimsy", + "normalizedName": "fragmentofharmonicwhimsy", + "artifacts": { + "goblet": { + "artifactName": "Ichor Shower Rhapsody", + "GOOD": "IchorShowerRhapsody", + "normalizedName": "ichorshowerrhapsody" + }, + "plume": { + "artifactName": "Ancient Sea's Nocturnal Musing", + "GOOD": "AncientSeasNocturnalMusing", + "normalizedName": "ancientseasnocturnalmusing" + }, + "circlet": { + "artifactName": "Whimsical Dance of the Withered", + "GOOD": "WhimsicalDanceOfTheWithered", + "normalizedName": "whimsicaldanceofthewithered" + }, + "flower": { + "artifactName": "Harmonious Symphony Prelude", + "GOOD": "HarmoniousSymphonyPrelude", + "normalizedName": "harmonioussymphonyprelude" + }, + "sands": { + "artifactName": "The Grand Jape of the Turning of Fate", + "GOOD": "TheGrandJapeOfTheTurningOfFate", + "normalizedName": "thegrandjapeoftheturningoffate" + } + } + }, + "gambler": { + "setName": "Gambler", + "GOOD": "Gambler", + "normalizedName": "gambler", + "artifacts": { + "goblet": { + "artifactName": "Gambler's Dice Cup", + "GOOD": "GamblersDiceCup", + "normalizedName": "gamblersdicecup" + }, + "plume": { + "artifactName": "Gambler's Feather Accessory", + "GOOD": "GamblersFeatherAccessory", + "normalizedName": "gamblersfeatheraccessory" + }, + "circlet": { + "artifactName": "Gambler's Earrings", + "GOOD": "GamblersEarrings", + "normalizedName": "gamblersearrings" + }, + "flower": { + "artifactName": "Gambler's Brooch", + "GOOD": "GamblersBrooch", + "normalizedName": "gamblersbrooch" + }, + "sands": { + "artifactName": "Gambler's Pocket Watch", + "GOOD": "GamblersPocketWatch", + "normalizedName": "gamblerspocketwatch" + } + } + }, + "gildeddreams": { + "setName": "Gilded Dreams", + "GOOD": "GildedDreams", + "normalizedName": "gildeddreams", + "artifacts": { + "goblet": { + "artifactName": "Honeyed Final Feast", + "GOOD": "HoneyedFinalFeast", + "normalizedName": "honeyedfinalfeast" + }, + "plume": { + "artifactName": "Feather of Judgment", + "GOOD": "FeatherOfJudgment", + "normalizedName": "featherofjudgment" + }, + "circlet": { + "artifactName": "Shadow of the Sand King", + "GOOD": "ShadowOfTheSandKing", + "normalizedName": "shadowofthesandking" + }, + "flower": { + "artifactName": "Dreaming Steelbloom", + "GOOD": "DreamingSteelbloom", + "normalizedName": "dreamingsteelbloom" + }, + "sands": { + "artifactName": "The Sunken Years", + "GOOD": "TheSunkenYears", + "normalizedName": "thesunkenyears" + } + } + }, + "gladiatorsfinale": { + "setName": "Gladiator's Finale", + "GOOD": "GladiatorsFinale", + "normalizedName": "gladiatorsfinale", + "artifacts": { + "goblet": { + "artifactName": "Gladiator's Intoxication", + "GOOD": "GladiatorsIntoxication", + "normalizedName": "gladiatorsintoxication" + }, + "plume": { + "artifactName": "Gladiator's Destiny", + "GOOD": "GladiatorsDestiny", + "normalizedName": "gladiatorsdestiny" + }, + "circlet": { + "artifactName": "Gladiator's Triumphus", + "GOOD": "GladiatorsTriumphus", + "normalizedName": "gladiatorstriumphus" + }, + "flower": { + "artifactName": "Gladiator's Nostalgia", + "GOOD": "GladiatorsNostalgia", + "normalizedName": "gladiatorsnostalgia" + }, + "sands": { + "artifactName": "Gladiator's Longing", + "GOOD": "GladiatorsLonging", + "normalizedName": "gladiatorslonging" + } + } + }, + "goldentroupe": { + "setName": "Golden Troupe", + "GOOD": "GoldenTroupe", + "normalizedName": "goldentroupe", + "artifacts": { + "goblet": { + "artifactName": "Golden Night's Bustle", + "GOOD": "GoldenNightsBustle", + "normalizedName": "goldennightsbustle" + }, + "plume": { + "artifactName": "Golden Bird's Shedding", + "GOOD": "GoldenBirdsShedding", + "normalizedName": "goldenbirdsshedding" + }, + "circlet": { + "artifactName": "Golden Troupe's Reward", + "GOOD": "GoldenTroupesReward", + "normalizedName": "goldentroupesreward" + }, + "flower": { + "artifactName": "Golden Song's Variation", + "GOOD": "GoldenSongsVariation", + "normalizedName": "goldensongsvariation" + }, + "sands": { + "artifactName": "Golden Era's Prelude", + "GOOD": "GoldenErasPrelude", + "normalizedName": "goldenerasprelude" + } + } + }, + "heartofdepth": { + "setName": "Heart of Depth", + "GOOD": "HeartOfDepth", + "normalizedName": "heartofdepth", + "artifacts": { + "goblet": { + "artifactName": "Goblet of Thundering Deep", + "GOOD": "GobletOfThunderingDeep", + "normalizedName": "gobletofthunderingdeep" + }, + "plume": { + "artifactName": "Gust of Nostalgia", + "GOOD": "GustOfNostalgia", + "normalizedName": "gustofnostalgia" + }, + "circlet": { + "artifactName": "Wine-Stained Tricorne", + "GOOD": "WineStainedTricorne", + "normalizedName": "winestainedtricorne" + }, + "flower": { + "artifactName": "Gilded Corsage", + "GOOD": "GildedCorsage", + "normalizedName": "gildedcorsage" + }, + "sands": { + "artifactName": "Copper Compass", + "GOOD": "CopperCompass", + "normalizedName": "coppercompass" + } + } + }, + "huskofopulentdreams": { + "setName": "Husk of Opulent Dreams", + "GOOD": "HuskOfOpulentDreams", + "normalizedName": "huskofopulentdreams", + "artifacts": { + "goblet": { + "artifactName": "Calabash of Awakening", + "GOOD": "CalabashOfAwakening", + "normalizedName": "calabashofawakening" + }, + "plume": { + "artifactName": "Plume of Luxury", + "GOOD": "PlumeOfLuxury", + "normalizedName": "plumeofluxury" + }, + "circlet": { + "artifactName": "Skeletal Hat", + "GOOD": "SkeletalHat", + "normalizedName": "skeletalhat" + }, + "flower": { + "artifactName": "Bloom Times", + "GOOD": "BloomTimes", + "normalizedName": "bloomtimes" + }, + "sands": { + "artifactName": "Song of Life", + "GOOD": "SongOfLife", + "normalizedName": "songoflife" + } + } + }, + "instructor": { + "setName": "Instructor", + "GOOD": "Instructor", + "normalizedName": "instructor", + "artifacts": { + "goblet": { + "artifactName": "Instructor's Tea Cup", + "GOOD": "InstructorsTeaCup", + "normalizedName": "instructorsteacup" + }, + "plume": { + "artifactName": "Instructor's Feather Accessory", + "GOOD": "InstructorsFeatherAccessory", + "normalizedName": "instructorsfeatheraccessory" + }, + "circlet": { + "artifactName": "Instructor's Cap", + "GOOD": "InstructorsCap", + "normalizedName": "instructorscap" + }, + "flower": { + "artifactName": "Instructor's Brooch", + "GOOD": "InstructorsBrooch", + "normalizedName": "instructorsbrooch" + }, + "sands": { + "artifactName": "Instructor's Pocket Watch", + "GOOD": "InstructorsPocketWatch", + "normalizedName": "instructorspocketwatch" + } + } + }, + "lavawalker": { + "setName": "Lavawalker", + "GOOD": "Lavawalker", + "normalizedName": "lavawalker", + "artifacts": { + "goblet": { + "artifactName": "Lavawalker's Epiphany", + "GOOD": "LavawalkersEpiphany", + "normalizedName": "lavawalkersepiphany" + }, + "plume": { + "artifactName": "Lavawalker's Salvation", + "GOOD": "LavawalkersSalvation", + "normalizedName": "lavawalkerssalvation" + }, + "circlet": { + "artifactName": "Lavawalker's Wisdom", + "GOOD": "LavawalkersWisdom", + "normalizedName": "lavawalkerswisdom" + }, + "flower": { + "artifactName": "Lavawalker's Resolution", + "GOOD": "LavawalkersResolution", + "normalizedName": "lavawalkersresolution" + }, + "sands": { + "artifactName": "Lavawalker's Torment", + "GOOD": "LavawalkersTorment", + "normalizedName": "lavawalkerstorment" + } + } + }, + "longnightsoath": { + "setName": "Long Night's Oath", + "GOOD": "LongNightsOath", + "normalizedName": "longnightsoath", + "artifacts": { + "goblet": { + "artifactName": "A Horn Unwinded", + "GOOD": "AHornUnwinded", + "normalizedName": "ahornunwinded" + }, + "plume": { + "artifactName": "Nightingale's Tail Feather", + "GOOD": "NightingalesTailFeather", + "normalizedName": "nightingalestailfeather" + }, + "circlet": { + "artifactName": "Dyed Tassel", + "GOOD": "DyedTassel", + "normalizedName": "dyedtassel" + }, + "flower": { + "artifactName": "Lightkeeper's Pledge", + "GOOD": "LightkeepersPledge", + "normalizedName": "lightkeeperspledge" + }, + "sands": { + "artifactName": "Undying One's Mourning Bell", + "GOOD": "UndyingOnesMourningBell", + "normalizedName": "undyingonesmourningbell" + } + } + }, + "luckydog": { + "setName": "Lucky Dog", + "GOOD": "LuckyDog", + "normalizedName": "luckydog", + "artifacts": { + "goblet": { + "artifactName": "Lucky Dog's Goblet", + "GOOD": "LuckyDogsGoblet", + "normalizedName": "luckydogsgoblet" + }, + "plume": { + "artifactName": "Lucky Dog's Eagle Feather", + "GOOD": "LuckyDogsEagleFeather", + "normalizedName": "luckydogseaglefeather" + }, + "circlet": { + "artifactName": "Lucky Dog's Silver Circlet", + "GOOD": "LuckyDogsSilverCirclet", + "normalizedName": "luckydogssilvercirclet" + }, + "flower": { + "artifactName": "Lucky Dog's Clover", + "GOOD": "LuckyDogsClover", + "normalizedName": "luckydogsclover" + }, + "sands": { + "artifactName": "Lucky Dog's Hourglass", + "GOOD": "LuckyDogsHourglass", + "normalizedName": "luckydogshourglass" + } + } + }, + "maidenbeloved": { + "setName": "Maiden Beloved", + "GOOD": "MaidenBeloved", + "normalizedName": "maidenbeloved", + "artifacts": { + "goblet": { + "artifactName": "Maiden's Fleeting Leisure", + "GOOD": "MaidensFleetingLeisure", + "normalizedName": "maidensfleetingleisure" + }, + "plume": { + "artifactName": "Maiden's Heart-Stricken Infatuation", + "GOOD": "MaidensHeartStrickenInfatuation", + "normalizedName": "maidensheartstrickeninfatuation" + }, + "circlet": { + "artifactName": "Maiden's Fading Beauty", + "GOOD": "MaidensFadingBeauty", + "normalizedName": "maidensfadingbeauty" + }, + "flower": { + "artifactName": "Maiden's Distant Love", + "GOOD": "MaidensDistantLove", + "normalizedName": "maidensdistantlove" + }, + "sands": { + "artifactName": "Maiden's Passing Youth", + "GOOD": "MaidensPassingYouth", + "normalizedName": "maidenspassingyouth" + } + } + }, + "marechausseehunter": { + "setName": "Marechaussee Hunter", + "GOOD": "MarechausseeHunter", + "normalizedName": "marechausseehunter", + "artifacts": { + "goblet": { + "artifactName": "Forgotten Vessel", + "GOOD": "ForgottenVessel", + "normalizedName": "forgottenvessel" + }, + "plume": { + "artifactName": "Masterpiece's Overture", + "GOOD": "MasterpiecesOverture", + "normalizedName": "masterpiecesoverture" + }, + "circlet": { + "artifactName": "Veteran's Visage", + "GOOD": "VeteransVisage", + "normalizedName": "veteransvisage" + }, + "flower": { + "artifactName": "Hunter's Brooch", + "GOOD": "HuntersBrooch", + "normalizedName": "huntersbrooch" + }, + "sands": { + "artifactName": "Moment of Judgment", + "GOOD": "MomentOfJudgment", + "normalizedName": "momentofjudgment" + } + } + }, + "martialartist": { + "setName": "Martial Artist", + "GOOD": "MartialArtist", + "normalizedName": "martialartist", + "artifacts": { + "goblet": { + "artifactName": "Martial Artist's Wine Cup", + "GOOD": "MartialArtistsWineCup", + "normalizedName": "martialartistswinecup" + }, + "plume": { + "artifactName": "Martial Artist's Feather Accessory", + "GOOD": "MartialArtistsFeatherAccessory", + "normalizedName": "martialartistsfeatheraccessory" + }, + "circlet": { + "artifactName": "Martial Artist's Bandana", + "GOOD": "MartialArtistsBandana", + "normalizedName": "martialartistsbandana" + }, + "flower": { + "artifactName": "Martial Artist's Red Flower", + "GOOD": "MartialArtistsRedFlower", + "normalizedName": "martialartistsredflower" + }, + "sands": { + "artifactName": "Martial Artist's Water Hourglass", + "GOOD": "MartialArtistsWaterHourglass", + "normalizedName": "martialartistswaterhourglass" + } + } + }, + "nightoftheskysunveiling": { + "setName": "Night of the Sky's Unveiling", + "GOOD": "NightOfTheSkysUnveiling", + "normalizedName": "nightoftheskysunveiling", + "artifacts": { + "goblet": { + "artifactName": "Vessel of Plenty", + "GOOD": "VesselOfPlenty", + "normalizedName": "vesselofplenty" + }, + "plume": { + "artifactName": "Feather of Indelible Sin", + "GOOD": "FeatherOfIndelibleSin", + "normalizedName": "featherofindeliblesin" + }, + "circlet": { + "artifactName": "Crown of the Befallen", + "GOOD": "CrownOfTheBefallen", + "normalizedName": "crownofthebefallen" + }, + "flower": { + "artifactName": "Bloom of the Mind's Desire", + "GOOD": "BloomOfTheMindsDesire", + "normalizedName": "bloomofthemindsdesire" + }, + "sands": { + "artifactName": "Revelation's Toll", + "GOOD": "RevelationsToll", + "normalizedName": "revelationstoll" + } + } + }, + "nighttimewhispersintheechoingwoods": { + "setName": "Nighttime Whispers in the Echoing Woods", + "GOOD": "NighttimeWhispersInTheEchoingWoods", + "normalizedName": "nighttimewhispersintheechoingwoods", + "artifacts": { + "goblet": { + "artifactName": "Magnanimous Ink Bottle", + "GOOD": "MagnanimousInkBottle", + "normalizedName": "magnanimousinkbottle" + }, + "plume": { + "artifactName": "Honest Quill", + "GOOD": "HonestQuill", + "normalizedName": "honestquill" + }, + "circlet": { + "artifactName": "Compassionate Ladies' Hat", + "GOOD": "CompassionateLadiesHat", + "normalizedName": "compassionateladieshat" + }, + "flower": { + "artifactName": "Selfless Floral Accessory", + "GOOD": "SelflessFloralAccessory", + "normalizedName": "selflessfloralaccessory" + }, + "sands": { + "artifactName": "Faithful Hourglass", + "GOOD": "FaithfulHourglass", + "normalizedName": "faithfulhourglass" + } + } + }, + "noblesseoblige": { + "setName": "Noblesse Oblige", + "GOOD": "NoblesseOblige", + "normalizedName": "noblesseoblige", + "artifacts": { + "goblet": { + "artifactName": "Royal Silver Urn", + "GOOD": "RoyalSilverUrn", + "normalizedName": "royalsilverurn" + }, + "plume": { + "artifactName": "Royal Plume", + "GOOD": "RoyalPlume", + "normalizedName": "royalplume" + }, + "circlet": { + "artifactName": "Royal Masque", + "GOOD": "RoyalMasque", + "normalizedName": "royalmasque" + }, + "flower": { + "artifactName": "Royal Flora", + "GOOD": "RoyalFlora", + "normalizedName": "royalflora" + }, + "sands": { + "artifactName": "Royal Pocket Watch", + "GOOD": "RoyalPocketWatch", + "normalizedName": "royalpocketwatch" + } + } + }, + "nymphsdream": { + "setName": "Nymph's Dream", + "GOOD": "NymphsDream", + "normalizedName": "nymphsdream", + "artifacts": { + "goblet": { + "artifactName": "Heroes' Tea Party", + "GOOD": "HeroesTeaParty", + "normalizedName": "heroesteaparty" + }, + "plume": { + "artifactName": "Wicked Mage's Plumule", + "GOOD": "WickedMagesPlumule", + "normalizedName": "wickedmagesplumule" + }, + "circlet": { + "artifactName": "Fell Dragon's Monocle", + "GOOD": "FellDragonsMonocle", + "normalizedName": "felldragonsmonocle" + }, + "flower": { + "artifactName": "Odyssean Flower", + "GOOD": "OdysseanFlower", + "normalizedName": "odysseanflower" + }, + "sands": { + "artifactName": "Nymph's Constancy", + "GOOD": "NymphsConstancy", + "normalizedName": "nymphsconstancy" + } + } + }, + "obsidiancodex": { + "setName": "Obsidian Codex", + "GOOD": "ObsidianCodex", + "normalizedName": "obsidiancodex", + "artifacts": { + "goblet": { + "artifactName": "Pre-Banquet of the Contenders", + "GOOD": "PreBanquetOfTheContenders", + "normalizedName": "prebanquetofthecontenders" + }, + "plume": { + "artifactName": "Root of the Spirit-Marrow", + "GOOD": "RootOfTheSpiritMarrow", + "normalizedName": "rootofthespiritmarrow" + }, + "circlet": { + "artifactName": "Crown of the Saints", + "GOOD": "CrownOfTheSaints", + "normalizedName": "crownofthesaints" + }, + "flower": { + "artifactName": "Reckoning of the Xenogenic", + "GOOD": "ReckoningOfTheXenogenic", + "normalizedName": "reckoningofthexenogenic" + }, + "sands": { + "artifactName": "Myths of the Night Realm", + "GOOD": "MythsOfTheNightRealm", + "normalizedName": "mythsofthenightrealm" + } + } + }, + "oceanhuedclam": { + "setName": "Ocean-Hued Clam", + "GOOD": "OceanHuedClam", + "normalizedName": "oceanhuedclam", + "artifacts": { + "goblet": { + "artifactName": "Pearl Cage", + "GOOD": "PearlCage", + "normalizedName": "pearlcage" + }, + "plume": { + "artifactName": "Deep Palace's Plume", + "GOOD": "DeepPalacesPlume", + "normalizedName": "deeppalacesplume" + }, + "circlet": { + "artifactName": "Crown of Watatsumi", + "GOOD": "CrownOfWatatsumi", + "normalizedName": "crownofwatatsumi" + }, + "flower": { + "artifactName": "Sea-Dyed Blossom", + "GOOD": "SeaDyedBlossom", + "normalizedName": "seadyedblossom" + }, + "sands": { + "artifactName": "Cowry of Parting", + "GOOD": "CowryOfParting", + "normalizedName": "cowryofparting" + } + } + }, + "paleflame": { + "setName": "Pale Flame", + "GOOD": "PaleFlame", + "normalizedName": "paleflame", + "artifacts": { + "goblet": { + "artifactName": "Surpassing Cup", + "GOOD": "SurpassingCup", + "normalizedName": "surpassingcup" + }, + "plume": { + "artifactName": "Wise Doctor's Pinion", + "GOOD": "WiseDoctorsPinion", + "normalizedName": "wisedoctorspinion" + }, + "circlet": { + "artifactName": "Mocking Mask", + "GOOD": "MockingMask", + "normalizedName": "mockingmask" + }, + "flower": { + "artifactName": "Stainless Bloom", + "GOOD": "StainlessBloom", + "normalizedName": "stainlessbloom" + }, + "sands": { + "artifactName": "Moment of Cessation", + "GOOD": "MomentOfCessation", + "normalizedName": "momentofcessation" + } + } + }, + "prayersfordestiny": { + "setName": "Prayers for Destiny", + "GOOD": "PrayersForDestiny", + "normalizedName": "prayersfordestiny", + "artifacts": { + "circlet": { + "artifactName": "Tiara of Torrents", + "GOOD": "TiaraOfTorrents", + "normalizedName": "tiaraoftorrents" + } + } + }, + "prayersforillumination": { + "setName": "Prayers for Illumination", + "GOOD": "PrayersForIllumination", + "normalizedName": "prayersforillumination", + "artifacts": { + "circlet": { + "artifactName": "Tiara of Flame", + "GOOD": "TiaraOfFlame", + "normalizedName": "tiaraofflame" + } + } + }, + "prayersforwisdom": { + "setName": "Prayers for Wisdom", + "GOOD": "PrayersForWisdom", + "normalizedName": "prayersforwisdom", + "artifacts": { + "circlet": { + "artifactName": "Tiara of Thunder", + "GOOD": "TiaraOfThunder", + "normalizedName": "tiaraofthunder" + } + } + }, + "prayerstospringtime": { + "setName": "Prayers to Springtime", + "GOOD": "PrayersToSpringtime", + "normalizedName": "prayerstospringtime", + "artifacts": { + "circlet": { + "artifactName": "Tiara of Frost", + "GOOD": "TiaraOfFrost", + "normalizedName": "tiaraoffrost" + } + } + }, + "resolutionofsojourner": { + "setName": "Resolution of Sojourner", + "GOOD": "ResolutionOfSojourner", + "normalizedName": "resolutionofsojourner", + "artifacts": { + "goblet": { + "artifactName": "Goblet of the Sojourner", + "GOOD": "GobletOfTheSojourner", + "normalizedName": "gobletofthesojourner" + }, + "plume": { + "artifactName": "Feather of Homecoming", + "GOOD": "FeatherOfHomecoming", + "normalizedName": "featherofhomecoming" + }, + "circlet": { + "artifactName": "Crown of Parting", + "GOOD": "CrownOfParting", + "normalizedName": "crownofparting" + }, + "flower": { + "artifactName": "Heart of Comradeship", + "GOOD": "HeartOfComradeship", + "normalizedName": "heartofcomradeship" + }, + "sands": { + "artifactName": "Sundial of the Sojourner", + "GOOD": "SundialOfTheSojourner", + "normalizedName": "sundialofthesojourner" + } + } + }, + "retracingbolide": { + "setName": "Retracing Bolide", + "GOOD": "RetracingBolide", + "normalizedName": "retracingbolide", + "artifacts": { + "goblet": { + "artifactName": "Summer Night's Waterballoon", + "GOOD": "SummerNightsWaterballoon", + "normalizedName": "summernightswaterballoon" + }, + "plume": { + "artifactName": "Summer Night's Finale", + "GOOD": "SummerNightsFinale", + "normalizedName": "summernightsfinale" + }, + "circlet": { + "artifactName": "Summer Night's Mask", + "GOOD": "SummerNightsMask", + "normalizedName": "summernightsmask" + }, + "flower": { + "artifactName": "Summer Night's Bloom", + "GOOD": "SummerNightsBloom", + "normalizedName": "summernightsbloom" + }, + "sands": { + "artifactName": "Summer Night's Moment", + "GOOD": "SummerNightsMoment", + "normalizedName": "summernightsmoment" + } + } + }, + "scholar": { + "setName": "Scholar", + "GOOD": "Scholar", + "normalizedName": "scholar", + "artifacts": { + "goblet": { + "artifactName": "Scholar's Ink Cup", + "GOOD": "ScholarsInkCup", + "normalizedName": "scholarsinkcup" + }, + "plume": { + "artifactName": "Scholar's Quill Pen", + "GOOD": "ScholarsQuillPen", + "normalizedName": "scholarsquillpen" + }, + "circlet": { + "artifactName": "Scholar's Lens", + "GOOD": "ScholarsLens", + "normalizedName": "scholarslens" + }, + "flower": { + "artifactName": "Scholar's Bookmark", + "GOOD": "ScholarsBookmark", + "normalizedName": "scholarsbookmark" + }, + "sands": { + "artifactName": "Scholar's Clock", + "GOOD": "ScholarsClock", + "normalizedName": "scholarsclock" + } + } + }, + "scrolloftheheroofcindercity": { + "setName": "Scroll of the Hero of Cinder City", + "GOOD": "ScrollOfTheHeroOfCinderCity", + "normalizedName": "scrolloftheheroofcindercity", + "artifacts": { + "goblet": { + "artifactName": "Wandering Scholar's Claw Cup", + "GOOD": "WanderingScholarsClawCup", + "normalizedName": "wanderingscholarsclawcup" + }, + "plume": { + "artifactName": "Mountain Ranger's Marker", + "GOOD": "MountainRangersMarker", + "normalizedName": "mountainrangersmarker" + }, + "circlet": { + "artifactName": "Demon-Warrior's Feather Mask", + "GOOD": "DemonWarriorsFeatherMask", + "normalizedName": "demonwarriorsfeathermask" + }, + "flower": { + "artifactName": "Beast Tamer's Talisman", + "GOOD": "BeastTamersTalisman", + "normalizedName": "beasttamerstalisman" + }, + "sands": { + "artifactName": "Mystic's Gold Dial", + "GOOD": "MysticsGoldDial", + "normalizedName": "mysticsgolddial" + } + } + }, + "shimenawasreminiscence": { + "setName": "Shimenawa's Reminiscence", + "GOOD": "ShimenawasReminiscence", + "normalizedName": "shimenawasreminiscence", + "artifacts": { + "goblet": { + "artifactName": "Hopeful Heart", + "GOOD": "HopefulHeart", + "normalizedName": "hopefulheart" + }, + "plume": { + "artifactName": "Shaft of Remembrance", + "GOOD": "ShaftOfRemembrance", + "normalizedName": "shaftofremembrance" + }, + "circlet": { + "artifactName": "Capricious Visage", + "GOOD": "CapriciousVisage", + "normalizedName": "capriciousvisage" + }, + "flower": { + "artifactName": "Entangling Bloom", + "GOOD": "EntanglingBloom", + "normalizedName": "entanglingbloom" + }, + "sands": { + "artifactName": "Morning Dew's Moment", + "GOOD": "MorningDewsMoment", + "normalizedName": "morningdewsmoment" + } + } + }, + "silkenmoonsserenade": { + "setName": "Silken Moon's Serenade", + "GOOD": "SilkenMoonsSerenade", + "normalizedName": "silkenmoonsserenade", + "artifacts": { + "goblet": { + "artifactName": "Joyous Glory of the Pure", + "GOOD": "JoyousGloryOfThePure", + "normalizedName": "joyousgloryofthepure" + }, + "plume": { + "artifactName": "Pristine Plume of the Blessed", + "GOOD": "PristinePlumeOfTheBlessed", + "normalizedName": "pristineplumeoftheblessed" + }, + "circlet": { + "artifactName": "Holy Crown of the Believer", + "GOOD": "HolyCrownOfTheBeliever", + "normalizedName": "holycrownofthebeliever" + }, + "flower": { + "artifactName": "Crystal Tear of the Wanderer", + "GOOD": "CrystalTearOfTheWanderer", + "normalizedName": "crystaltearofthewanderer" + }, + "sands": { + "artifactName": "Frost Devotee's Delirium", + "GOOD": "FrostDevoteesDelirium", + "normalizedName": "frostdevoteesdelirium" + } + } + }, + "songofdayspast": { + "setName": "Song of Days Past", + "GOOD": "SongOfDaysPast", + "normalizedName": "songofdayspast", + "artifacts": { + "goblet": { + "artifactName": "Promised Dream of Days Past", + "GOOD": "PromisedDreamOfDaysPast", + "normalizedName": "promiseddreamofdayspast" + }, + "plume": { + "artifactName": "Recollection of Days Past", + "GOOD": "RecollectionOfDaysPast", + "normalizedName": "recollectionofdayspast" + }, + "circlet": { + "artifactName": "Poetry of Days Past", + "GOOD": "PoetryOfDaysPast", + "normalizedName": "poetryofdayspast" + }, + "flower": { + "artifactName": "Forgotten Oath of Days Past", + "GOOD": "ForgottenOathOfDaysPast", + "normalizedName": "forgottenoathofdayspast" + }, + "sands": { + "artifactName": "Echoing Sound From Days Past", + "GOOD": "EchoingSoundFromDaysPast", + "normalizedName": "echoingsoundfromdayspast" + } + } + }, + "tenacityofthemillelith": { + "setName": "Tenacity of the Millelith", + "GOOD": "TenacityOfTheMillelith", + "normalizedName": "tenacityofthemillelith", + "artifacts": { + "goblet": { + "artifactName": "Noble's Pledging Vessel", + "GOOD": "NoblesPledgingVessel", + "normalizedName": "noblespledgingvessel" + }, + "plume": { + "artifactName": "Ceremonial War-Plume", + "GOOD": "CeremonialWarPlume", + "normalizedName": "ceremonialwarplume" + }, + "circlet": { + "artifactName": "General's Ancient Helm", + "GOOD": "GeneralsAncientHelm", + "normalizedName": "generalsancienthelm" + }, + "flower": { + "artifactName": "Flower of Accolades", + "GOOD": "FlowerOfAccolades", + "normalizedName": "flowerofaccolades" + }, + "sands": { + "artifactName": "Orichalceous Time-Dial", + "GOOD": "OrichalceousTimeDial", + "normalizedName": "orichalceoustimedial" + } + } + }, + "theexile": { + "setName": "The Exile", + "GOOD": "TheExile", + "normalizedName": "theexile", + "artifacts": { + "goblet": { + "artifactName": "Exile's Goblet", + "GOOD": "ExilesGoblet", + "normalizedName": "exilesgoblet" + }, + "plume": { + "artifactName": "Exile's Feather", + "GOOD": "ExilesFeather", + "normalizedName": "exilesfeather" + }, + "circlet": { + "artifactName": "Exile's Circlet", + "GOOD": "ExilesCirclet", + "normalizedName": "exilescirclet" + }, + "flower": { + "artifactName": "Exile's Flower", + "GOOD": "ExilesFlower", + "normalizedName": "exilesflower" + }, + "sands": { + "artifactName": "Exile's Pocket Watch", + "GOOD": "ExilesPocketWatch", + "normalizedName": "exilespocketwatch" + } + } + }, + "thunderingfury": { + "setName": "Thundering Fury", + "GOOD": "ThunderingFury", + "normalizedName": "thunderingfury", + "artifacts": { + "goblet": { + "artifactName": "Omen of Thunderstorm", + "GOOD": "OmenOfThunderstorm", + "normalizedName": "omenofthunderstorm" + }, + "plume": { + "artifactName": "Survivor of Catastrophe", + "GOOD": "SurvivorOfCatastrophe", + "normalizedName": "survivorofcatastrophe" + }, + "circlet": { + "artifactName": "Thunder Summoner's Crown", + "GOOD": "ThunderSummonersCrown", + "normalizedName": "thundersummonerscrown" + }, + "flower": { + "artifactName": "Thunderbird's Mercy", + "GOOD": "ThunderbirdsMercy", + "normalizedName": "thunderbirdsmercy" + }, + "sands": { + "artifactName": "Hourglass of Thunder", + "GOOD": "HourglassOfThunder", + "normalizedName": "hourglassofthunder" + } + } + }, + "thundersoother": { + "setName": "Thundersoother", + "GOOD": "Thundersoother", + "normalizedName": "thundersoother", + "artifacts": { + "goblet": { + "artifactName": "Thundersoother's Goblet", + "GOOD": "ThundersoothersGoblet", + "normalizedName": "thundersoothersgoblet" + }, + "plume": { + "artifactName": "Thundersoother's Plume", + "GOOD": "ThundersoothersPlume", + "normalizedName": "thundersoothersplume" + }, + "circlet": { + "artifactName": "Thundersoother's Diadem", + "GOOD": "ThundersoothersDiadem", + "normalizedName": "thundersoothersdiadem" + }, + "flower": { + "artifactName": "Thundersoother's Heart", + "GOOD": "ThundersoothersHeart", + "normalizedName": "thundersoothersheart" + }, + "sands": { + "artifactName": "Hour of Soothing Thunder", + "GOOD": "HourOfSoothingThunder", + "normalizedName": "hourofsoothingthunder" + } + } + }, + "tinymiracle": { + "setName": "Tiny Miracle", + "GOOD": "TinyMiracle", + "normalizedName": "tinymiracle", + "artifacts": { + "goblet": { + "artifactName": "Tiny Miracle's Goblet", + "GOOD": "TinyMiraclesGoblet", + "normalizedName": "tinymiraclesgoblet" + }, + "plume": { + "artifactName": "Tiny Miracle's Feather", + "GOOD": "TinyMiraclesFeather", + "normalizedName": "tinymiraclesfeather" + }, + "circlet": { + "artifactName": "Tiny Miracle's Earrings", + "GOOD": "TinyMiraclesEarrings", + "normalizedName": "tinymiraclesearrings" + }, + "flower": { + "artifactName": "Tiny Miracle's Flower", + "GOOD": "TinyMiraclesFlower", + "normalizedName": "tinymiraclesflower" + }, + "sands": { + "artifactName": "Tiny Miracle's Hourglass", + "GOOD": "TinyMiraclesHourglass", + "normalizedName": "tinymiracleshourglass" + } + } + }, + "travelingdoctor": { + "setName": "Traveling Doctor", + "GOOD": "TravelingDoctor", + "normalizedName": "travelingdoctor", + "artifacts": { + "goblet": { + "artifactName": "Traveling Doctor's Medicine Pot", + "GOOD": "TravelingDoctorsMedicinePot", + "normalizedName": "travelingdoctorsmedicinepot" + }, + "plume": { + "artifactName": "Traveling Doctor's Owl Feather", + "GOOD": "TravelingDoctorsOwlFeather", + "normalizedName": "travelingdoctorsowlfeather" + }, + "circlet": { + "artifactName": "Traveling Doctor's Handkerchief", + "GOOD": "TravelingDoctorsHandkerchief", + "normalizedName": "travelingdoctorshandkerchief" + }, + "flower": { + "artifactName": "Traveling Doctor's Silver Lotus", + "GOOD": "TravelingDoctorsSilverLotus", + "normalizedName": "travelingdoctorssilverlotus" + }, + "sands": { + "artifactName": "Traveling Doctor's Pocket Watch", + "GOOD": "TravelingDoctorsPocketWatch", + "normalizedName": "travelingdoctorspocketwatch" + } + } + }, + "unfinishedreverie": { + "setName": "Unfinished Reverie", + "GOOD": "UnfinishedReverie", + "normalizedName": "unfinishedreverie", + "artifacts": { + "goblet": { + "artifactName": "The Wine-Flask Over Which the Plan Was Hatched", + "GOOD": "TheWineFlaskOverWhichThePlanWasHatched", + "normalizedName": "thewineflaskoverwhichtheplanwashatched" + }, + "plume": { + "artifactName": "Faded Emerald Tail", + "GOOD": "FadedEmeraldTail", + "normalizedName": "fadedemeraldtail" + }, + "circlet": { + "artifactName": "Crownless Crown", + "GOOD": "CrownlessCrown", + "normalizedName": "crownlesscrown" + }, + "flower": { + "artifactName": "Dark Fruit of Bright Flowers", + "GOOD": "DarkFruitOfBrightFlowers", + "normalizedName": "darkfruitofbrightflowers" + }, + "sands": { + "artifactName": "Moment of Attainment", + "GOOD": "MomentOfAttainment", + "normalizedName": "momentofattainment" + } + } + }, + "vermillionhereafter": { + "setName": "Vermillion Hereafter", + "GOOD": "VermillionHereafter", + "normalizedName": "vermillionhereafter", + "artifacts": { + "goblet": { + "artifactName": "Moment of the Pact", + "GOOD": "MomentOfThePact", + "normalizedName": "momentofthepact" + }, + "plume": { + "artifactName": "Feather of Nascent Light", + "GOOD": "FeatherOfNascentLight", + "normalizedName": "featherofnascentlight" + }, + "circlet": { + "artifactName": "Thundering Poise", + "GOOD": "ThunderingPoise", + "normalizedName": "thunderingpoise" + }, + "flower": { + "artifactName": "Flowering Life", + "GOOD": "FloweringLife", + "normalizedName": "floweringlife" + }, + "sands": { + "artifactName": "Solar Relic", + "GOOD": "SolarRelic", + "normalizedName": "solarrelic" + } + } + }, + "viridescentvenerer": { + "setName": "Viridescent Venerer", + "GOOD": "ViridescentVenerer", + "normalizedName": "viridescentvenerer", + "artifacts": { + "goblet": { + "artifactName": "Viridescent Venerer's Vessel", + "GOOD": "ViridescentVenerersVessel", + "normalizedName": "viridescentvenerersvessel" + }, + "plume": { + "artifactName": "Viridescent Arrow Feather", + "GOOD": "ViridescentArrowFeather", + "normalizedName": "viridescentarrowfeather" + }, + "circlet": { + "artifactName": "Viridescent Venerer's Diadem", + "GOOD": "ViridescentVenerersDiadem", + "normalizedName": "viridescentvenerersdiadem" + }, + "flower": { + "artifactName": "In Remembrance of Viridescent Fields", + "GOOD": "InRemembranceOfViridescentFields", + "normalizedName": "inremembranceofviridescentfields" + }, + "sands": { + "artifactName": "Viridescent Venerer's Determination", + "GOOD": "ViridescentVenerersDetermination", + "normalizedName": "viridescentvenerersdetermination" + } + } + }, + "vourukashasglow": { + "setName": "Vourukasha's Glow", + "GOOD": "VourukashasGlow", + "normalizedName": "vourukashasglow", + "artifacts": { + "goblet": { + "artifactName": "Feast of Boundless Joy", + "GOOD": "FeastOfBoundlessJoy", + "normalizedName": "feastofboundlessjoy" + }, + "plume": { + "artifactName": "Vibrant Pinion", + "GOOD": "VibrantPinion", + "normalizedName": "vibrantpinion" + }, + "circlet": { + "artifactName": "Heart of Khvarena's Brilliance", + "GOOD": "HeartOfKhvarenasBrilliance", + "normalizedName": "heartofkhvarenasbrilliance" + }, + "flower": { + "artifactName": "Stamen of Khvarena's Origin", + "GOOD": "StamenOfKhvarenasOrigin", + "normalizedName": "stamenofkhvarenasorigin" + }, + "sands": { + "artifactName": "Ancient Abscission", + "GOOD": "AncientAbscission", + "normalizedName": "ancientabscission" + } + } + }, + "wandererstroupe": { + "setName": "Wanderer's Troupe", + "GOOD": "WanderersTroupe", + "normalizedName": "wandererstroupe", + "artifacts": { + "goblet": { + "artifactName": "Wanderer's String-Kettle", + "GOOD": "WanderersStringKettle", + "normalizedName": "wanderersstringkettle" + }, + "plume": { + "artifactName": "Bard's Arrow Feather", + "GOOD": "BardsArrowFeather", + "normalizedName": "bardsarrowfeather" + }, + "circlet": { + "artifactName": "Conductor's Top Hat", + "GOOD": "ConductorsTopHat", + "normalizedName": "conductorstophat" + }, + "flower": { + "artifactName": "Troupe's Dawnlight", + "GOOD": "TroupesDawnlight", + "normalizedName": "troupesdawnlight" + }, + "sands": { + "artifactName": "Concert's Final Hour", + "GOOD": "ConcertsFinalHour", + "normalizedName": "concertsfinalhour" + } + } + } +} \ No newline at end of file diff --git a/data/ik-inventorylists/characters.json b/data/ik-inventorylists/characters.json new file mode 100644 index 0000000..02fa332 --- /dev/null +++ b/data/ik-inventorylists/characters.json @@ -0,0 +1,1677 @@ +{ + "aino": { + "GOOD": "Aino", + "ConstellationName": [ + "Cistellula Mira" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 1 + }, + "albedo": { + "GOOD": "Albedo", + "ConstellationName": [ + "Princeps Cretaceus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 0 + }, + "alhaitham": { + "GOOD": "Alhaitham", + "ConstellationName": [ + "Vultur Volans" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 0 + }, + "aloy": { + "GOOD": "Aloy", + "ConstellationName": [ + "Nora Fortis" + ], + "ConstellationOrder": [ + "burst", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 3 + }, + "amber": { + "GOOD": "Amber", + "ConstellationName": [ + "Lepus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "pyro" + ], + "WeaponType": 3 + }, + "aratakiitto": { + "GOOD": "AratakiItto", + "ConstellationName": [ + "Taurus Iracundus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 1 + }, + "arlecchino": { + "GOOD": "Arlecchino", + "ConstellationName": [ + "Ignis Purgatorius" + ], + "ConstellationOrder": [ + "auto", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 2 + }, + "baizhu": { + "GOOD": "Baizhu", + "ConstellationName": [ + "Lagenaria" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "dendro" + ], + "WeaponType": 4 + }, + "barbara": { + "GOOD": "Barbara", + "ConstellationName": [ + "Crater" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "beidou": { + "GOOD": "Beidou", + "ConstellationName": [ + "Victor Mare" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 1 + }, + "bennett": { + "GOOD": "Bennett", + "ConstellationName": [ + "Rota Calamitas" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 0 + }, + "candace": { + "GOOD": "Candace", + "ConstellationName": [ + "Sagitta Scutum" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 2 + }, + "charlotte": { + "GOOD": "Charlotte", + "ConstellationName": [ + "Hualina Veritas" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 4 + }, + "chasca": { + "GOOD": "Chasca", + "ConstellationName": [ + "Vultur Gryphus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 3 + }, + "chevreuse": { + "GOOD": "Chevreuse", + "ConstellationName": [ + "Sclopetum Ensiferum" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 2 + }, + "chiori": { + "GOOD": "Chiori", + "ConstellationName": [ + "Cisoria" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 0 + }, + "chongyun": { + "GOOD": "Chongyun", + "ConstellationName": [ + "Nubis Caesor" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 1 + }, + "citlali": { + "GOOD": "Citlali", + "ConstellationName": [ + "Patina Anavatlaca" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 4 + }, + "clorinde": { + "GOOD": "Clorinde", + "ConstellationName": [ + "Rapperia" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 0 + }, + "collei": { + "GOOD": "Collei", + "ConstellationName": [ + "Leptailurus Cervarius" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 3 + }, + "columbina": { + "GOOD": "Columbina", + "ConstellationName": [ + "???" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "cyno": { + "GOOD": "Cyno", + "ConstellationName": [ + "Lupus Aureus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 2 + }, + "dahlia": { + "GOOD": "Dahlia", + "ConstellationName": [ + "Cantus Choralis" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 0 + }, + "dehya": { + "GOOD": "Dehya", + "ConstellationName": [ + "Mantichora" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "pyro" + ], + "WeaponType": 1 + }, + "diluc": { + "GOOD": "Diluc", + "ConstellationName": [ + "Noctua" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 1 + }, + "diona": { + "GOOD": "Diona", + "ConstellationName": [ + "Feles" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 3 + }, + "dori": { + "GOOD": "Dori", + "ConstellationName": [ + "Magicae Lucerna" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 1 + }, + "durin": { + "GOOD": "Durin", + "ConstellationName": [ + "Draco Rubedo" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "pyro" + ], + "WeaponType": 0 + }, + "emilie": { + "GOOD": "Emilie", + "ConstellationName": [ + "Pomum de Ambra" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 2 + }, + "escoffier": { + "GOOD": "Escoffier", + "ConstellationName": [ + "Dulciaria Structura" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 2 + }, + "eula": { + "GOOD": "Eula", + "ConstellationName": [ + "Aphros Delos" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 1 + }, + "faruzan": { + "GOOD": "Faruzan", + "ConstellationName": [ + "Flosculi Implexi" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 3 + }, + "fischl": { + "GOOD": "Fischl", + "ConstellationName": [ + "Corvus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 3 + }, + "flins": { + "GOOD": "Flins", + "ConstellationName": [ + "Laterna Vigilis" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 2 + }, + "freminet": { + "GOOD": "Freminet", + "ConstellationName": [ + "Automaton" + ], + "ConstellationOrder": [ + "auto", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 1 + }, + "furina": { + "GOOD": "Furina", + "ConstellationName": [ + "Animula Choragi" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 0 + }, + "gaming": { + "GOOD": "Gaming", + "ConstellationName": [ + "Leo Expergiscens" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 1 + }, + "ganyu": { + "GOOD": "Ganyu", + "ConstellationName": [ + "Sinae Unicornis" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 3 + }, + "gorou": { + "GOOD": "Gorou", + "ConstellationName": [ + "Canis Bellatoris" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 3 + }, + "hutao": { + "GOOD": "HuTao", + "ConstellationName": [ + "Papilio Charontis" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 2 + }, + "iansan": { + "GOOD": "Iansan", + "ConstellationName": [ + "Carnotaurus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 2 + }, + "ifa": { + "GOOD": "Ifa", + "ConstellationName": [ + "Catena Opele" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "illuga": { + "GOOD": "Illuga", + "ConstellationName": [ + "Oriolus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "geo" + ], + "WeaponType": 2 + }, + "ineffa": { + "GOOD": "Ineffa", + "ConstellationName": [ + "Vanilla Planifolia" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 2 + }, + "jahoda": { + "GOOD": "Jahoda", + "ConstellationName": [ + "Fragum" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 3 + }, + "jean": { + "GOOD": "Jean", + "ConstellationName": [ + "Leo Minor" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 0 + }, + "kachina": { + "GOOD": "Kachina", + "ConstellationName": [ + "Ochotona Princeps" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 2 + }, + "kaedeharakazuha": { + "GOOD": "KaedeharaKazuha", + "ConstellationName": [ + "Acer Palmatum" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 0 + }, + "kaeya": { + "GOOD": "Kaeya", + "ConstellationName": [ + "Pavo Ocellus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 0 + }, + "kamisatoayaka": { + "GOOD": "KamisatoAyaka", + "ConstellationName": [ + "Grus Nivis" + ], + "ConstellationOrder": [ + "burst", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 0 + }, + "kamisatoayato": { + "GOOD": "KamisatoAyato", + "ConstellationName": [ + "Cypressus Custos" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 0 + }, + "kaveh": { + "GOOD": "Kaveh", + "ConstellationName": [ + "Paradisaea" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "dendro" + ], + "WeaponType": 1 + }, + "keqing": { + "GOOD": "Keqing", + "ConstellationName": [ + "Trulla Cementarii" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 0 + }, + "kinich": { + "GOOD": "Kinich", + "ConstellationName": [ + "Chimaera Alebriius" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 1 + }, + "kirara": { + "GOOD": "Kirara", + "ConstellationName": [ + "Arcella" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 0 + }, + "klee": { + "GOOD": "Klee", + "ConstellationName": [ + "Trifolium" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 4 + }, + "kujousara": { + "GOOD": "KujouSara", + "ConstellationName": [ + "Flabellum" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 3 + }, + "kukishinobu": { + "GOOD": "KukiShinobu", + "ConstellationName": [ + "Tribulatio Demptio" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 0 + }, + "lanyan": { + "GOOD": "LanYan", + "ConstellationName": [ + "Hirundo Lazuli" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "lauma": { + "GOOD": "Lauma", + "ConstellationName": [ + "Cerva Nivea" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "dendro" + ], + "WeaponType": 4 + }, + "layla": { + "GOOD": "Layla", + "ConstellationName": [ + "Luscinia" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 0 + }, + "linnea": { + "GOOD": "Linnea", + "ConstellationName": [ + "Alcyon" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 3 + }, + "lisa": { + "GOOD": "Lisa", + "ConstellationName": [ + "Tempus Fugit" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 4 + }, + "lohen": { + "GOOD": "Lohen", + "ConstellationName": [ + "Lepus Miles" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 2 + }, + "lynette": { + "GOOD": "Lynette", + "ConstellationName": [ + "Felis Alba" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 0 + }, + "lyney": { + "GOOD": "Lyney", + "ConstellationName": [ + "Felis Fuscus" + ], + "ConstellationOrder": [ + "auto", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 3 + }, + "manequin1": { + "GOOD": "Manequin1", + "ConstellationName": [ + "Support entry to omit manequins during scanning; GOOD does not support manequins." + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro", + "pyro", + "dendro", + "geo", + "hydro", + "anemo" + ], + "WeaponType": 0 + }, + "manequin2": { + "GOOD": "Manequin2", + "ConstellationName": [ + "Support entry to omit manequins during scanning; GOOD does not support manequins." + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro", + "pyro", + "dendro", + "geo", + "hydro", + "anemo" + ], + "WeaponType": 0 + }, + "mavuika": { + "GOOD": "Mavuika", + "ConstellationName": [ + "Sol Invictus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "pyro" + ], + "WeaponType": 1 + }, + "mika": { + "GOOD": "Mika", + "ConstellationName": [ + "Palumbus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 2 + }, + "mona": { + "GOOD": "Mona", + "ConstellationName": [ + "Astrolabos" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "mualani": { + "GOOD": "Mualani", + "ConstellationName": [ + "Phoca Neomonachus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "nahida": { + "GOOD": "Nahida", + "ConstellationName": [ + "Sapientia Oromasdis" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 4 + }, + "navia": { + "GOOD": "Navia", + "ConstellationName": [ + "Rosa Multiflora" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 1 + }, + "nefer": { + "GOOD": "Nefer", + "ConstellationName": [ + "Ludus Latrunculorum" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 4 + }, + "neuvillette": { + "GOOD": "Neuvillette", + "ConstellationName": [ + "???" + ], + "ConstellationOrder": [ + "auto", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "nicole": { + "GOOD": "Nicole", + "ConstellationName": [ + "Reliquiarium" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 4 + }, + "nilou": { + "GOOD": "Nilou", + "ConstellationName": [ + "Lotos Somno" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 0 + }, + "ningguang": { + "GOOD": "Ningguang", + "ConstellationName": [ + "Opus Aequilibrium" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "geo" + ], + "WeaponType": 4 + }, + "noelle": { + "GOOD": "Noelle", + "ConstellationName": [ + "Parma Cordis" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 1 + }, + "ororon": { + "GOOD": "Ororon", + "ConstellationName": [ + "Vampyrum Spectrum" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 3 + }, + "prune": { + "GOOD": "Prune", + "ConstellationName": [ + "Turris Venefica" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "qiqi": { + "GOOD": "Qiqi", + "ConstellationName": [ + "Pristina Nola" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 0 + }, + "raidenshogun": { + "GOOD": "RaidenShogun", + "ConstellationName": [ + "Imperatrix Umbrosa" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 2 + }, + "razor": { + "GOOD": "Razor", + "ConstellationName": [ + "Lupus Minor" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "electro" + ], + "WeaponType": 1 + }, + "rosaria": { + "GOOD": "Rosaria", + "ConstellationName": [ + "Spinea Corona" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 2 + }, + "sandrone": { + "GOOD": "Sandrone", + "ConstellationName": [ + "Narcissorolegium" + ], + "ConstellationOrder": [ + "burst", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 1 + }, + "sangonomiyakokomi": { + "GOOD": "SangonomiyaKokomi", + "ConstellationName": [ + "Dracaena Somnolenta" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 4 + }, + "sayu": { + "GOOD": "Sayu", + "ConstellationName": [ + "Nyctereutes Minor" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 1 + }, + "sethos": { + "GOOD": "Sethos", + "ConstellationName": [ + "Basileos Delta" + ], + "ConstellationOrder": [ + "auto", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 3 + }, + "shenhe": { + "GOOD": "Shenhe", + "ConstellationName": [ + "Crista Doloris" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 2 + }, + "shikanoinheizou": { + "GOOD": "ShikanoinHeizou", + "ConstellationName": [ + "Cervus Minor" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "sigewinne": { + "GOOD": "Sigewinne", + "ConstellationName": [ + "Nereides" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 3 + }, + "skirk": { + "GOOD": "Skirk", + "ConstellationName": [ + "Crystallina" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "cryo" + ], + "WeaponType": 0 + }, + "sucrose": { + "GOOD": "Sucrose", + "ConstellationName": [ + "Ampulla" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "tartaglia": { + "GOOD": "Tartaglia", + "ConstellationName": [ + "Monoceros Caeli" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "hydro" + ], + "WeaponType": 3 + }, + "thoma": { + "GOOD": "Thoma", + "ConstellationName": [ + "Rubeum Scutum" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 2 + }, + "tighnari": { + "GOOD": "Tighnari", + "ConstellationName": [ + "Vulpes Zerda" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "dendro" + ], + "WeaponType": 3 + }, + "traveler": { + "GOOD": "Traveler", + "Element": [ + "electro", + "pyro", + "dendro", + "geo", + "hydro", + "anemo" + ], + "ConstellationOrder": {}, + "WeaponType": 0 + }, + "varesa": { + "GOOD": "Varesa", + "ConstellationName": [ + "Mascara Luctatori" + ], + "ConstellationOrder": [ + "burst", + "auto" + ], + "Element": [ + "electro" + ], + "WeaponType": 4 + }, + "varka": { + "GOOD": "Varka", + "ConstellationName": [ + "Lupus Majoris" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 1 + }, + "venti": { + "GOOD": "Venti", + "ConstellationName": [ + "Carmen Dei" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 3 + }, + "wanderer": { + "GOOD": "Wanderer", + "ConstellationName": [ + "Peregrinus" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "wriothesley": { + "GOOD": "Wriothesley", + "ConstellationName": [ + "Cerberus" + ], + "ConstellationOrder": [ + "auto", + "burst" + ], + "Element": [ + "cryo" + ], + "WeaponType": 4 + }, + "xiangling": { + "GOOD": "Xiangling", + "ConstellationName": [ + "Trulla" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "pyro" + ], + "WeaponType": 2 + }, + "xianyun": { + "GOOD": "Xianyun", + "ConstellationName": [ + "Grus Serena" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "xiao": { + "GOOD": "Xiao", + "ConstellationName": [ + "Alatus Nemeseos" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 2 + }, + "xilonen": { + "GOOD": "Xilonen", + "ConstellationName": [ + "Panthera Ocelota" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 0 + }, + "xingqiu": { + "GOOD": "Xingqiu", + "ConstellationName": [ + "Fabulae Textile" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 0 + }, + "xinyan": { + "GOOD": "Xinyan", + "ConstellationName": [ + "Fila Ignium" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 1 + }, + "yaemiko": { + "GOOD": "YaeMiko", + "ConstellationName": [ + "Divina Vulpes" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "electro" + ], + "WeaponType": 4 + }, + "yanfei": { + "GOOD": "Yanfei", + "ConstellationName": [ + "Bestia Iustitia" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 4 + }, + "yaoyao": { + "GOOD": "Yaoyao", + "ConstellationName": [ + "Osmanthus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "dendro" + ], + "WeaponType": 2 + }, + "yelan": { + "GOOD": "Yelan", + "ConstellationName": [ + "Umbrabilis Orchis" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "hydro" + ], + "WeaponType": 3 + }, + "yoimiya": { + "GOOD": "Yoimiya", + "ConstellationName": [ + "Carassius Auratus" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "pyro" + ], + "WeaponType": 3 + }, + "yumemizukimizuki": { + "GOOD": "YumemizukiMizuki", + "ConstellationName": [ + "Tapirus Somniator" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "anemo" + ], + "WeaponType": 4 + }, + "yunjin": { + "GOOD": "YunJin", + "ConstellationName": [ + "Opera Grandis" + ], + "ConstellationOrder": [ + "burst", + "skill" + ], + "Element": [ + "geo" + ], + "WeaponType": 2 + }, + "zhongli": { + "GOOD": "Zhongli", + "ConstellationName": [ + "???" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 2 + }, + "zibai": { + "GOOD": "Zibai", + "ConstellationName": [ + "Equa Alba" + ], + "ConstellationOrder": [ + "skill", + "burst" + ], + "Element": [ + "geo" + ], + "WeaponType": 0 + } +} \ No newline at end of file diff --git a/data/ik-inventorylists/materials.json b/data/ik-inventorylists/materials.json new file mode 100644 index 0000000..03c40ea --- /dev/null +++ b/data/ik-inventorylists/materials.json @@ -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" +} \ No newline at end of file diff --git a/data/ik-inventorylists/version.txt b/data/ik-inventorylists/version.txt new file mode 100644 index 0000000..07a7b03 --- /dev/null +++ b/data/ik-inventorylists/version.txt @@ -0,0 +1 @@ +6.7.0 \ No newline at end of file diff --git a/data/ik-inventorylists/weapons.json b/data/ik-inventorylists/weapons.json new file mode 100644 index 0000000..2e93461 --- /dev/null +++ b/data/ik-inventorylists/weapons.json @@ -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" +} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 870a65c..98564af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,18 +6,27 @@ This document describes the structure, boundaries, flows, and technical rules of Genshin Artifact Assistant is a local desktop application. Electron owns OS integration, screen capture, IPC, and overlay windows. React owns the interactive UI. Domain logic for OCR parsing, scoring, scanner state, and recommendations lives in TypeScript modules under `src/lib`. +The scanner performance direction is now native-first: the C# input helper owns +the fast capture/click/scroll loop and uses vendored Inventory Kamera +`inventorylists` as the scanner dictionary source. Electron is the process, +IPC, packaging, hotkey, and dev-control shell. React is only the visual control +and status surface for this path. + ```mermaid flowchart LR User["User"] Genshin["Genshin Impact Window"] Electron["Electron Main Process"] + Native["C# Input Helper / Native Scanner"] React["React Renderer"] Parser["OCR Parser and Scoring"] LocalData["Local Snapshot / Future SQLite"] User --> React React --> Electron - Electron --> Genshin + Electron --> Native + Native --> Genshin + Native --> Electron Electron --> React React --> Parser Parser --> React @@ -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/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup | | `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable | +| `electron/services/nativeScannerProcessingService.ts` | Post-capture processor for native IK runs; reads crop jobs, OCRs/parses card crops, matches IK metadata, writes processing outputs, and safely loads native crop previews | +| `electron/services/nativeScannerResultWorkflowService.ts` | Authoritative promotion and review workflow for native results; reloads run state, validates edits, updates durable results/logs, and only writes the artifact store after explicit confirmation | | `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling | | `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` | +| `native/input-helper/IkInventoryLists.cs` | Loads and validates the vendored IK `inventorylists` feature catalog | +| `native/input-helper/NativeScannerFiles.cs` | Writes native scanner manifest, status, and JSONL crop-job files | | `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store | +| `src/lib/ikArtifactMatcher.ts` | Pure IK inventorylist matcher for native artifact set/piece/slot validation and GOOD key metadata | +| `src/lib/ikCatalogMatcher.ts` | Pure IK inventorylist matcher for simple weapon, character, and material names plus compact GOOD-key samples | +| `src/lib/ikScanCapabilities.ts` | Pure capability summary that separates IK catalog coverage from implemented native capture support | +| `src/lib/scanResultEntry.ts` | Pure durable scan-result entry/status helpers that keep extraction confidence separate from deferred artifact value evaluation | | `src/App.tsx` | Thin React entry that renders the app page | | `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing | | `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly | | `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration | -| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and 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/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/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 | @@ -72,6 +89,7 @@ flowchart LR | `src/lib/upgradeProjection.ts` | Planned best/middle/worst upgrade projection for under-leveled artifacts | | `src/lib/scoring.ts` | Recommendation and build scoring logic | | `src/lib/demoData.ts` | Temporary local demo snapshot | +| `data/ik-inventorylists/*` | 1:1 vendored Inventory Kamera inventory lists used by the native scanner data preflight and future matching | | `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats | | `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` | | `src/types/*` | Shared app, capture, and domain contracts | @@ -117,9 +135,14 @@ sequenceDiagram | Capture sources | Electron desktopCapturer | Electron main | Scan UI | | Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview | | OCR crops | Electron main | Electron main | Details modal, parser | +| Native card crops | C# input helper | Native scanner run directory under app userData | Electron status, inventory preview, later OCR/parse queue | +| IK inventorylists | `data/ik-inventorylists` copied from Inventory Kamera 1.4.4 | Source data package | Native scanner data preflight and future matcher | +| IK simple catalog match | IK weapon/character/material inventorylist catalog | `IkCatalogItemMatch` | Future category scanners, inventory catalog evidence | | Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser | -| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, future local DB | -| Scan result entry | Scan loop and parser/evaluator | Renderer domain logic | Live scan rail, artifact inventory, summary | +| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, post-capture report, future local DB | +| IK artifact match | IK artifact inventorylist catalog | `ScanResultIkMatch` | Native post-capture review gate, scan result entry, inventory detail | +| Parser field confidence | Native post-capture parser | `ScanResultFieldConfidence` | Scan result entry, inventory detail review signal | +| Scan result entry | Scan loop or native post-capture processor | `StoredScanResultEntry` / native run `scan-results.json` | Live scan rail, artifact inventory, summary | | Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons | | Upgrade projection | Projection helper | `src/lib` | Artifact detail view only | | Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training | @@ -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. - Dev-only probes under `http://127.0.0.1:17317` are used for live validation: `/automation/probe-click?index=N` tests one read-only tile selection, and - `/scanner/start?entry=visible-inventory&limit=N` starts an auto-scan with a - temporary limit payload from an already visible artifact detail view. + `/scanner/start?limit=N` starts the native capture scan with a temporary + limit payload from an already visible artifact detail view. The live known-good result on 2026-07-07 is documented in [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). - Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts. - Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read". -- Artifact grid automation uses Inventory Kamera's 32-target full-page model - (`8 x 4` safe click targets). The apparent lower fifth row sits in the - bottom control band on 16:9 captures and is not clicked automatically. +- Artifact grid automation uses 32 safe click targets per full page + (`8 x 4`). The apparent lower fifth row sits in the bottom control band on + 16:9 captures and is not clicked automatically. - Guided auto-entry is state gated. The normal scan button first performs a lightweight no-OCR preflight; OCR/store/review work starts only after the artifact inventory grid and artifact detail card are visually confirmed. @@ -167,12 +190,54 @@ sequenceDiagram - Item verification uses the artifact OCR capture's own detail fingerprint, so the loop no longer performs a separate card-ready capture before OCR. Page waits remain fingerprint based and can proceed as soon as the inventory pane - changes and stabilizes, while still accepting IK-like 100 ms scroll readiness - points. + changes and stabilizes. - Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan). - The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them. -Parsed artifacts from both modes are persisted into `artifact-store.json` keyed by a content signature that excludes the equipped character, so re-equipping updates a record instead of duplicating it. Leveling an artifact currently creates a new record (documented limitation until rescan-merge exists). +**Native IK capture scan** is the new high-speed path. It runs inside the C# +sidecar, computes a fixed 16:9 8x4 visible-inventory grid, clicks and scrolls +natively, captures the artifact detail card as PNG crops, and reports progress +through IPC/dev-control. The renderer only starts, stops, and displays status. +OCR, parsing, GOOD persistence, and artifact value evaluation are deliberately +separate follow-up stages so capture speed is not blocked by UI work. + +Each native run writes a self-contained run directory under app userData: + +- `manifest.json`: run schema, IK data version/categories, Genshin bounds, + grid, detail crop rectangle, explicit scan category, and downstream queue + contract. +- `capture-jobs.jsonl`: one job per captured card crop with page/row/column, + client/screen coordinates, click event count, image path, and downstream + `ocr-parse-store` marker. Jobs include the scan category; today only + `artifacts` produces jobs. Native preflight and post-capture processing also + guard this category boundary so catalog-only weapon, character, and material + entries cannot be accidentally parsed as artifacts. +- `status.json`: latest scanner status snapshot for recovery and dev tooling. + Its `supportedCategories` block separates IK catalog availability from native + capture support: artifacts are the only native-capture category today, while + weapons, characters, and materials are catalog-only until their own capture + flows have evidence. +- `scan-results.json`: durable per-artifact result entries with capture + metadata, parsed artifact identity when available, extraction status, and + value status. Native capture currently writes `deferred` value status for + clean extraction and `review` for uncertain extraction. +- `processing-report.json`: optional post-capture OCR/parse report generated + from `capture-jobs.jsonl`. It records `queueConcurrency` for the bounded + post-capture OCR/parse worker and is non-persisting by default until native + crop OCR has live validation evidence. + +Parsed artifacts from manual and renderer auto-scan modes are persisted into +`artifact-store.json` keyed by a content signature that excludes the equipped +character, so re-equipping updates a record instead of duplicating it. Leveling +an artifact currently creates a new record (documented limitation until +rescan-merge exists). Native IK capture currently writes card crops first; a +downstream OCR/parse processor can report parsed artifacts from those crops +without slowing capture. Store promotion remains opt-in until native crop OCR is +validated. +The Inventory surface derives a dry-run promotion summary from native +`scan-results.json` entries and the local artifact store. It can show which +native artifacts are ready for explicit promotion, already stored, review-only, +or blocked, but it does not write store records by itself. During auto-scan, artifact store writes can be batched and flushed after the click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path. Auto-scan artifact captures also bypass Electron source-list enumeration and use @@ -192,10 +257,19 @@ Architecture rules: OCR/debug stats stay in diagnostics or detail. - A scan result row preserves extraction status and artifact value status as separate data even when the UI shows one compact pill. -- The artifact inventory view owns browsing, filtering, sorting, and opening - detail. +- The artifact inventory view owns browsing, filtering, sorting, opening detail, + and showing the current artifact-only native pipeline state. +- Weapons, materials, and character details stay out of the active Inventory UI + until their values are actually scanned; loaded IK catalog data alone is not a + user-facing scanner capability. - The artifact detail view owns screenshot/crop inspection, OCR confidence, parser notes, value score reasons, and upgrade projection. +- Native crop previews are served through the Electron bridge only for PNG paths + inside the selected native scanner run directory. +- Native artifact post-processing uses IK artifact set/piece/slot matching as a + review gate. A conflict is extraction uncertainty, not a weak artifact value. +- Native artifact post-processing stores per-field parser confidence so detail + review can explain which OCR/parser fields are trustworthy. - Upgrade projection is a local deterministic/probabilistic helper, never a claim that an artifact will roll a specific way. @@ -228,8 +302,9 @@ Future queue refactor: - OCR/parse/evaluation may process bounded queued screenshot/crop jobs. - Queueing must preserve stop behavior, duplicate handling, review decisions, and the existing read-only safety boundary. -- The queue refactor is secondary to content extraction and result/inventory - contracts while current scan speed remains acceptable. +- The native post-capture processor already consumes crop jobs with bounded + parallelism while preserving report order. Further queue work is secondary to + live throughput evidence and result quality. ## Security And Safety @@ -251,10 +326,8 @@ Future queue refactor: ## Performance -Current OCR is still measured against the IK target rather than assumed good. -The app keeps a Tesseract.js worker pool, can use the Inventory-Kamera -`genshin_fast_09_04_21.traineddata` path for comparison, and reports capture, -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. +Current OCR is measured through the eval harness and live scan assessments +rather than assumed good. The app keeps a Tesseract.js worker pool and reports +capture, OCR, card-ready, scroll-ready, active-scan, and projected-100 timings. For the next product phase, performance work should not displace extraction quality, result clarity, or review safety unless evidence shows a regression. diff --git a/docs/AUTOMATION_LIVE_SCAN.md b/docs/AUTOMATION_LIVE_SCAN.md index d138568..d4a71a0 100644 --- a/docs/AUTOMATION_LIVE_SCAN.md +++ b/docs/AUTOMATION_LIVE_SCAN.md @@ -9,34 +9,31 @@ Validated live with Genshin open in the artifact inventory at 1920x1080, English UI: - `npm run dev:admin` starts the app elevated after the user confirms UAC. -- Runtime status reported `isElevated: true`, `genshinFound: true`, and +- Runtime status reports `isElevated: true`, `genshinFound: true`, and `targetProcess: "GenshinImpact"`. -- The safe probe endpoint `/automation/probe-click?index=1` focused Genshin, - moved the cursor to the second visible inventory tile, clicked it, and changed - the artifact detail panel fingerprint. -- Probe result: `clicked: true`, `inputBlocked: false`, - `foregroundProcess: "GenshinImpact"`, and `changed: true`. -- A bounded live auto-scan via `/scanner/start?limit=2` completed with: - `clicked: 2`, `attempted: 2`, `verified: 2`, `parsed: 2`, `stored: 2`, - `review: 2`, `misses: 0`, `status: "done"`. -- On 2026-07-08, a visible-inventory 50-artifact run completed with `50/50` - parsed and stored, `0` review, `0` duplicates, and `0` misses. Throughput - was still slow at `61765 ms` elapsed (`1235 ms/artifact`). -- On 2026-07-09, the current-engine visible-inventory path completed - `/scanner/start?entry=visible-inventory&limit=20&engine=current` with - `20/20` verified and parsed, `19` stored, `1` duplicate, `0` review, and - `0` misses in `8047 ms` elapsed (`402 ms/artifact`). A same-session artifact - detail capture also persisted an equipped footer as `equipped: "Citlali"` and - an unlocked grey lock as `locked: false`. +- `/automation/probe-click?index=1` performs one read-only inventory tile + selection click. +- The visible-inventory auto-scan path is the production baseline. It requires + the Artifact inventory to already be open with a visible artifact detail card. +- On 2026-07-09, `/scanner/start?entry=visible-inventory&limit=100` completed + with `100/100` attempted, verified, and parsed, `98` stored, `2` duplicates, + `0` review samples, `0` misses, `4` pages, and `393 ms/artifact`. +- On 2026-07-09, `npm run scan:native:smoke` completed against runtime + signature `2026-07-09-native-ik-visual-probe`: native visual preflight passed + at 1920x1080 with `32` grid targets, guarded probe changed the detail panel, + native capture wrote `2/2` artifact card crops in `510 ms`, and post-capture + processing parsed `2/2` with `0` review, `0` errors, `queueConcurrency: 2`, + and `persisted: false`. +- The same native path later completed dry 20/50/100-item runs. The final run + captured `100/100` crops across 4 pages in `24,673 ms`, parsed `100/100`, + produced `0` errors, routed 3 implausible OCR values into Review, and wrote + nothing to the artifact store. Full evidence and the parser/timestamp fixes + are recorded in + [NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md). -This proves that the current elevated app plus helper path can deliver mouse -movement and click input to the focused Genshin client in this environment. - -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. +The old alternate OCR-engine and comparison paths have been retired. The app now +runs the current OCR/capture path only. Performance claims should be stated as +current-run repeatability evidence, not as external-tool parity. ## Elevation And UAC @@ -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 Secure Desktop UAC prompt for itself. After confirmation, the app can verify -its own runtime through the dev status endpoint. +the Secure Desktop UAC prompt for itself. Useful checks: @@ -70,11 +66,12 @@ Expected runtime facts before automatic scan: - `isElevated: true` - `genshinFound: true` - `targetProcess: "GenshinImpact"` -- hotkeys registered +- `/health.appBuild.signature` matches `APP_RUNTIME_SIGNATURE` in + `electron/main.ts` ## Mouse And Click Validation -Use the probe before broad auto-scan work: +Use a probe before broad auto-scan work: ```powershell Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" | @@ -86,8 +83,8 @@ feed, enhance, lock, unlock, spend, or modify game resources. Interpretation: -- `click.ok: true`, `clicked: true`, `inputBlocked: false` means Windows did not - block SendInput/UIPI in the current configuration. +- `click.ok: true`, `clicked: true`, and `inputBlocked: false` means Windows did + not block SendInput/UIPI in the current configuration. - `focused: true` and `foregroundProcess: "GenshinImpact"` means the click was sent while Genshin was foreground. - `changed: true` means the detail panel changed after the click. @@ -95,83 +92,14 @@ Interpretation: neighboring artifacts render identically; retry with another `index`, `row`, or `col`. -Examples: - -```powershell -# Second visible tile -Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?index=1" - -# Specific grid cell -Invoke-RestMethod "http://127.0.0.1:17317/automation/probe-click?row=0&col=3" -``` - ## Bounded Live Auto-Scan -For live validation, prefer a bounded scan first: +Start with a tiny bounded run: ```powershell Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?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: ```powershell @@ -179,219 +107,57 @@ Invoke-RestMethod "http://127.0.0.1:17317/scanner/status" | 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 -Invoke-RestMethod "http://127.0.0.1:17317/health" | - ConvertTo-Json -Depth 6 +npm run scan:native:smoke +npm run scan:native:smoke:5 ``` -The response must include `appBuild.signature` and -`appBuild.expectedOcrWorkerPoolSize`. If `appBuild` is missing, or -`/scanner/status` still reports the old OCR warmup start time, the local port is -still owned by a stale elevated Electron process. Close the old Administrator -window/app and restart with `npm run dev:admin` before running scanner probes. +It checks `/health`, `/scanner/native/data`, +`/scanner/native/preflight?category=artifacts` including the native visual +blank-capture guard, runs the guarded +`/automation/probe-click?index=1`, starts `/scanner/start?limit=N&category=artifacts`, +waits for the native helper to finish, runs `/scanner/native/process` with +`persist=0`, and loads `/scanner/native/results`. Evidence is written to: -Use the status `stats` timing fields for IK comparisons: `elapsedMs`, -`activeScanMs`, `writeFlushMs`, `averageMsPerParsed`, -`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. +```text +outputs/native-live-smoke// +``` -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`, - `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: +The native preflight and start endpoints both accept an explicit category: ```powershell -Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/status" -Invoke-RestMethod "http://127.0.0.1:17317/scanner/lookup/regenerate" -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" +Invoke-RestMethod "http://127.0.0.1:17317/scanner/native/preflight?category=artifacts" +Invoke-RestMethod "http://127.0.0.1:17317/scanner/start?limit=2&category=artifacts" ``` -The benchmark endpoint measures the current Tesseract.js engine and the -Inventory-Kamera-traineddata Tesseract.js path against the artifact crop set and -returns timing/field counts, min/p50/p90/max timing, OCR p50/p90 timing, -20/45/100-artifact projections, skipped-OCR count, and the active OCR worker -pool size. It also returns per-field OCR timings under -`ocrFieldAverages`, which is the first place to look before changing crop or -parser behavior. Individual captures also report whether the artifact was -detected as `sanctified`; level/substat crops are shifted in that state to match -Inventory Kamera's crop model. By default it uses the auto-scan `fast` OCR profile, -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. +Only `artifacts` is implemented as native capture today. `weapons`, +`characters`, and `materials` are expected to block with a catalog-only message +at preflight/start time until their category-specific capture flows are +implemented and validated. They are not part of the active UI scope while those +values are not scanned. +Artifact preflight also blocks when the Genshin client capture is blank, +almost entirely white/black, or too visually uniform to be trusted. +The probe-click endpoint additionally requires a detected Genshin-client +artifact inventory grid and visible artifact detail card before sending input. -## Diagnostic Evidence - -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 +## Evidence Commands After the elevated app is running and Genshin is open on the artifact inventory, the non-elevated terminal can drive the local dev-control endpoints and save a @@ -407,130 +173,49 @@ The helper writes timestamped JSON snapshots and a transcript to: outputs/live-soak// ``` -Default sequence: - -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`: +For short iteration: ```powershell -npm run scan:live:preflight -npm run scan:live:preflight: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 +npm run scan:iterate:validated +npm run scan:iterate:validated:wait ``` -`scan:live:preflight` checks `/health`, `/scanner/status`, the current -`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=`, 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: +For a full 2, 5, 20, 45, 100 current-run evidence chain: ```powershell -npm run scan:assessment:validate -- --latest -npm run scan:assessment:validate -- --input=\scan-performance-assessment.json +npm run scan:goal:validated +npm run scan:goal:validated:wait ``` -`--latest` searches `outputs/live-soak/` for the newest -`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: +For later-session repeatability without changing OCR engines: ```powershell npm run scan:repeatability:wait ``` -That command runs the current visible-inventory engine at 20, 45, and 100 -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. +Validate a saved assessment before using it as final evidence: -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=\scan-performance-assessment.json --summary +``` + +Optional budget flags: + +```powershell +npm run scan:assessment:validate -- --latest --summary --limit=20 --max-active-average-ms=333 --max-capture-roundtrip-overhead-ms=120 +``` + +`--max-active-average-ms=333` is the strict 3 artifacts/second check. A passing +100-artifact current run is valid scanner evidence; it is not evidence that the +3 artifacts/second budget was met unless this budget check also passes. + +The assessment self-test does not need Genshin: ```powershell npm run scan:assessment:test ``` -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 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 -worklist, not ground truth. Only after the expected fields are confirmed or -corrected against the real artifact should a case be moved into -`src/eval/corpus/confirmedReviewCorpus.ts`. This prevents the parser from -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/`. +worklist, not ground truth. Only after expected fields are confirmed or +corrected against the real artifact should a case move into +`src/eval/corpus/confirmedReviewCorpus.ts`. For a manually checked candidate, generate a paste-ready confirmed-case snippet: @@ -555,54 +236,12 @@ For a manually checked candidate, generate a paste-ready confirmed-case snippet: npm run eval:prepare-confirmed -- --candidate= --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 -Do not describe the current implementation as bypassing anti-cheat. The app -does not read memory, hook the process, inject code, modify game files, inspect -packets, or interact with kernel drivers. It uses normal Windows screen capture, -focus, cursor movement, wheel, and click input. - -The practical finding is narrower: - -- A non-elevated app can be blocked by Windows integrity/UIPI when the target - process is elevated or protected. -- Running the app elevated fixed input delivery in the tested environment. -- Genshin's anti-cheat may still affect behavior on other machines, game modes, - overlays, or future versions. Re-run the probe before trusting broad scans. +Do not describe the implementation as bypassing anti-cheat. The app does not +read memory, hook the process, inject code, modify game files, inspect packets, +or interact with kernel drivers. It uses normal Windows screen capture, focus, +cursor movement, wheel, and click input. Never add automation that deletes, feeds, enhances, locks/unlocks, spends resources, reads memory, hooks, injects, or modifies Genshin. @@ -613,12 +252,9 @@ The current 16:9 layout profile is calibrated from a 1920x1080 English artifact-inventory capture: - detail rect approximately `x=1308`, `y=120`, `width=492`, `height=838` -- inventory grid: `8 x 4` safe automated targets, matching Inventory Kamera's - 32-artifact full-page model. The apparent lower fifth row is in the bottom - control band and is intentionally not clicked during auto-scan. +- inventory grid: `8 x 4` safe automated targets - first tile center: `x=179`, `y=254`, `row=0`, `col=0` - second tile center: `x=325`, `y=254`, `row=0`, `col=1` -- inventory count crop successfully read `2059/2400` in the live session The profile is resolution-scaled for 16:9. Off-profile setups should be treated as higher risk and validated with Smart Capture plus the probe. @@ -635,5 +271,3 @@ Before marking an automation change done: 6. For scan-loop changes, run `/scanner/start?entry=visible-inventory&limit=2` before any broader scan. 7. Record new live findings in this file and in `docs/scanner-rework-status.md`. -8. For IK-target claims, attach or cite `scan-performance-assessment.json` from - a non-stale `npm run scan:goal:compare:validated` run. diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md index 9be9445..2f99d3e 100644 --- a/docs/CHECKLISTS.md +++ b/docs/CHECKLISTS.md @@ -13,25 +13,24 @@ - [ ] `npm run build` passes. - [ ] 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`. -- [ ] `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. - [ ] `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. -- [ ] 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. +- [ ] Use `npm run scan:iterate:validated` or `npm run scan:iterate:validated:wait` for short 20-artifact tuning loops. +- [ ] Prefer `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` for the final live run because it runs preflight, scan, and assessment validation in sequence. - [ ] The run includes `scan-performance-assessment.json`. - [ ] `npm run scan:assessment:validate -- --latest` or explicit `--input=\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. - [ ] For iteration claims, the 20-artifact run finishes cleanly and is validated with `--limit=20`. - [ ] For final claims, the 100-artifact run finishes cleanly. - [ ] Parsed count is at least the requested count. - [ ] Miss rate is at or below 2%. - [ ] Review rate is at or below 15%. -- [ ] Speed comparison uses active scan timing plus quality, not click count alone. -- [ ] The default OCR engine is changed only after same-capture benchmark evidence. +- [ ] Speed reporting uses active scan timing plus quality, not click count alone. ## UI Change @@ -48,6 +47,19 @@ - [ ] The live rail shows finished artifact rows only, not intermediate OCR/debug state. - [ ] Each live row has scan number, artifact name or compact fallback, value score, and a short status pill. - [ ] Extraction confidence and artifact value remain separate in the data model. +- [ ] Native post-capture runs write `scan-results.json` with extraction status and deferred/review value status. +- [ ] Native post-capture reports include bounded queue evidence such as `queueConcurrency` when OCR/parse workers run. +- [ ] Native artifact post-processing records IK/GOOD match metadata and sends IK set/piece/slot conflicts to review. +- [ ] Native scan result details preserve parser field confidence for review instead of hiding uncertainty. +- [ ] Native inventory promotion is previewed as a dry-run decision before any artifact-store write path is enabled. +- [ ] Weapon, character, and material IK matching claims are backed by category-specific capture evidence, not just catalog availability. +- [ ] Native `supportedCategories` distinguishes `catalogAvailable` from `nativeCaptureSupported`. +- [ ] Native scan start, run manifest, status, and capture jobs carry an explicit scan category. +- [ ] Native preflight and post-capture processing block catalog-only categories before OCR/parsing. +- [ ] Native preflight blocks blank or too-uniform Genshin captures before any click is sent. +- [ ] Native crop previews are loaded only through the Electron bridge and stay constrained to PNG files inside the selected run directory. +- [ ] Native live smoke uses `npm run scan:native:smoke` before broad native timing or store-promotion claims. +- [ ] Artifact-only scope is visible in the UI; weapons, materials, and character details are not presented as active scanner features while they are not scanned. - [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision. - [ ] Duplicate state is represented separately from artifact quality. - [ ] The artifact inventory view can browse stored scan results without opening diagnostics. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md new file mode 100644 index 0000000..b8ef67b --- /dev/null +++ b/docs/CURRENT_STATUS.md @@ -0,0 +1,139 @@ +# Current App Status + +Updated: 2026-07-09 + +This document is the short current-state entry point. For deeper architecture +details see [ARCHITECTURE.md](ARCHITECTURE.md). For the scanner and inventory +roadmap see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md). +For live scanner runbooks see [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). + +## Summary + +The app is now an Artifact-first local scanner with a native high-speed capture +path validated at 20/50/100-item scale in the current live session. The current product scope is intentionally limited to +artifacts. Weapons, materials, and character details may exist as vendored IK +catalog data, but they are not active scanner features while their values are +not scanned. + +Electron/React should act as the visual control and status surface. The C# +helper owns fast Genshin focus, click, scroll, and card-crop capture for the +native path. OCR, parsing, review, promotion, and value evaluation remain +separate downstream stages so capture speed is not blocked by UI work. + +## What Works Now + +- Smart Capture can read the currently opened artifact detail view through + focused crops, OCR preprocessing, deterministic parsing, confidence, and + notes. +- The visible-inventory auto-scan path is the current stable production + baseline: preflight, grid detection, read-only tile selection, detail + verification, OCR, parse, store/review, scroll, and summary. +- The native IK-style artifact capture path is wired through the C# helper. + It captures visible 16:9 Artifact inventory detail card crops and writes a + self-contained run directory. +- Native runs write `manifest.json`, `capture-jobs.jsonl`, and `status.json`. + Post-capture processing can consume the job file and write + `scan-results.json` plus `processing-report.json`. +- Vendored Inventory Kamera `inventorylists` are copied under + `data/ik-inventorylists` and packaged as scanner reference data. Current + version is `6.7.0`. +- Native artifact post-processing matches parsed artifacts against IK artifact + set/piece/slot data and sends conflicts to review. +- The scan page has a compact latest-results rail. Native parsed results are + labeled as `Geparst`, persisted results as stored, uncertain results as + review, and value evaluation as `Wert offen`. +- The Inventory view can browse native scan results, stored artifacts, and + snapshot fallback rows. It shows native crop previews, field confidence, + IK/GOOD metadata, dry-run promotion status, and a per-result + `Naechster Schritt`. +- The Inventory view now shows an Artifact-only pipeline strip for scope, + native capture, OCR queue, review gate, promotion, and evidence. +- GOOD import/export, review samples, local text replacements, scanner + diagnostics, OCR eval, and local artifact storage exist. + +## What Was Just Done + +- The active UI scope was narrowed to Artifact scanning only. +- Weapon, material, and character-detail IK catalog coverage was removed from + the primary Inventory feature surface so it cannot look like implemented scan + support. +- Native result labels were made more honest: + `Geparst` means extracted, `Stored` means persisted, `Review` means unsafe, + and `Wert offen` means artifact value evaluation has not run yet. +- The Inventory detail panel gained a `Naechster Schritt` card: + ready for promotion, review first, already in store, blocked, or value later. +- New filters were added for native rows and promotable rows. +- Native 20/50/100-item dry runs were completed. A live OCR decimal-loss bug + and missing native result timestamp were fixed and regression-tested. +- Native Inventory results now support explicit single-result promotion with a + second confirmation, authoritative main-process revalidation, duplicate + detection, store write, scan-result update, and `promotion-log.jsonl`. +- Review-required native results now have an inline editor for identity, + main stat/value, level, substats, equipped state, lock state, and notes. + Approve requires IK identity validation, canonical main-value validation, + and legal substat rolls; Reject keeps the result blocked. Approved cases + write `review-log.jsonl` and reuse `review-samples.jsonl` for OCR eval export. +- The three real Review rows from the 100-item native run were manually + corrected and approved through that workflow. The run now has zero remaining + Review rows, all three corrections reached the eval candidate pipeline, and + the confirmed cases are permanent OCR regressions. +- The parser can now repair the confirmed dropped/extra-digit substat cases at + +20 only when one legal 5-star roll-count combination exists. Ambiguous OCR + stays reviewable. +- Project docs and roadmap were updated to state Artifact-only scope and the + next scanner/product priorities. + +## Current Evidence + +- `npm run lint` passed. +- `npm test` passed with `252` tests. +- `npm run build` passed. +- `git diff --check` passed; only existing CRLF warnings were reported for + `electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`. +- Native IK dry runs on 2026-07-09 completed at 20, 50, and 100 items with + complete crop/job/result counts, `0` processing errors, and `persisted: false`. + The 100-item run captured `100/100` across 4 pages in `24,673 ms`, parsed + `100/100`, and safely routed 3 OCR value losses into Review. See + [NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md). +- The older visible-inventory path has broader live evidence, including a + 100-artifact run with `100/100` verified and parsed, `98` stored, + `2` duplicates, `0` review samples, `0` misses, and `393 ms/artifact`. + +## Known Limits + +- Native IK-style capture has current-session scale evidence, but later-session + repeatability and packaged-app evidence are still open. +- The native path is limited to visible 16:9 Artifact inventory with a visible + detail card. +- Native post-capture results do not persist by default. Clean selected results + can now be promoted explicitly after a second UI confirmation. +- Artifact value scoring and upgrade projection are not implemented for native + results yet. +- Review/edit/approve is implemented for one selected native result at a time. + Batch review is intentionally not available. +- The `333 ms/artifact` target for 3 artifacts/second is not proven. +- The native capture worker exceeded 4 artifacts/second, but the smoke runner + still captures and processes sequentially; 3 artifacts/second end to end is + therefore not proven. +- Weapons, materials, and character details are intentionally ignored until + their values are actually scanned. + +## What Is Next + +1. Add Artifact value evaluation after extraction is trustworthy. Keep value + reasons separate from OCR confidence. +2. Add detail-level value reasons and optional upgrade projection. Projection + must stay labeled as probabilistic. +3. Confirm packaged-app behavior for the C# helper, IK lists, preload bridge, + native crop previews, and smoke commands. +4. Repeat the native 20/50/100 evidence in a later session to prove repeatability. +5. Return to recommendations only after native artifact ingestion, promotion, + review, value scoring, and repeatability are strong enough. + +## Not Next + +- Do not prioritize weapons, materials, or character details yet. +- Do not auto-persist native scan results before review/promotion is safe. +- Do not claim Inventory Kamera parity until broad native Artifact runs prove + repeatable speed and quality. +- Do not merge extraction confidence and artifact value into one UI score. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 77500fc..6e55b70 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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-009 | Resolution-anchored layout profiles and OCR preprocessing over color detection | Accepted | 2026-07-05 | | ADR-010 | Elevated dev runner and bounded live automation probes | Accepted | 2026-07-07 | -| ADR-011 | 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-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 @@ -47,7 +48,8 @@ Accepted ### Context -The user wants an app that works without Inventory Kamera, Genshin Optimizer, Enka, or HoYoLAB as core dependencies. +The user wants an app that works without optimizer imports, external scanner +tools, Enka, or HoYoLAB as core dependencies. ### Decision @@ -184,9 +186,8 @@ Accepted The persistent PowerShell helper (ADR-006) still carries Windows PowerShell 5.1 quirks (the `Marshal::SizeOf` interop bug), compiles Win32 interop at startup, and captures each frame by writing a PNG to the temp directory and reading it -back. Inventory Kamera - the proven reference for automated Genshin scanning - -uses a C#/.NET stack with InputSimulator (SendInput) and direct GDI/BitBlt -capture. +back. A C#/.NET sidecar with SendInput and direct GDI/BitBlt capture is a +cleaner fit for the validated Windows automation path. ### Decision @@ -215,10 +216,9 @@ Accepted The current pipeline finds the artifact detail panel with hardcoded orange/green color thresholds (`inferDetailRect`) and then crops fixed percentages of that guessed rectangle. This is brittle against HDR, color profiles, UI scale, aspect -ratio, and game UI updates. Inventory Kamera instead requires borderless 16:9 and -scales fixed crop coordinates from a reference resolution, then feeds Tesseract -preprocessed (grayscale, upscaled, thresholded) crops - which is why general -Tesseract is accurate enough for them. +ratio, and game UI updates. A stricter borderless 16:9 profile with fixed crop +coordinates from a reference resolution is easier to validate and reproduce than +per-frame color hunting. ### Decision @@ -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, 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 -Accepted +Superseded ### Context -The product target is not merely to click through 100 artifacts quickly. It is -to scan the first 100 artifacts with accuracy at least as good as Inventory -Kamera and speed equal to or better than Inventory Kamera. A faster scan that -creates too many misses, review samples, or false positives is worse than a -slower qualified run. - -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. +The project previously carried alternate OCR-engine and comparison paths to +decide whether the current scanner should change OCR defaults. The current +visible-inventory path has now produced clean 100-artifact evidence, and the +next product phase is result clarity, artifact inventory, detail evaluation, and +corpus growth. Keeping an alternate engine path increases app size and +maintenance surface without serving the current user workflow. ### Decision -Use a quality-gated live soak and benchmark before changing the default OCR -engine or claiming IK parity. A qualified scan result must: +Retire the alternate OCR-engine and current-vs-reference comparison paths. Keep a +single current OCR/capture path and preserve the quality-gated live soak +assessment. A qualified scan result must: - finish cleanly, - parse at least the requested count, @@ -324,22 +321,18 @@ engine or claiming IK parity. A qualified scan result must: `scripts/live-soak.ps1` writes the evidence bundle and `scan-performance-assessment.json`. `npm run scan:assessment:test` verifies that the ranking logic rejects fast but low-quality synthetic runs without needing -Genshin. The assessment also records `goal100Decision` and -`goal100.comparisonComplete`; IK-target claims require a qualified 100-artifact -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. +Genshin. `npm run scan:assessment:validate -- --summary` prints the assessment +path and `createdAt` timestamp so reports can cite the exact evidence file. ### Consequences - Speed claims cannot be based on click count or elapsed time alone. -- A new OCR engine cannot become the default just because it is theoretically - closer to IK; it must win the same-capture benchmark and a qualified live run. +- A new OCR engine is out of scope until scanner UX, inventory, detail + evaluation, and corpus growth justify reopening that work. - Stale elevated Electron instances are treated as invalid evidence, not as a harmless warning. - The validated `:wait` scan scripts are acceptable for manual post-UAC startup; non-waiting scripts remain useful when automation should fail fast. -- The goal remains open until the 100-artifact qualified comparison is captured. ## 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. - Recommendation work has a cleaner dependency chain: trusted scans, compact results, artifact inventory, detail evaluation, then build recommendations. -- Speed work remains possible, but it is secondary unless measured timings show - a real regression. +- Renderer-loop speed work remains secondary unless measured timings show a real + regression; native capture speed work is handled by ADR-013. + +## ADR-013: Move High-Speed Scanning Into The Native Helper And Vendor IK Inventorylists + +### Status + +Accepted + +### Context + +Inventory Kamera is fast because the game-control loop is native and the UI does +not perform per-artifact work. The Electron renderer path paid for focus, +capture, OCR, parse, persistence, status updates, and UI state inside one loop. +The user also has Inventory Kamera 1.4.4 locally, including `inventorylists` +that can be used 1:1 as scanner reference data. + +### Decision + +Use the C# input helper as the high-speed scanner service. Electron starts, +stops, packages, and reports status. React remains a visual control surface. The +IK `inventorylists` are copied into `data/ik-inventorylists` and packaged as +extra resources. The first native milestone captures artifact detail card crops +quickly; OCR, parsing, GOOD persistence, and evaluation are separate downstream +steps. Each native run writes `manifest.json`, `capture-jobs.jsonl`, and +`status.json`; downstream workers must consume those files instead of asking the +renderer to do per-artifact scanner work. + +The first downstream worker is a post-capture processor that reads +`capture-jobs.jsonl` and writes both `scan-results.json` and +`processing-report.json`. `scan-results.json` is the durable result contract: +one entry per captured artifact card with extraction status, artifact identity +when parsed, review state, and deferred value status. `processing-report.json` +remains the diagnostic OCR/parse report. The worker OCRs/parses after capture +and does not persist by default; DB writes require explicit opt-in after native +crop OCR is validated with live evidence. + +### Consequences + +- Capture throughput can be optimized without renderer roundtrips per artifact. +- The scanner data source now matches IK's artifact names and set/piece keys. + Other vendored IK lists are data only until those values are actually scanned. +- Evaluation can remain deferred without blocking capture speed. +- Live safety stays read-only: no memory reads, hooks, injection, game-file + modification, deleting, feeding, enhancing, or spending resources. +- Non-16:9 layouts currently block in native preflight until layout support is + expanded. diff --git a/docs/MERGE_READINESS.md b/docs/MERGE_READINESS.md index 958cc4d..8573408 100644 --- a/docs/MERGE_READINESS.md +++ b/docs/MERGE_READINESS.md @@ -1,6 +1,6 @@ # Scanner Merge Evidence -Merged branch: `codex/ik-scanner-progress` +Merged branch: scanner baseline branch Target branch: `main` Merge commit: `c025daa` Merged on: 2026-07-09 @@ -13,17 +13,17 @@ This document records the evidence used to merge the scanner branch into | Area | Status | Evidence | | --- | --- | --- | | 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` | | Whitespace check | Passed | `git diff --check` | | OCR eval gate | Passed | `npm run eval` with `23/23` exact-match cases and `100%` critical fields | -| Scan assessment self-test | Passed | `npm run scan:assessment:test`; the fixture 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 | -| 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` | | Unlocked lock state | Passed for live smoke | 2026-07-09 Smart Capture reported `locked: false` on an unlocked artifact; after restart, `lockSignal.ratio: 0` with threshold `0.06` | | Positive locked lock state | Passed | 2026-07-09 Smart Capture on a visibly locked artifact reported `locked: true`, `lockSignal.ratio: 0.14797913950456323`, threshold `0.06` | -| Lock-state persistence | Passed | 2026-07-09 `/scanner/start?entry=visible-inventory&limit=1&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 | | 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 | @@ -37,7 +37,7 @@ npm run build 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 lock-state fix. @@ -49,10 +49,9 @@ lock-state fix. - The optional `3 artifacts/second` target is not proven. The stable current path is closer to `2.5` to `2.75 artifacts/second` on clean 20-artifact live runs. -- Native Tesseract/IK-traineddata is not the default. The latest documented - qualified live 100-artifact comparison in this environment had `current` as - winner; the assessment self-test is a synthetic validator fixture, not a live - winner claim. +- Alternate OCR-engine comparison paths have been retired. The current scanner + path is the maintained baseline; future engine work should be reopened only + with a new explicit product reason. ## Merge Result diff --git a/docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md b/docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md new file mode 100644 index 0000000..3383c54 --- /dev/null +++ b/docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md @@ -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. diff --git a/docs/PROJECT.md b/docs/PROJECT.md index ff4e4b7..a52fc2d 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -3,8 +3,7 @@ This document is the source of truth for project intent, scope, runtime facts, and operational expectations. For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md). -For the latest Inventory-Kamera comparison work, see -[scanner-ik-progress-report.md](scanner-ik-progress-report.md). +For the current app status, see [CURRENT_STATUS.md](CURRENT_STATUS.md). For the next scan-result and artifact-inventory product phase, see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md). For the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md). @@ -15,7 +14,7 @@ For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md). | Field | Value | | --- | --- | | 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 | | Target users | Genshin Impact players who want artifact decisions without complex optimizer setup | | 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. - Keep extraction confidence separate from artifact value so uncertain OCR becomes review, not a misleading low score. - Provide a browsable local artifact inventory with detail views before promoting broader recommendations. -- Keep the app offline-first and usable without 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. ## 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-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending | | FR-011 | Farming overlay for reward scans. | Later | Prototype shell | -| FR-012 | Show active scan results as a minimal right-side rail with artifact number, name or compact fallback, value score, and status pill. | Should | Planned | -| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | 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 | Partial foundation | | FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Planned | ## Non-Functional Requirements @@ -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 | | 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 | | 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 | @@ -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`. - The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping. - Review samples, learned replacements, parser notes, and stored artifacts already persist locally. +- The scan surface has an initial recent-results rail backed by the newest + stored artifacts. - The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions. -- The scanner now has an Inventory-Kamera comparison path: 32 safe artifact - targets per page, lookup-derived fields, fast OCR crop profile, current vs. - IK-traineddata benchmark endpoint, and a quality-gated live soak runner. +- The scanner now has a validated visible-inventory path: 32 safe artifact + targets per page, lookup-derived fields, fast OCR crop profile, and a + quality-gated live soak runner. - Elevated live automation is validated in the current dev environment: `/automation/probe-click?index=1` changed the selected artifact and `/scanner/start?limit=2` completed with 2/2 verified reads and 0 misses. @@ -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 a Git-ignored human-labeling worklist. This is the next quality phase before adding more OCR corpus cases or trusting review queue data as labels. +- Native IK-style Artifact capture is wired through the C# helper. It writes + card crops and run artifacts for downstream OCR/parse processing instead of + making React do per-artifact work in the hot capture loop. +- The native post-capture processor can write `scan-results.json` and + `processing-report.json`, preserve parser field confidence, match Artifact + results against IK artifact set/piece/slot data, and keep store persistence + opt-in. +- The Inventory view now has an Artifact-only pipeline surface for scope, + native capture, OCR queue, review gate, promotion, and evidence. Native rows + expose crop previews, IK/GOOD metadata, dry-run promotion state, and a + `Naechster Schritt` card. +- The active UI intentionally hides weapon, material, and character-detail IK + catalog coverage until those values are actually scanned. ### What is still structurally weak - The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be. - Broader scan soak testing has reached clean 20-, 45-, and 100-artifact runs - with 0 misses on the current engine. The current-vs-IK-traineddata comparison - is now captured; `current` won the qualified 100-artifact comparison on - 2026-07-08. + with 0 misses on the current engine. The current 2026-07-09 100-artifact run + completed `100/100` verified and parsed with `0` review and `0` misses. - OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be. - Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way. -- The scan page is cleaner than before, but it still needs the next minimalist - result-rail pass so the main flow shows preview plus completed artifact - outcomes instead of live diagnostic/stat content. +- The scan page now has a minimalist recent-results rail, and the Inventory view + can inspect native Artifact results, crop previews, IK match state, promotion + dry-runs, and pipeline risk/status. Value scoring remains incomplete; + single-result review/edit/approve is implemented and batch review is + intentionally unavailable. +- The new native IK-style path has same-session 20/50/100 live scale evidence + with complete capture/result counts and safe Review gating. Later-session + repeatability and packaged behavior remain open. +- Store promotion from native `scan-results.json` now supports one selected, + confirmed result at a time with main-process revalidation and a durable log. + Batch promotion intentionally remains unavailable. +- Native Review results can be corrected and approved or rejected one at a + time. Approval revalidates IK identity, canonical main values, and legal + substat rolls, then feeds the existing review-to-eval candidate pipeline. - Recommendations and build logic exist, but artifact inventory, detail review, and value scoring should land first so recommendations have trustworthy inputs. -- The latest source has completed the final current-vs-IK-traineddata live - comparison for this environment. Repeatability and 3 artifacts/second are - still open. +- Repeatability and 3 artifacts/second are still open; speed work should not + outrank result clarity, inventory UX, or corpus growth while the current path + is stable. ### Current product conclusion -The app has crossed from OCR-demo/prototype into a validated scanner baseline on -`main`. The current merge-ready path is the visible-inventory scan flow: the +The app has crossed from OCR-demo/prototype into an Artifact-first scanner app. +The broadest proven live path is still the visible-inventory scan flow: the operator opens Artifact inventory with a visible detail card, the app verifies the state, scans read-only, persists parsed artifacts, and keeps uncertain data -reviewable. The next product phase is not another broad speed rewrite; current -speed is acceptable for now. The priority is better content extraction, a -minimal scan-result rail, a browsable artifact inventory, detail evaluation, and -corpus growth before recommendations become the core product surface. +reviewable. The newer native IK-style path is the intended high-speed direction: +the helper captures Artifact card crops quickly, while OCR, parsing, review, +promotion, and value evaluation run downstream. + +The next product phase is not broad category expansion. Weapons, materials, and +character details stay out of active scope. The priority is later-session +native repeatability, value +evaluation, and detail explanations before recommendations become the core +product surface. ## Product Direction - Artifact scanning is the first-class feature. - Character optimization returns only after scan quality is trustworthy. - Team building stays out of the critical path until artifact ingestion is stable. -- Inventory Kamera remains a reference for scan choreography and page movement, not a runtime dependency. +- External scanners are not runtime dependencies. The app owns its scan + choreography, OCR, and quality gates. - Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion. - The scan workspace should be an operator surface: preview, live result rail, Stop, status, and review access. Detailed stats and debug evidence belong in @@ -302,7 +333,17 @@ Outcome: and click-through details. 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) for the implementation phases and acceptance criteria. @@ -332,24 +373,27 @@ Status: ## 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 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), preserving separate extraction confidence and artifact value. -3. Rework the scan page into preview plus minimal result rail; keep diagnostic - stats out of the primary scan surface. -4. Add the artifact inventory menu and detail view before expanding broad build - recommendations. -5. Grow the confirmed OCR corpus from review samples exported by +5. Finish value reasons in the existing Artifact inventory detail view before + expanding broad build recommendations. +6. Keep the existing scan preview/result rail and Inventory workflow compact; + move new diagnostics behind the dedicated diagnostics surface. +7. Continue growing the confirmed OCR corpus from review samples exported by `npm run eval:review-candidates` and prepared through `npm run eval:prepare-confirmed`. -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. -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. -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 stored artifacts. diff --git a/docs/ocr-eval.md b/docs/ocr-eval.md index 8c30ba7..b77de4f 100644 --- a/docs/ocr-eval.md +++ b/docs/ocr-eval.md @@ -2,10 +2,10 @@ Field-level accuracy measurement for the artifact OCR parser. This is the gate every OCR, crop, layout, or parser change runs against (see ADR-007). -It is necessary but not sufficient for the IK target: live scan speed and -review/miss rates are measured by `npm run scan:iterate:compare:validated` for -short iteration and `npm run scan:goal:compare:validated` for the final -100-artifact proof. Use the `:wait` variants directly after UAC startup. +It is necessary but not sufficient for live scanner proof: scan speed and +review/miss rates are measured by `npm run scan:iterate:validated` for short +iteration and `npm run scan:goal:validated` for the final 100-artifact proof. +Use the `:wait` variants directly after UAC startup. ## Run it @@ -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 test # runs the eval gate alongside the rest of the suite npm run scan:assessment:test # verifies quality-first scan ranking logic -npm run scan:iterate: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: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 breakdown (critical fields marked with `*`), and every failing case with an `expected "..." got "..."` diff. -Repeatability runs are single-engine evidence. Use them to prove that the -visible-inventory scanner stays stable across later sessions, but keep -current-vs-IK claims on `scan:goal:compare:validated:*`. +Repeatability runs prove whether the visible-inventory scanner stays stable +across later sessions. They are current-path evidence and should be reported +with review/miss rates plus timing, not just click count. ## How it works diff --git a/docs/scanner-ik-progress-report.md b/docs/scanner-ik-progress-report.md deleted file mode 100644 index 96fb033..0000000 --- a/docs/scanner-ik-progress-report.md +++ /dev/null @@ -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. diff --git a/docs/scanner-results-inventory-roadmap.md b/docs/scanner-results-inventory-roadmap.md index 554d4c9..ba98566 100644 --- a/docs/scanner-results-inventory-roadmap.md +++ b/docs/scanner-results-inventory-roadmap.md @@ -1,14 +1,22 @@ # Scanner Results And Artifact Inventory Roadmap This document defines the next product phase after the validated visible-inventory -scanner baseline. The scanner is already fast enough for the current milestone; -the next work should improve artifact content extraction, review safety, and a -minimal inventory experience that makes scanned artifacts useful. +scanner baseline and the new native IK-style capture direction. The next capture +milestone optimizes speed by moving game-control and card-crop capture into the +C# helper. Artifact extraction, review safety, and inventory UX stay separate +downstream work so the Electron app remains a visual/status surface instead of +the worker. + +For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md). ## Product Stance - Keep the scan workspace focused on operation, not analysis. - Keep the live preview as the dominant surface. +- Keep Electron/React out of per-artifact scanner work in the fast path. +- Use IK `inventorylists` 1:1 as the scanner dictionary source for artifacts. + Other IK lists may stay vendored, but weapons, materials, and character + details are not active product scope until their values are actually scanned. - Move debug metrics, OCR internals, and detailed evaluation behind details, diagnostics, or the inventory view. - Do not merge scan confidence and artifact value into one ambiguous score. @@ -64,29 +72,31 @@ reasons in the detail view before it becomes a recommendation source. ## Planned Pipeline Shape -The current scan loop can keep shipping while the UI and data contracts are -built. A fuller producer/consumer pipeline is a later implementation step: +The current TypeScript scan loop can keep shipping as fallback while the native +pipeline takes over high-speed capture. The target producer/consumer pipeline is: ```mermaid flowchart LR - Capture["Single capture and game-control worker"] - Queue["Bounded screenshot/crop queue"] + Native["C# native click, scroll, capture worker"] + Queue["Bounded card-crop queue"] OCR["OCR and parse workers"] - Eval["Artifact evaluation"] + Match["IK inventorylists matching"] + Eval["Artifact evaluation (deferred)"] Aggregate["Aggregator and store"] UI["Live rail and inventory"] - Capture --> Queue + Native --> Queue Queue --> OCR - OCR --> Eval + OCR --> Match + Match --> Eval Eval --> Aggregate Aggregate --> UI ``` Constraints: -- Only one worker may control Genshin input, focus, click, scroll, or failsafe - polling. +- Only the native helper may control Genshin input, focus, click, scroll, card + capture, or failsafe polling in the fast path. - OCR/parse/evaluation workers may run concurrently on already captured screenshot/crop jobs. - The queue must be bounded, initially around `4-8` jobs, so the scanner does @@ -94,28 +104,42 @@ Constraints: - The pipeline must preserve current safety rules: no memory reads, hooks, injection, game-file changes, deleting, feeding, enhancing, locking/unlocking, or spending resources. -- Do not implement the queue refactor before the result/inventory contracts are - stable, unless timing evidence shows the current loop has become the blocker. +- Evaluation may be omitted until after capture speed and crop quality are + validated. ## Implementation Phases ### Phase 0 - Documentation and contracts -Status: prepared by this document. +Status: prepared by this document and ADR-013. Outcome: - Product direction is documented. - Main docs point to this roadmap. - Acceptance criteria and checklists exist before code changes. +- Native run artifacts are part of the scanner contract: + `manifest.json`, `capture-jobs.jsonl`, `status.json`, + `scan-results.json`, and `processing-report.json`. ### Phase 1 - Result data model +Status: foundation implemented for native post-capture processing. The native +scanner reports run status and writes crop jobs; the post-capture processor now +writes durable per-artifact `scan-results.json` entries next to the diagnostic +`processing-report.json`. Native artifact results can now carry IK inventorylist +match metadata, and IK set/piece/slot conflicts force review instead of clean +extraction. The post-capture OCR/parse worker runs as a bounded queue while +preserving result order. `scan-results.json` also stores parser field confidence +metadata for native artifact details. Evaluation remains explicitly deferred. + Outcome: - Add a durable scan result entry model with sequence number, capture metadata, parsed artifact identity, extraction status, artifact value score, value - status, duplicate/review flags, and timestamps. + status, duplicate/review flags, and timestamps. Initial native entries use + `valueStatus: "deferred"` for clean parses and `valueStatus: "review"` for + uncertain extraction. - Keep existing stored artifact records compatible. - Add tests for status derivation so low-confidence OCR cannot become a normal `Good` or `Strong` result. @@ -130,6 +154,12 @@ Likely files: ### Phase 2 - Minimal live result rail +Status: foundation implemented for native post-capture results. The scan main +section shows newest stored artifacts as fallback and can display the latest +native `scan-results.json` entries after post-processing. Result rail rows can +open the Inventory surface for crop/IK/detail inspection. Value scores are still +pending. + Outcome: - Rework the scan main section into preview plus right-side result rail. @@ -148,6 +178,33 @@ Likely files: ### Phase 3 - Artifact inventory view +Status: foundation implemented and now scoped to active Artifact scanning only. +The app has an `Inventory` navigation item with +a compact browser for native `scan-results.json` entries, stored artifacts, and +snapshot fallback rows. Filtering, sorting, a detail panel, and secure native +crop preview loading are present. The view now surfaces the vendored IK +Artifact version/counts, active Artifact-only scope, compact pipeline state for +native capture, OCR queue, review, promotion, and evidence, plus per-result +IK/GOOD match status for native artifacts. The inventory view also computes a + dry-run promotion summary from `scan-results.json` plus the local artifact + store, separating `speicherbar`, already stored, review, and blocked native + results. One selected clean result can now be promoted after a second UI + confirmation; the main process revalidates the run result, writes the store, + updates `scan-results.json`, and appends `promotion-log.jsonl`. Weapons, + materials, and character details +remain hidden from the active feature UI while they are not scanned. The native +helper still reports the category distinction in +`supportedCategories` via `catalogAvailable`, `nativeCaptureSupported`, and +`scanStatus`, so dev-control evidence cannot accidentally claim that every IK +catalog has an implemented scanner. Native scan start, status, manifest, and +capture jobs now carry an explicit scan category; only `artifacts` can currently +produce native capture jobs. Value scoring is still pending; single-result +review/edit/approve is implemented in the detail phase below. + +Support code for simple IK weapon, character, and material name/GOOD-key +matching exists, but those categories still need their own capture flows before +they can be claimed as scanned inventory. + Outcome: - Add a menu item for scanned artifact inventory. @@ -167,6 +224,14 @@ Likely files: ### Phase 4 - Artifact detail view +Status: started for native scan results. Inventory detail can show the native +card crop from the run directory through the Electron bridge, constrained to +PNG files inside the active native run folder. It also shows stored parser +field-confidence rows from native `scan-results.json`, IK/GOOD metadata, dry-run +promotion state, and a `Naechster Schritt` card. Native Review results now have +an inline field editor with approve/reject, authoritative validation, run logs, +and review-to-eval export. Value reasons are still pending. + Outcome: - Clicking a live row or inventory item opens detail. @@ -185,6 +250,9 @@ Likely files: ### Phase 5 - Artifact value evaluation +Status: next product feature after the now-completed same-session native scale, +selected promotion, and review/edit/approve workflow. + Outcome: - Add a deterministic artifact value evaluator before promoting build @@ -204,6 +272,8 @@ Likely files: ### Phase 6 - Upgrade projection +Status: later detail-level feature after artifact value evaluation. + Outcome: - For artifacts below max level, show optional projection only in detail. @@ -230,6 +300,11 @@ Outcome: - Preserve stop/failsafe behavior and review decisions. - Compare throughput against the current baseline without weakening accuracy. +Status: started for post-capture processing. Native capture already writes +crop jobs without waiting for OCR. The downstream processor now consumes those +jobs with bounded parallelism and writes stable ordered reports. Live throughput +comparison remains a final validation item. + Likely files: - `src/lib/autoScanLoop.ts` @@ -239,6 +314,9 @@ Likely files: ### Phase 8 - Recommendation promotion +Status: intentionally delayed until native artifact ingestion, review, +promotion, value scoring, and repeatability are trustworthy. + Outcome: - Promote account-level recommendations only after scan result quality, @@ -263,6 +341,11 @@ Outcome: - `npm run lint` - `npm test` - `npm run build` +- `git diff --check` +- `npm run scan:native:smoke` before claiming native capture plus + post-capture processing on live Genshin data. +- Broader native Artifact runs with 20/50/100 items before claiming IK-style + speed or stability. - Add unit tests for score/status derivation and upgrade projection. - For scanner-facing changes, run a low-limit visible-inventory live scan before wider validation. diff --git a/docs/scanner-rework-status.md b/docs/scanner-rework-status.md index 43f6fe9..9c7f725 100644 --- a/docs/scanner-rework-status.md +++ b/docs/scanner-rework-status.md @@ -1,300 +1,173 @@ -# Scanner rework status +# Scanner Rework Status -Progress on the approved scanner/OCR rework. See ADR-007/008/009/010 in -[DECISIONS.md](DECISIONS.md) for the decisions behind these. For the current -live automation runbook, see -[AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md). -For the next result/inventory product phase, see -[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md). +Updated: 2026-07-09 -## 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 -report. +## Was Es Kann -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: - 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. +## Was Zuletzt Gemacht Wurde -## 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 - [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. +## Was Noch Nicht -## 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 -[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md). -Summary: +## Wo Es Noch Probleme Macht -1. Add result data contracts that preserve extraction confidence separately from - artifact value score. -2. Rework the scan view into screenshot/preview plus a compact right-side live - result rail. -3. Add a scanned artifact inventory menu with minimal list/grid rows and - score/review pills. -4. Add click-through artifact detail with screenshot/crops, parsed fields, OCR - confidence, value reasons, and review state. -5. Add upgrade projection later as a detail-only feature with worst/middle/best - cases and explicit uncertainty. -6. Defer the full screenshot queue/worker pipeline until the result/inventory - contracts are stable or timing evidence shows the current loop is the blocker. +- Die native Pipeline hat jetzt 20/50/100-Skalenevidenz aus einer Live-Session. + Spaetere Session-Repeatability und Packaged-App-Evidenz stehen noch aus. +- Capture roundtrip and OCR remain the main timing costs in the 100-artifact + path. +- Auto-scan still requires a visible artifact inventory detail card for the + production path; broader auto-entry modes remain dev experiments. +- OCR/parser quality is good on the confirmed corpus, but review samples must be + manually labeled before they can become permanent eval ground truth. +- Non-16:9 or unusual game layouts are intentionally higher risk and should + block or fall into review instead of silently scanning. +- The UI now labels these risks in the Artifact Inventory pipeline. Same-session + scale is proven, but external-tool parity and later-session repeatability are + intentionally not claimed. -## 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 -resolution or without UI work best tested live: +- Repeat the bounded 20/50/100 native runs in a later session and in a packaged + app to verify that the current post-capture queue remains repeatable. +- Keep native persistence explicit: clean selected results can be promoted + after confirmation, while uncertain results must pass single-item review. +- Finish value scoring and value-reason contracts in the existing detail view from + `docs/scanner-results-inventory-roadmap.md`. +- Keep diagnostics available but out of the primary scan surface. +- Continue expanding confirmed OCR eval cases from real review samples. The + first three native Review cases are corrected, approved, and permanent + regression cases. +- Add artifact value/detail evaluation without collapsing extraction confidence + and artifact value into one ambiguous status. +- Continue performance work only when it reduces capture/OCR overhead without + weakening review, miss, duplicate, or safety gates. -1. **Validate/tune OCR preprocessing** on more real captures — confirm invert + - threshold + upscale factor help (not hurt) actual Tesseract reads. The - text-level eval harness cannot measure image preprocessing. -2. **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. +## Was Als Naechstes Ansteht -5. **3 artifacts/second iteration** - not reached yet. The next credible path is - either native Tesseract/IK-traineddata integration that materially reduces - substat OCR time, or a larger capture pipeline change that avoids full-frame - PNG/Base64 transport without hurting safety checks. The target remains - `<= 6667 ms` elapsed for 20 parsed artifacts with 0 misses and no silent OCR - review regression. The latest clean 20-artifact repeatability run reached - `336 ms/artifact`, so 3/s remains close but unproven. +1. Artifact Value Evaluation ergaenzen, aber erst nach sauberer Extraktion und + weiterhin getrennt von OCR/Parser-Confidence. +2. Detail-Ansicht um Value-Gruende und optionale Upgrade-Projektion erweitern. +3. Packaged-App-Verhalten fuer Helper, IK-Listen, Preload-Bridge, + Crop-Preview und Smoke-Kommandos pruefen. +4. Empfehlungen und Build-UX erst danach wieder nach vorne ziehen. -6. **Broader scan soak test** — direct-GDI current-engine runs now passed at - `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. +## Current Validation Commands -Visible-page limits up to 20, scroll/page-transition limit 45, the final -100-artifact current-vs-IK-traineddata comparison, equipped footer live smokes, -and one positive locked-artifact persistence proof have passed for the current -environment. Remaining soak work is repeatability, OCR corpus growth, additional -equipped/locked repeats, and optional 3 artifacts/second speed work. +```powershell +npm run scan:live:preflight +npm run scan:iterate:validated +npm run scan:goal:validated +npm run scan:repeatability:wait +npm run scan:assessment:validate -- --latest --summary +npm run eval +npm test +npm run build +``` -## 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 -those via `reviewSampleToEvalCase` and commit them into `src/eval/corpus/` so the -harness keeps measuring real-world accuracy across patches. See -[ocr-eval.md](ocr-eval.md). +```powershell +npm run lint # passed +npm test # passed, 252 tests +npm run build # passed +git diff --check +``` + +`git diff --check` passed with existing CRLF warnings for +`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`. diff --git a/electron/bootstrap/ipcBootstrap.ts b/electron/bootstrap/ipcBootstrap.ts index 05802cf..81833b8 100644 --- a/electron/bootstrap/ipcBootstrap.ts +++ b/electron/bootstrap/ipcBootstrap.ts @@ -19,6 +19,17 @@ import type { SaveSnapshotResult, GoodDatabase, GoodImportFileResult, + NativeScannerCatalogStatus, + NativeScannerDataStatus, + NativeScannerImageLoadStatus, + NativeScannerPreflightStatus, + NativeScannerProcessStatus, + NativeScannerPromotionStatus, + NativeScannerReviewArtifactInput, + NativeScannerReviewStatus, + NativeScannerResultsLoadStatus, + NativeScannerRunStatus, + NativeScannerStartCategory, ScannerStatusPayload, } from "../../src/types/global.js"; import type { @@ -33,6 +44,17 @@ interface AppHandlersDependencies { moveMainWindowOffGenshin: () => Promise; focusGenshinForScanStart: () => Promise; publishScannerStatus: (status: ScannerStatusPayload) => Promise; + nativeScannerDataStatus: () => Promise; + nativeScannerCatalog: () => Promise; + nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise; + nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise; + nativeScannerStop: () => Promise; + nativeScannerStatus: () => Promise; + nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise; + nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise; + nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise; + nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise; + nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise; readRuntimeInfo: () => Promise; loadSnapshotFromDisk: () => Promise; saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise; @@ -73,6 +95,17 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) { moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin, focusGenshinForScanStart: dependencies.focusGenshinForScanStart, publishScannerStatus: dependencies.publishScannerStatus, + nativeScannerDataStatus: dependencies.nativeScannerDataStatus, + nativeScannerCatalog: dependencies.nativeScannerCatalog, + nativeScannerPreflight: dependencies.nativeScannerPreflight, + nativeScannerStart: dependencies.nativeScannerStart, + nativeScannerStop: dependencies.nativeScannerStop, + nativeScannerStatus: dependencies.nativeScannerStatus, + nativeScannerProcessRun: dependencies.nativeScannerProcessRun, + nativeScannerLoadResults: dependencies.nativeScannerLoadResults, + nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults, + nativeScannerReviewResult: dependencies.nativeScannerReviewResult, + nativeScannerLoadImage: dependencies.nativeScannerLoadImage, getRuntimeInfo: dependencies.readRuntimeInfo, loadSnapshot: dependencies.loadSnapshotFromDisk, saveSnapshot: dependencies.saveSnapshotToDisk, diff --git a/electron/devControlServer.ts b/electron/devControlServer.ts index 99b190e..2f8437c 100644 --- a/electron/devControlServer.ts +++ b/electron/devControlServer.ts @@ -9,6 +9,13 @@ import type { CaptureSourceInfo, ClickResult, ReviewSampleListResult, + NativeScannerCatalogStatus, + NativeScannerDataStatus, + NativeScannerImageLoadStatus, + NativeScannerPreflightStatus, + NativeScannerProcessStatus, + NativeScannerResultsLoadStatus, + NativeScannerRunStatus, ScannerCommand, ScannerStatusPayload, AppRuntimeInfo, @@ -24,7 +31,16 @@ interface DevControlServerDependencies { sendScannerCommand: (command: ScannerCommand | "probe-click") => void; clickScreen: (x: number, y: number) => Promise; scannerStatus: () => ScannerStatusPayload; - warmOcr: (engine: "current" | "ik-traineddata") => Promise; + nativeScannerDataStatus: () => Promise; + nativeScannerCatalog: () => Promise; + nativeScannerPreflight: (options?: { category?: string }) => Promise; + nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise; + nativeScannerStop: () => Promise; + nativeScannerStatus: () => Promise; + nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise; + nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise; + nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise; + warmOcr: (engine: "current") => Promise; loadReviewSamples: (limit?: number) => Promise; listCaptureSources: () => Promise; captureSource: ( @@ -161,23 +177,17 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv } if (url.pathname === "/scanner/start") { 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 command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine - ? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine } - : "start-auto"; - deps.sendScannerCommand(command); - writeDevJson(res, 200, { ok: true, command }); + const category = url.searchParams.get("category") ?? undefined; + deps.nativeScannerStart({ ...(hasLimit ? { limit } : {}), ...(category ? { category } : {}) }) + .then((scanner) => writeDevJson(res, 200, { ok: true, scanner })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/stop") { - deps.sendScannerCommand("stop"); - writeDevJson(res, 200, { ok: true, command: "stop" }); + deps.nativeScannerStop() + .then((scanner) => writeDevJson(res, 200, { ok: true, scanner })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/probe") { @@ -198,13 +208,68 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv return; } if (url.pathname === "/scanner/status") { - writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() }); + deps.nativeScannerStatus() + .then((nativeScanner) => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus(), nativeScanner })) + .catch(() => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() })); + return; + } + if (url.pathname === "/scanner/native/data") { + deps.nativeScannerDataStatus() + .then((status) => writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/native/catalog") { + deps.nativeScannerCatalog() + .then((catalog) => writeDevJson(res, catalog.data.valid ? 200 : 409, { ok: catalog.data.valid, catalog })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/native/preflight") { + const category = url.searchParams.get("category") ?? undefined; + deps.nativeScannerPreflight(category ? { category } : undefined) + .then((status) => writeDevJson(res, status.ready ? 200 : 409, { ok: status.ready, status })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/native/process") { + const runDir = url.searchParams.get("runDir") ?? undefined; + const persist = url.searchParams.get("persist") === "1"; + const limit = Number(url.searchParams.get("limit") ?? Number.NaN); + deps.nativeScannerProcessRun({ + runDir, + persist, + limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, + }) + .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/native/results") { + const runDir = url.searchParams.get("runDir") ?? undefined; + const limit = Number(url.searchParams.get("limit") ?? Number.NaN); + deps.nativeScannerLoadResults({ + runDir, + limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, + }) + .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/native/image") { + const runDir = url.searchParams.get("runDir") ?? undefined; + const imagePath = url.searchParams.get("imagePath"); + if (!imagePath) { + writeDevJson(res, 400, { ok: false, error: "imagePath query param is required" }); + return; + } + deps.nativeScannerLoadImage({ runDir, imagePath }) + .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/ocr/warmup") { - const engineParam = url.searchParams.get("engine"); - const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current"; - deps.warmOcr(engine) + deps.warmOcr("current") .then((status) => writeDevJson(res, 200, { ok: true, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; @@ -226,14 +291,8 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv if (url.pathname === "/scanner/benchmark-ocr") { const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1)); const sourceId = url.searchParams.get("sourceId"); - const engineParam = url.searchParams.get("engine"); const profileParam = url.searchParams.get("profile"); 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() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); @@ -243,7 +302,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv } const benchmarkSource = source; - async function runEngineBenchmark(engine: "current" | "ik-traineddata") { + async function runCurrentBenchmark() { const startedAt = Date.now(); const captures: Array<{ index: number; @@ -265,7 +324,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, { ocrMode: "artifact", ocrProfile, - ocrEngine: engine, + ocrEngine: "current", omitFullFrame: true, omitInventoryPreview: true, skipOcrUnlessArtifactDetail: true, @@ -316,7 +375,7 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv ]), ); return { - engine, + engine: "current", nativeTesseract: "not-enabled", workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null, ocrProfile, @@ -340,13 +399,10 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv captures, }; } - const summaries = []; - for (const engine of engines) { - summaries.push(await runEngineBenchmark(engine)); - } + const summary = await runCurrentBenchmark(); writeDevJson(res, 200, { 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) })); @@ -390,6 +446,22 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv } const before = await deps.captureSource(source.id, 150, true, { skipOcr: true }); + const grid = before.inventoryGrid; + const detail = before.artifactDetail; + const gridReady = before.captureTarget === "genshin-client" + && grid?.source === "detected" + && (grid.confidence ?? 0) >= 60; + const detailReady = Boolean(detail?.present && detail.confidence >= 45); + if (!gridReady || !detailReady) { + writeDevJson(res, 409, { + ok: false, + error: "Artifact inventory detail view is not ready for probe-click.", + captureTarget: before.captureTarget, + grid: grid ? { rows: grid.rows, cols: grid.cols, source: grid.source, confidence: grid.confidence } : null, + artifactDetail: detail ?? null, + }); + return; + } const centers = before.inventoryGrid?.centers ?? []; const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol) ? centers.find((center) => center.row === requestedRow && center.col === requestedCol) diff --git a/electron/ipc/appHandlers.ts b/electron/ipc/appHandlers.ts index ee76b82..623e379 100644 --- a/electron/ipc/appHandlers.ts +++ b/electron/ipc/appHandlers.ts @@ -2,6 +2,17 @@ import { ipcMain } from "electron"; import type { BooleanResult, FocusGenshinResult, + NativeScannerCatalogStatus, + NativeScannerDataStatus, + NativeScannerImageLoadStatus, + NativeScannerPreflightStatus, + NativeScannerProcessStatus, + NativeScannerPromotionStatus, + NativeScannerReviewArtifactInput, + NativeScannerReviewStatus, + NativeScannerResultsLoadStatus, + NativeScannerRunStatus, + NativeScannerStartCategory, RuntimeInfo, SaveSnapshotResult, ScannerStatusPayload, @@ -13,6 +24,17 @@ interface AppCommandDependencies { moveMainWindowOffGenshin: () => Promise; focusGenshinForScanStart: () => Promise; publishScannerStatus: (status: ScannerStatusPayload) => Promise; + nativeScannerDataStatus: () => Promise; + nativeScannerCatalog: () => Promise; + nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory | string }) => Promise; + nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise; + nativeScannerStop: () => Promise; + nativeScannerStatus: () => Promise; + nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise; + nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise; + nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise; + nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise; + nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise; getRuntimeInfo: () => Promise; loadSnapshot: () => Promise; saveSnapshot: (snapshot: AppSnapshot) => Promise; @@ -26,6 +48,17 @@ export function registerAppHandlers({ moveMainWindowOffGenshin, focusGenshinForScanStart, publishScannerStatus, + nativeScannerDataStatus, + nativeScannerCatalog, + nativeScannerPreflight, + nativeScannerStart, + nativeScannerStop, + nativeScannerStatus, + nativeScannerProcessRun, + nativeScannerLoadResults, + nativeScannerPromoteResults, + nativeScannerReviewResult, + nativeScannerLoadImage, getRuntimeInfo, loadSnapshot, saveSnapshot, @@ -42,6 +75,17 @@ export function registerAppHandlers({ await publishScannerStatus(status); return { ok: true }; }); + ipcMain.handle("scanner:nativeDataStatus", async () => nativeScannerDataStatus()); + ipcMain.handle("scanner:nativeCatalog", async () => nativeScannerCatalog()); + ipcMain.handle("scanner:nativePreflight", async (_event, options?: { category?: NativeScannerStartCategory | string }) => nativeScannerPreflight(options)); + ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options)); + ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop()); + ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus()); + ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options)); + ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options)); + ipcMain.handle("scanner:nativePromoteResults", async (_event, options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options)); + ipcMain.handle("scanner:nativeReviewResult", async (_event, options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => nativeScannerReviewResult(options)); + ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options)); ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo()); ipcMain.handle("snapshot:load", async () => loadSnapshot()); ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot)); diff --git a/electron/main.ts b/electron/main.ts index 841bb2f..aa64145 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,17 +6,33 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createWorker, PSM } from "tesseract.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 { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; import { createDevControlServer } from "./devControlServer.js"; import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.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 { CaptureOptions, CaptureResult, GoodDatabase, + NativeScannerCatalogStatus, + NativeScannerDataStatus, + NativeScannerImageLoadStatus, + NativeScannerPreflightStatus, + NativeScannerProcessStatus, + NativeScannerPromotionStatus, + NativeScannerReviewArtifactInput, + NativeScannerReviewStatus, + NativeScannerResultsLoadStatus, + NativeScannerRunStatus, OcrResult, AppRuntimeInfo, ScannerCommand, @@ -54,7 +70,7 @@ app.commandLine.appendSwitch("disable-gpu-sandbox"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); 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 = {}; let devControlServer: Server | null = null; @@ -214,6 +230,305 @@ async function publishScannerStatus(status: ScannerStatusPayload) { 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 { + 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 = {}) { + 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 { + return getInputHelperService().nativeScannerDataStatus(nativeScannerDataDir()); +} + +async function nativeScannerCatalog(): Promise { + return getInputHelperService().nativeScannerCatalog(nativeScannerDataDir()); +} + +async function loadNativeIkArtifactCatalog(): Promise { + const catalog = await nativeScannerCatalog(); + return catalog.data.valid ? { artifacts: catalog.artifacts } : null; +} + +async function nativeScannerPreflight(options: { category?: string } = {}): Promise { + 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 { + 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 { + const status = await getInputHelperService().nativeScannerStop(); + publishNativeScannerStatus(status); + return status; +} + +async function nativeScannerStatus(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const service = createNativeScannerService(); + return service.loadImage(options); +} + async function readRuntimeInfo() { try { const result = await getInputHelperService().getRuntimeInfo(); @@ -446,6 +761,9 @@ function focusMainWindow() { } function sendScannerCommand(command: ScannerCommand | "probe-click") { + if (command === "stop") { + void nativeScannerStop().catch(() => undefined); + } getAppWindowManager().sendScannerCommand(command); } @@ -468,6 +786,15 @@ function startDevControlServer() { sendScannerCommand, clickScreen: clickScreenCommand, scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }), + nativeScannerDataStatus, + nativeScannerCatalog, + nativeScannerPreflight, + nativeScannerStart, + nativeScannerStop, + nativeScannerStatus, + nativeScannerProcessRun, + nativeScannerLoadResults, + nativeScannerLoadImage, warmOcr: (engine) => warmOcrWorkerPool(engine), loadReviewSamples, listCaptureSources, @@ -483,15 +810,13 @@ function createOverlayWindow() { getAppWindowManager().createOverlayWindow(); } -// Inventory Kamera keeps a pool of native Tesseract engines and scans artifact -// fields concurrently. Our fast artifact profile has four useful OCR parameter -// groups, so the default pool is four workers unless the machine is smaller or -// GAA_OCR_WORKERS explicitly overrides it. +// The fast artifact profile has four useful OCR parameter groups, so the +// default pool is four workers unless the machine is smaller or GAA_OCR_WORKERS +// explicitly overrides it. const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize(); -const IK_TRAINEDDATA_LANG = "genshin_fast_09_04_21"; type OcrWorker = Awaited>; -type OcrWorkerEngine = "current" | "ik-traineddata"; +type OcrWorkerEngine = "current"; type OcrCropPayload = { id: string; label: string; image: Buffer }; type OcrWarmupStatus = { engine: OcrWorkerEngine; @@ -513,11 +838,9 @@ type OcrWorkerPoolState = { const ocrWorkerPools: Record = { current: { poolPromise: null, runQueue: Promise.resolve() }, - "ik-traineddata": { poolPromise: null, runQueue: Promise.resolve() }, }; const ocrWarmupStatuses: Record = { current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE }, - "ik-traineddata": { engine: "ik-traineddata", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE }, }; function resolveOcrWorkerPoolSize() { @@ -541,42 +864,13 @@ function appRuntimeInfo(): AppRuntimeInfo { } function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine { - return options.ocrEngine === "ik-traineddata" ? "ik-traineddata" : "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`))) ?? ""; + void options; + return "current"; } function getOcrWorkerOptions(engine: OcrWorkerEngine) { - if (engine !== "ik-traineddata") return { lang: "eng", options: undefined }; - const langPath = findIkTessdataDir(); - 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"), - }, - }; + void engine; + return { lang: "eng", options: undefined }; } function getOcrWorkerPool(engine: OcrWorkerEngine) { @@ -594,7 +888,7 @@ function getOcrWorkerPool(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) => { const state = ocrWorkerPools[engineId]; const broken = state.poolPromise; @@ -710,7 +1004,6 @@ function warmOcrWorkerPool(engine: OcrWorkerEngine = "current") { function getOcrWarmupStatus() { return { current: ocrWarmupStatuses.current, - "ik-traineddata": ocrWarmupStatuses["ik-traineddata"], }; } @@ -1334,7 +1627,7 @@ async function buildCaptureResult( : await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine); const ocrMs = Date.now() - ocrStartedAt; 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); return { @@ -1458,6 +1751,23 @@ function initializeAppLifecycle() { moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(), focusGenshinForScanStart: () => focusGenshinForScanStart(), 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(), loadSnapshotFromDisk: () => loadSnapshotFromDisk(), saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot), diff --git a/electron/preload.cjs b/electron/preload.cjs index 20d4a08..e2cc489 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -23,6 +23,17 @@ contextBridge.exposeInMainWorld("assistantApi", { exportGood: (payload) => ipcRenderer.invoke("good:export", payload), importGoodFile: () => ipcRenderer.invoke("good:importFile"), publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status), + nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"), + nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"), + nativeScannerPreflight: (options) => ipcRenderer.invoke("scanner:nativePreflight", options), + nativeScannerStart: (options) => ipcRenderer.invoke("scanner:nativeStart", options), + nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"), + nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"), + nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options), + nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options), + nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options), + nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options), + nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options), showOverlay: () => ipcRenderer.invoke("overlay:show"), hideOverlay: () => ipcRenderer.invoke("overlay:hide"), onScannerCommand: (callback) => { diff --git a/electron/preload.ts b/electron/preload.ts index c8d1d66..7b1a68a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js"; +import type { CaptureOptions, GoodDatabase, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js"; import type { StoredArtifactRecord } from "../src/types/storage.js"; import type { AppSnapshot } from "../src/types/domain.js"; @@ -26,6 +26,17 @@ contextBridge.exposeInMainWorld("assistantApi", { exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload), importGoodFile: () => ipcRenderer.invoke("good:importFile"), publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status), + nativeScannerDataStatus: () => ipcRenderer.invoke("scanner:nativeDataStatus"), + nativeScannerCatalog: () => ipcRenderer.invoke("scanner:nativeCatalog"), + nativeScannerPreflight: (options?: { category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativePreflight", options), + nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options), + nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"), + nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"), + nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => ipcRenderer.invoke("scanner:nativeProcessRun", options), + nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => ipcRenderer.invoke("scanner:nativeLoadResults", options), + nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => ipcRenderer.invoke("scanner:nativePromoteResults", options), + nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => ipcRenderer.invoke("scanner:nativeReviewResult", options), + nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options), showOverlay: () => ipcRenderer.invoke("overlay:show"), hideOverlay: () => ipcRenderer.invoke("overlay:hide"), onScannerCommand: (callback: (command: ScannerCommand) => void) => { diff --git a/electron/services/inputHelper.ts b/electron/services/inputHelper.ts index 42c8058..26f1d92 100644 --- a/electron/services/inputHelper.ts +++ b/electron/services/inputHelper.ts @@ -8,6 +8,10 @@ import type { GdiCaptureResult, HelperOperationResponse, KeyPressResult, + NativeScannerCatalogStatus, + NativeScannerDataStatus, + NativeScannerPreflightStatus, + NativeScannerRunStatus, WindowBounds, RuntimeInfo, ScrollResult, @@ -153,6 +157,12 @@ export interface InputHelperService { keyPress(key: string): Promise; getAutomationGuard(): Promise; capturePrimaryScreenViaGdi(): Promise; + nativeScannerDataStatus(dataDir: string): Promise; + nativeScannerCatalog(dataDir: string): Promise; + nativeScannerPreflight(dataDir: string, category?: string): Promise; + nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }): Promise; + nativeScannerStop(): Promise; + nativeScannerStatus(): Promise; dispose(): void; } @@ -303,6 +313,39 @@ export function createInputHelperService(options: { userDataPath: string; exePat }; } + function scannerPayload(result: HelperOperationResponse): T { + return result.scanner as T; + } + + async function nativeScannerDataStatus(dataDir: string) { + return scannerPayload(await request("scanner-data-status", { dataDir }, 5000)); + } + + async function nativeScannerCatalog(dataDir: string) { + return scannerPayload(await request("scanner-catalog", { dataDir }, 8000)); + } + + async function nativeScannerPreflight(dataDir: string, category = "artifacts") { + return scannerPayload(await request("scanner-preflight", { dataDir, category }, 8000)); + } + + async function nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }) { + return scannerPayload(await request("scanner-start", { + dataDir: options.dataDir, + outputRoot: options.outputRoot, + limit: options.limit ?? 100, + category: options.category ?? "artifacts", + }, 8000)); + } + + async function nativeScannerStop() { + return scannerPayload(await request("scanner-stop", {}, 4000)); + } + + async function nativeScannerStatus() { + return scannerPayload(await request("scanner-status", {}, 4000)); + } + return { getRuntimeInfo, focusGenshinWindow, @@ -313,6 +356,12 @@ export function createInputHelperService(options: { userDataPath: string; exePat keyPress, getAutomationGuard, capturePrimaryScreenViaGdi, + nativeScannerDataStatus, + nativeScannerCatalog, + nativeScannerPreflight, + nativeScannerStart, + nativeScannerStop, + nativeScannerStatus, dispose: () => inputHelper.dispose(), }; } diff --git a/electron/services/inputHelperPowerShellFallback.ts b/electron/services/inputHelperPowerShellFallback.ts index 93dc086..ab1ef7c 100644 --- a/electron/services/inputHelperPowerShellFallback.ts +++ b/electron/services/inputHelperPowerShellFallback.ts @@ -87,11 +87,9 @@ function Send-MouseInput { return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) } -# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves -# with bare SetCursorPos, then clicks via the InputSimulator library's -# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE -# SendInput call (two INPUT structs in the same array) - back-to-back with no -# artificial delay between them, unlike two separate SendInput calls with a +# Uses bare SetCursorPos, then sends button-down and button-up as ONE SendInput +# call (two INPUT structs in the same array) - back-to-back with no artificial +# delay between them, unlike two separate SendInput calls with a # Start-Sleep in between. Returns the number of injected events (2 = ok). function Send-MouseClickBatch { $down = New-Object Native.InputHelper+INPUT @@ -208,7 +206,7 @@ function Find-GenshinWindow { # Plain SetForegroundWindow from this background helper process is silently # refused by Windows' foreground lock. Attach our thread's input queue to the # target (and current foreground) window thread and clear the lock timeout, so -# the foreground change is honored - the same technique Inventory Kamera uses. +# the foreground change is honored. function Force-Foreground { param([IntPtr]$hwnd) $current = [Native.InputHelper]::GetCurrentThreadId() @@ -313,13 +311,10 @@ while ($true) { } $targetX = [int]$cmd.x $targetY = [int]$cmd.y - # Matches Inventory Kamera's verified-working sequence exactly: bare - # SetCursorPos immediately followed by a click, with NO extra move - # event and NO artificial delay between moving and clicking - IK's - # Navigation.Click(x, y) does SetCursor() then Click() back-to-back, - # zero gap. Settling delays only happen after the click, in the scan - # loop. Down+up are sent as one SendInput call (see - # Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick(). + # Bare SetCursorPos immediately followed by a click, with NO extra move + # event and NO artificial delay between moving and clicking. Settling + # delays only happen after the click, in the scan loop. Down+up are sent + # as one SendInput call (see Send-MouseClickBatch). [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null $point = Get-CursorPoint $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) diff --git a/electron/services/nativeScannerProcessingService.ts b/electron/services/nativeScannerProcessingService.ts new file mode 100644 index 0000000..20802cf --- /dev/null +++ b/electron/services/nativeScannerProcessingService.ts @@ -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; + loadResults(options?: { runDir?: string; limit?: number }): Promise; + promoteResults(options: { runDir?: string; resultIds: string[] }): Promise; + reviewResult(options: { + runDir?: string; + resultId: string; + action: "approve" | "reject"; + artifact?: NativeScannerReviewArtifactInput; + note?: string; + }): Promise; + loadImage(options: { runDir?: string; imagePath: string }): Promise; +} + +interface NativeScannerProcessingServiceDependencies { + resolveRunDir(runDir?: string): string; + buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise; + loadArtifacts?: () => Promise; + saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; + saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>; + loadIkArtifactCatalog?: () => Promise; +} + +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 { + 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 { + 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( + items: readonly TInput[], + concurrency: number, + worker: (item: TInput, index: number) => Promise, +) { + const output = Array(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; + 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) { + 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, 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; +} diff --git a/electron/services/nativeScannerResultWorkflowService.ts b/electron/services/nativeScannerResultWorkflowService.ts new file mode 100644 index 0000000..a93f3c3 --- /dev/null +++ b/electron/services/nativeScannerResultWorkflowService.ts @@ -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; + saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; + saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>; + loadIkArtifactCatalog?: () => Promise; +} + +export interface NativeScannerResultWorkflowService { + promoteResults(options: { runDir?: string; resultIds: string[] }): Promise; + reviewResult(options: { + runDir?: string; + resultId: string; + action: "approve" | "reject"; + artifact?: NativeScannerReviewArtifactInput; + note?: string; + }): Promise; +} + +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, + 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; + 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); +} diff --git a/native/input-helper/IkInventoryLists.cs b/native/input-helper/IkInventoryLists.cs new file mode 100644 index 0000000..3b709aa --- /dev/null +++ b/native/input-helper/IkInventoryLists.cs @@ -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(); + 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 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(), + weapons = Array.Empty(), + characters = Array.Empty(), + materials = Array.Empty(), + }; + } + + 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 LoadStringMapCatalog(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var entries = new List(); + 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 LoadCharacterCatalog(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var entries = new List(); + 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 LoadArtifactCatalog(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var entries = new List(); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries; + foreach (var set in doc.RootElement.EnumerateObject()) + { + var pieces = new List(); + 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(); + 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", + }; +} diff --git a/native/input-helper/NativeScannerFiles.cs b/native/input-helper/NativeScannerFiles.cs new file mode 100644 index 0000000..ea62775 --- /dev/null +++ b/native/input-helper/NativeScannerFiles.cs @@ -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(); + } +} diff --git a/native/input-helper/Program.cs b/native/input-helper/Program.cs index 12a8439..c0c988d 100644 --- a/native/input-helper/Program.cs +++ b/native/input-helper/Program.cs @@ -19,6 +19,7 @@ namespace GenshinAssistant.InputHelper; internal static class Program { private static IntPtr _genshinHwnd = IntPtr.Zero; + private static readonly NativeScannerService Scanner = new(); private static int Main() { @@ -109,9 +110,8 @@ internal static class Program var targetX = GetInt(root, "x"); var targetY = GetInt(root, "y"); - // Bare SetCursorPos then a batched down+up click, matching the - // verified Inventory Kamera sequence: no extra move event, no - // gap between move and click. + // Bare SetCursorPos then a batched down+up click: no extra move + // event, no gap between move and click. Native.SetCursorPos(targetX, targetY); Native.GetCursorPos(out var pt); var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2; @@ -238,6 +238,36 @@ internal static class Program break; } + case "scanner-data-status": + response["scanner"] = Scanner.DataStatus(GetString(root, "dataDir")); + break; + + case "scanner-catalog": + response["scanner"] = IkInventoryLists.CatalogPayload(GetString(root, "dataDir")); + break; + + case "scanner-preflight": + response["scanner"] = Scanner.Preflight( + GetString(root, "dataDir"), + NormalizeScannerCategory(GetString(root, "category"))); + break; + + case "scanner-start": + response["scanner"] = Scanner.Start( + GetString(root, "dataDir"), + GetString(root, "outputRoot"), + GetInt(root, "limit"), + NormalizeScannerCategory(GetString(root, "category"))); + break; + + case "scanner-stop": + response["scanner"] = Scanner.Stop(); + break; + + case "scanner-status": + response["scanner"] = Scanner.Status(); + break; + default: response["ok"] = false; response["error"] = "unknown op"; @@ -262,6 +292,24 @@ internal static class Program public bool F9; } + private static string NormalizeScannerCategory(string category) + { + var value = (category ?? "").Trim().ToLowerInvariant(); + return value switch + { + "" => "artifacts", + "artifact" => "artifacts", + "artifacts" => "artifacts", + "weapon" => "weapons", + "weapons" => "weapons", + "character" => "characters", + "characters" => "characters", + "material" => "materials", + "materials" => "materials", + _ => value, + }; + } + private struct FocusInfo { public IntPtr Hwnd; @@ -308,10 +356,9 @@ internal static class Program } // Plain SetForegroundWindow from a background process is silently refused by - // Windows' foreground lock. Inventory Kamera and other reliable automation - // tools bypass it by attaching the calling thread's input queue to the target - // (and current-foreground) window thread and clearing the lock timeout, so the - // foreground change is honored. Without this the auto-scan aborts with + // Windows' foreground lock. Attach the calling thread's input queue to the + // target (and current-foreground) window thread and clear the lock timeout, + // so the foreground change is honored. Without this the auto-scan aborts with // "Genshin konnte nicht in den Vordergrund geholt werden". private static bool ForceForeground(IntPtr hwnd) { @@ -478,6 +525,701 @@ internal static class Program if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true; return false; } + + private sealed class NativeScannerService + { + private readonly object gate = new(); + private ScannerRunStatus current = ScannerRunStatus.Idle(); + private bool stopRequested; + private Task? worker; + + public object DataStatus(string dataDir) + { + return IkInventoryLists.Load(dataDir).ToPayload(); + } + + public object Preflight(string dataDir, string category) + { + var data = IkInventoryLists.Load(dataDir); + var supportedCategories = data.SupportedCategoriesPayload(); + if (!data.Valid) + { + return new + { + data = data.ToPayload(), + category, + supportedCategories, + categoryReady = false, + genshinFound = false, + bounds = (object?)null, + isSixteenNine = false, + grid = NativeGrid.Empty().ToPayload(), + ready = false, + blockReason = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}", + }; + } + + if (category != "artifacts") + { + return new + { + data = data.ToPayload(), + category, + supportedCategories, + categoryReady = false, + genshinFound = false, + bounds = (object?)null, + isSixteenNine = false, + grid = NativeGrid.Empty().ToPayload(), + ready = false, + blockReason = NativeCaptureUnsupportedMessage(category), + }; + } + + var bounds = GetGenshinClientBounds(); + var grid = bounds is null ? NativeGrid.Empty() : NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height); + var isSixteenNine = bounds is not null && IsSixteenNine(bounds.Value.Width, bounds.Value.Height); + var layoutReady = bounds is not null && isSixteenNine && grid.Targets.Count >= 32; + var visual = layoutReady ? CaptureVisualSignal(bounds!.Value) : null; + var ready = layoutReady && (visual?.Ready ?? false); + var blockReason = ready + ? "" + : bounds is null + ? "Genshin window not found." + : !isSixteenNine + ? $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9." + : grid.Targets.Count < 32 + ? $"Native grid incomplete: {grid.Targets.Count}/32 targets." + : visual?.BlockReason ?? "Genshin capture visual preflight failed."; + return new + { + data = data.ToPayload(), + category, + supportedCategories, + categoryReady = true, + genshinFound = bounds is not null, + bounds = bounds is null + ? null + : new + { + left = bounds.Value.Left, + top = bounds.Value.Top, + width = bounds.Value.Width, + height = bounds.Value.Height, + }, + isSixteenNine, + grid = grid.ToPayload(), + visual = visual?.ToPayload(), + ready, + blockReason, + }; + } + + public object Start(string dataDir, string outputRoot, int limit, string category) + { + lock (gate) + { + if (current.Running) return current.ToPayload(); + stopRequested = false; + var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800); + var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss"); + current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category); + var data = IkInventoryLists.Load(dataDir); + current.DataVersion = data.Version; + current.SupportedCategories = data.SupportedCategoriesPayload(); + if (!data.Valid) + { + current.Running = false; + current.Status = "blocked"; + current.Message = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}"; + return current.ToPayload(); + } + if (category != "artifacts") + { + current.Running = false; + current.Status = "blocked"; + current.Message = NativeCaptureUnsupportedMessage(category); + return current.ToPayload(); + } + worker = Task.Run(() => RunCaptureLoop(dataDir, outputRoot, safeLimit, runId, category)); + return current.ToPayload(); + } + } + + public object Stop() + { + lock (gate) + { + stopRequested = true; + current.Message = current.Running ? "stop requested" : current.Message; + return current.ToPayload(); + } + } + + public object Status() + { + lock (gate) + { + return current.ToPayload(); + } + } + + private void RunCaptureLoop(string dataDir, string outputRoot, int limit, string runId, string category) + { + try + { + var data = IkInventoryLists.Load(dataDir); + if (!data.Valid) + { + Finish("blocked", $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}"); + return; + } + + lock (gate) + { + current.DataVersion = data.Version; + current.SupportedCategories = data.SupportedCategoriesPayload(); + } + + if (category != "artifacts") + { + Finish("blocked", NativeCaptureUnsupportedMessage(category)); + return; + } + + var bounds = GetGenshinClientBounds(); + if (bounds is null) + { + Finish("blocked", "Genshin window not found."); + return; + } + if (!IsSixteenNine(bounds.Value.Width, bounds.Value.Height)) + { + Finish("blocked", $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9."); + return; + } + + var focus = FocusGenshinWindow(includeProcessNames: false); + if (!focus.Focused) + { + Finish("blocked", "Genshin could not be focused."); + return; + } + + var visual = CaptureVisualSignal(bounds.Value); + if (!visual.Ready) + { + Finish("blocked", visual.BlockReason); + return; + } + + var runDir = PrepareRunDirectory(outputRoot, runId); + var manifestPath = Path.Combine(runDir, "manifest.json"); + var jobsPath = Path.Combine(runDir, "capture-jobs.jsonl"); + var statusPath = Path.Combine(runDir, "status.json"); + var grid = NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height); + var detailRect = DetailRect(bounds.Value.Width, bounds.Value.Height); + lock (gate) + { + current.RunDir = runDir; + current.ManifestPath = manifestPath; + current.JobsPath = jobsPath; + current.StatusPath = statusPath; + current.SupportedCategories = data.SupportedCategoriesPayload(); + } + WriteRunManifest(manifestPath, data, bounds.Value, grid, detailRect, limit, runId, category); + WriteStatusFileSafe(); + using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8); + var captured = 0; + var page = 1; + + while (captured < limit) + { + foreach (var target in grid.Targets) + { + if (ShouldStop()) + { + Finish("stopped", "stop requested"); + return; + } + if (captured >= limit) break; + + var screenX = bounds.Value.Left + target.X; + var screenY = bounds.Value.Top + target.Y; + Native.SetCursorPos(screenX, screenY); + var sent = SendMouseClickBatch(); + Thread.Sleep(190); + + var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png"); + using (var card = CaptureArtifactCard(bounds.Value)) + { + card.Save(cardPath, ImageFormat.Png); + } + + var job = new NativeCaptureJob( + captured + 1, + page, + target.Row, + target.Col, + target.X, + target.Y, + screenX, + screenY, + sent, + Path.GetFileName(cardPath), + cardPath, + DateTimeOffset.Now, + detailRect.Width, + detailRect.Height, + category); + NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload()); + + captured++; + lock (gate) + { + current.Captured = captured; + current.Queued = captured; + current.Clicked = captured; + current.Pages = page; + current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked"; + current.LastArtifactPath = cardPath; + current.LastJob = job.ToPayload(); + current.ActiveMs = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds); + } + WriteStatusFileSafe(); + } + + if (captured >= limit) break; + ScrollOneArtifactPage(grid, bounds.Value); + page++; + lock (gate) + { + current.Pages = page; + current.Message = $"scrolled to page {page}"; + } + WriteStatusFileSafe(); + Thread.Sleep(120); + } + + Finish("done", $"captured {captured} {category} card crops"); + } + catch (Exception ex) + { + Finish("blocked", ex.Message); + } + } + + private static string PrepareRunDirectory(string outputRoot, string runId) + { + var root = string.IsNullOrWhiteSpace(outputRoot) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GenshinArtifactAssistant", "native-scans") + : outputRoot; + var runDir = Path.Combine(root, runId); + Directory.CreateDirectory(runDir); + return runDir; + } + + private static Bitmap CaptureArtifactCard(Rect bounds) + { + var card = DetailRect(bounds.Width, bounds.Height); + var bitmap = new Bitmap(card.Width, card.Height, PixelFormat.Format32bppArgb); + using var graphics = Graphics.FromImage(bitmap); + graphics.CopyFromScreen(bounds.Left + card.Left, bounds.Top + card.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy); + return bitmap; + } + + private static Rect DetailRect(int width, int height) + { + return new Rect + { + Left = (int)Math.Round(width * 0.681), + Top = (int)Math.Round(height * 0.111), + Width = Math.Max(1, (int)Math.Round(width * 0.256)), + Height = Math.Max(1, (int)Math.Round(height * 0.776)), + }; + } + + private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds) + { + Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY); + Thread.Sleep(25); + for (var index = 0; index < 39; index++) + { + SendMouseWheel(-120); + Thread.Sleep(1); + } + } + + private bool ShouldStop() + { + lock (gate) + { + if (stopRequested) return true; + } + var state = GetCursorState(); + return state.Escape || state.Enter || state.F9; + } + + private void Finish(string status, string message) + { + lock (gate) + { + current.Running = false; + current.Status = status; + current.Message = message; + current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue + ? 0 + : (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds); + stopRequested = false; + } + WriteStatusFileSafe(); + } + + private void WriteStatusFileSafe() + { + string statusPath; + object payload; + lock (gate) + { + statusPath = current.StatusPath; + payload = current.ToPayload(); + } + if (string.IsNullOrWhiteSpace(statusPath)) return; + try + { + NativeScannerFiles.WriteJson(statusPath, new { scanner = payload }); + } + catch + { + // Status files are a recovery aid; scan control remains in memory. + } + } + + private static void WriteRunManifest( + string manifestPath, + IkInventoryListStatus data, + Rect bounds, + NativeGrid grid, + Rect detailRect, + int target, + string runId, + string category) + { + NativeScannerFiles.WriteJson(manifestPath, new + { + schemaVersion = 1, + kind = "native-ik-category-card-crop-scan", + runId, + category, + createdAt = DateTimeOffset.Now, + target, + data = data.ToPayload(), + bounds = new + { + left = bounds.Left, + top = bounds.Top, + width = bounds.Width, + height = bounds.Height, + }, + grid = grid.ToPayload(), + detailRect = new + { + left = detailRect.Left, + top = detailRect.Top, + width = detailRect.Width, + height = detailRect.Height, + }, + outputs = new + { + jobs = "capture-jobs.jsonl", + status = "status.json", + }, + downstream = new + { + queue = "capture-jobs.jsonl", + next = "ocr-parse-store", + evaluation = "deferred", + category, + }, + }); + } + + private static bool IsSixteenNine(int width, int height) + { + if (height <= 0) return false; + const double ratio = 16.0 / 9.0; + var actual = width / (double)height; + return Math.Abs(actual - ratio) <= ratio * 0.02; + } + + private static NativeVisualSignal CaptureVisualSignal(Rect bounds) + { + try + { + using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy); + } + return NativeVisualSignal.FromBitmap(bitmap); + } + catch (Exception ex) + { + return NativeVisualSignal.Blocked($"Genshin capture visual preflight failed: {ex.Message}"); + } + } + + private static string NativeCaptureUnsupportedMessage(string category) + => $"Native capture for category '{category}' is not implemented yet; IK catalog is available only."; + + } + + private sealed class NativeVisualSignal + { + public bool Ready { get; private init; } + public int Samples { get; private init; } + public double WhitePct { get; private init; } + public double DarkPct { get; private init; } + public double ColorPct { get; private init; } + public double LumaStdDev { get; private init; } + public string BlockReason { get; private init; } = ""; + + public static NativeVisualSignal Blocked(string reason) => new() + { + Ready = false, + Samples = 0, + BlockReason = reason, + }; + + public static NativeVisualSignal FromBitmap(Bitmap bitmap) + { + var stepX = Math.Max(1, bitmap.Width / 120); + var stepY = Math.Max(1, bitmap.Height / 80); + var samples = 0; + var white = 0; + var dark = 0; + var colorful = 0; + double lumaSum = 0; + double lumaSqSum = 0; + + for (var y = 0; y < bitmap.Height; y += stepY) + { + for (var x = 0; x < bitmap.Width; x += stepX) + { + var pixel = bitmap.GetPixel(x, y); + var max = Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)); + var min = Math.Min(pixel.R, Math.Min(pixel.G, pixel.B)); + var luma = 0.2126 * pixel.R + 0.7152 * pixel.G + 0.0722 * pixel.B; + samples++; + if (pixel.R >= 245 && pixel.G >= 245 && pixel.B >= 245) white++; + if (pixel.R <= 12 && pixel.G <= 12 && pixel.B <= 12) dark++; + if (max - min >= 18) colorful++; + lumaSum += luma; + lumaSqSum += luma * luma; + } + } + + if (samples <= 0) return Blocked("Genshin capture visual preflight produced no samples."); + + var mean = lumaSum / samples; + var variance = Math.Max(0, (lumaSqSum / samples) - mean * mean); + var stdDev = Math.Sqrt(variance); + var whitePct = white * 100.0 / samples; + var darkPct = dark * 100.0 / samples; + var colorPct = colorful * 100.0 / samples; + var blankWhite = whitePct >= 96 && stdDev <= 10; + var blankDark = darkPct >= 96 && stdDev <= 10; + var tooUniform = stdDev <= 4 && colorPct <= 1.5; + var ready = !(blankWhite || blankDark || tooUniform); + var reason = ready + ? "" + : blankWhite + ? "Genshin capture is blank or almost entirely white." + : blankDark + ? "Genshin capture is blank or almost entirely black." + : "Genshin capture is too uniform for native scanning."; + + return new NativeVisualSignal + { + Ready = ready, + Samples = samples, + WhitePct = Math.Round(whitePct, 1), + DarkPct = Math.Round(darkPct, 1), + ColorPct = Math.Round(colorPct, 1), + LumaStdDev = Math.Round(stdDev, 1), + BlockReason = reason, + }; + } + + public object ToPayload() => new + { + ready = Ready, + samples = Samples, + whitePct = WhitePct, + darkPct = DarkPct, + colorPct = ColorPct, + lumaStdDev = LumaStdDev, + blockReason = BlockReason, + }; + } + + private sealed class ScannerRunStatus + { + public bool Running { get; set; } + public string Status { get; set; } = "idle"; + public string RunId { get; set; } = ""; + public int Target { get; set; } + public int Captured { get; set; } + public int Queued { get; set; } + public int Clicked { get; set; } + public int Pages { get; set; } + public int ActiveMs { get; set; } + public string Message { get; set; } = ""; + public string OutputRoot { get; set; } = ""; + public string RunDir { get; set; } = ""; + public string ManifestPath { get; set; } = ""; + public string JobsPath { get; set; } = ""; + public string StatusPath { get; set; } = ""; + public string DataVersion { get; set; } = ""; + public string Category { get; set; } = "artifacts"; + public string LastArtifactPath { get; set; } = ""; + public object? SupportedCategories { get; set; } + public object? LastJob { get; set; } + public DateTimeOffset StartedAt { get; set; } + + public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" }; + + public static ScannerRunStatus Started(string runId, int target, string outputRoot, string category) => new() + { + Running = true, + Status = "running", + RunId = runId, + Category = category, + Target = target, + OutputRoot = outputRoot, + StartedAt = DateTimeOffset.Now, + Message = $"native {category} scanner started", + }; + + public object ToPayload() => new + { + running = Running, + status = Status, + runId = RunId, + target = Target, + captured = Captured, + queued = Queued, + clicked = Clicked, + pages = Pages, + activeMs = ActiveMs, + message = Message, + outputRoot = OutputRoot, + runDir = RunDir, + manifestPath = ManifestPath, + jobsPath = JobsPath, + statusPath = StatusPath, + dataVersion = DataVersion, + category = Category, + lastArtifactPath = LastArtifactPath, + supportedCategories = SupportedCategories, + lastJob = LastJob, + }; + } + + private sealed record NativeCaptureJob( + int Sequence, + int Page, + int Row, + int Col, + int ClientX, + int ClientY, + int ScreenX, + int ScreenY, + uint ClickEventsSent, + string RelativePath, + string AbsolutePath, + DateTimeOffset CapturedAt, + int DetailWidth, + int DetailHeight, + string Category) + { + public object ToPayload() => new + { + sequence = Sequence, + category = Category, + page = Page, + row = Row, + col = Col, + clientX = ClientX, + clientY = ClientY, + screenX = ScreenX, + screenY = ScreenY, + clickEventsSent = ClickEventsSent, + relativePath = RelativePath, + absolutePath = AbsolutePath, + capturedAt = CapturedAt, + detail = new + { + width = DetailWidth, + height = DetailHeight, + }, + kind = $"{Category.TrimEnd('s')}-detail-card-crop", + downstream = "ocr-parse-store", + }; + } + + private sealed class NativeGrid + { + public List 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(); + 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 diff --git a/package.json b/package.json index 71546d8..bce8496 100644 --- a/package.json +++ b/package.json @@ -20,20 +20,19 @@ "eval:prepare-confirmed": "node scripts/prepare-confirmed-review-case.cjs", "scan:soak": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1", "scan:goal": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun", - "scan:goal:current": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine current", - "scan:goal:ik": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine ik-traineddata", - "scan:goal:compare": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -GoalRun -ScanEngine compare", - "scan:goal:compare:validated": "npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", - "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: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:goal:validated": "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary", + "scan:goal:validated:wait": "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary", + "scan:iterate": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr", + "scan:iterate:validated": "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20", + "scan:iterate:validated:wait": "npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20", "scan:repeatability": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current", - "scan:repeatability:wait": "npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current --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:wait": "node scripts/live-preflight.cjs --wait=120", "scan:assessment:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -SelfTestAssessment", "scan:assessment:validate": "node scripts/validate-scan-assessment.cjs", + "scan:native:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1", + "scan:native:smoke:5": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1 -Limit 5", "helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish", "data:genshin": "node scripts/generate-genshin-data.cjs" }, @@ -75,6 +74,13 @@ "filter": [ "**/*" ] + }, + { + "from": "data/ik-inventorylists", + "to": "ik-inventorylists", + "filter": [ + "**/*" + ] } ], "win": { diff --git a/scripts/live-soak.ps1 b/scripts/live-soak.ps1 index de5fa4a..c2ce1cd 100644 --- a/scripts/live-soak.ps1 +++ b/scripts/live-soak.ps1 @@ -7,12 +7,12 @@ param( [string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"), [switch]$GoalRun, [switch]$RepeatabilityRun, - [ValidateSet("current", "ik-traineddata", "compare")] + [ValidateSet("current")] [string]$ScanEngine = "current", [switch]$BenchmarkOcr, [int]$BenchmarkLimit = 5, - [ValidateSet("current", "ik-traineddata", "compare")] - [string]$BenchmarkEngine = "compare", + [ValidateSet("current")] + [string]$BenchmarkEngine = "current", [ValidateSet("fast", "full")] [string]$BenchmarkProfile = "fast", [switch]$SkipSmartCapture, @@ -205,11 +205,11 @@ function Get-TimingBottleneck([object]$Timing) { function Get-TimingRecommendation([object]$Timing) { $bottleneck = Get-TimingBottleneck $Timing 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" { 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." } - "scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling against IK's 100ms fast-scroll wait." } + "card-ready" { return "Card-ready dominates; tune detail fingerprint polling before OCR changes." } + "scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling before OCR changes." } default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." } } } @@ -296,7 +296,7 @@ function New-PerformanceAssessment([object[]]$Summaries) { limit = [int]$group.Name engineCount = $entries.Count 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 winnerQualified = $winner.qualified winnerMissRate = $winner.missRate @@ -312,18 +312,16 @@ function New-PerformanceAssessment([object[]]$Summaries) { $goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1) $goal100Decision = "not-run: missing 100-artifact assessment" if ($goal100.Count -gt 0) { - if (-not $goal100[0].comparisonComplete) { - $goal100Decision = "not-comparable: current and ik-traineddata were not both run" - } elseif (-not $goal100[0].winnerQualified) { + if (-not $goal100[0].winnerQualified) { $goal100Decision = "not-qualified: 100-artifact winner failed quality gates" } else { - $goal100Decision = "qualified-comparison: winner=$($goal100[0].winnerEngine)" + $goal100Decision = "qualified: winner=$($goal100[0].winnerEngine)" } } return [pscustomobject]@{ createdAt = (Get-Date).ToString("o") goalLimit = 100 - goalEngines = @("current", "ik-traineddata") + goalEngines = @("current") goal100Decision = $goal100Decision goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null } limits = $limitReports @@ -335,11 +333,10 @@ function Write-PerformanceAssessment([object]$Assessment) { Write-Host "assessment goal100: $($Assessment.goal100Decision)" } 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.winnerEngine, $limit.winnerQualified, - $limit.comparisonComplete, ($limit.enginesCompared -join ","), $limit.winnerMissRate, $limit.winnerReviewRate, @@ -372,34 +369,34 @@ function Invoke-AssessmentSelfTest { limit = 100 status = "done" parsed = 100 - review = 22 + review = 0 misses = 0 - activeAverageMsPerParsed = 700 - averageMsPerParsed = 760 - activeProjectedMsFor100 = 70000 - averageOcrMs = 260 - averageCaptureMs = 160 - averageCaptureRoundTripMs = 340 - averageCaptureRoundTripOverheadMs = 180 - averageCardReadyMs = 190 - averageScrollReadyMs = 60 + activeAverageMsPerParsed = 500 + averageMsPerParsed = 520 + activeProjectedMsFor100 = 50000 + averageOcrMs = 180 + averageCaptureMs = 120 + averageCaptureRoundTripMs = 260 + averageCaptureRoundTripOverheadMs = 140 + averageCardReadyMs = 100 + averageScrollReadyMs = 50 }, [pscustomobject]@{ - engine = "ik-traineddata" + engine = "high-review" limit = 100 status = "done" parsed = 100 - review = 4 + review = 22 misses = 0 - activeAverageMsPerParsed = 820 - averageMsPerParsed = 870 - activeProjectedMsFor100 = 82000 - averageOcrMs = 210 - averageCaptureMs = 170 - averageCaptureRoundTripMs = 300 - averageCaptureRoundTripOverheadMs = 130 - averageCardReadyMs = 205 - averageScrollReadyMs = 80 + activeAverageMsPerParsed = 300 + averageMsPerParsed = 330 + activeProjectedMsFor100 = 30000 + averageOcrMs = 100 + averageCaptureMs = 90 + averageCaptureRoundTripMs = 190 + averageCaptureRoundTripOverheadMs = 100 + averageCardReadyMs = 40 + averageScrollReadyMs = 20 }, [pscustomobject]@{ engine = "current" @@ -418,23 +415,6 @@ function Invoke-AssessmentSelfTest { averageCardReadyMs = 80 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]@{ engine = "broken-fast" limit = 45 @@ -476,43 +456,37 @@ function Invoke-AssessmentSelfTest { $limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0] $limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0] - if ($goal100.winnerEngine -ne "ik-traineddata") { - throw "Assessment self-test failed: expected ik-traineddata to win limit=100, got '$($goal100.winnerEngine)'." + if ($goal100.winnerEngine -ne "current") { + throw "Assessment self-test failed: expected current to win limit=100, got '$($goal100.winnerEngine)'." } if (-not $goal100.winnerQualified) { throw "Assessment self-test failed: expected limit=100 winner to be qualified." } - if (-not $goal100.comparisonComplete) { - 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") { + if ($assessment.goal100Decision -ne "qualified: winner=current") { throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'." } if ($limit20.winnerEngine -ne "current") { throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'." } - if (-not $limit20.comparisonComplete) { - throw "Assessment self-test failed: expected limit=20 to be a complete current vs ik-traineddata comparison." - } if (-not $limit20.winnerQualified) { throw "Assessment self-test failed: expected limit=20 winner to be qualified." } if ($limit45.winnerEngine -ne "current") { throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'." } - if (@($goal100.engines | Where-Object { $_.engine -eq "current" })[0].qualityDecision -ne "not-qualified: review rate above 15%") { - throw "Assessment self-test failed: expected high-review current run to be rejected." + if (@($goal100.engines | Where-Object { $_.engine -eq "high-review" })[0].qualityDecision -ne "not-qualified: review rate above 15%") { + throw "Assessment self-test failed: expected high-review run to be rejected." } if (@($limit45.engines | Where-Object { $_.engine -eq "broken-fast" })[0].qualityDecision -ne "not-qualified: miss rate above 2%") { throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate." } - $singleEngineAssessment = New-PerformanceAssessment -Summaries @( + $missingGoalAssessment = New-PerformanceAssessment -Summaries @( [pscustomobject]@{ engine = "current" - limit = 100 + limit = 20 status = "done" - parsed = 100 + parsed = 20 review = 0 misses = 0 activeAverageMsPerParsed = 500 @@ -526,8 +500,8 @@ function Invoke-AssessmentSelfTest { averageScrollReadyMs = 50 } ) - if ($singleEngineAssessment.goal100Decision -ne "not-comparable: current and ik-traineddata were not both run") { - throw "Assessment self-test failed: expected single-engine 100 run to be not-comparable, got '$($singleEngineAssessment.goal100Decision)'." + if ($missingGoalAssessment.goal100Decision -ne "not-run: missing 100-artifact assessment") { + throw "Assessment self-test failed: expected missing 100 run to be not-run, got '$($missingGoalAssessment.goal100Decision)'." } Write-PerformanceAssessment $assessment @@ -543,43 +517,23 @@ function Invoke-OcrBenchmark { ) 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 - 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" - $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 - 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 - Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f ` - $summary.engine, - $summary.averageMs, - $summary.averageOcrMs, - $summary.p50Ms, - $summary.p90Ms, - $summary.projectedMs.artifacts100, - $summary.skippedOcrCaptures, - $summary.workerPoolSize) - } + $summary = $benchmark.summary + Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f ` + $summary.engine, + $summary.averageMs, + $summary.averageOcrMs, + $summary.p50Ms, + $summary.p90Ms, + $summary.projectedMs.artifacts100, + $summary.skippedOcrCaptures, + $summary.workerPoolSize) return $benchmark } @@ -707,7 +661,7 @@ $Limits = @($Limits | ForEach-Object { $limit }) -$ScanEngines = if ($ScanEngine -eq "compare") { @("current", "ik-traineddata") } else { @($ScanEngine) } +$ScanEngines = @($ScanEngine) $RunSummaries = @() $transcriptPath = Join-Path $RunDir "transcript.log" @@ -728,7 +682,7 @@ try { if ($health.appBuild) { Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)" if ($health.appBuild.expectedOcrWorkerPoolSize -lt 4) { - Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for 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) { if ($limit -lt 1) { continue } Write-Host "Starting bounded scanner run limit=$limit engine=$engine" - $start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit&engine=$engine" + $start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit" Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null $finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine diff --git a/scripts/native-live-smoke.ps1 b/scripts/native-live-smoke.ps1 new file mode 100644 index 0000000..7fe14b2 --- /dev/null +++ b/scripts/native-live-smoke.ps1 @@ -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) + } +} diff --git a/scripts/validate-scan-assessment.cjs b/scripts/validate-scan-assessment.cjs index f4ca537..4619fae 100644 --- a/scripts/validate-scan-assessment.cjs +++ b/scripts/validate-scan-assessment.cjs @@ -51,7 +51,6 @@ function validateAssessment(assessment, options = {}) { const errors = []; const expectedWinner = options.expectedWinner || "any"; const expectedLimit = options.limit === undefined ? 100 : Number(options.limit); - const allowSingleEngine = options.allowSingleEngine === true; const maxActiveAverageMsPerParsed = options.maxActiveAverageMsPerParsed === undefined ? null : Number(options.maxActiveAverageMsPerParsed); @@ -77,26 +76,20 @@ function validateAssessment(assessment, options = {}) { errors.push(`Missing limit=${expectedLimit} assessment.`); } - if (!allowSingleEngine && expectedLimit === 100 && assessment.goal100Decision !== `qualified-comparison: winner=${limitAssessment?.winnerEngine}`) { - errors.push(`goal100Decision is not a qualified comparison: ${assessment.goal100Decision || ""}`); + if (expectedLimit === 100 && assessment.goal100Decision !== `qualified: winner=${limitAssessment?.winnerEngine}`) { + errors.push(`goal100Decision is not a qualified 100-artifact run: ${assessment.goal100Decision || ""}`); } if (limitAssessment?.limit !== expectedLimit) { errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? ""}.`); } - 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 (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) { errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? ""}'.`); } const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : []; - const engineNames = new Set(engines.map((entry) => entry?.engine)); - 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) { + if (engines.length < 1) { errors.push(`limit=${expectedLimit} must include at least one engine result.`); } @@ -191,8 +184,8 @@ function main() { ? findLatestAssessment(argValue("root", defaultAssessmentRoot())) : argValue("input", process.argv[2] || ""); const expectedWinner = argValue("expect-winner", "any"); - if (!["any", "current", "ik-traineddata"].includes(expectedWinner)) { - throw new Error("--expect-winner must be one of: any, current, ik-traineddata."); + if (!["any", "current"].includes(expectedWinner)) { + throw new Error("--expect-winner must be one of: any, current."); } const limit = Number(argValue("limit", "100")); const assessment = loadAssessment(inputPath); @@ -201,7 +194,6 @@ function main() { const result = validateAssessment(assessment, { expectedWinner, limit, - allowSingleEngine: hasFlag("allow-single-engine"), maxActiveAverageMsPerParsed: maxActiveAverageMsPerParsed === "" ? undefined : maxActiveAverageMsPerParsed, maxCaptureRoundTripOverheadMs: maxCaptureRoundTripOverheadMs === "" ? undefined : maxCaptureRoundTripOverheadMs, }); diff --git a/src/eval/corpus/confirmedReviewCorpus.ts b/src/eval/corpus/confirmedReviewCorpus.ts index 5f1f27d..4e63395 100644 --- a/src/eval/corpus/confirmedReviewCorpus.ts +++ b/src/eval/corpus/confirmedReviewCorpus.ts @@ -11,4 +11,92 @@ export interface ConfirmedReviewEvalCase extends OcrEvalCase { // Human-confirmed review samples belong here after their `expect` values were // checked against the real artifact. Do not paste unconfirmed exporter output // directly from outputs/review-eval-candidates/. -export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = []; +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", + }, + }, +]; diff --git a/src/eval/nativeScannerProcessingService.test.ts b/src/eval/nativeScannerProcessingService.test.ts new file mode 100644 index 0000000..98317d9 --- /dev/null +++ b/src/eval/nativeScannerProcessingService.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/eval/scanAssessmentValidatorScript.test.ts b/src/eval/scanAssessmentValidatorScript.test.ts index c2d676a..78d2422 100644 --- a/src/eval/scanAssessmentValidatorScript.test.ts +++ b/src/eval/scanAssessmentValidatorScript.test.ts @@ -6,28 +6,27 @@ import { describe, expect, it } from "vitest"; function validAssessment() { return { - createdAt: "2026-07-08T12:00:00.000Z", - goal100Decision: "qualified-comparison: winner=ik-traineddata", + createdAt: "2026-07-09T12:00:00.000Z", + goal100Decision: "qualified: winner=current", goal100: { limit: 100, - comparisonComplete: true, - winnerEngine: "ik-traineddata", + singleEngine: true, + winnerEngine: "current", winnerQualified: true, - winnerActiveAverageMsPerParsed: 820, - winnerActiveProjectedMsFor100: 82000, - winnerAverageCaptureRoundTripMs: 420, - winnerAverageCaptureRoundTripOverheadMs: 160, + winnerActiveAverageMsPerParsed: 393, + winnerActiveProjectedMsFor100: 39300, + winnerAverageCaptureRoundTripMs: 333, + winnerAverageCaptureRoundTripOverheadMs: 148, winnerMissRate: 0, - winnerReviewRate: 0.04, + winnerReviewRate: 0, engines: [ - { engine: "ik-traineddata", qualified: true, missRate: 0, reviewRate: 0.04, averageCaptureRoundTripMs: 420, averageCaptureRoundTripOverheadMs: 160 }, - { engine: "current", qualified: true, missRate: 0, reviewRate: 0.06, averageCaptureRoundTripMs: 460, averageCaptureRoundTripOverheadMs: 190 }, + { engine: "current", qualified: true, missRate: 0, reviewRate: 0, averageCaptureRoundTripMs: 333, averageCaptureRoundTripOverheadMs: 148 }, ], }, limits: [ { limit: 20, - comparisonComplete: true, + singleEngine: true, winnerEngine: "current", winnerQualified: true, winnerActiveAverageMsPerParsed: 390, @@ -38,7 +37,6 @@ function validAssessment() { winnerReviewRate: 0.05, engines: [ { 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", () => { - it("accepts a qualified complete 100-artifact comparison", () => { + it("accepts a qualified 100-artifact current run", () => { const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); try { const inputPath = writeAssessment(dir, validAssessment()); @@ -70,14 +68,10 @@ describe("scan assessment validator", () => { const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); try { const inputPath = writeAssessment(dir, validAssessment()); - const output = execFileSync( - "node", - ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=ik-traineddata"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); + const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--expect-winner=current"], { + cwd: process.cwd(), + encoding: "utf8", + }); expect(JSON.parse(output).ok).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); @@ -94,28 +88,24 @@ describe("scan assessment validator", () => { }); expect(output).toContain("scan assessment: PASS"); 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("winner: ik-traineddata"); - expect(output).toContain("activeAvg: 820ms/artifact"); - expect(output).toContain("captureRoundTripOverhead: 160ms"); + expect(output).toContain("winner: current"); + expect(output).toContain("activeAvg: 393ms/artifact"); + expect(output).toContain("captureRoundTripOverhead: 148ms"); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("accepts a qualified complete 20-artifact comparison", () => { + it("accepts a qualified 20-artifact current run", () => { const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); try { const inputPath = writeAssessment(dir, validAssessment()); - const output = execFileSync( - "node", - ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); + const output = execFileSync("node", ["scripts/validate-scan-assessment.cjs", `--input=${inputPath}`, "--summary", "--limit=20"], { + cwd: process.cwd(), + encoding: "utf8", + }); expect(output).toContain("scan assessment: PASS"); expect(output).toContain("limit: 20"); expect(output).toContain("winner: current"); @@ -228,35 +218,35 @@ describe("scan assessment validator", () => { it("keeps the validated npm scripts wired through preflight and the correct assessment limit", () => { const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")); - expect(packageJson.scripts["scan:goal:compare:validated"]).toBe( - "npm run scan:live:preflight && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + expect(packageJson.scripts["scan:goal:validated"]).toBe( + "npm run scan:live:preflight && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary", ); - expect(packageJson.scripts["scan:goal:compare:validated:wait"]).toBe( - "npm run scan:live:preflight:wait && npm run scan:goal:compare && npm run scan:assessment:validate -- --latest --summary", + expect(packageJson.scripts["scan:goal:validated:wait"]).toBe( + "npm run scan:live:preflight:wait && npm run scan:goal && npm run scan:assessment:validate -- --latest --summary", ); - expect(packageJson.scripts["scan:iterate:compare"]).toBe( - "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -ScanEngine compare -BenchmarkOcr", + expect(packageJson.scripts["scan:iterate"]).toBe( + "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -Limits 20 -BenchmarkOcr", ); - expect(packageJson.scripts["scan:iterate:compare:validated"]).toBe( - "npm run scan:live:preflight && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + expect(packageJson.scripts["scan:iterate:validated"]).toBe( + "npm run scan:live:preflight && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20", ); - expect(packageJson.scripts["scan:iterate:compare:validated:wait"]).toBe( - "npm run scan:live:preflight:wait && npm run scan:iterate:compare && npm run scan:assessment:validate -- --latest --summary --limit=20", + expect(packageJson.scripts["scan:iterate:validated:wait"]).toBe( + "npm run scan:live:preflight:wait && npm run scan:iterate && npm run scan:assessment:validate -- --latest --summary --limit=20", ); expect(packageJson.scripts["scan:repeatability"]).toBe( "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\live-soak.ps1 -RepeatabilityRun -ScanEngine current", ); expect(packageJson.scripts["scan:repeatability:wait"]).toBe( - "npm run scan:live:preflight:wait && npm run scan:repeatability && npm run scan:assessment:validate -- --latest --summary --limit=100 --expect-winner=current --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-")); try { const inputPath = writeAssessment(dir, validAssessment()); 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(), stdio: "pipe", }), @@ -269,10 +259,12 @@ describe("scan assessment validator", () => { it("prints errors in summary mode for rejected assessments", () => { const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); 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 = ""; 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(), encoding: "utf8", stdio: "pipe", @@ -281,60 +273,7 @@ describe("scan assessment validator", () => { stdout = String((error as { stdout?: string }).stdout || ""); } expect(stdout).toContain("scan assessment: FAIL"); - expect(stdout).toContain("Expected winner"); - } 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"); + expect(stdout).toContain("goal100Decision is not a qualified 100-artifact run"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -438,8 +377,8 @@ describe("scan assessment validator", () => { it("validates the latest assessment under a live-soak root", () => { const dir = mkdtempSync(path.join(tmpdir(), "gaa-assessment-")); try { - const older = path.join(dir, "2026-07-08T10-00-00"); - const newer = path.join(dir, "2026-07-08T11-00-00"); + const older = path.join(dir, "2026-07-09T10-00-00"); + const newer = path.join(dir, "2026-07-09T11-00-00"); mkdirSync(older); mkdirSync(newer); writeAssessment(older, { goal100Decision: "not-run: missing 100-artifact assessment" }); @@ -451,7 +390,7 @@ describe("scan assessment validator", () => { }); const parsed = JSON.parse(output); expect(parsed.ok).toBe(true); - expect(parsed.inputPath).toContain("2026-07-08T11-00-00"); + expect(parsed.inputPath).toContain("2026-07-09T11-00-00"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/features/inventory/InventoryView.tsx b/src/features/inventory/InventoryView.tsx new file mode 100644 index 0000000..9009e67 --- /dev/null +++ b/src/features/inventory/InventoryView.tsx @@ -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 ( +
+
+
+

Native Artifact Inventory

+

Artifact Scan-Ergebnisse

+
+
+
+ {totalRows} gesamt + {nativeCount} native + {storedCount} store + {reviewCount} review +
+ +
+
+ +
+
+ {catalogSummary.ok ? "Artifact IK bereit" : "IK Listen pruefen"} + {catalogSummary.title} + {catalogSummary.detail} +
+ {catalogSummary.counts.length > 0 && ( +
+ {catalogSummary.counts.map((entry) => ( + + {entry.value} + {entry.label} + {entry.sample && {entry.sample}} + + ))} +
+ )} +
+ +
+ {pipelineSummary.cards.map((card) => ( + + {card.title} + {card.value} + {card.detail} + + ))} +
+

{pipelineSummary.caption}

+ +
+
+ {promotionSummary.title} + {promotionSummary.detail} + Analyse aus scan-results.json und lokalem Artifact-Store. Store-Schreibzugriff bleibt ein expliziter naechster Schritt. +
+
+ {promotionSummary.ready}Speicherbar + {promotionSummary.alreadyStored + promotionSummary.persisted}Im Store + {promotionSummary.review}Review + {promotionSummary.blocked}Blockiert +
+
+ +
+
+ {filterOptions.map((option) => ( + + ))} +
+ +
+ +
+
+ {rows.length > 0 ? rows.map((row) => ( + + )) : ( +

Keine Scan-Ergebnisse geladen.

+ )} +
+ +