feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
+6
-6
@@ -2,14 +2,14 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Builds
|
# Builds
|
||||||
|
build/
|
||||||
dist/
|
dist/
|
||||||
dist-electron/
|
dist-electron/
|
||||||
outputs/dist/
|
outputs/
|
||||||
outputs/admin-start/
|
|
||||||
outputs/live-capture/
|
# Native .NET build artifacts
|
||||||
outputs/live-soak/
|
**/bin/
|
||||||
outputs/native-live-smoke/
|
**/obj/
|
||||||
outputs/review-eval-candidates/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -61,3 +61,18 @@ A task is done when:
|
|||||||
- Relevant checks passed or skipped checks are explained.
|
- Relevant checks passed or skipped checks are explained.
|
||||||
- Scanner/OCR changes fail safely into review or uncertainty.
|
- Scanner/OCR changes fail safely into review or uncertainty.
|
||||||
- The final summary names the changed areas and remaining risks.
|
- The final summary names the changed areas and remaining risks.
|
||||||
|
|
||||||
|
## Obsidian Handoff
|
||||||
|
|
||||||
|
For durable project changes, follow:
|
||||||
|
|
||||||
|
`C:\Users\vmbao\Desktop\Ordner\Obsidian-Vault\Resources\Obsidian\Agent Project Sync Protocol.md`
|
||||||
|
|
||||||
|
The compact project mirror is:
|
||||||
|
|
||||||
|
`C:\Users\vmbao\Desktop\Ordner\Obsidian-Vault\Projects\Genshin Artifact Assistant\README.md`
|
||||||
|
|
||||||
|
- Update this repository's canonical docs first, then the Vault mirror at semantic checkpoints.
|
||||||
|
- Sync material changes to scope, status, architecture, decisions, verified scanner state, major risks, next priorities, and repeatable workflows.
|
||||||
|
- Do not mirror every source edit, raw log, or transient debugging result.
|
||||||
|
- If the Vault is unavailable, report the exact pending handoff instead of claiming synchronization.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
|
||||||
|
`AGENTS.md` is authoritative for shared repository instructions. Do not duplicate those rules here. Claude auto memory is non-authoritative; persist durable project changes through the repository documentation and Obsidian handoff defined there.
|
||||||
@@ -9,8 +9,21 @@ Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging
|
|||||||
- Smart Capture for the currently visible artifact detail view with focused OCR crops, parser confidence, and review notes.
|
- 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.
|
- 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.
|
- Native IK-style artifact capture path that writes card crops and run artifacts for post-capture processing.
|
||||||
|
- Packaged full-owned-inventory acceptance at 2,211 Artifacts over 70 pages,
|
||||||
|
followed by current-package reprocessing at 136 Review (6.15%), 0 errors,
|
||||||
|
and 0 store writes.
|
||||||
- Vendored Inventory Kamera `inventorylists` under `data/ik-inventorylists` as artifact reference data.
|
- 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.
|
- Scan result rail plus Inventory view for native results, stored artifacts, crop previews, IK/GOOD match state, explicit promotion, single-result review/edit/approve, and Artifact-only pipeline state.
|
||||||
|
- Deterministic roll-efficiency evaluation with reason codes and no fake build-fit claim.
|
||||||
|
- Score-free, versioned Build-Fit V1 evidence contract plus deterministic
|
||||||
|
five-slot/Set/aggregate/conflict verification, a four-profile sourced corpus
|
||||||
|
(Furina, Neuvillette, Raiden Hyperbloom, Nahida Deepwood), explicit source
|
||||||
|
expiry, and a separate read-only evidence-coverage ranker. The Builds view
|
||||||
|
shows at most three explainable native-scan suggestions; it never treats
|
||||||
|
Roll-Efficiency, OCR confidence, character ownership, team, weapon, or
|
||||||
|
rotation as a guessed fit score.
|
||||||
|
- Detail-only Worst/Middle/Best upgrade projection for under-leveled, unambiguously identified 5-star Artifacts with four known legal substats.
|
||||||
|
- Offline replay gate for existing native scan results and an unpacked-package resource/ASAR verifier.
|
||||||
- GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and read-only overlay preview shell.
|
- GOOD import/export, review samples, OCR eval, diagnostics, local artifact store, demo triage/build views, and read-only overlay preview shell.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -24,6 +37,10 @@ This project uses the engineering template from `https://git.noveria.net/bao/tem
|
|||||||
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
|
- [docs/CONVENTIONS.md](docs/CONVENTIONS.md)
|
||||||
- [docs/DECISIONS.md](docs/DECISIONS.md)
|
- [docs/DECISIONS.md](docs/DECISIONS.md)
|
||||||
- [docs/CHECKLISTS.md](docs/CHECKLISTS.md)
|
- [docs/CHECKLISTS.md](docs/CHECKLISTS.md)
|
||||||
|
- [docs/ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](docs/ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md)
|
||||||
|
- [docs/NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](docs/NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md)
|
||||||
|
- [docs/TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](docs/TOMORROW_LIVE_TEST_PLAN_2026-07-10.md)
|
||||||
|
- [docs/BUILD_FIT_CONTRACT_V1.md](docs/BUILD_FIT_CONTRACT_V1.md)
|
||||||
- [docs/workflow.md](docs/workflow.md)
|
- [docs/workflow.md](docs/workflow.md)
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -64,8 +81,18 @@ Der UAC-Prompt lässt sich nicht dauerhaft abschalten; das ist Windows-Design. D
|
|||||||
npm run lint
|
npm run lint
|
||||||
npm test
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run scan:native:replay
|
||||||
|
npm run package:offline-check
|
||||||
|
npm run acceptance:offline
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`scan:native:replay` only reads existing `scan-results.json`; it does not start
|
||||||
|
Genshin or send input. `package:offline-check` builds and verifies the unpacked
|
||||||
|
Windows app without launching it.
|
||||||
|
`acceptance:offline` is the full non-live gate: cleanup/helper safety tests,
|
||||||
|
TypeScript, all unit/eval tests, saved 20/50/100 validation, a fresh packaged
|
||||||
|
build, package verification, dependency audits, and `git diff --check`.
|
||||||
|
|
||||||
## Safety Boundaries
|
## Safety Boundaries
|
||||||
|
|
||||||
- No memory reads.
|
- No memory reads.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Design QA
|
||||||
|
|
||||||
|
## Evidence Recorded At Validation Time
|
||||||
|
|
||||||
|
The image files listed below were ephemeral local test outputs. They were
|
||||||
|
intentionally removed during the 2026-07-11 workspace cleanup; the viewport,
|
||||||
|
runtime, interaction coverage, and findings documented here are the retained
|
||||||
|
durable record.
|
||||||
|
|
||||||
|
- Source visual truth: `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||||
|
- Rendered implementation: `outputs/ui-qa/packaged-live-stream20-results-ui-redesign.png`
|
||||||
|
- Full-view comparison: `outputs/ui-qa/comparison-scanner-live-before-after.png`
|
||||||
|
- Focused scanner comparison: `outputs/ui-qa/comparison-scanner-live-focus.png`
|
||||||
|
- Inventory modal post-fix evidence: `outputs/ui-qa/responsive-inventory-tech-1104-fixed.png`
|
||||||
|
- Remaining rendered views: `outputs/ui-qa/final-inventory.png`, `final-triage.png`, `final-builds.png`, `final-overlay.png`, and `final-diagnose.png`
|
||||||
|
- Viewport: 1304x821 CSS pixels for the matched live scanner comparison; additional reflow checks at 1104x681 and 652x410.
|
||||||
|
- State: both scanner comparison images show a completed, non-persisting 20-Artifact run with 20 parsed results and zero Review/errors. The revised implementation was captured from the packaged elevated runtime after live run `20260710-214406`.
|
||||||
|
|
||||||
|
## Primary Interactions Checked
|
||||||
|
|
||||||
|
- Scanner navigation and fresh empty result state.
|
||||||
|
- Scanoptionen open/close, initial focus, focus loop, scope selection, Artifact/row unit selection, and stepper controls.
|
||||||
|
- Inventory list roving focus, technical modal open/close and focus loop.
|
||||||
|
- Review deep-link into the filtered Inventory.
|
||||||
|
- Overlay open action and toast feedback.
|
||||||
|
- Diagnostics disclosures.
|
||||||
|
- Fresh packaged Settings-UI streaming run with result insertion motion, independent evaluation progress, 20 result rows, and no store writes.
|
||||||
|
- Renderer exception, `console.error`, and error-log checks on Scanner, Inventory, and Overlay: zero errors.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
No actionable P0, P1, or P2 findings remain.
|
||||||
|
|
||||||
|
- The Scanner result surface is now the dominant work region: approximately 634 px wide at the accepted desktop viewport versus approximately 388 px for the guide/capture surface.
|
||||||
|
- Readiness checks are compact inline status chips, the advanced disclosure no longer consumes a layout row, and Scanoptionen opens in a focus-trapped modal.
|
||||||
|
- Evaluation status is a flat progress strip rather than a nested card, leaving the result rail visibly primary during and after scanning.
|
||||||
|
- Collection-level technical evidence and GOOD interop no longer compete with core browsing or diagnostics status.
|
||||||
|
|
||||||
|
## Required Fidelity Surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: the existing Inter/system sans stack, optical weights, heading hierarchy, line heights, truncation, and dense 12 px metadata treatment remain consistent. No clipped primary label was found in the accepted desktop or reflow screenshots.
|
||||||
|
- Spacing and layout rhythm: the 0.76/1.24 scanner split, compact readiness row, flat status strips, and modalized secondary controls create a clearer large-to-small hierarchy. Document dimensions matched the viewport for every accepted case.
|
||||||
|
- Colors and visual tokens: the implementation stays on the existing Galaxy tokens for void/nebula surfaces, violet/cyan accents, mint success, amber warning, borders, focus, shadows, and motion.
|
||||||
|
- Image quality and asset fidelity: existing Lucide icons and real Artifact crop previews are retained. No placeholder artwork, handcrafted SVG, emoji, or replacement asset was introduced.
|
||||||
|
- Copy and content: Scanner, Review, Builds, and safety language remain Artifact-first, read-only, and explicit about Review versus roll efficiency versus future Build-Fit.
|
||||||
|
|
||||||
|
## Comparison History
|
||||||
|
|
||||||
|
### Iteration 1
|
||||||
|
|
||||||
|
- P1: at 1104x681 the Inventory technical modal could render under the sidebar because it lived inside the animated view stacking context.
|
||||||
|
- Fix: render the modal through a `document.body` portal while retaining the shared focus trap and Galaxy modal styling.
|
||||||
|
- Post-fix evidence: `outputs/ui-qa/responsive-inventory-tech-1104-fixed.png`; the entire 934 px dialog is visible, centered, focus-contained, and within the viewport.
|
||||||
|
- P2: six pipeline stages in one row truncated useful technical descriptions even after moving them into the modal.
|
||||||
|
- Fix: use a three-column/two-row modal grid with two-line descriptions.
|
||||||
|
- Post-fix evidence: the same post-fix modal screenshot shows all six stages without accidental horizontal clipping.
|
||||||
|
|
||||||
|
### Iteration 2
|
||||||
|
|
||||||
|
- The matched live 20-result comparison found no remaining P0/P1/P2 hierarchy, clipping, density, or affordance issue.
|
||||||
|
- The packaged runner observed live capture/evaluation overlap, result slide-in motion, up to 9 visible result rows and 12 evaluated results while capture was still running, then reconciled 20/20 final rows.
|
||||||
|
- Focused regions were required because the readiness controls, evaluation strip, row density, and panel-width change are too small to judge reliably from a reduced full-app image alone.
|
||||||
|
|
||||||
|
## Residual Test Gaps
|
||||||
|
|
||||||
|
- A complete full-owned-inventory soak and non-16:9/Genshin-language profiles remain scanner-scope acceptance work, not design mismatches in this QA.
|
||||||
|
- The live runner records the mid-capture overlap and motion metrics, while the durable screenshot captures the matched completed state rather than a transient frame.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
+279
-37
@@ -62,8 +62,11 @@ flowchart LR
|
|||||||
| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery |
|
| `electron/appWindowManager.ts` | Main window and overlay window lifecycle, menu-bar removal, dashboard focus behavior, and renderer window command delivery |
|
||||||
| `electron/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup |
|
| `electron/services/inputHelper.ts` | Stable JSON protocol client for the compiled C# input/capture sidecar plus PowerShell fallback startup |
|
||||||
| `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable |
|
| `electron/services/inputHelperPowerShellFallback.ts` | PowerShell fallback script body for environments where the compiled helper is unavailable |
|
||||||
| `electron/services/nativeScannerProcessingService.ts` | Post-capture processor for native IK runs; reads crop jobs, OCRs/parses card crops, matches IK metadata, writes processing outputs, and safely loads native crop previews |
|
| `electron/services/nativeScannerProcessingService.ts` | Streaming native-result processor; tails crop jobs, runs bounded ordered OCR/parse/evaluation, publishes result deltas, writes durable processing outputs, and safely loads native crop previews |
|
||||||
| `electron/services/nativeScannerResultWorkflowService.ts` | Authoritative promotion 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/nativeScannerResultWorkflowService.ts` | Authoritative promotion, review, and local-deletion workflow for native results; reloads run state, validates edits, appends local tombstones, safely removes contained crops, and writes/removes the artifact store only after explicit confirmation |
|
||||||
|
| `electron/services/nativeScannerResultTombstones.ts` | Append-only local tombstone contract that hides removed native rows without rewriting their raw scan evidence |
|
||||||
|
| `electron/services/nativeScannerRunTiming.ts` | Atomic, first-observation timing record for request, native scanner, capture, processing, durable-result, and reconciliation milestones |
|
||||||
|
| `electron/repositories/artifactStoreRepository.ts` | Local JSON artifact-store persistence including exact single-record removal through the main-process boundary |
|
||||||
| `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling |
|
| `electron/services/goodFileService.ts` | Local GOOD export file writing and GOOD import file dialog/read handling |
|
||||||
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` |
|
||||||
| `native/input-helper/IkInventoryLists.cs` | Loads and validates the vendored IK `inventorylists` feature catalog |
|
| `native/input-helper/IkInventoryLists.cs` | Loads and validates the vendored IK `inventorylists` feature catalog |
|
||||||
@@ -72,29 +75,49 @@ flowchart LR
|
|||||||
| `src/lib/ikArtifactMatcher.ts` | Pure IK inventorylist matcher for native artifact set/piece/slot validation and GOOD key metadata |
|
| `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/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/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/lib/scanResultEntry.ts` | Pure durable scan-result entry/status helpers that keep extraction confidence separate from versioned Artifact evaluation |
|
||||||
| `src/App.tsx` | Thin React entry that renders the app page |
|
| `src/App.tsx` | Thin React entry that renders the app page |
|
||||||
| `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing |
|
| `src/pages/AppPage.tsx` and `src/pages/app/*` | App page composition and high-level layout routing |
|
||||||
|
| `src/features/layout/*` | Scanner-first navigation, grouped workspace/tool items, shell, topbar readiness, and view-level metadata |
|
||||||
|
| `src/features/feedback/*` | Renderer-only feedback primitives: queued mutation toasts, inline spinner, and content skeletons |
|
||||||
| `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly |
|
| `src/features/scan/hooks/useScanViewController.ts` | Scan feature state composition and view-controller assembly |
|
||||||
| `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration |
|
| `src/features/scan/hooks/scanViewScanActions.ts` | Manual scan and visible-grid scan orchestration |
|
||||||
| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and ESC/B fallback paths |
|
| `src/features/scan/hooks/scanViewEntryActions.ts` | Guided auto-entry choreography for visible inventory, direct inventory, and ESC/B fallback paths |
|
||||||
| `src/features/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports |
|
| `src/features/scan/hooks/useScanGoodInterop.ts` | Scan-page GOOD import/export actions against renderer repository ports |
|
||||||
| `src/features/inventory/*` | Scanned-artifact inventory browser for native result entries, stored artifacts, filters, sorting, compact detail state, native crop preview display, and IK catalog status |
|
| `src/features/inventory/*` | Scanned-artifact inventory browser for native result entries, stored artifacts, filters, sorting, compact detail state, native crop preview display, and IK catalog status |
|
||||||
|
| `src/features/settings/*` | Application settings modal, including persisted renderer-language selection |
|
||||||
|
| `src/i18n/*` | Renderer locale contract, English/German message catalogs, local persistence, document-language synchronization, and translation hook |
|
||||||
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
|
| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes |
|
||||||
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
|
| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data |
|
||||||
| `src/lib/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
|
| `src/lib/genshinLookup.ts` | Pure lookup and validation API for generated Genshin data |
|
||||||
| `src/lib/autoScanEntry.ts` | Pure entry-mode planning and auto-scan preflight validation |
|
| `src/lib/autoScanEntry.ts` | Pure entry-mode planning and auto-scan preflight validation |
|
||||||
| `src/lib/cardReadyGate.ts` | Detail/page fingerprint readiness gate for scan timing |
|
| `src/lib/cardReadyGate.ts` | Detail/page fingerprint readiness gate for scan timing |
|
||||||
| `src/lib/artifactEvaluation.ts` | Planned deterministic artifact value evaluation with score reasons and review-safe output |
|
| `src/lib/artifactEvaluation.ts` | Deterministic legal-roll efficiency, review/unknown safety, reason codes, stored-result enrichment, and conservative 5-star Worst/Middle/Best under-level projection |
|
||||||
| `src/lib/upgradeProjection.ts` | Planned best/middle/worst upgrade projection for under-leveled artifacts |
|
| `src/lib/buildFitContract*.ts`, `src/lib/buildFitEligibility.ts`, and `src/types/buildFit.ts` | Versioned Build-Fit facade, contract validation including source expiry, native-result input mapping, hard extraction/Review eligibility gates, conflict evidence, and score-free V1 assessment output |
|
||||||
| `src/lib/scoring.ts` | Recommendation and build scoring logic |
|
| `src/lib/buildFitCombination.ts` | Deterministic score-free five-Artifact verifier for unique slots, Set-plan shapes, sourced full-build aggregate context, nested eligibility, and assignment/equipment conflicts |
|
||||||
|
| `src/lib/buildFitEvidenceRanking.ts` | Bounded, deterministic, read-only selection of up to three source-bound complete combinations; it uses Set/Main-Stat/Substat evidence only and never Roll-Efficiency or OCR confidence as a rank weight |
|
||||||
|
| `src/lib/buildFitExplicitContext.ts` | Converts a user-confirmed, session-only full-build value into a source-bound aggregate context without inferring weapon/team/rotation values |
|
||||||
|
| `src/data/buildFitProfiles.ts` | Versioned curated profile corpus with explicit target assumptions, direct source references, expiry, safe evidence thresholds, and no authority outside its documented scope |
|
||||||
|
| `src/eval/nativeScanReplay.ts` | Node-only deterministic replay/report contract for repeatedly evaluating existing native scan results without capture or input |
|
||||||
|
| `scripts/replay-native-results.ts` | CLI wrapper that locates saved scan results and writes ignored offline replay evidence |
|
||||||
|
| `scripts/validate-saved-native-runs.ts` | Read-only accepted-target validator (including 2,211) for run contracts, PNG containment, non-persistence, review provenance, deterministic evaluation, and projection boundaries |
|
||||||
|
| `scripts/analyze-native-review.ts` | Read-only reparse and reason-distribution analyzer for a saved native run, including current Review-rate gating without capture or persistence |
|
||||||
|
| `scripts/run-offline-acceptance.mjs` | Sequential non-live acceptance runner that records lint, tests, OCR/assessment, saved-run validation, package build/verification, audits, and diff-check outcomes |
|
||||||
|
| `scripts/run-packaged-builds-acceptance.mjs` | Built-renderer acceptance runner for fresh English locale, German switching, Builds state, focus, reduced motion, overflow, screenshots, diagnostics, and local-delete confirmation probing |
|
||||||
|
| `scripts/native-live-smoke.ps1` | Non-persisting native smoke runner that validates result reconciliation and complete shared-clock timing evidence |
|
||||||
|
| `scripts/verify-packaged-app.mjs` | Static and unpacked-app verifier for executable, ASAR entries, relative renderer assets, current runtime signature, preload, C# helper, IK resources, and version consistency |
|
||||||
|
| `electron/runtimePaths.ts` | Pure runtime-resource policy: packaged builds use `process.resourcesPath`; repository fallbacks exist only in development unless an explicit environment override is set |
|
||||||
|
| `src/lib/scoring.ts` | Legacy demo-only recommendation/build scorer; real local account snapshots never call it, while Build-Fit reads source-safe native evidence through its separate ranker |
|
||||||
| `src/lib/demoData.ts` | Temporary local demo snapshot |
|
| `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 |
|
| `data/ik-inventorylists/*` | 1:1 vendored Inventory Kamera inventory lists used by the native scanner data preflight and future matching |
|
||||||
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
|
| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats |
|
||||||
| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
|
| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` |
|
||||||
| `src/types/*` | Shared app, capture, and domain contracts |
|
| `src/types/*` | Shared app, capture, and domain contracts |
|
||||||
| `src/styles/global.css` | Stylesheet entrypoint importing split style modules |
|
| `src/styles/global.css` | Legacy stylesheet entrypoint importing the base and diagnostics modules |
|
||||||
| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay styles |
|
| `src/styles/tokens.css` | Galaxy color, surface, typography, spacing, depth, motion, and layer tokens |
|
||||||
|
| `src/styles/motion.css` and `src/styles/feedback.css` | Focus-visible, reduced-motion, page/micro-motion, toast, spinner, and skeleton presentation |
|
||||||
|
| `src/styles/*-galaxy.css` and `src/features/inventory/inventory-galaxy.css` | Feature-scoped Galaxy shell, scanner, secondary-view, and inventory presentation |
|
||||||
|
| `src/styles/base.css` | Shared application, layout, scanner workspace, modal, triage, build, and overlay compatibility styles |
|
||||||
| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles |
|
| `src/styles/diagnostics.css` | Diagnose/dev-view specific styles |
|
||||||
|
|
||||||
## Dependency Rules
|
## Dependency Rules
|
||||||
@@ -103,7 +126,16 @@ flowchart LR
|
|||||||
- Electron main process must not import React renderer modules.
|
- Electron main process must not import React renderer modules.
|
||||||
- Pure parsing and scoring modules must not depend on Electron APIs.
|
- Pure parsing and scoring modules must not depend on Electron APIs.
|
||||||
- OCR uncertainty must be represented in data, not hidden in UI only.
|
- OCR uncertainty must be represented in data, not hidden in UI only.
|
||||||
|
- Feedback primitives may reflect an operation but must not become the source of
|
||||||
|
truth for scan, review, promotion, or persistence state.
|
||||||
- Generated folders must not be treated as source of truth.
|
- Generated folders must not be treated as source of truth.
|
||||||
|
- Application copy is rendered through the locale provider. New, missing, or
|
||||||
|
invalid persisted locale values resolve to English; German is a user-selected
|
||||||
|
renderer preference. This does not expand the English-only Genshin OCR/crop
|
||||||
|
profile.
|
||||||
|
- Local-delete flows must remain behind the Electron main-process boundary.
|
||||||
|
They may remove app-local records and contained crop files only; they must not
|
||||||
|
call the input helper or dispatch game input.
|
||||||
|
|
||||||
## Smart Capture Flow
|
## Smart Capture Flow
|
||||||
|
|
||||||
@@ -139,12 +171,16 @@ sequenceDiagram
|
|||||||
| IK inventorylists | `data/ik-inventorylists` copied from Inventory Kamera 1.4.4 | Source data package | Native scanner data preflight and future matcher |
|
| 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 |
|
| IK simple catalog match | IK weapon/character/material inventorylist catalog | `IkCatalogItemMatch` | Future category scanners, inventory catalog evidence |
|
||||||
| Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser |
|
| Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser |
|
||||||
|
| Application locale | Local renderer storage key `gaa-ui-locale` | `src/i18n` | App shell and feature copy; defaults to English |
|
||||||
| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, post-capture report, future local DB |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Scan result entry | Scan loop or native post-capture processor | `StoredScanResultEntry` / native run `scan-results.json` | Live scan rail, artifact inventory, summary |
|
||||||
|
| Native-result tombstone | Confirmed local deletion | Native run `deleted-results.jsonl` | Native-result loaders; raw `scan-results.json` remains replay evidence |
|
||||||
|
| Native run timing | First observed lifecycle events | Native run `run-timing.json` | Live-smoke validator, diagnostics, timing reports |
|
||||||
| Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
|
| Artifact evaluation | Deterministic evaluator | `src/lib` | Result pills, inventory sort/filter, detail reasons |
|
||||||
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only |
|
| Upgrade projection | Projection helper | `src/lib` | Artifact detail view only |
|
||||||
|
| Build-Fit suggestions | Latest complete native `scan-results.json` plus explicit session context | Renderer-only Build-Fit model | Builds surface; read-only profile evidence, never legacy demo scoring |
|
||||||
| Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training |
|
| Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training |
|
||||||
| Stored artifacts | Manual/automatic scans | Electron userData `artifact-store.json` (dedupe by content signature) | Future triage, recommendations, SQLite migration |
|
| Stored artifacts | Manual/automatic scans | Electron userData `artifact-store.json` (dedupe by content signature) | Future triage, recommendations, SQLite migration |
|
||||||
| Recommendations | Scoring module | Renderer domain logic | Triage and builds views |
|
| Recommendations | Scoring module | Renderer domain logic | Triage and builds views |
|
||||||
@@ -161,6 +197,10 @@ sequenceDiagram
|
|||||||
capture. Mouse movement is sent as iterated relative input deltas instead of
|
capture. Mouse movement is sent as iterated relative input deltas instead of
|
||||||
relying on a single absolute cursor jump. The helper verifies the cursor
|
relying on a single absolute cursor jump. The helper verifies the cursor
|
||||||
reached the target and refuses to click otherwise.
|
reached the target and refuses to click otherwise.
|
||||||
|
- Native and PowerShell helper discovery accepts only the exact supported game
|
||||||
|
process names `GenshinImpact` and `YuanShen`. Cached HWNDs are revalidated
|
||||||
|
against that whitelist before reuse, so the assistant cannot become its own
|
||||||
|
capture/input target merely because its title contains `Genshin`.
|
||||||
- `npm run dev:admin` is the validated dev path for automation when elevated
|
- `npm run dev:admin` is the validated dev path for automation when elevated
|
||||||
input is required. The elevated PowerShell startup is handled by
|
input is required. The elevated PowerShell startup is handled by
|
||||||
`scripts/dev-admin.ps1` and logged to `outputs/admin-start/admin-dev.log`.
|
`scripts/dev-admin.ps1` and logged to `outputs/admin-start/admin-dev.log`.
|
||||||
@@ -193,13 +233,67 @@ sequenceDiagram
|
|||||||
changes and stabilizes.
|
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).
|
- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan).
|
||||||
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them.
|
- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them.
|
||||||
|
- Inventory cleanup is separate from game interaction: a confirmed local removal may tombstone one native scan result, delete only its contained PNG crop, and optionally remove its explicitly linked local Store record. It never invokes the input helper or changes Genshin data.
|
||||||
|
|
||||||
**Native IK capture scan** is the new high-speed path. It runs inside the C#
|
**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
|
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
|
natively, captures the artifact detail card as PNG crops, and reports progress
|
||||||
through IPC/dev-control. The renderer only starts, stops, and displays status.
|
through IPC/dev-control. The renderer starts/stops capture and starts one
|
||||||
OCR, parsing, GOOD persistence, and artifact value evaluation are deliberately
|
downstream processor when the run directory becomes available. OCR, parsing,
|
||||||
separate follow-up stages so capture speed is not blocked by UI work.
|
review gating, and artifact value evaluation run concurrently on bounded
|
||||||
|
workers so capture speed is not blocked by UI work; GOOD persistence remains a
|
||||||
|
separate explicit action.
|
||||||
|
|
||||||
|
Before the first native Artifact click, the helper moves the cursor to the grid
|
||||||
|
anchor, sends a fixed bounded upward wheel sequence that covers the supported
|
||||||
|
2,400-item inventory capacity, and waits for the inventory to stabilize. Total,
|
||||||
|
top-reset, and active capture timings remain separate so reset cost does not
|
||||||
|
distort capture throughput. For streaming end-to-end timing, the app must use
|
||||||
|
one wall-clock span from native start through final result reconciliation;
|
||||||
|
capture and processing durations may overlap and must never be added. Genshin may be
|
||||||
|
focused once during scan startup; after that, the helper compares the actual
|
||||||
|
foreground HWND with the original Genshin HWND immediately before every click
|
||||||
|
and every wheel event. Focus loss blocks the run without refocusing or sending
|
||||||
|
further input. Stop, ESC, Enter, and F9 use the same guard path.
|
||||||
|
|
||||||
|
The bounded wheel reset remains the production start strategy. Closing or
|
||||||
|
toggling the inventory with `B` and then selecting the Artifact tab is only a
|
||||||
|
candidate experiment: it depends on the current game/menu state and an
|
||||||
|
additional coordinate click, and it has not been live benchmarked against the
|
||||||
|
accepted wheel reset. It must not replace that reset without a guarded A/B live
|
||||||
|
test that proves both reliability and a meaningful timing benefit.
|
||||||
|
|
||||||
|
### Scan Scope Resolution
|
||||||
|
|
||||||
|
The normal renderer scan starts with `{ mode: "all" }`. Before starting the
|
||||||
|
native helper it reads the inventory counter and resolves the target from the
|
||||||
|
currently owned Artifact count (`current`), never from the slot capacity
|
||||||
|
(`total`). Capacity is only a plausibility bound. A missing, invalid, or
|
||||||
|
out-of-range owned count blocks a full-inventory run instead of guessing a
|
||||||
|
target, because the native helper does not yet have a reliable end-of-list
|
||||||
|
detector.
|
||||||
|
|
||||||
|
The optional bounded scopes are normalized before they reach the helper:
|
||||||
|
|
||||||
|
- an Artifact limit becomes `min(requested artifacts, owned artifacts)`;
|
||||||
|
- a row limit becomes `min(requested rows * detected grid columns, owned
|
||||||
|
artifacts)`; the accepted 16:9 profile currently has eight columns;
|
||||||
|
- every resolved target stays within the supported 2,400-Artifact safety bound.
|
||||||
|
|
||||||
|
Scope selection is session state. A new app session returns to the full owned
|
||||||
|
inventory default rather than silently reusing an earlier partial limit.
|
||||||
|
|
||||||
|
The Artifact-limit branch has one real Settings-UI acceptance run at limit 5:
|
||||||
|
Genshin/detail/grid preflight passed; target, captured, processed, and parsed
|
||||||
|
all reached 5; review and processing errors stayed at 0; persistence remained
|
||||||
|
disabled; and the session rail showed five results. A later packaged Settings-
|
||||||
|
UI run resolved one row to eight Artifacts and completed 8/8. The packaged
|
||||||
|
full-owned-inventory run then resolved 2,211 from the OCR-owned count, captured
|
||||||
|
2,211 crops over 70 pages, and populated 2,211 session rows with live capture/
|
||||||
|
evaluation overlap. The initial 490-Review result correctly failed the 15% gate;
|
||||||
|
current-package reprocessing of the same complete corpus passed at 136 Review
|
||||||
|
(6.15%), zero errors, and zero persistence writes. Saved-run validation passed
|
||||||
|
with zero issues.
|
||||||
|
|
||||||
Each native run writes a self-contained run directory under app userData:
|
Each native run writes a self-contained run directory under app userData:
|
||||||
|
|
||||||
@@ -218,32 +312,99 @@ Each native run writes a self-contained run directory under app userData:
|
|||||||
weapons, characters, and materials are catalog-only until their own capture
|
weapons, characters, and materials are catalog-only until their own capture
|
||||||
flows have evidence.
|
flows have evidence.
|
||||||
- `scan-results.json`: durable per-artifact result entries with capture
|
- `scan-results.json`: durable per-artifact result entries with capture
|
||||||
metadata, parsed artifact identity when available, extraction status, and
|
metadata, parsed artifact identity, extraction status, versioned value
|
||||||
value status. Native capture currently writes `deferred` value status for
|
evaluation, reason codes, roll breakdown, and score. Clean legal Artifact
|
||||||
clean extraction and `review` for uncertain extraction.
|
extraction writes `evaluated`; uncertain extraction writes `review`;
|
||||||
- `processing-report.json`: optional post-capture OCR/parse report generated
|
inconsistent/incomplete value data writes `unknown`. `deferred` remains a
|
||||||
from `capture-jobs.jsonl`. It records `queueConcurrency` for the bounded
|
compatibility state and for non-evaluated category/build context boundaries.
|
||||||
post-capture OCR/parse worker and is non-persisting by default until native
|
- `processing-report.json`: incremental and final OCR/parse report generated
|
||||||
crop OCR has live validation evidence.
|
from the tailed `capture-jobs.jsonl`. It records `queueConcurrency` for the
|
||||||
|
bounded worker pool and is non-persisting by default until native crop OCR
|
||||||
|
has live validation evidence.
|
||||||
|
- `run-timing.json`: atomic `native-scanner-run-timing-v1` evidence. It records
|
||||||
|
first-observed request, scanner, capture, processing, durable-result, and
|
||||||
|
result-reconciliation timestamps, then derives request-to-durable and
|
||||||
|
request-to-reconciled durations directly from those endpoints.
|
||||||
|
- `deleted-results.jsonl`: append-only tombstones for confirmed local removals.
|
||||||
|
Loaders hide tombstoned rows while preserving the original result array for
|
||||||
|
replay/audit. A tombstone can name one safely contained crop and an optional
|
||||||
|
exact linked Store record; it is not a game action.
|
||||||
|
|
||||||
Parsed artifacts from manual and renderer auto-scan modes are persisted into
|
Parsed artifacts from manual and renderer auto-scan modes are persisted into
|
||||||
`artifact-store.json` keyed by a content signature that excludes the equipped
|
`artifact-store.json` keyed by a content signature that excludes the equipped
|
||||||
character, so re-equipping updates a record instead of duplicating it. Leveling
|
character, so re-equipping updates a record instead of duplicating it. Leveling
|
||||||
an artifact currently creates a new record (documented limitation until
|
an artifact currently creates a new record (documented limitation until
|
||||||
rescan-merge exists). Native IK capture currently writes card crops first; a
|
rescan-merge exists). Native IK capture flushes each crop job immediately; the
|
||||||
downstream OCR/parse processor can report parsed artifacts from those crops
|
downstream processor tails, OCRs, parses, evaluates, and publishes that job
|
||||||
without slowing capture. Store promotion remains opt-in until native crop OCR is
|
without waiting for the full capture target. Store promotion remains opt-in
|
||||||
validated.
|
until native crop OCR is validated.
|
||||||
The Inventory surface derives a dry-run promotion summary from native
|
The Inventory surface derives a dry-run promotion summary from native
|
||||||
`scan-results.json` entries and the local artifact store. It can show which
|
`scan-results.json` entries and the local artifact store. It can show which
|
||||||
native artifacts are ready for explicit promotion, already stored, review-only,
|
native artifacts are ready for explicit promotion, already stored, review-only,
|
||||||
or blocked, but it does not write store records by itself.
|
or blocked, but it does not write store records by itself. It evaluates legacy
|
||||||
|
saved results on load, displays roll-efficiency reasons, and computes
|
||||||
|
under-level projection without requiring a rescan.
|
||||||
During auto-scan, artifact store writes can be batched and flushed after the
|
During auto-scan, artifact store writes can be batched and flushed after the
|
||||||
click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path.
|
click/capture/OCR loop to avoid per-artifact save/reload churn in the hot path.
|
||||||
Auto-scan artifact captures also bypass Electron source-list enumeration and use
|
Auto-scan artifact captures also bypass Electron source-list enumeration and use
|
||||||
the GDI capture helper directly once the selected source/Genshin state has been
|
the GDI capture helper directly once the selected source/Genshin state has been
|
||||||
preflighted. Manual captures and source refresh still use `desktopCapturer`.
|
preflighted. Manual captures and source refresh still use `desktopCapturer`.
|
||||||
|
|
||||||
|
### Run Timing Evidence
|
||||||
|
|
||||||
|
The timing contract is intentionally separate from scanner status polling. The
|
||||||
|
first valid timestamp for each milestone is immutable, so retries and later
|
||||||
|
renderer polls cannot rewrite a measured interval. `requestToResultsReconciledMs`
|
||||||
|
is calculated only from `requestStartedAt` to `resultsReconciledAt`; capture and
|
||||||
|
processing can overlap and are never summed to create a synthetic end-to-end
|
||||||
|
duration. A live timing claim requires all terminal markers, a valid ordering,
|
||||||
|
and final result reconciliation.
|
||||||
|
|
||||||
|
### Local Artifact Removal
|
||||||
|
|
||||||
|
The detail delete control is an app-data correction tool, not an in-game action.
|
||||||
|
It is unavailable while the relevant native scan or processor is active and
|
||||||
|
requires a user confirmation. For a native result, the authoritative workflow
|
||||||
|
first appends a tombstone, filters the row from the local view, then removes a
|
||||||
|
PNG only after verifying that its resolved path stays within the selected run
|
||||||
|
directory. The raw `scan-results.json` evidence is never rewritten. A linked
|
||||||
|
Store record is removable only when it exactly matches the selected local
|
||||||
|
record and the user explicitly includes it; a Store-only row uses the same
|
||||||
|
main-process-scoped local removal. None of these paths invoke the input helper,
|
||||||
|
focus Genshin, or modify game data.
|
||||||
|
|
||||||
|
## Scanner-First Information Architecture
|
||||||
|
|
||||||
|
The renderer follows the user's task sequence instead of exposing every
|
||||||
|
technical subsystem at the same level:
|
||||||
|
|
||||||
|
1. **Scanner** is the default workspace and owns readiness, start/stop,
|
||||||
|
progress, preview, the completed-result rail, and access to review.
|
||||||
|
2. **Artifacts** owns collection browsing, filters, selected detail, explicit
|
||||||
|
single-result promotion/review, and secondary technical evidence. Collection-
|
||||||
|
level pipeline evidence opens in a modal; selected-result OCR evidence stays
|
||||||
|
in a local disclosure.
|
||||||
|
3. **Review** summarizes extraction uncertainty and routes the user back to the
|
||||||
|
relevant Artifact detail. It does not make trash, feed, or deletion claims.
|
||||||
|
4. **Builds** reads only the newest complete native result run because it
|
||||||
|
retains canonical IK Set keys and per-field confidence. It can show at most
|
||||||
|
three source-bound, read-only profile suggestions from the separate evidence
|
||||||
|
ranker. Profile expiry, Review gates, complete Set shape, required aggregate
|
||||||
|
context, conflict evidence, and non-overlap remain explicit. Current roll
|
||||||
|
efficiency is never presented as build fit, and character ownership/team/
|
||||||
|
weapon/rotation are not inferred from Artifact data.
|
||||||
|
5. **Overlay** and **Diagnostics** are secondary tools. They remain available
|
||||||
|
without competing with the primary scan-to-review journey.
|
||||||
|
6. **Settings** is a shell-level secondary modal. It currently owns the
|
||||||
|
persisted app-language preference; changing it updates renderer copy only
|
||||||
|
and does not imply support for another Genshin OCR language.
|
||||||
|
|
||||||
|
On the Scanner surface, app/Genshin readiness and one primary start/stop action
|
||||||
|
come before source selection or manual capture. Advanced source, scope, and
|
||||||
|
manual-read controls stay in one focus-trapped modal so opening them never
|
||||||
|
reflows the scanner workspace. This is a presentation rule; it does not weaken
|
||||||
|
preflight, focus, review, or persistence gates.
|
||||||
|
|
||||||
## Scan Results And Inventory UX
|
## Scan Results And Inventory UX
|
||||||
|
|
||||||
The next product surface is documented in
|
The next product surface is documented in
|
||||||
@@ -251,14 +412,21 @@ The next product surface is documented in
|
|||||||
Architecture rules:
|
Architecture rules:
|
||||||
|
|
||||||
- The active scan view is an operator surface, not the full evaluator. It should
|
- The active scan view is an operator surface, not the full evaluator. It should
|
||||||
show the screenshot/preview, the right-side live result rail, Stop/status
|
show readiness, one primary start/stop action, the left preview/work surface,
|
||||||
controls, and review access.
|
the right-side live result rail, progress/status, and review access. While a
|
||||||
- The live result rail receives completed artifact results only. Intermediate
|
scan is active, the left work surface owns capture phase and target progress;
|
||||||
OCR/debug stats stay in diagnostics or detail.
|
the right result surface owns the independent OCR/parse/evaluation progress
|
||||||
|
and completed-result stream. A second standalone progress card would
|
||||||
|
duplicate that state and is not part of the layout.
|
||||||
|
- The live result rail receives fully evaluated artifacts from the current app
|
||||||
|
scan session only, in ascending capture sequence, and starts empty on app
|
||||||
|
launch. Intermediate OCR/debug payloads stay in diagnostics or detail, while
|
||||||
|
older persisted records remain available in the Artifacts view.
|
||||||
- A scan result row preserves extraction status and artifact value status as
|
- A scan result row preserves extraction status and artifact value status as
|
||||||
separate data even when the UI shows one compact pill.
|
separate data even when the UI shows one compact pill.
|
||||||
- The artifact inventory view owns browsing, filtering, sorting, opening detail,
|
- The artifact inventory view owns browsing, filtering, sorting, opening detail,
|
||||||
and showing the current artifact-only native pipeline state.
|
and showing the current artifact-only native pipeline state. Pipeline and IK
|
||||||
|
evidence should be collapsed unless the user asks for it.
|
||||||
- Weapons, materials, and character details stay out of the active Inventory UI
|
- 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
|
until their values are actually scanned; loaded IK catalog data alone is not a
|
||||||
user-facing scanner capability.
|
user-facing scanner capability.
|
||||||
@@ -272,12 +440,21 @@ Architecture rules:
|
|||||||
review can explain which OCR/parser fields are trustworthy.
|
review can explain which OCR/parser fields are trustworthy.
|
||||||
- Upgrade projection is a local deterministic/probabilistic helper, never a
|
- Upgrade projection is a local deterministic/probabilistic helper, never a
|
||||||
claim that an artifact will roll a specific way.
|
claim that an artifact will roll a specific way.
|
||||||
|
- `build-fit-evidence-ranking-v1` is not a damage formula or optimizer. It
|
||||||
|
ranks only documented Set/Main-Stat/Substat rule coverage after the scanner
|
||||||
|
confidence gate. It never consumes Roll-Efficiency, roll magnitude, or OCR
|
||||||
|
confidence as a ranking component.
|
||||||
|
- A profile with an expired source review fails closed. Required aggregate
|
||||||
|
targets remain deferred unless an explicit, user-confirmed session context
|
||||||
|
references a declared local/user contract source.
|
||||||
|
|
||||||
Planned result flow:
|
Implemented result flow:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
ScanLoop["Scan loop"]
|
ScanLoop["Native capture producer"]
|
||||||
|
Queue["capture-jobs.jsonl"]
|
||||||
|
Workers["Bounded OCR/parse workers"]
|
||||||
Parsed["Parsed artifact"]
|
Parsed["Parsed artifact"]
|
||||||
ReviewGate["Extraction confidence / review gate"]
|
ReviewGate["Extraction confidence / review gate"]
|
||||||
Value["Artifact value evaluator"]
|
Value["Artifact value evaluator"]
|
||||||
@@ -286,7 +463,9 @@ flowchart LR
|
|||||||
Inventory["Artifact inventory"]
|
Inventory["Artifact inventory"]
|
||||||
Detail["Artifact detail"]
|
Detail["Artifact detail"]
|
||||||
|
|
||||||
ScanLoop --> Parsed
|
ScanLoop --> Queue
|
||||||
|
Queue --> Workers
|
||||||
|
Workers --> Parsed
|
||||||
Parsed --> ReviewGate
|
Parsed --> ReviewGate
|
||||||
ReviewGate --> Value
|
ReviewGate --> Value
|
||||||
Value --> Store
|
Value --> Store
|
||||||
@@ -296,15 +475,78 @@ flowchart LR
|
|||||||
Inventory --> Detail
|
Inventory --> Detail
|
||||||
```
|
```
|
||||||
|
|
||||||
Future queue refactor:
|
Queue and replay state:
|
||||||
|
|
||||||
- One capture/game-control worker may click, scroll, focus, and poll failsafes.
|
- One capture/game-control producer may click, scroll, focus, poll failsafes,
|
||||||
- OCR/parse/evaluation may process bounded queued screenshot/crop jobs.
|
and append flushed crop jobs.
|
||||||
|
- Up to four OCR/parse/evaluation workers tail complete queue records while the
|
||||||
|
producer runs. Temporary EOF is non-terminal; producer terminal plus a fully
|
||||||
|
drained queue is terminal.
|
||||||
- Queueing must preserve stop behavior, duplicate handling, review decisions,
|
- Queueing must preserve stop behavior, duplicate handling, review decisions,
|
||||||
and the existing read-only safety boundary.
|
and the existing read-only safety boundary.
|
||||||
- The native post-capture processor already consumes crop jobs with bounded
|
- Results are sequence-deduplicated, reordered to capture order, evaluated
|
||||||
parallelism while preserving report order. Further queue work is secondary to
|
before publication, exposed to the renderer as monotonic deltas, and written
|
||||||
live throughput evidence and result quality.
|
incrementally to `scan-results.json` before final reconciliation.
|
||||||
|
- Saved `scan-results.json` can be replayed repeatedly through the same value
|
||||||
|
and projection code. Replay hashes the complete derived payload and fails
|
||||||
|
when a clean parsed Artifact remains unevaluated; it never touches Genshin.
|
||||||
|
|
||||||
|
## UI Feedback And Motion
|
||||||
|
|
||||||
|
- Scan progress is durable view state and remains visible while a scan runs;
|
||||||
|
toast messages never replace progress, blockers, or final result state.
|
||||||
|
- Scan progress is measured against the resolved owned-inventory target or the
|
||||||
|
user-selected Artifact/row limit and distinguishes capture/read, parse,
|
||||||
|
persistence, review, duplicate, and error outcomes.
|
||||||
|
- Skeletons reserve the Inventory list/detail layout during initial reads.
|
||||||
|
Inline spinners indicate bounded refresh, preview, promotion, and review work.
|
||||||
|
- Toasts report the outcome of explicit renderer mutations such as promotion,
|
||||||
|
review approval/rejection, and GOOD import/export. Errors use alert semantics;
|
||||||
|
informational and success messages use polite live regions.
|
||||||
|
- Shared Galaxy design tokens own semantic colors, surfaces, spacing, depth,
|
||||||
|
focus, and motion timing. Feature styles consume those tokens instead of
|
||||||
|
inventing scanner-state colors independently.
|
||||||
|
- Micro-motion may clarify entry, progress, and state changes, but functionality
|
||||||
|
must remain complete with `prefers-reduced-motion: reduce`; focus-visible
|
||||||
|
treatment and text/icons must carry meaning without motion or color alone.
|
||||||
|
|
||||||
|
## Evaluation Boundaries
|
||||||
|
|
||||||
|
- `roll-efficiency-v1` measures legal substat roll-tier quality only.
|
||||||
|
- Set preference, main-stat desirability, character role, and substat usefulness
|
||||||
|
are build context and remain `deferred`.
|
||||||
|
- Locked/equipped state affects workflow, not intrinsic roll quality.
|
||||||
|
- Evaluation uses rarity-specific roll tables and legal total roll counts.
|
||||||
|
- Review or incomplete data receives no confident normal score.
|
||||||
|
- `roll-efficiency-projection-v1` is available only for unambiguously identified
|
||||||
|
5-star Artifacts with four known legal substats below +20. It models legal
|
||||||
|
tier bounds, not guaranteed future rolls; 4-star or rarity-ambiguous data is
|
||||||
|
deliberately unavailable.
|
||||||
|
|
||||||
|
## Packaging Boundary
|
||||||
|
|
||||||
|
- Production packaging uses Electron 43 and electron-builder.
|
||||||
|
- Renderer/main/preload live inside `app.asar`; the native helper and IK lists
|
||||||
|
are explicit unpacked `extraResources`.
|
||||||
|
- Vite uses the relative base `./`, because production windows load
|
||||||
|
`dist/index.html` with `BrowserWindow.loadFile` instead of an HTTP origin.
|
||||||
|
- In packaged mode helper and IK lookup use `process.resourcesPath` and do not
|
||||||
|
search `process.cwd()` or the repository implicitly. Explicit
|
||||||
|
`INPUT_HELPER_EXE` and `IK_INVENTORYLISTS_DIR` overrides remain available.
|
||||||
|
- `npm run package:offline-check` builds and verifies those paths without
|
||||||
|
launching the app or sending game input. The verifier rejects absolute
|
||||||
|
renderer asset references and a packaged runtime signature that differs from
|
||||||
|
current source.
|
||||||
|
- The built-renderer execution of `npm run package:live:builds` passed the
|
||||||
|
current English/German locale flow, Builds loading/context/focus, reduced
|
||||||
|
motion, overflow, and local-delete confirmation probe. It is renderer
|
||||||
|
evidence only: it did not launch an elevated packaged executable or prove its
|
||||||
|
UAC/bridge/native-runtime path, and it is not a substitute for native Genshin
|
||||||
|
smoke evidence.
|
||||||
|
- Packaged runtime, UAC, preload calls, native capture, result loading, and
|
||||||
|
Inventory rendering passed live acceptance from `%TEMP%` on 2026-07-10.
|
||||||
|
The packaged 5er remained non-persistent; see
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
## Security And Safety
|
## Security And Safety
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Artifact Evaluation And Offline Validation - 2026-07-10
|
||||||
|
|
||||||
|
Historical pre-live snapshot. The deferred live work described here was
|
||||||
|
completed later on 2026-07-10; see
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
|
This report records the implementation of the five post-ingestion phases. No
|
||||||
|
live Genshin smoke test was run during this work. Existing native scan results
|
||||||
|
were used as the only real Artifact input.
|
||||||
|
|
||||||
|
## Implemented Phases
|
||||||
|
|
||||||
|
### 1. Deterministic Artifact evaluation
|
||||||
|
|
||||||
|
- `src/lib/artifactEvaluation.ts` implements `roll-efficiency-v1`.
|
||||||
|
- The score measures legal substat roll-tier efficiency from `0-100`; it does
|
||||||
|
not pretend to be universal character or build value.
|
||||||
|
- Main stat validity, extraction confidence, roll legality, and build fit stay
|
||||||
|
separate.
|
||||||
|
- Clean results become `evaluated`; uncertain extraction remains `review`;
|
||||||
|
inconsistent or incomplete clean data becomes `unknown` instead of receiving
|
||||||
|
a fabricated score.
|
||||||
|
- Locked and equipped state do not change Artifact quality.
|
||||||
|
|
||||||
|
### 2. Inventory and detail integration
|
||||||
|
|
||||||
|
- Native `scan-results.json` entries now carry a versioned value evaluation,
|
||||||
|
score, reason codes, legal roll counts, and per-substat efficiency.
|
||||||
|
- Legacy saved results are evaluated on load without requiring a rescan.
|
||||||
|
- The scan result rail shows compact roll-efficiency values.
|
||||||
|
- Inventory adds evaluated/open filters and roll-value sorting.
|
||||||
|
- Artifact detail shows score reasons, roll breakdown, and the explicit
|
||||||
|
`Build-Fit offen` boundary.
|
||||||
|
- Review results never receive a normal quality score.
|
||||||
|
|
||||||
|
### 3. Offline packaged-app verification
|
||||||
|
|
||||||
|
- Electron was upgraded from 33 to `43.1.0` because the old runtime was listed
|
||||||
|
by the production dependency audit.
|
||||||
|
- Deprecated `NativeImage.getBitmap()` calls were migrated to `toBitmap()`.
|
||||||
|
- `electron-builder`, `@electron/asar`, and repeatable package scripts were
|
||||||
|
added.
|
||||||
|
- `npm run package:offline-check` builds the C# helper, renderer, Electron main
|
||||||
|
process, and an unpacked Windows app without launching it.
|
||||||
|
- `scripts/verify-packaged-app.mjs` verifies the executable, ASAR entries,
|
||||||
|
relative renderer assets, current runtime signature, preload, helper, IK
|
||||||
|
lists, and matching IK version.
|
||||||
|
- Packaged helper and IK lookup now use only `process.resourcesPath` by default;
|
||||||
|
repository paths remain development-only and cannot mask missing packaged
|
||||||
|
resources.
|
||||||
|
- The unpacked app passed `27/27` project/package checks. Runtime launch and
|
||||||
|
Genshin interaction remain deliberately deferred to the next live session.
|
||||||
|
|
||||||
|
### 4. Offline repeatability replay
|
||||||
|
|
||||||
|
- `npm run scan:native:replay -- --run-dir=<saved-run> --repeats=5` evaluates a
|
||||||
|
saved `scan-results.json` repeatedly and compares deterministic SHA-256
|
||||||
|
payload hashes.
|
||||||
|
- It writes `outputs/native-replay/<run>/native-replay-report.json` and fails if
|
||||||
|
a clean parsed Artifact cannot be evaluated.
|
||||||
|
- The replay never focuses, captures, clicks, scrolls, or starts Genshin.
|
||||||
|
|
||||||
|
| Saved run | Repeats | Results | Evaluated | Review | Unknown | Projection | Deterministic |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | --- | --- |
|
||||||
|
| `20260709-223006` | 5 | 20 | 20 | 0 | 0 | 20 complete | yes |
|
||||||
|
| `20260709-223041` | 5 | 50 | 49 | 1 historical review | 0 | 49 complete | yes |
|
||||||
|
| `20260709-223441` | 5 | 100 | 100 | 0 | 0 | 9 available, 91 complete | yes |
|
||||||
|
|
||||||
|
The 100-result run produced a score range of `76.1-92.5` with average `84.3`.
|
||||||
|
This is roll-tier efficiency, not build fit.
|
||||||
|
|
||||||
|
### 5. Upgrade projection
|
||||||
|
|
||||||
|
- Under-leveled, unambiguously identified 5-star Artifacts with four known legal
|
||||||
|
substats receive deterministic worst/middle/best roll-efficiency bounds up to
|
||||||
|
+20.
|
||||||
|
- The calculation uses remaining +4 steps and legal rarity-specific roll tiers.
|
||||||
|
- Three-substat, 4-star, ambiguous-rarity, review, incomplete, or illegal data
|
||||||
|
disables projection.
|
||||||
|
- +20 Artifacts are marked complete and do not show invented future rolls.
|
||||||
|
- Every visible projection states that target substat and roll tier are random
|
||||||
|
and that the result is not guaranteed.
|
||||||
|
|
||||||
|
## Static Evidence
|
||||||
|
|
||||||
|
- `npm run lint`: passed
|
||||||
|
- `npm test`: passed, `262/262` tests in `40/40` files
|
||||||
|
- `npm run build`: passed
|
||||||
|
- `npm run eval`: `26/26` exact cases, `77/77` fields
|
||||||
|
- `npm run scan:assessment:test`: passed
|
||||||
|
- `npm run package:verify`: `27/27` checks passed
|
||||||
|
- `npm audit`: `0` vulnerabilities
|
||||||
|
- `npm audit --omit=dev`: `0` vulnerabilities
|
||||||
|
- `git diff --check`: passed; only the known line-ending warnings may be shown
|
||||||
|
|
||||||
|
## Additional Non-Live Runtime Evidence
|
||||||
|
|
||||||
|
The current source build was started once through the normal non-elevated dev
|
||||||
|
runtime without calling capture, preflight, probe, click, scroll, or scan
|
||||||
|
endpoints. It proved the following boundaries without sending Genshin input:
|
||||||
|
|
||||||
|
- `/health` reported the current runtime signature, one main window, and all
|
||||||
|
four registered hotkeys.
|
||||||
|
- `/scanner/status` found `GenshinImpact`, reported `isElevated: false`, and
|
||||||
|
reached the C# helper while the native scanner remained `idle`.
|
||||||
|
- IK data `6.7.0` loaded as valid with 61 Artifact sets and 289 pieces;
|
||||||
|
weapons, characters, and materials remained `catalog_only`.
|
||||||
|
- Four OCR workers warmed successfully in `212 ms`.
|
||||||
|
- The saved 100-result run loaded through the runtime and legacy `deferred`
|
||||||
|
values were enriched to `evaluated` without rewriting the source run.
|
||||||
|
- A valid native PNG crop loaded successfully. An outside path, a JSON file,
|
||||||
|
and a traversal path were all blocked with HTTP `409`.
|
||||||
|
- The runtime was then shut down through `/dev/shutdown`; no stale Electron
|
||||||
|
process or port listener remained.
|
||||||
|
|
||||||
|
`npm run scan:native:validate:saved` now validates the newest complete
|
||||||
|
20/50/100 evidence set in one read-only command. It checks run contracts, PNG
|
||||||
|
containment, non-persistence, review-log provenance, five-repeat determinism,
|
||||||
|
scores, and projection boundaries. The current evidence set passed with zero
|
||||||
|
issues.
|
||||||
|
|
||||||
|
`npm run acceptance:offline` is the durable umbrella gate for work that does
|
||||||
|
not require Genshin input. It runs lint, all unit tests, OCR eval, assessment
|
||||||
|
self-test, saved native evidence validation, a fresh helper/app package build,
|
||||||
|
27 package checks, full and production dependency audits, and
|
||||||
|
`git diff --check`. The current run passed every step and writes its machine-
|
||||||
|
readable report under `outputs/offline-acceptance/`.
|
||||||
|
|
||||||
|
## Deliberately Not Claimed Yet
|
||||||
|
|
||||||
|
- No packaged executable was launched.
|
||||||
|
- No live Smart Capture or native scan was started.
|
||||||
|
- No later-session 20/50/100 live repeatability claim was added.
|
||||||
|
- No Inventory Kamera parity or 3-artifacts/second end-to-end claim was added.
|
||||||
|
- Build-fit scoring and account recommendations remain separate later work.
|
||||||
|
|
||||||
|
The required live and visual checks are listed in
|
||||||
|
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||||
@@ -30,6 +30,31 @@ English UI:
|
|||||||
nothing to the artifact store. Full evidence and the parser/timestamp fixes
|
nothing to the artifact store. Full evidence and the parser/timestamp fixes
|
||||||
are recorded in
|
are recorded in
|
||||||
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
|
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
|
||||||
|
- On 2026-07-10, a fresh later-session chain passed with the hardened native
|
||||||
|
helper: `5/20/50/100`, all parsed/evaluated, `0` Review, `0` processing
|
||||||
|
errors, and `0` store writes. The new 100er captured `100/100` over four
|
||||||
|
pages at `4.37/s` and processed at `3.15/s`, with no duplicate PNG hash group,
|
||||||
|
repeated page, or identical page boundary.
|
||||||
|
- A later real Settings-UI acceptance selected `Eigenes Limit -> Artefakte ->
|
||||||
|
5`. Genshin, visible detail, and the 4x8 grid passed preflight;
|
||||||
|
target/captured/processed/parsed were all 5, Review and processing errors were
|
||||||
|
0, persistence stayed disabled, and the current-session result rail contained
|
||||||
|
five entries. A later packaged row-limit run resolved one detected row to
|
||||||
|
eight Artifacts and passed 8/8 with the same non-persistence boundary.
|
||||||
|
- The packaged full-owned-inventory run `20260710-223233` resolved the target
|
||||||
|
from the OCR-confirmed owned count `2,211`, captured all 2,211 crops over 70
|
||||||
|
pages, streamed results during capture, and produced zero processing errors
|
||||||
|
and zero store writes. Its first parser pass correctly failed the 15% Review
|
||||||
|
gate at 490 Review. After the structurally safe five-star +0 repair, the
|
||||||
|
current package reprocessed the same complete corpus at 136 Review (6.15%);
|
||||||
|
the saved-run validator then passed with zero issues.
|
||||||
|
- The bounded inventory-top reset now completes in about `1.7 s` instead of
|
||||||
|
about `50.7 s`; the 3,200-event range and per-event foreground/stop guards
|
||||||
|
remain intact.
|
||||||
|
- A packaged build started from `%TEMP%` and completed a native 5er through
|
||||||
|
renderer, preload, IPC, packaged helper, and packaged IK resources with
|
||||||
|
`persist=false`. See
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
The old alternate OCR-engine and comparison paths have been retired. The app now
|
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
|
runs the current OCR/capture path only. Performance claims should be stated as
|
||||||
@@ -126,6 +151,17 @@ npm run scan:native:smoke
|
|||||||
npm run scan:native:smoke:5
|
npm run scan:native:smoke:5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the gated live scale chain, run and validate each size before continuing:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 20
|
||||||
|
npm run scan:native:validate:saved -- --targets=20
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 50
|
||||||
|
npm run scan:native:validate:saved -- --targets=50
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\native-live-smoke.ps1 -Limit 100
|
||||||
|
npm run scan:native:validate:saved -- --targets=100
|
||||||
|
```
|
||||||
|
|
||||||
It checks `/health`, `/scanner/native/data`,
|
It checks `/health`, `/scanner/native/data`,
|
||||||
`/scanner/native/preflight?category=artifacts` including the native visual
|
`/scanner/native/preflight?category=artifacts` including the native visual
|
||||||
blank-capture guard, runs the guarded
|
blank-capture guard, runs the guarded
|
||||||
@@ -140,6 +176,67 @@ outputs/native-live-smoke/<timestamp>/
|
|||||||
Use this native smoke path before enabling any artifact-store promotion from
|
Use this native smoke path before enabling any artifact-store promotion from
|
||||||
native `scan-results.json`.
|
native `scan-results.json`.
|
||||||
|
|
||||||
|
For a deliberate packaged-runtime acceptance, first build with
|
||||||
|
`npm run package:offline-check`, then launch the unpacked EXE from a foreign
|
||||||
|
working directory with a loopback-only temporary CDP port:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$exe = (Resolve-Path "outputs\dist\win-unpacked\Genshin Artifact Assistant.exe").Path
|
||||||
|
Start-Process -FilePath $exe `
|
||||||
|
-ArgumentList "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=9223" `
|
||||||
|
-WorkingDirectory $env:TEMP -Verb RunAs
|
||||||
|
npm run package:live:inspect
|
||||||
|
```
|
||||||
|
|
||||||
|
`package:live:inspect` is read-only. The following command is a real bounded
|
||||||
|
five-card Genshin scan and therefore requires the same live authorization and
|
||||||
|
preconditions as every other input test:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run package:live:scan5
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner calls packaged renderer/preload/IPC methods serially, validates the
|
||||||
|
5-result non-persistence contract, and writes JSON plus a screenshot below
|
||||||
|
`outputs/packaged-live/`. Close the packaged app after testing so the temporary
|
||||||
|
CDP port disappears.
|
||||||
|
|
||||||
|
For the streaming pipeline, use the real Settings-UI path with a bounded target
|
||||||
|
large enough to observe overlap:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run package:live:ui-stream20
|
||||||
|
```
|
||||||
|
|
||||||
|
This acceptance is stricter than a correct final count. It requires native
|
||||||
|
capture to be observed as running while at least one processing worker has
|
||||||
|
finished and at least one current-session result card is already visible. It
|
||||||
|
also records the maximum evaluated/result counts seen during capture, verifies
|
||||||
|
all 20 final entries, and checks the insertion animation unless the operating
|
||||||
|
system requests reduced motion. Persistence remains disabled.
|
||||||
|
|
||||||
|
The same packaged Settings-UI runner exposes all accepted scope paths:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run package:live:ui-scan5
|
||||||
|
npm run package:live:ui-row1
|
||||||
|
npm run package:live:ui-stream20
|
||||||
|
npm run package:live:ui-full-inventory
|
||||||
|
```
|
||||||
|
|
||||||
|
`package:live:ui-full-inventory` explicitly selects `Gesamtes Inventar` and
|
||||||
|
never supplies a numeric fallback. It accepts the target only from the running
|
||||||
|
scanner status after the app has read the current owned Artifact count. The
|
||||||
|
runner rejects targets outside `1..2400`, requires exact captured/processed/
|
||||||
|
parsed/result/UI-row counts, checks `pages = ceil(target / 32)`, enforces at
|
||||||
|
most 15% Review with zero processing errors, verifies streaming overlap and
|
||||||
|
document fit, and requires `persist=false` with zero store writes. Its timeout
|
||||||
|
is 15 minutes. The real elevated run `20260710-223233` reached 2,211/2,211 over
|
||||||
|
70 pages. Because the initial parser produced 490 Review, the gate rejected the
|
||||||
|
first result honestly; current-package reprocessing reduced that to 136 Review
|
||||||
|
(6.15%), and `scan:native:validate:saved -- --targets=2211` passed with zero
|
||||||
|
issues. This two-stage evidence is the accepted full-inventory baseline.
|
||||||
|
|
||||||
The native preflight and start endpoints both accept an explicit category:
|
The native preflight and start endpoints both accept an explicit category:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -157,8 +254,32 @@ almost entirely white/black, or too visually uniform to be trusted.
|
|||||||
The probe-click endpoint additionally requires a detected Genshin-client
|
The probe-click endpoint additionally requires a detected Genshin-client
|
||||||
artifact inventory grid and visible artifact detail card before sending input.
|
artifact inventory grid and visible artifact detail card before sending input.
|
||||||
|
|
||||||
|
At native Artifact-scan startup, the helper first resets the inventory to the
|
||||||
|
top with a bounded upward wheel sequence at the calibrated grid anchor and then
|
||||||
|
waits for the UI to stabilize. Genshin is focused only for initial startup. The
|
||||||
|
native loop checks that the same Genshin HWND is still foreground immediately
|
||||||
|
before every click and every wheel event; focus loss stops the run without an
|
||||||
|
automatic refocus. Stop, ESC, Enter, and F9 remain active during the reset,
|
||||||
|
stabilization, clicks, captures, and page scrolling. Native status reports
|
||||||
|
`initialTopResetMs` and `totalMs` separately; `activeMs` starts after the reset
|
||||||
|
and remains the capture-rate denominator.
|
||||||
|
|
||||||
|
Do not replace this production reset with `B` plus an Artifact-tab click based
|
||||||
|
on event count alone. Reopening depends on the current menu/inventory state and
|
||||||
|
adds a key toggle plus a coordinate target. It has not been live benchmarked
|
||||||
|
against the accepted bounded wheel reset. If evaluated later, run it as a
|
||||||
|
separate guarded A/B probe and compare successful top-state detection, focus/
|
||||||
|
stop behavior, coordinate robustness, and elapsed reset time before changing
|
||||||
|
the default.
|
||||||
|
|
||||||
## Evidence Commands
|
## Evidence Commands
|
||||||
|
|
||||||
|
The commands in this first subsection exercise the older visible-inventory
|
||||||
|
engine and its assessment format. They remain useful for regression work on
|
||||||
|
that engine, but they do not post-process or validate the current native C#
|
||||||
|
capture path. Use `native-live-smoke.ps1` plus
|
||||||
|
`scan:native:validate:saved` for current native acceptance.
|
||||||
|
|
||||||
After the elevated app is running and Genshin is open on the artifact inventory,
|
After the elevated app is running and Genshin is open on the artifact inventory,
|
||||||
the non-elevated terminal can drive the local dev-control endpoints and save a
|
the non-elevated terminal can drive the local dev-control endpoints and save a
|
||||||
full evidence bundle:
|
full evidence bundle:
|
||||||
@@ -210,12 +331,35 @@ npm run scan:assessment:validate -- --latest --summary --limit=20 --max-active-a
|
|||||||
100-artifact current run is valid scanner evidence; it is not evidence that the
|
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.
|
3 artifacts/second budget was met unless this budget check also passes.
|
||||||
|
|
||||||
|
The historical `native-live-smoke.ps1` path is intentionally serial: it waits
|
||||||
|
for native capture to finish and only then invokes post-capture processing.
|
||||||
|
Do not divide a target by the sum of those two durations and describe the
|
||||||
|
result as the current streaming pipeline's end-to-end rate. The 2026-07-11
|
||||||
|
read-only streaming-20 check (`20260711-090019`) proved overlap (a result was
|
||||||
|
visible before capture completed) and observed 20/20 clean results at 4.43
|
||||||
|
capture/s and 2.39/s from request issue to result reconciliation. That bounded
|
||||||
|
observation is not a general performance claim: native start, capture start/end,
|
||||||
|
processing start/end, and final reconciliation still need one persisted shared
|
||||||
|
clock before publishing an end-to-end budget result.
|
||||||
|
|
||||||
The assessment self-test does not need Genshin:
|
The assessment self-test does not need Genshin:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run scan:assessment:test
|
npm run scan:assessment:test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Saved native results can be re-evaluated without Genshin, focus changes, or
|
||||||
|
input automation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run scan:native:replay -- --run-dir=<native-run-dir> --repeats=5
|
||||||
|
```
|
||||||
|
|
||||||
|
This is an offline determinism and result-contract check. It does not replace a
|
||||||
|
live capture smoke test. The ordered checklist was executed on 2026-07-10; its
|
||||||
|
results are in
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
## Review-To-Eval Quality Loop
|
## Review-To-Eval Quality Loop
|
||||||
|
|
||||||
After any live scan that creates review samples, export candidates before adding
|
After any live scan that creates review samples, export candidates before adding
|
||||||
@@ -263,11 +407,13 @@ as higher risk and validated with Smart Capture plus the probe.
|
|||||||
|
|
||||||
Before marking an automation change done:
|
Before marking an automation change done:
|
||||||
|
|
||||||
1. Run `npm run lint`.
|
1. Run `npm run helper:test` when native input safety changed.
|
||||||
2. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
|
2. Run `npm run lint`.
|
||||||
3. Run `npm test`.
|
3. Run `npx tsc -p tsconfig.electron.json` when Electron/preload/main changed.
|
||||||
4. Run `npm run build`.
|
4. Run `npm test`.
|
||||||
5. If Genshin is available, run `/automation/probe-click?index=1`.
|
5. Run `npm run build`.
|
||||||
6. For scan-loop changes, run
|
6. If Genshin is available, run `/automation/probe-click?index=1`.
|
||||||
`/scanner/start?entry=visible-inventory&limit=2` before any broader scan.
|
7. For native scan-loop changes, run `npm run scan:native:smoke:5` before any
|
||||||
7. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
20/50/100 progression. Use the explicit visible-inventory endpoint only
|
||||||
|
when that legacy engine itself changed.
|
||||||
|
8. Record new live findings in this file and in `docs/scanner-rework-status.md`.
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Build-Fit Contract V1
|
||||||
|
|
||||||
|
Status: the versioned contract, hard extraction gates, deterministic five-piece
|
||||||
|
verifier, four source-bound profiles, source expiry, a separate explainable
|
||||||
|
evidence-coverage ranker, session-only aggregate context, and a read-only Builds
|
||||||
|
surface are implemented and offline-tested. This document does not claim a new
|
||||||
|
live or packaged UI acceptance.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`build-fit-contract-v1` defines what must be known before an Artifact may enter
|
||||||
|
a character/build evaluation. It remains separate from:
|
||||||
|
|
||||||
|
- OCR/extraction confidence, which is a hard eligibility gate;
|
||||||
|
- `roll-efficiency-v1`, which describes legal roll-tier quality without build
|
||||||
|
context;
|
||||||
|
- `build-fit-evidence-ranking-v1`, a separate product rule that ranks only
|
||||||
|
source-bound profile evidence and is not a damage, optimizer, OCR-confidence,
|
||||||
|
or Roll-Efficiency score.
|
||||||
|
|
||||||
|
The foundation lives in:
|
||||||
|
|
||||||
|
- `src/types/buildFit.ts` for versioned contracts and ranking payloads;
|
||||||
|
- `src/lib/buildFitContractValidation.ts` for schema, safety-floor, and source-
|
||||||
|
expiry validation;
|
||||||
|
- `src/lib/buildFitEligibility.ts` for native-result mapping and deterministic
|
||||||
|
eligibility evidence;
|
||||||
|
- `src/lib/buildFitCombination.ts` for complete-build shape, aggregate-context,
|
||||||
|
and conflict verification;
|
||||||
|
- `src/lib/buildFitEvidenceRanking.ts` for bounded deterministic selection;
|
||||||
|
- `src/lib/buildFitExplicitContext.ts` for session-only user-confirmed
|
||||||
|
non-Artifact context;
|
||||||
|
- `src/data/buildFitProfiles.ts` for curated assumptions, provenance, expiry,
|
||||||
|
and direct source references;
|
||||||
|
- `src/features/builds/*` for the read-only renderer surface.
|
||||||
|
|
||||||
|
## Contract Inputs
|
||||||
|
|
||||||
|
Every character/build target provides:
|
||||||
|
|
||||||
|
| Area | V1 requirement |
|
||||||
|
| --- | --- |
|
||||||
|
| Identity | Stable id, positive revision, character id/name, target id/label/role/description, and explicit unique assumptions |
|
||||||
|
| Provenance | Game version, review timestamp, explicit expiry, and at least one versioned curated, user, or imported source |
|
||||||
|
| Set plans | Explained preferred or alternative legal 2-piece, 4-piece, or 2+2 shapes |
|
||||||
|
| Main stats | Explained priority 1-3 choices for Sands, Goblet, and Circlet; Flower and Plume stay fixed |
|
||||||
|
| Substats | Unique canonical stats with explained relative weights in `(0, 1]` |
|
||||||
|
| Aggregate targets | Optional full-build minimum/maximum targets; never inferred from one Artifact |
|
||||||
|
| Evidence gates | Overall extraction confidence, required field confidences, and all identity/value fields |
|
||||||
|
| Conflicts | Explicit `warn` or `block` behavior for equipment and cross-build assignment conflicts |
|
||||||
|
|
||||||
|
V1 single-Artifact and combination assessments deliberately continue to emit
|
||||||
|
`score: null` and `recommendationStatus: deferred`. The separate ranker can
|
||||||
|
only work with a complete safe combination and a non-expired profile.
|
||||||
|
|
||||||
|
## Curated Profile Corpus
|
||||||
|
|
||||||
|
`build-fit-profile-corpus-v2` has four deliberately narrow candidates. Every
|
||||||
|
profile has a direct KQM reference, game-version label, review timestamp, and
|
||||||
|
`validUntil` date. A stale profile fails closed before it can rank a build.
|
||||||
|
|
||||||
|
| Profile | Scope | Sourced Set plan | Guide version |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Furina Off-field Solo Hydro C0-C1 | Ousia off-field damage and Burst support; complete ER context required | 4pc Golden Troupe | KQM `Luna II` |
|
||||||
|
| Neuvillette On-field Charged Attack | On-field Charged Attack damage, not driver-only | 4pc Marechaussee Hunter | KQM `Luna IV` |
|
||||||
|
| Raiden Off-field Hyperbloom Trigger | Off-field Hyperbloom trigger only; no Burst/Aggravate/Quickbloom claim | 4pc Flower of Paradise Lost or 4pc Gilded Dreams | KQM `5.7` |
|
||||||
|
| Nahida Off-field Deepwood Support | Off-field Skill application while Nahida is the intended Deepwood holder | 4pc Deepwood Memories | KQM `5.6` |
|
||||||
|
|
||||||
|
The direct guide references are stored in `src/data/buildFitProfiles.ts`:
|
||||||
|
|
||||||
|
- `https://keqingmains.com/q/furina-quickguide/`
|
||||||
|
- `https://keqingmains.com/q/neuvillette-quickguide/`
|
||||||
|
- `https://keqingmains.com/q/raiden-quickguide/`
|
||||||
|
- `https://keqingmains.com/q/nahida-quickguide/`
|
||||||
|
|
||||||
|
Tests validate every profile against vendored IK 6.7.0 character and Artifact-
|
||||||
|
set keys. Each profile has a Golden complete-set fixture; Furina also has the
|
||||||
|
context-pending fixture, and every additional profile has a Review adversarial
|
||||||
|
fixture. No profile source is treated as a universal build rule outside its
|
||||||
|
documented assumptions.
|
||||||
|
|
||||||
|
## Eligibility And Five-Piece Verification
|
||||||
|
|
||||||
|
1. A persisted native Artifact result maps to `build-fit-input-v1`; store-only
|
||||||
|
rows are intentionally not used because they do not retain IK set keys and
|
||||||
|
per-field confidence evidence.
|
||||||
|
2. Non-Artifact categories, Review, non-final extraction, missing fields,
|
||||||
|
confidence below the contract floor, and free-text Set guesses block before
|
||||||
|
profile evaluation.
|
||||||
|
3. `build-fit-combination-input-v1` verifies exactly five unique canonical
|
||||||
|
slots, nested Artifact gates, complete 2pc/4pc/2+2 Set forms, sourced
|
||||||
|
aggregate context, and conflicts in deterministic slot order.
|
||||||
|
4. Missing full-build context remains `pending`; a missed aggregate target is
|
||||||
|
fit evidence, not an OCR repair opportunity.
|
||||||
|
|
||||||
|
The verifier remains order-independent, source-bound, and score-free.
|
||||||
|
|
||||||
|
## Evidence Ranking And Explicit Context
|
||||||
|
|
||||||
|
`build-fit-evidence-ranking-v1` is a separate, deterministic, read-only layer
|
||||||
|
over complete eligible combinations from the newest complete native scanner run.
|
||||||
|
It returns at most three non-overlapping profile suggestions.
|
||||||
|
|
||||||
|
Its displayed `evidenceCoverage` is intentionally narrow and explainable:
|
||||||
|
|
||||||
|
- a visible component for the sourced preferred or alternative Set plan;
|
||||||
|
- the documented priority for the three variable main-stat slots;
|
||||||
|
- presence/ordinal coverage of the profile's documented substats;
|
||||||
|
- required aggregate targets only when they are met.
|
||||||
|
|
||||||
|
It never uses Roll-Efficiency, artifact roll value, OCR confidence, a guessed
|
||||||
|
character roster, team, weapon, rotation, or hidden damage constants as a rank
|
||||||
|
component. OCR confidence remains a gate only. Conflicts stay visible as
|
||||||
|
warnings and do not become a concealed damage penalty.
|
||||||
|
|
||||||
|
Furina's 200%+ Solo-Hydro Energy Recharge target is the current aggregate
|
||||||
|
context example. The user may enter and explicitly confirm the complete
|
||||||
|
non-Artifact contribution for the current session. The value is not inferred
|
||||||
|
from a scan, is not persisted, and is accepted only through the declared
|
||||||
|
`local-build-context-v1` source. Without confirmation, Furina stays deferred.
|
||||||
|
|
||||||
|
## Builds Surface Boundary
|
||||||
|
|
||||||
|
The Builds surface:
|
||||||
|
|
||||||
|
- reads only the newest complete native result run, up to the 2,400-Artifact
|
||||||
|
scan safety bound;
|
||||||
|
- shows profile, Set path, five selected artifacts, rule coverage, sources,
|
||||||
|
reasons, warnings, and blocked-context explanations;
|
||||||
|
- exposes at most three non-overlapping candidates;
|
||||||
|
- repeats that character ownership, team, weapon, and rotation are not proven
|
||||||
|
by an Artifact scan;
|
||||||
|
- never starts a scan, writes the Artifact store, edits a review, or sends
|
||||||
|
in-game input.
|
||||||
|
|
||||||
|
`src/lib/scoring.ts`, `data/presets.json`, and `createLocalAccountSnapshot`
|
||||||
|
remain legacy/demo boundaries. Real local snapshots still do not run through
|
||||||
|
the legacy scorer or emit legacy builds/recommendations.
|
||||||
|
|
||||||
|
## Next Acceptance Steps
|
||||||
|
|
||||||
|
1. The saved 20/50/100/2,211 validation gates passed for this checkpoint;
|
||||||
|
preserve them for later Build-Fit changes and do not weaken scanner review
|
||||||
|
gates.
|
||||||
|
2. Perform a packaged renderer/UI acceptance before calling the new Builds
|
||||||
|
surface live-accepted.
|
||||||
|
3. Refresh every profile before its explicit expiry and add Golden plus
|
||||||
|
Review/low-confidence adversarial coverage for every new profile.
|
||||||
|
4. Visually label the remaining 136 Review crops before changing parser or
|
||||||
|
profile rules; do not relax gates from aggregate statistics.
|
||||||
|
5. Add later profiles only when their team/weapon/rotation assumptions can be
|
||||||
|
represented explicitly without hidden constants. No suggestion may imply
|
||||||
|
deletion, feeding, enhancement, or automatic in-game mutation.
|
||||||
+224
-3
@@ -5,10 +5,34 @@
|
|||||||
- [ ] The expected crop or capture behavior is clear.
|
- [ ] The expected crop or capture behavior is clear.
|
||||||
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
|
- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files.
|
||||||
- [ ] Auto-scan starts only from confirmed artifact inventory plus visible detail card, or blocks with a reason.
|
- [ ] Auto-scan starts only from confirmed artifact inventory plus visible detail card, or blocks with a reason.
|
||||||
|
- [ ] Native Artifact scan resets the inventory to the top with a bounded grid-anchor scroll and stabilization wait.
|
||||||
|
- [ ] A user-facing full-inventory run resolves its target from the OCR-confirmed
|
||||||
|
current owned Artifact count, never from inventory capacity; missing or
|
||||||
|
invalid current count blocks safely before native start.
|
||||||
|
- [ ] Artifact and row limits both clamp to the owned count and the 2,400 target
|
||||||
|
safety bound; row conversion uses the detected grid column count.
|
||||||
|
- [ ] A `B` plus Artifact-tab reopen is not promoted over the bounded wheel reset
|
||||||
|
without a guarded live A/B test for state safety, targeting, and timing.
|
||||||
|
- [ ] Native automated clicks and wheel events require Genshin to remain foreground; focus loss stops without refocusing.
|
||||||
|
- [ ] Stop, ESC, Enter, and F9 remain active during native reset, capture, and scrolling.
|
||||||
- [ ] Capture failures are shown to the user.
|
- [ ] Capture failures are shown to the user.
|
||||||
- [ ] OCR uncertainty remains inspectable in Details.
|
- [ ] OCR uncertainty remains inspectable in Details.
|
||||||
- [ ] Parser output does not silently trust low-confidence text.
|
- [ ] Parser output does not silently trust low-confidence text.
|
||||||
|
- [ ] Current native capture jobs preserve direct visual `starCount`,
|
||||||
|
`starConfidence`, and `starSource`; an absent, invalid, or conflicting star
|
||||||
|
field is never silently assumed to be 5★.
|
||||||
|
- [ ] Direct visual rarity ambiguity enters `Review` before value evaluation.
|
||||||
|
- [ ] Scanner controls contain no automatic in-game salvage, Quick Select,
|
||||||
|
deletion, feed, enhance, lock/unlock, or resource-spending action.
|
||||||
|
- [ ] Any Inventory local-result removal is explicitly confirmed, tombstones the
|
||||||
|
result before crop cleanup, preserves raw `scan-results.json` evidence,
|
||||||
|
remains contained to app-local files, and never invokes game input. An
|
||||||
|
optional Store-record removal is limited to the exact linked local record.
|
||||||
|
- [ ] A native-result deletion is blocked while capture or processing is active,
|
||||||
|
remains idempotent after its tombstone, and cannot remove a crop outside the
|
||||||
|
selected run directory.
|
||||||
- [ ] `npm run lint` passes.
|
- [ ] `npm run lint` passes.
|
||||||
|
- [ ] `npm run helper:test` passes when native input safety changed.
|
||||||
- [ ] `npm test` passes when parser/scoring logic changed.
|
- [ ] `npm test` passes when parser/scoring logic changed.
|
||||||
- [ ] `npm run build` passes.
|
- [ ] `npm run build` passes.
|
||||||
- [ ] Manual Smart Capture is tested when possible.
|
- [ ] Manual Smart Capture is tested when possible.
|
||||||
@@ -30,21 +54,101 @@
|
|||||||
- [ ] Parsed count is at least the requested count.
|
- [ ] Parsed count is at least the requested count.
|
||||||
- [ ] Miss rate is at or below 2%.
|
- [ ] Miss rate is at or below 2%.
|
||||||
- [ ] Review rate is at or below 15%.
|
- [ ] Review rate is at or below 15%.
|
||||||
- [ ] Speed reporting uses active scan timing plus quality, not click count alone.
|
- [ ] Capture rate, processing rate, and end-to-end rate are reported as separate metrics.
|
||||||
|
- [ ] A streaming end-to-end claim records native start, capture start/end,
|
||||||
|
processing start/end, and final result reconciliation on one shared wall clock.
|
||||||
|
- [ ] `run-timing.json` is present, uses `native-scanner-run-timing-v1`, and
|
||||||
|
contains valid ordered first-observed lifecycle markers through durable result
|
||||||
|
reconciliation.
|
||||||
|
- [ ] The smoke report validates target/capture/queue/parse/durable-result
|
||||||
|
reconciliation together with the timing record; a historical timing report is
|
||||||
|
not reused for a current-build claim.
|
||||||
|
- [ ] Overlapping capture and processing durations are never added to derive an
|
||||||
|
end-to-end rate.
|
||||||
|
- [ ] Speed reporting uses quality gates, not click count alone.
|
||||||
|
|
||||||
|
## Revised Scope Live Acceptance
|
||||||
|
|
||||||
|
- [x] Current elevated-dev 50-artifact smoke `20260711-154827`: target/captured/
|
||||||
|
clicked/queued/processed/parsed/loaded `50/50`, 2 pages, 0 Review, 0 errors,
|
||||||
|
0 Store writes, `persist=false`, and 50 jobs/non-empty PNGs. A valid
|
||||||
|
`native-scanner-run-timing-v1` record reconciled at `28,022 ms`; it is not a
|
||||||
|
3/s performance claim.
|
||||||
|
|
||||||
|
- [x] Real Settings-UI Artifact limit 5: Genshin/detail/grid preflight passed;
|
||||||
|
target/captured/processed/parsed `5/5`, Review/errors `0`, `persist=false`, and
|
||||||
|
five current-session result rows.
|
||||||
|
- [x] Complete packaged full-inventory baseline resolves OCR-owned target 2,211,
|
||||||
|
captures 2,211 crops over 70 pages, reprocesses all 2,211 with the current
|
||||||
|
package at 136 Review (6.15%), 0 errors, `persist=false`, and passes saved-run
|
||||||
|
validation with 0 issues.
|
||||||
|
- [x] Real packaged Settings-UI row limit 1 resolves the detected eight-column
|
||||||
|
grid to target 8 and finishes captured/processed/parsed `8/8`, Review/errors
|
||||||
|
`0`, `persist=false`; foreground loss is proven to block with zero clicks.
|
||||||
|
- [ ] Any `B`-reopen candidate is benchmarked separately and does not replace
|
||||||
|
the accepted wheel reset without stronger reliability and timing evidence.
|
||||||
|
|
||||||
## UI Change
|
## UI Change
|
||||||
|
|
||||||
|
- [ ] Scanner, Artifacts, Review, and Builds preview follow the primary user
|
||||||
|
journey; Overlay and Diagnostics remain secondary tools.
|
||||||
|
- [ ] The Scanner page shows app/Genshin readiness and one unambiguous primary
|
||||||
|
start/stop action before advanced controls.
|
||||||
- [ ] The main workflow remains visible without unnecessary scrolling.
|
- [ ] The main workflow remains visible without unnecessary scrolling.
|
||||||
- [ ] Debug or secondary information is moved behind buttons/modals where appropriate.
|
- [ ] Debug or secondary information is moved behind buttons/modals where appropriate.
|
||||||
|
- [ ] Builds stays visibly labeled as a preview until Build-Fit is implemented;
|
||||||
|
roll efficiency is not presented as character/build suitability.
|
||||||
|
- [ ] Review describes extraction correction and never implies trash, feed,
|
||||||
|
deletion, or an in-game mutation.
|
||||||
- [ ] Disabled states prevent actions without required data.
|
- [ ] Disabled states prevent actions without required data.
|
||||||
|
- [ ] Initial content loading reserves layout with a skeleton; bounded actions
|
||||||
|
use a spinner; scan progress remains durable view state.
|
||||||
|
- [ ] During scanning, the left main work surface shows phase,
|
||||||
|
processed-versus-target progress, and read/parsed/stored/review/duplicate/error
|
||||||
|
outcomes without a duplicate standalone progress card.
|
||||||
|
- [ ] Toasts confirm explicit mutations and failures without replacing durable
|
||||||
|
progress, review, blocked, or error state.
|
||||||
|
- [ ] Status meaning is available through text/icons and not color or animation alone.
|
||||||
|
- [ ] Keyboard focus is visible for navigation, buttons, disclosures, fields,
|
||||||
|
dialogs, and toast dismissal.
|
||||||
|
- [ ] `prefers-reduced-motion: reduce` disables non-essential motion without
|
||||||
|
hiding state changes or feedback.
|
||||||
- [ ] Text fits in controls and panels.
|
- [ ] Text fits in controls and panels.
|
||||||
- [ ] Desktop viewport is checked manually.
|
- [ ] The Scanner, Artifacts, Review, Builds preview, Overlay, and Diagnostics
|
||||||
|
views are checked at the packaged desktop viewport for clipping, overflow,
|
||||||
|
accidental page-level scroll, nested-scroll traps, and empty/loading/error states.
|
||||||
|
- [ ] Primary scanner controls remain usable at the smallest supported desktop
|
||||||
|
window; narrower layouts reflow instead of clipping cards or action labels.
|
||||||
|
- [ ] Before/after screenshots or equivalent current-build visual evidence are
|
||||||
|
inspected for the changed views.
|
||||||
|
- [ ] A fresh/missing/invalid app locale starts in English, German can be
|
||||||
|
selected in Settings, and the preference persists locally without changing
|
||||||
|
the English-only Genshin scanner contract.
|
||||||
|
- [ ] Localized UI acceptance uses stable semantic hooks where possible and
|
||||||
|
verifies that translated labels do not cause clipping, focus, or overflow.
|
||||||
|
- [ ] The artifact detail local-delete control states its app-local scope,
|
||||||
|
requires confirmation, and remains unavailable for an active native run.
|
||||||
|
- [ ] `npm run lint` and relevant renderer tests pass.
|
||||||
- [ ] `npm run build` passes.
|
- [ ] `npm run build` passes.
|
||||||
|
|
||||||
## Scan Results And Inventory UX
|
## Scan Results And Inventory UX
|
||||||
|
|
||||||
- [ ] The scan page keeps preview, Stop/status, and the live result rail visible without page-level scrolling.
|
- [ ] The scan page keeps preview, Stop/status, and the live result rail visible without page-level scrolling.
|
||||||
- [ ] The live rail shows finished artifact rows only, not intermediate OCR/debug state.
|
- [ ] The result rail is empty on a fresh app launch, contains only results from
|
||||||
|
the current app scan session, and leaves persisted history in Artifacts.
|
||||||
|
- [ ] Progress and completion use the resolved owned-inventory target or the
|
||||||
|
selected Artifact/row limit consistently.
|
||||||
|
- [ ] Capture and evaluation progress advance independently: the left bar uses
|
||||||
|
captured/target while the right result panel uses evaluated/target.
|
||||||
|
- [ ] Processing starts once when the native run first exposes `runDir`, keeps
|
||||||
|
waiting across temporary JSONL EOF, and finishes only after producer terminal
|
||||||
|
plus queue drain.
|
||||||
|
- [ ] The live rail shows fully evaluated artifact rows only, not intermediate
|
||||||
|
OCR/debug state, and appends every current-session row in capture order.
|
||||||
|
- [ ] Result delta polling deduplicates stable IDs/sequences and a final full
|
||||||
|
load reconciles captured, processed, and durable-result counts.
|
||||||
|
- [ ] Newly inserted result rows use a left-to-right mount cue; reduced-motion
|
||||||
|
users receive the same content and status without the animation.
|
||||||
- [ ] Each live row has scan number, artifact name or compact fallback, value score, and a short status pill.
|
- [ ] Each live row has scan number, artifact name or compact fallback, value score, and a short status pill.
|
||||||
- [ ] Extraction confidence and artifact value remain separate in the data model.
|
- [ ] Extraction confidence and artifact value remain separate in the data model.
|
||||||
- [ ] Native post-capture runs write `scan-results.json` with extraction status and deferred/review value status.
|
- [ ] Native post-capture runs write `scan-results.json` with extraction status and deferred/review value status.
|
||||||
@@ -61,6 +165,12 @@
|
|||||||
- [ ] Native live smoke uses `npm run scan:native:smoke` before broad native timing or store-promotion claims.
|
- [ ] 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.
|
- [ ] Artifact-only scope is visible in the UI; weapons, materials, and character details are not presented as active scanner features while they are not scanned.
|
||||||
- [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision.
|
- [ ] Low-confidence or conflicting reads show `Review` instead of a normal weak/strong value decision.
|
||||||
|
- [ ] Explicit visual 1–4★ native results use `excluded`, receive no score,
|
||||||
|
projection, or store promotion, and remain visible for manual inspection.
|
||||||
|
- [ ] The separate read-only `Nicht 5★` filter contains only explicitly
|
||||||
|
recognized 1–4★ entries and retains screenshot/crop plus parsed details.
|
||||||
|
- [ ] The `Nicht 5★` view does not call entries safe to salvage and exposes no
|
||||||
|
automatic salvage or Quick Select control.
|
||||||
- [ ] Duplicate state is represented separately from artifact quality.
|
- [ ] Duplicate state is represented separately from artifact quality.
|
||||||
- [ ] The artifact inventory view can browse stored scan results without opening diagnostics.
|
- [ ] The artifact inventory view can browse stored scan results without opening diagnostics.
|
||||||
- [ ] Inventory filters/sorting cover review, score, set, slot, equipped, locked, and newest scan where data exists.
|
- [ ] Inventory filters/sorting cover review, score, set, slot, equipped, locked, and newest scan where data exists.
|
||||||
@@ -76,13 +186,124 @@
|
|||||||
- [ ] Character, set, stat, and slot dictionaries are updated deliberately.
|
- [ ] Character, set, stat, and slot dictionaries are updated deliberately.
|
||||||
- [ ] Tests cover changed behavior when practical.
|
- [ ] Tests cover changed behavior when practical.
|
||||||
|
|
||||||
|
## Artifact Roll Evaluation
|
||||||
|
|
||||||
|
- [ ] Extraction status and value status remain separate fields.
|
||||||
|
- [ ] Clean legal results become `evaluated`; Review receives no normal score.
|
||||||
|
- [ ] Only explicitly confirmed 5★ evidence enters `roll-efficiency-v1` for
|
||||||
|
current native jobs.
|
||||||
|
- [ ] Explicit visual 1–4★ evidence becomes `excluded`, not a weak score;
|
||||||
|
missing, invalid, or conflicting direct star evidence becomes `Review`.
|
||||||
|
- [ ] Incomplete or inconsistent clean data becomes `unknown`, not a guessed score.
|
||||||
|
- [ ] Score is labeled as roll efficiency, not universal Artifact or build value.
|
||||||
|
- [ ] Build fit stays explicitly deferred without character/build context.
|
||||||
|
- [ ] Locked and equipped state do not change roll efficiency.
|
||||||
|
- [ ] Rarity-specific legal values and total roll counts are enforced.
|
||||||
|
- [ ] Reason codes and per-substat roll breakdown explain the result.
|
||||||
|
- [ ] Legacy saved scan results are enriched on load without requiring a rescan.
|
||||||
|
- [ ] Unit tests cover evaluated, review, unknown, ambiguity, and rarity edges.
|
||||||
|
|
||||||
|
## Build-Fit Contract
|
||||||
|
|
||||||
|
- [x] Input, contract, and assessment schemas are explicitly versioned.
|
||||||
|
- [x] Contracts require character/target identity, revision, game-versioned
|
||||||
|
provenance, explained Set/Main-Stat/Substat rules, aggregate targets, and
|
||||||
|
explicit conflict behavior.
|
||||||
|
- [x] V1 safety floors cannot be lowered below 80% overall extraction and 70%
|
||||||
|
for every required identity/value field.
|
||||||
|
- [x] Review, non-parsed, incomplete, low-confidence, or non-canonical Set data
|
||||||
|
blocks before downstream fit work.
|
||||||
|
- [x] Native Artifact results map into the contract without guessing a Set key;
|
||||||
|
non-Artifact categories are rejected.
|
||||||
|
- [x] Roll-Efficiency is recorded only as separate context and is never used as
|
||||||
|
Build-Fit eligibility or presented as a fit score.
|
||||||
|
- [x] V1 assessments retain `score: null` and deferred recommendations even
|
||||||
|
when the separate evidence-ranking layer is available.
|
||||||
|
- [x] Real local account snapshots do not call the legacy demo scorer.
|
||||||
|
- [x] Four current profiles (Furina, Neuvillette, Raiden Hyperbloom, Nahida
|
||||||
|
Deepwood) are source-bound, game-versioned, IK-6.7.0-key validated, have
|
||||||
|
explicit expiry, and are covered by Golden plus Review/low-confidence or
|
||||||
|
pending-context adversarial fixtures.
|
||||||
|
- [ ] Every further character/build profile is sourced, game-versioned,
|
||||||
|
reviewed, expiry-bound, and covered by Golden/adversarial fixtures before
|
||||||
|
product use.
|
||||||
|
- [x] Five-piece evaluation verifies unique slots, Set shape, sourced aggregate
|
||||||
|
targets, nested extraction gates, and cross-character/build conflicts together;
|
||||||
|
missing non-Artifact context remains `pending` rather than guessed.
|
||||||
|
- [x] The Builds UI reads only the newest complete native run and exposes at
|
||||||
|
most three non-overlapping, read-only suggestions with profile, source,
|
||||||
|
Set/Main-Stat/Substat evidence, conflicts, uncertainty, and selection reason.
|
||||||
|
- [x] The ranker never uses `roll-efficiency-v1`, roll magnitude, or OCR
|
||||||
|
confidence as a rank weight; confidence stays a hard gate.
|
||||||
|
- [x] Required aggregate targets accept only explicit, user-confirmed,
|
||||||
|
session-only local context and otherwise defer the profile.
|
||||||
|
- [x] The built-renderer Builds/UI acceptance verifies fresh English, German
|
||||||
|
switching/restoration, loading, deferred/context, suggestion, focus,
|
||||||
|
reduced-motion, overflow, and native plus Store local-delete confirmation probes.
|
||||||
|
- [ ] An elevated packaged-executable run separately verifies the same renderer
|
||||||
|
behavior when making a package-runtime claim; built-renderer evidence alone
|
||||||
|
does not prove UAC, bridge, or native runtime behavior.
|
||||||
|
|
||||||
|
## Upgrade Projection
|
||||||
|
|
||||||
|
- [ ] Projection appears only below +20 with four known legal substats.
|
||||||
|
- [ ] Worst is less than or equal to Middle; Middle is less than or equal to Best.
|
||||||
|
- [ ] Remaining roll count matches the current Artifact level.
|
||||||
|
- [ ] Review, illegal, incomplete, three-substat, 4-star, and rarity-ambiguous
|
||||||
|
data disables projection.
|
||||||
|
- [ ] +20 is complete and does not invent future rolls.
|
||||||
|
- [ ] UI states that target substat and tier are random and not guaranteed.
|
||||||
|
- [ ] Projection does not claim character/build usefulness.
|
||||||
|
|
||||||
|
## Native Offline Replay
|
||||||
|
|
||||||
|
- [ ] Replay reads an existing `scan-results.json` and never starts capture/input.
|
||||||
|
- [ ] At least three repeats produce the same full derived-payload hash.
|
||||||
|
- [ ] Every clean parsed Artifact is evaluated or the command fails.
|
||||||
|
- [ ] Explicit 1–4★ entries replay as stable `excluded` results and are not
|
||||||
|
counted as clean-but-unevaluated failures.
|
||||||
|
- [ ] Review remains Review and is not counted as an evaluation failure.
|
||||||
|
- [ ] Report includes value/projection counts and score range.
|
||||||
|
- [ ] Accepted bounded 20/50/100 runs and the 2,211 full-inventory corpus are
|
||||||
|
replayed before changing the evaluation contract.
|
||||||
|
- [ ] `npm run scan:native:validate:saved` passes for the latest complete
|
||||||
|
evidence set, including `-- --targets=2211` for full-inventory-sensitive work.
|
||||||
|
- [ ] Saved-run validation confirms manifest/status/job/result counts, PNG
|
||||||
|
containment, zero processing errors, non-persistence, review provenance,
|
||||||
|
and projection ordering.
|
||||||
|
|
||||||
## Release Or Packaging
|
## Release Or Packaging
|
||||||
|
|
||||||
|
- [ ] `npm run acceptance:offline` passes before the live acceptance session.
|
||||||
- [ ] Production build succeeds.
|
- [ ] Production build succeeds.
|
||||||
- [ ] Electron preload bridge is copied to `dist-electron/preload.cjs`.
|
- [ ] Electron preload bridge is copied to `dist-electron/preload.cjs`.
|
||||||
- [ ] App starts outside browser preview.
|
- [ ] App starts outside browser preview.
|
||||||
- [ ] Smart Capture bridge is available.
|
- [ ] Smart Capture bridge is available.
|
||||||
- [ ] No generated debug artifacts are included accidentally.
|
- [ ] No generated debug artifacts are included accidentally.
|
||||||
|
- [ ] `npm audit --omit=dev` reports zero production vulnerabilities.
|
||||||
|
- [ ] `npm run package:offline-check` passes before packaged runtime testing.
|
||||||
|
- [ ] ASAR contains renderer, Electron main, preload, and package manifest.
|
||||||
|
- [ ] Packaged `dist/index.html` uses relative `./assets/...` references; the
|
||||||
|
verifier rejects root-absolute `/assets/...` references.
|
||||||
|
- [ ] `resources/input-helper/InputHelper.exe` exists in the unpacked app.
|
||||||
|
- [ ] `resources/ik-inventorylists` exists and its version matches source data.
|
||||||
|
- [ ] Packaged helper/IK resolution uses `process.resourcesPath` without an
|
||||||
|
implicit `process.cwd()` repository fallback.
|
||||||
|
- [ ] Packaged and source `APP_RUNTIME_SIGNATURE` values match, so evidence from
|
||||||
|
an older runtime is rejected as stale.
|
||||||
|
- [x] `npm run package:live:builds` passed against the built renderer: fresh
|
||||||
|
English, German selection, restored English, Builds loading/context/focus,
|
||||||
|
reduced motion, no document overflow, and native plus Store delete-confirmation UI probes.
|
||||||
|
- [ ] The same UI evidence is collected from an elevated packaged executable
|
||||||
|
before claiming current package-runtime/UAC behavior.
|
||||||
|
- [ ] Packaged runtime launch, UAC, bridge, helper, and crop preview are tested
|
||||||
|
separately; offline resource checks do not prove runtime behavior.
|
||||||
|
|
||||||
|
## Next Live Acceptance Session
|
||||||
|
|
||||||
|
Use [TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md)
|
||||||
|
in order. Start with packaged launch and existing Inventory data, then one
|
||||||
|
Smart Capture, native 5, and only then gated 20/50/100 runs.
|
||||||
|
|
||||||
## Git Merge And Gitea Push
|
## Git Merge And Gitea Push
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -27,8 +27,23 @@ This document defines project engineering standards.
|
|||||||
- Avoid long, overfilled cards on scanner pages.
|
- Avoid long, overfilled cards on scanner pages.
|
||||||
- Do not collapse OCR/extraction confidence and artifact value into one ambiguous UI state; uncertain reads should be visibly `Review`.
|
- Do not collapse OCR/extraction confidence and artifact value into one ambiguous UI state; uncertain reads should be visibly `Review`.
|
||||||
- Artifact value pills should be short, stable labels backed by deterministic data, with detailed reasons behind click-through detail.
|
- Artifact value pills should be short, stable labels backed by deterministic data, with detailed reasons behind click-through detail.
|
||||||
- The design direction is dark purple fintech glassmorphism with premium, focused controls.
|
- The design direction is a premium, cinematic Galaxy system with restrained
|
||||||
|
glass surfaces, semantic contrast, and focused controls; reuse shared tokens
|
||||||
|
instead of feature-local state colors.
|
||||||
|
- Use skeletons for initial content layout, inline spinners for bounded work,
|
||||||
|
and toasts only for explicit mutation outcomes. Durable progress, blockers,
|
||||||
|
and errors stay in the owning view.
|
||||||
|
- Respect `prefers-reduced-motion`, keep focus-visible styling intact, and never
|
||||||
|
rely on color or animation as the only status signal.
|
||||||
- Disable buttons when their required data does not exist.
|
- Disable buttons when their required data does not exist.
|
||||||
|
- Route user-facing renderer copy through `src/i18n`. English is the default
|
||||||
|
application locale; German is an explicit settings choice. Do not use an app
|
||||||
|
language switch to imply that another Genshin OCR/capture language is
|
||||||
|
supported.
|
||||||
|
- Keep stable `data-*` hooks for scripted renderer acceptance. Tests should not
|
||||||
|
depend on localized visible strings when a semantic hook is available.
|
||||||
|
- A destructive-looking local-data control must say what it removes and state
|
||||||
|
that it does not change Genshin. Require confirmation before local deletion.
|
||||||
|
|
||||||
## Scanner Rules
|
## Scanner Rules
|
||||||
|
|
||||||
@@ -36,6 +51,9 @@ This document defines project engineering standards.
|
|||||||
- Confidence and raw OCR details must remain inspectable.
|
- Confidence and raw OCR details must remain inspectable.
|
||||||
- Heuristics should fail safely into unknown fields or review notes.
|
- Heuristics should fail safely into unknown fields or review notes.
|
||||||
- Do not add irreversible game actions.
|
- Do not add irreversible game actions.
|
||||||
|
- Treat global aliases as unsafe when the OCR text is ambiguous across pieces.
|
||||||
|
Canonicalize a partial artifact name only when independent high-confidence
|
||||||
|
field evidence, such as a direct slot read, leaves one valid match.
|
||||||
- Keep auto-entry choreography separate from scan-loop execution. Entry code may navigate to a readable artifact detail state; loop code should process verified grid targets.
|
- Keep auto-entry choreography separate from scan-loop execution. Entry code may navigate to a readable artifact detail state; loop code should process verified grid targets.
|
||||||
|
|
||||||
## TypeScript Rules
|
## TypeScript Rules
|
||||||
@@ -49,6 +67,12 @@ This document defines project engineering standards.
|
|||||||
- Use unit tests for parser and scoring logic.
|
- Use unit tests for parser and scoring logic.
|
||||||
- Use build/type checks for Electron IPC contract changes.
|
- Use build/type checks for Electron IPC contract changes.
|
||||||
- Use manual Smart Capture smoke tests for crop and capture changes.
|
- Use manual Smart Capture smoke tests for crop and capture changes.
|
||||||
|
- For native timing claims, validate a complete `run-timing.json` and derive
|
||||||
|
end-to-end time from one request-to-reconciliation interval. Never add
|
||||||
|
overlapping capture and processing durations.
|
||||||
|
- Test local native-result removal for active-run blocking, tombstone filtering,
|
||||||
|
raw-evidence preservation, crop-path containment, and exact Store-record
|
||||||
|
scope.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
+103
-125
@@ -1,139 +1,117 @@
|
|||||||
# Current App Status
|
# Current App Status
|
||||||
|
|
||||||
Updated: 2026-07-09
|
Updated: 2026-07-11 — current-build live-smoke checkpoint for the
|
||||||
|
English-default renderer, local artifact removal, shared-clock timing evidence,
|
||||||
|
and visually reviewed OCR corpus corrections.
|
||||||
|
|
||||||
This document is the short current-state entry point. For deeper architecture
|
Historical live evidence remains documented in
|
||||||
details see [ARCHITECTURE.md](ARCHITECTURE.md). For the scanner and inventory
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
roadmap see [scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
The current checkpoint and its explicit remaining release gates are documented
|
||||||
For live scanner runbooks see [AUTOMATION_LIVE_SCAN.md](AUTOMATION_LIVE_SCAN.md).
|
in
|
||||||
|
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md).
|
||||||
|
The fresh current-build smoke is documented in
|
||||||
|
[NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||||
|
|
||||||
## Summary
|
## What It Can Do
|
||||||
|
|
||||||
The app is now an Artifact-first local scanner with a native high-speed capture
|
- Read-only Artifact scanning remains scoped to the accepted English 16:9
|
||||||
path validated at 20/50/100-item scale in the current live session. The current product scope is intentionally limited to
|
Genshin Artifact inventory with a visible detail card. The scanner guards the
|
||||||
artifacts. Weapons, materials, and character details may exist as vendored IK
|
target process/window, foreground state, stop controls, and input safety.
|
||||||
catalog data, but they are not active scanner features while their values are
|
- The native path captures card crops while bounded OCR/parse/evaluation workers
|
||||||
not scanned.
|
stream ordered safe results into the session rail. Review, roll efficiency,
|
||||||
|
crop evidence, parser confidence, and local promotion/review actions remain
|
||||||
|
separate concepts.
|
||||||
|
- Artifacts can be browsed, filtered, sorted, and inspected with local crop,
|
||||||
|
parser, IK, review, and value evidence. Explicit 1–4★ native results remain
|
||||||
|
excluded/manual-review entries; they do not become a salvage or Quick Select
|
||||||
|
workflow.
|
||||||
|
- The Builds surface reads only the newest complete native run and shows at
|
||||||
|
most three read-only, source-bound evidence suggestions. It is not an
|
||||||
|
optimizer, DPS score, ownership inference, or game-action surface.
|
||||||
|
- The application UI now defaults to English. German is selectable in Settings
|
||||||
|
and persists locally. The preference updates the renderer/document language
|
||||||
|
only; it does not change the English-Genshin Smart Capture contract.
|
||||||
|
- Artifact detail now has a confirmed local-delete action for a wrong scan,
|
||||||
|
screenshot, or Store row. Native deletion appends a tombstone, keeps raw
|
||||||
|
`scan-results.json` evidence intact, deletes only a PNG proven to be within
|
||||||
|
the selected run directory, and can remove only an explicitly chosen exact
|
||||||
|
linked Store record. It never focuses Genshin, invokes the input helper, or
|
||||||
|
performs an in-game action.
|
||||||
|
- Native runs now write `run-timing.json` using
|
||||||
|
`native-scanner-run-timing-v1`. It records first-observed request, scanner,
|
||||||
|
capture, processing, durable-result, and reconciliation milestones so the
|
||||||
|
end-to-end duration is measured from one shared clock.
|
||||||
|
- The former 136-item full-inventory Review set has been visually audited for
|
||||||
|
the current corpus work: 128 are explicit 1–4★ entries, six have the same
|
||||||
|
exact duplicated-decimal OCR defect, one requires slot-safe name resolution,
|
||||||
|
and one incomplete crop intentionally remains `Review`.
|
||||||
|
|
||||||
Electron/React should act as the visual control and status surface. The C#
|
## Verified Evidence
|
||||||
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
|
The historical scanner acceptance is still useful regression evidence, but it
|
||||||
|
does not certify the current change set by itself:
|
||||||
|
|
||||||
- Smart Capture can read the currently opened artifact detail view through
|
- Historical dev runs completed 5/20/50/100 with zero Review, errors, and Store
|
||||||
focused crops, OCR preprocessing, deterministic parsing, confidence, and
|
writes; the historical packaged paths include bounded 5/8/20 and the 2,211
|
||||||
notes.
|
full-owned-inventory soak.
|
||||||
- The visible-inventory auto-scan path is the current stable production
|
- The saved 50-artifact evidence set still validates at `50/50`, with zero
|
||||||
baseline: preflight, grid detection, read-only tile selection, detail
|
replay issues, zero Review, zero unknown, and no projection failures.
|
||||||
verification, OCR, parse, store/review, scroll, and summary.
|
- The current checkpoint has focused coverage for locale persistence/catalog
|
||||||
- The native IK-style artifact capture path is wired through the C# helper.
|
parity, artifact Store removal, native-result tombstones/crop containment,
|
||||||
It captures visible 16:9 Artifact inventory detail card crops and writes a
|
shared-clock timing validation, and the new confirmed OCR corpus cases.
|
||||||
self-contained run directory.
|
- The final current-build offline gate passed: `npm run acceptance:offline`
|
||||||
- Native runs write `manifest.json`, `capture-jobs.jsonl`, and `status.json`.
|
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||||
Post-capture processing can consume the job file and write
|
validation, package verification (27/27), both dependency audits, and the
|
||||||
`scan-results.json` plus `processing-report.json`.
|
diff check.
|
||||||
- Vendored Inventory Kamera `inventorylists` are copied under
|
- The built-renderer Builds/UI acceptance passed with stable semantic hooks:
|
||||||
`data/ik-inventorylists` and packaged as scanner reference data. Current
|
fresh English, German selection, restored English, Builds context/focus,
|
||||||
version is `6.7.0`.
|
reduced motion, overflow, and native plus Store local-delete confirmation
|
||||||
- Native artifact post-processing matches parsed artifacts against IK artifact
|
probes. This is deliberately not described as an elevated packaged-executable
|
||||||
set/piece/slot data and sends conflicts to review.
|
run.
|
||||||
- The scan page has a compact latest-results rail. Native parsed results are
|
- The elevated development current-build smoke `20260711-154827` passed with
|
||||||
labeled as `Geparst`, persisted results as stored, uncertain results as
|
50/50 target/capture/click/queue/parse/load reconciliation, 0 Review,
|
||||||
review, and value evaluation as `Wert offen`.
|
0 processing errors, 0 Store writes, `persist=false`, and a complete
|
||||||
- The Inventory view can browse native scan results, stored artifacts, and
|
`native-scanner-run-timing-v1` record. Request-to-reconciliation was
|
||||||
snapshot fallback rows. It shows native crop previews, field confidence,
|
`28,022 ms` (`~1.78 Artifacts/s`); that is a functional/safety result, not a
|
||||||
IK/GOOD metadata, dry-run promotion status, and a per-result
|
3/s performance claim.
|
||||||
`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
|
## What Still Needs Testing
|
||||||
|
|
||||||
- The active UI scope was narrowed to Artifact scanning only.
|
- If making a current package-runtime claim, separately launch the elevated
|
||||||
- Weapon, material, and character-detail IK catalog coverage was removed from
|
packaged executable and verify UAC, preload bridge, helper, and renderer
|
||||||
the primary Inventory feature surface so it cannot look like implemented scan
|
behavior. The passing built-renderer acceptance does not prove that boundary.
|
||||||
support.
|
- Repeat the passing native and Store local-delete confirmation probes during an
|
||||||
- Native result labels were made more honest:
|
elevated packaged-executable acceptance if a package-runtime release claim is
|
||||||
`Geparst` means extracted, `Stored` means persisted, `Review` means unsafe,
|
needed.
|
||||||
and `Wert offen` means artifact value evaluation has not run yet.
|
Unit coverage already protects the authoritative tombstone, active-run,
|
||||||
- The Inventory detail panel gained a `Naechster Schritt` card:
|
containment, and exact-Store boundaries.
|
||||||
ready for promotion, review first, already in store, blocked, or value later.
|
- Do not claim German Genshin OCR/capture support: only the application UI is
|
||||||
- New filters were added for native rows and promotable rows.
|
localized. Non-16:9 layouts, other Genshin UI languages, and other inventory
|
||||||
- Native 20/50/100-item dry runs were completed. A live OCR decimal-loss bug
|
categories remain outside accepted scanner scope.
|
||||||
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
|
## Known Limitations
|
||||||
|
|
||||||
- `npm run lint` passed.
|
- OCR can still fail on a new game layout or unreadable crop. The app must route
|
||||||
- `npm test` passed with `252` tests.
|
uncertainty to `Review`; it must not relax matching thresholds or invent a
|
||||||
- `npm run build` passed.
|
canonical name from ambiguous text.
|
||||||
- `git diff --check` passed; only existing CRLF warnings were reported for
|
- The current parser fixes only the observed exact duplicate decimal punctuation
|
||||||
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
|
and uses slot-based artifact-name resolution only when independent
|
||||||
- Native IK dry runs on 2026-07-09 completed at 20, 50, and 100 items with
|
high-confidence slot evidence gives one valid match. The incomplete audited
|
||||||
complete crop/job/result counts, `0` processing errors, and `persisted: false`.
|
crop stays `Review` by design.
|
||||||
The 100-item run captured `100/100` across 4 pages in `24,673 ms`, parsed
|
- Build-Fit remains evidence ranking, not character/build optimization. Team,
|
||||||
`100/100`, and safely routed 3 OCR value losses into Review. See
|
weapon, rotation, character ownership, and unsourced aggregate values are not
|
||||||
[NATIVE_SCANNER_VALIDATION_2026-07-09.md](NATIVE_SCANNER_VALIDATION_2026-07-09.md).
|
inferred from Artifacts.
|
||||||
- The older visible-inventory path has broader live evidence, including a
|
- The current live 50-artifact smoke measured `28,022 ms` request-to-result
|
||||||
100-artifact run with `100/100` verified and parsed, `98` stored,
|
reconciliation (`~1.78 Artifacts/s`). The functional gate passed, but the
|
||||||
`2` duplicates, `0` review samples, `0` misses, and `393 ms/artifact`.
|
separate 3/s performance target remains unproven.
|
||||||
|
|
||||||
## Known Limits
|
## Next Priorities
|
||||||
|
|
||||||
- Native IK-style capture has current-session scale evidence, but later-session
|
1. Keep the passing built-renderer acceptance distinct from the optional
|
||||||
repeatability and packaged-app evidence are still open.
|
elevated packaged-executable runtime/UAC check.
|
||||||
- The native path is limited to visible 16:9 Artifact inventory with a visible
|
2. Clean generated workspace outputs, commit the coherent checkpoint, and push
|
||||||
detail card.
|
the intended branch; the current-build 50-artifact live gate has passed.
|
||||||
- Native post-capture results do not persist by default. Clean selected results
|
3. Continue the next quality phase with visually confirmed corpus growth,
|
||||||
can now be promoted explicitly after a second UI confirmation.
|
fail-closed parser behavior, profile-source refreshes, and packaged UI
|
||||||
- Artifact value scoring and upgrade projection are not implemented for native
|
regression coverage. Do not broaden game-category scope or add in-game
|
||||||
results yet.
|
mutation.
|
||||||
- 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.
|
|
||||||
|
|||||||
+627
-9
@@ -19,6 +19,19 @@ This document contains Architecture Decision Records.
|
|||||||
| ADR-011 | Retire alternate OCR comparison paths after current scanner baseline | Superseded | 2026-07-09 |
|
| ADR-011 | Retire alternate OCR comparison paths after current scanner baseline | Superseded | 2026-07-09 |
|
||||||
| ADR-012 | Separate live scan results, artifact inventory, and value evaluation | Accepted | 2026-07-09 |
|
| ADR-012 | Separate live scan results, artifact inventory, and value evaluation | Accepted | 2026-07-09 |
|
||||||
| ADR-013 | Move high-speed scanning into the native helper and vendor IK inventorylists | Accepted | 2026-07-09 |
|
| ADR-013 | Move high-speed scanning into the native helper and vendor IK inventorylists | Accepted | 2026-07-09 |
|
||||||
|
| ADR-014 | Separate roll efficiency from build fit and project only legal roll bounds | Accepted | 2026-07-10 |
|
||||||
|
| ADR-015 | Adopt a scanner-first Galaxy UI and explicit feedback primitives | Accepted | 2026-07-10 |
|
||||||
|
| ADR-016 | Default to the owned inventory and keep scan feedback session-bound | Accepted | 2026-07-10 |
|
||||||
|
| ADR-017 | Stream native capture jobs into bounded evaluation workers | Accepted | 2026-07-10 |
|
||||||
|
| ADR-018 | Gate Build-Fit behind a versioned evidence contract before scoring | Accepted | 2026-07-10 |
|
||||||
|
| ADR-019 | Repair five-star +0 main values only from structural rarity evidence | Accepted | 2026-07-10 |
|
||||||
|
| ADR-020 | Target only exact supported Genshin process names | Accepted | 2026-07-10 |
|
||||||
|
| ADR-021 | Require explicit profile assumptions and source-bound aggregate context | Accepted | 2026-07-10 |
|
||||||
|
| ADR-022 | Rank only source-bound Build-Fit evidence with profile expiry | Accepted | 2026-07-11 |
|
||||||
|
| ADR-023 | Fail-closed visual rarity gate and read-only non-five-star inventory | Accepted | 2026-07-11 |
|
||||||
|
| ADR-024 | Default renderer copy to English while preserving the English scanner boundary | Accepted | 2026-07-11 |
|
||||||
|
| ADR-025 | Make artifact deletion an app-local tombstone workflow | Accepted | 2026-07-11 |
|
||||||
|
| ADR-026 | Persist first-observed native run timing on one shared clock | Accepted | 2026-07-11 |
|
||||||
|
|
||||||
## ADR-001: Build A Local Electron App First
|
## ADR-001: Build A Local Electron App First
|
||||||
|
|
||||||
@@ -377,9 +390,9 @@ Adopt a split scan/result/inventory model:
|
|||||||
- Add artifact detail evaluation before promoting broad recommendations.
|
- Add artifact detail evaluation before promoting broad recommendations.
|
||||||
- Add upgrade projection only as a detail-level feature, with worst/middle/best
|
- Add upgrade projection only as a detail-level feature, with worst/middle/best
|
||||||
projected value scores and clear uncertainty labeling.
|
projected value scores and clear uncertainty labeling.
|
||||||
- Keep the producer/consumer screenshot queue as a later implementation phase:
|
- Use one game-control producer for click/scroll/capture and bounded downstream
|
||||||
one game-control worker may click/scroll/capture, while OCR/parse/evaluation
|
OCR/parse/evaluation workers. Publish only fully evaluated entries into the
|
||||||
workers can process bounded queued jobs once the UI/data contracts are stable.
|
current-session result rail.
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
@@ -416,22 +429,627 @@ steps. Each native run writes `manifest.json`, `capture-jobs.jsonl`, and
|
|||||||
`status.json`; downstream workers must consume those files instead of asking the
|
`status.json`; downstream workers must consume those files instead of asking the
|
||||||
renderer to do per-artifact scanner work.
|
renderer to do per-artifact scanner work.
|
||||||
|
|
||||||
The first downstream worker is a post-capture processor that reads
|
The first downstream worker is a native result processor that tails
|
||||||
`capture-jobs.jsonl` and writes both `scan-results.json` and
|
`capture-jobs.jsonl` and writes both `scan-results.json` and
|
||||||
`processing-report.json`. `scan-results.json` is the durable result contract:
|
`processing-report.json`. `scan-results.json` is the durable result contract:
|
||||||
one entry per captured artifact card with extraction status, artifact identity
|
one entry per captured artifact card with extraction status, artifact identity
|
||||||
when parsed, review state, and deferred value status. `processing-report.json`
|
when parsed, review state, and versioned value evaluation. `processing-report.json`
|
||||||
remains the diagnostic OCR/parse report. The worker OCRs/parses after capture
|
remains the diagnostic OCR/parse report. The worker may OCR, parse, and evaluate
|
||||||
and does not persist by default; DB writes require explicit opt-in after native
|
while capture is still producing jobs, then drains the remaining queue after a
|
||||||
crop OCR is validated with live evidence.
|
done or stopped producer. It does not persist by default; DB writes require
|
||||||
|
explicit opt-in after native crop OCR is validated with live evidence.
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
- Capture throughput can be optimized without renderer roundtrips per artifact.
|
- Capture throughput can be optimized without renderer roundtrips per artifact.
|
||||||
- The scanner data source now matches IK's artifact names and set/piece keys.
|
- 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.
|
Other vendored IK lists are data only until those values are actually scanned.
|
||||||
- Evaluation can remain deferred without blocking capture speed.
|
- Evaluation remains downstream and cannot block capture speed.
|
||||||
- Live safety stays read-only: no memory reads, hooks, injection, game-file
|
- Live safety stays read-only: no memory reads, hooks, injection, game-file
|
||||||
modification, deleting, feeding, enhancing, or spending resources.
|
modification, deleting, feeding, enhancing, or spending resources.
|
||||||
- Non-16:9 layouts currently block in native preflight until layout support is
|
- Non-16:9 layouts currently block in native preflight until layout support is
|
||||||
expanded.
|
expanded.
|
||||||
|
|
||||||
|
## ADR-014: Separate Roll Efficiency From Build Fit And Project Only Legal Roll Bounds
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
A universal Artifact quality score is misleading without a character, role,
|
||||||
|
team, and build target. However, the visible substat values do contain one
|
||||||
|
objective signal: how efficient their legal roll tiers were. Extraction
|
||||||
|
confidence is a third concept and must not be collapsed into either one.
|
||||||
|
|
||||||
|
Under-leveled Artifacts also invite false precision. A projection cannot know
|
||||||
|
which substat receives the next roll or which legal tier is selected.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Implement `roll-efficiency-v1` as a context-free `0-100` score derived only
|
||||||
|
from rarity-specific legal roll values, legal total roll counts, and displayed
|
||||||
|
rounding. Persist reason codes and per-substat breakdown. Keep build fit
|
||||||
|
explicitly `deferred`; do not add set, main-stat preference, locked state, or
|
||||||
|
equipped state to roll efficiency.
|
||||||
|
|
||||||
|
Review, incomplete, or inconsistent results receive `review` or `unknown` and
|
||||||
|
no normal score. For under-leveled Artifacts, show a detail-only
|
||||||
|
Worst/Middle/Best bound from remaining +4 steps and legal roll tiers, only for
|
||||||
|
an unambiguously identified 5-star Artifact when all four current substats are
|
||||||
|
known. Label it as non-guaranteed; 4-star or rarity-ambiguous data stays
|
||||||
|
unavailable in V1.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Users can compare roll-tier luck without being told that every high-roll
|
||||||
|
Artifact fits every build.
|
||||||
|
- Build-aware recommendation scoring remains a separate future contract.
|
||||||
|
- Existing saved native results can be evaluated without rescanning.
|
||||||
|
- Offline replay can hash derived results and detect non-determinism.
|
||||||
|
- Projection is conservative and unavailable when the fourth substat or clean
|
||||||
|
extraction is missing.
|
||||||
|
|
||||||
|
## ADR-015: Adopt A Scanner-First Galaxy UI And Explicit Feedback Primitives
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. The original Builds lock is narrowed by ADR-022: the surface may now
|
||||||
|
show only source-bound, read-only evidence suggestions, never a general build
|
||||||
|
or optimizer claim.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The application exposed scanner setup, technical pipeline state, inventory,
|
||||||
|
review, prototype recommendations, overlay, and diagnostics with similar visual
|
||||||
|
weight. The default scan path was harder to understand than the underlying
|
||||||
|
workflow, while the Builds and triage surfaces could imply confidence that the
|
||||||
|
current Build-Fit contract and review data do not support.
|
||||||
|
|
||||||
|
The renderer also lacked one consistent contract for loading, mutation
|
||||||
|
feedback, focus, and motion. Local status text alone made successful promotion,
|
||||||
|
review, import, or export actions easy to miss.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Adopt a scanner-first information architecture and tokenized Galaxy visual
|
||||||
|
system:
|
||||||
|
|
||||||
|
- Group Scanner, Artifacts, Review, and the explicitly labeled Builds preview
|
||||||
|
as the main workspace. Keep Overlay and Diagnostics as secondary tools.
|
||||||
|
- Lead the Scanner page with app/Genshin readiness and one primary start/stop
|
||||||
|
action. Keep source selection, scan scope, and manual capture under advanced
|
||||||
|
options while preserving all preflight and safety gates.
|
||||||
|
- Treat Review as scan-data correction. Do not derive or display trash, feed,
|
||||||
|
deletion, or universal quality judgments from extraction uncertainty.
|
||||||
|
- Keep Builds clearly bounded until the versioned Build-Fit contract exists.
|
||||||
|
Under ADR-022 it may show only source-bound, read-only evidence suggestions;
|
||||||
|
`roll-efficiency-v1` remains visible in Artifact detail but is not relabeled
|
||||||
|
as build suitability.
|
||||||
|
- Centralize semantic colors, surfaces, focus, depth, spacing, and motion in
|
||||||
|
Galaxy design tokens. Use shared skeleton, spinner, and toast primitives for
|
||||||
|
content loading and explicit mutation outcomes.
|
||||||
|
- Preserve durable progress and error state in the owning view. Toasts are
|
||||||
|
transient confirmation only. Respect `prefers-reduced-motion` and keep state
|
||||||
|
understandable through text/icons without relying on animation or color.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- A first-time user can follow the scan-to-review journey without opening
|
||||||
|
diagnostics or understanding the native pipeline.
|
||||||
|
- Technical evidence remains available but no longer dominates the default
|
||||||
|
inventory and scan surfaces.
|
||||||
|
- Loading and mutation feedback becomes consistent across features without
|
||||||
|
changing the scanner, review, or persistence authority boundaries.
|
||||||
|
- New renderer views must reuse the shared tokens and feedback semantics and
|
||||||
|
pass keyboard, reduced-motion, overflow, and desktop visual acceptance.
|
||||||
|
|
||||||
|
## ADR-016: Default To The Owned Inventory And Keep Scan Feedback Session-Bound
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. The real Settings-UI Artifact-limit path is live accepted at 5/5, the
|
||||||
|
packaged row-limit path at one detected eight-column row is accepted at 8/8,
|
||||||
|
and the packaged full-owned-inventory baseline is accepted at 2,211/2,211 over
|
||||||
|
70 pages with current-package reprocessing at 136 Review (6.15%).
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
A small numeric default makes the main scan action behave like a sample even
|
||||||
|
when the user's intent is to import the whole owned Artifact collection. At the
|
||||||
|
same time, using the inventory capacity as the target would be incorrect: it is
|
||||||
|
not the number of owned Artifacts, and the native scanner does not yet detect
|
||||||
|
the physical end of the list reliably enough to stop an oversized target
|
||||||
|
without risking repeated bottom rows.
|
||||||
|
|
||||||
|
The Scanner also showed progress in a separate card while the larger left work
|
||||||
|
surface displayed onboarding or a capture. That split made the most important
|
||||||
|
running state harder to follow. Loading previous stored/native results into the
|
||||||
|
right rail at startup further blurred the distinction between this run and the
|
||||||
|
long-lived Artifact collection.
|
||||||
|
|
||||||
|
Reopening the inventory with `B` could theoretically replace many upward wheel
|
||||||
|
events, but it also introduces menu-toggle state, tab-selection coordinates,
|
||||||
|
and another guarded key/click sequence. It has not been live benchmarked as a
|
||||||
|
more reliable reset.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Default the user-facing scan scope to the entire currently owned Artifact
|
||||||
|
inventory.
|
||||||
|
- Read and validate the current owned count at scan start. Use that `current`
|
||||||
|
value as the hard target basis; never substitute the inventory capacity
|
||||||
|
`total`.
|
||||||
|
- If the current owned count is unavailable or invalid, block a full-inventory
|
||||||
|
scan with a reviewable reason instead of guessing.
|
||||||
|
- Offer one optional limit with either Artifacts or rows. Convert rows through
|
||||||
|
the detected grid column count and clamp both forms to the owned count and the
|
||||||
|
2,400-Artifact safety bound.
|
||||||
|
- Keep scope selection session-local so a new app launch returns to the full
|
||||||
|
owned inventory default.
|
||||||
|
- During a run, use the left main work surface for capture phase and
|
||||||
|
captured-versus-target progress. Keep the independently advancing OCR/parse/
|
||||||
|
evaluation progress beside the live results on the right. Remove the
|
||||||
|
duplicate standalone progress card.
|
||||||
|
- Keep the right result rail current-session only and empty on app launch.
|
||||||
|
Persisted history remains available in the Artifacts view.
|
||||||
|
- Retain the bounded, foreground-guarded wheel-to-top reset as the production
|
||||||
|
path. Treat `B` plus Artifact-tab reopening only as a future live A/B
|
||||||
|
experiment.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The primary action matches the common collection-import intent without
|
||||||
|
hiding a partial numeric default.
|
||||||
|
- A row limit remains understandable while the helper still receives one
|
||||||
|
bounded Artifact count.
|
||||||
|
- Full-inventory start now depends on a trustworthy owned-count read and fails
|
||||||
|
safely when that evidence is missing.
|
||||||
|
- The Scanner separates ephemeral run feedback from the durable Artifact
|
||||||
|
collection and has one clear progress surface.
|
||||||
|
- The accepted 5/20/50/100 evidence validates the wheel reset and bounded native
|
||||||
|
engine. Real packaged Settings-UI runs additionally validate Artifact limit 5,
|
||||||
|
one detected row at 8/8, streaming overlap at 20/20, and the complete owned
|
||||||
|
target at 2,211/2,211 over 70 pages. The complete corpus passes the current
|
||||||
|
Review gate at 136/2,211 (6.15%), zero errors, and zero writes. A `B`-based
|
||||||
|
reset remains only an unaccepted experiment.
|
||||||
|
|
||||||
|
## ADR-017: Stream Native Capture Jobs Into Bounded Evaluation Workers
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. Packaged 20-result and 2,211-result runs prove that evaluated result
|
||||||
|
cards become visible while native capture is still running; deterministic
|
||||||
|
service/UI tests cover ordering, deltas, reconciliation, and reduced motion.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Native capture already appends and flushes one line per crop to
|
||||||
|
`capture-jobs.jsonl`, but Electron previously opened that queue only after the
|
||||||
|
helper had captured the complete target. Users therefore saw no results during
|
||||||
|
long runs and then waited for a second, fully sequential phase. The right-side
|
||||||
|
result rail also could not explain evaluation independently from capture.
|
||||||
|
|
||||||
|
Starting the whole processor repeatedly on every poll would duplicate OCR,
|
||||||
|
race durable files, and make ordering non-deterministic. Treating a temporary
|
||||||
|
end-of-file as completion would lose jobs that the still-running producer adds
|
||||||
|
later.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Start one single-flight processor as soon as the active native run first
|
||||||
|
exposes a non-empty `runDir`.
|
||||||
|
- Tail complete JSONL records while the native producer is running. A temporary
|
||||||
|
empty queue means `waiting`, not completion; a partial final line is retried.
|
||||||
|
- Deduplicate jobs by sequence, run at most four OCR/parse/evaluation workers,
|
||||||
|
and publish completed results in canonical capture-sequence order.
|
||||||
|
- A result reaches the renderer only after extraction review gating and
|
||||||
|
`roll-efficiency-v1` evaluation have produced its final safe status.
|
||||||
|
- Keep capture and evaluation counters independent. The left workspace reports
|
||||||
|
capture progress; the right result surface reports evaluated/target plus
|
||||||
|
parsed, review, and error counts.
|
||||||
|
- Load live results as monotonic sequence deltas and reconcile against the full
|
||||||
|
durable result set before declaring a successful session complete.
|
||||||
|
- Do not finish processing until capture is terminal and every observed job is
|
||||||
|
drained. A user stop ends game input immediately but keeps already captured
|
||||||
|
jobs available for evaluation; the final session outcome remains `stopped`.
|
||||||
|
- Persist intermediate ordered `scan-results.json` snapshots so a renderer
|
||||||
|
refresh or process boundary does not make completed work disappear.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Long scans provide useful, reviewable results before capture ends.
|
||||||
|
- Capture speed is isolated from OCR latency while bounded concurrency prevents
|
||||||
|
unbounded CPU/memory growth.
|
||||||
|
- Single-flight processing, sequence deltas, and final reconciliation prevent
|
||||||
|
duplicate cards, stale-run leakage, and false-complete summaries.
|
||||||
|
- The current-session rail can keep all results in top-to-bottom capture order;
|
||||||
|
newly mounted cards use motion only as an insertion cue and respect reduced
|
||||||
|
motion.
|
||||||
|
- Live acceptance must measure actual phase overlap, not infer streaming from a
|
||||||
|
correct final result count.
|
||||||
|
- A streaming end-to-end claim must use one shared wall clock from native start
|
||||||
|
through final result reconciliation. Capture and processing durations are
|
||||||
|
retained as separate diagnostics and must never be added when they overlap.
|
||||||
|
|
||||||
|
## ADR-018: Gate Build-Fit Behind A Versioned Evidence Contract Before Scoring
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. The contract, validator, native-result adapter, deterministic five-
|
||||||
|
piece evidence verifier, four sourced profiles, safety tests, legacy production-
|
||||||
|
path separation, and the separate read-only ranker are implemented. ADR-022
|
||||||
|
defines the visible ranking/expiry policy; the V1 assessments remain score-free.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The repository already contained an early preset scorer that could emit
|
||||||
|
character recommendations and builds from local Artifact records. Its weights,
|
||||||
|
thresholds, inferred characters, and partial builds were useful as a prototype,
|
||||||
|
but they had no versioned provenance or validated Build-Fit semantics. Hiding
|
||||||
|
those suggestions in the Builds UI was not enough while real account snapshots
|
||||||
|
still calculated them internally.
|
||||||
|
|
||||||
|
Roll-Efficiency, OCR confidence, and character/build suitability are different
|
||||||
|
concepts. A high legal-roll score does not prove that a Set, Main Stat, or
|
||||||
|
Substat mix fits a target, while low or Review extraction data must never enter
|
||||||
|
downstream fit logic.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Introduce `build-fit-contract-v1`, `build-fit-input-v1`, and
|
||||||
|
`build-fit-assessment-v1` as separate shared contracts.
|
||||||
|
- Require stable identity/revision, game-versioned provenance, explained legal
|
||||||
|
Set plans, variable-slot Main-Stat priorities, relative Substat weights,
|
||||||
|
optional full-build aggregate targets, and explicit conflict policies.
|
||||||
|
- Enforce V1 safety floors of 80% overall extraction confidence and 70% on all
|
||||||
|
required identity/value fields. Contracts may be stricter but cannot weaken
|
||||||
|
those floors.
|
||||||
|
- Require final parsed/non-Review data and a canonical IK Set key. Missing or
|
||||||
|
uncertain evidence produces reason-coded blockers.
|
||||||
|
- Map safe facts and conflicts into structured evidence. Verify complete
|
||||||
|
five-piece slot/Set shape and sourced aggregate context without ranking it,
|
||||||
|
and keep V1 output at `score: null` plus `recommendationStatus: deferred`
|
||||||
|
until a sourced profile corpus and ranking formula are validated.
|
||||||
|
- Record Roll-Efficiency as separate context and never fold it into Build-Fit
|
||||||
|
eligibility or imply that it is a fit score.
|
||||||
|
- Stop running real local account snapshots through the legacy scorer. Keep
|
||||||
|
that implementation limited to explicit demo/test fixtures.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Sourced profiles and ranking fixtures can evolve on the combination verifier
|
||||||
|
without changing the scanner/OCR authority boundary.
|
||||||
|
- Review, low-confidence, incomplete, or non-canonical data fails closed before
|
||||||
|
any character recommendation.
|
||||||
|
- The Builds page can show source-bound evidence candidates while remaining
|
||||||
|
explicit about scope, uncertainty, and missing non-Artifact context.
|
||||||
|
- No user-facing fit score exists yet; `eligible` means only safe input for a
|
||||||
|
future ranker.
|
||||||
|
- Complete-build aggregate targets stay `pending` unless an explicit non-
|
||||||
|
Artifact contribution references known contract sources; no character,
|
||||||
|
weapon, or team baseline is guessed.
|
||||||
|
- The detailed contract and next gates are documented in
|
||||||
|
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||||
|
|
||||||
|
## ADR-019: Repair Five-Star +0 Main Values Only From Structural Rarity Evidence
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. The rule is implemented in the OCR parser, covered by positive and
|
||||||
|
ambiguity-preserving tests, and validated against the complete 2,211-crop corpus.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The first full-inventory parser pass routed 490 results to Review. Of the 388
|
||||||
|
low-confidence Main-Value cases, 360 were level 0 and already had four parsed
|
||||||
|
Substats. A lower-rarity Artifact cannot begin at level 0 with four Substats, so
|
||||||
|
that combination is structural evidence for five-star rarity. Without that
|
||||||
|
evidence, a +0 Main Value can be valid at several rarities and must stay
|
||||||
|
reviewable.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Derive the canonical five-star +0 Main Value only when `level === 0` and
|
||||||
|
exactly four Substats were parsed.
|
||||||
|
- Mark the repaired Main Value with explicit parser confidence; do not silently
|
||||||
|
generalize the rule to three, two, or incomplete Substats.
|
||||||
|
- Keep all ambiguous rarity cases in Review and retain reason-coded analysis of
|
||||||
|
the saved corpus.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Current-package reprocessing reduced Review from 490 to 136 without
|
||||||
|
reclassifying any previously clean result into Review.
|
||||||
|
- 360 Main Values were repaired from structural evidence; ambiguous three-
|
||||||
|
Substat +0 Artifacts remain visible for review.
|
||||||
|
- Future rarity extraction can replace this inference, but it must preserve the
|
||||||
|
same fail-closed behavior when rarity evidence is uncertain.
|
||||||
|
|
||||||
|
## ADR-020: Target Only Exact Supported Genshin Process Names
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. Both the native helper and PowerShell fallback use the exact whitelist;
|
||||||
|
the final packaged no-Genshin safety smoke passed with no source and no errors.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
A broad process-name match containing `Genshin` allowed the assistant process
|
||||||
|
itself to look like a game target after the real client closed. Reusing a cached
|
||||||
|
HWND without validating its owning process could preserve the same unsafe state.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Recognize only `GenshinImpact` and `YuanShen` as supported game processes.
|
||||||
|
- Revalidate every cached HWND against the exact whitelist before returning it;
|
||||||
|
clear invalid cache state immediately.
|
||||||
|
- Apply the same behavior to the PowerShell fallback and keep the allow-list
|
||||||
|
covered by helper/static tests.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Closing Genshin produces `genshinFound=false`, HWND `0`, an empty target
|
||||||
|
process, and no capture source even while the assistant remains open.
|
||||||
|
- New official client process names require an explicit reviewed allow-list
|
||||||
|
change instead of being picked up accidentally.
|
||||||
|
|
||||||
|
## ADR-021: Require Explicit Profile Assumptions And Source-Bound Aggregate Context
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. The contract requires target assumptions and source-bound aggregate
|
||||||
|
context; the profile corpus, expiry gate, and Golden/adversarial context tests
|
||||||
|
are implemented.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Character recommendations vary with constellation, weapon, teammates, rotation,
|
||||||
|
and game version. A single ER threshold or Main-Stat order without those
|
||||||
|
assumptions would look authoritative while silently applying the wrong build
|
||||||
|
context. Artifact contributions alone also cannot prove a total-build stat such
|
||||||
|
as Energy Recharge because character and weapon values are outside the scan.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Require every V1 target to list non-empty, unique assumptions alongside its
|
||||||
|
role and description.
|
||||||
|
- Store curated candidates in a versioned corpus with game version, review time,
|
||||||
|
source version, and direct reference.
|
||||||
|
- Start narrowly with Furina off-field/Solo Hydro C0-C1 from the KQM Furina
|
||||||
|
Quick Guide labeled `Luna II`; do not generalize its 200%+ ER requirement to
|
||||||
|
other Hydro counts, C2+, no-Burst teams, weapons, or rotations.
|
||||||
|
- Treat Substat weights as documented ordinal source order only, never as damage
|
||||||
|
multipliers or a ranking formula.
|
||||||
|
- Evaluate a full-build aggregate target only when a complete non-Artifact value
|
||||||
|
references an allowed curated/user/imported source. Otherwise keep it
|
||||||
|
`pending`.
|
||||||
|
- Cross-check profile character and Set keys against vendored IK 6.7.0 data.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Profiles remain auditable without turning hidden team/weapon assumptions into
|
||||||
|
Artifact-derived facts.
|
||||||
|
- Team, weapon, Favonius, and rotation inputs cannot be smuggled in as hidden
|
||||||
|
constants; callers must provide them explicitly.
|
||||||
|
- Profile expansion now carries a clear maintenance cost: source review, version
|
||||||
|
update, catalog validation, Golden fixtures, and adversarial safety cases.
|
||||||
|
|
||||||
|
## ADR-022: Rank Only Source-Bound Build-Fit Evidence With Profile Expiry
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted. `build-fit-evidence-ranking-v1`, four profile fixtures, explicit
|
||||||
|
source expiry, session-only aggregate context, and the read-only Builds surface
|
||||||
|
are implemented and offline-tested. Packaged renderer acceptance remains open.
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The score-free V1 verifier can prove that five scanned Artifacts are structurally
|
||||||
|
safe and satisfy a profile's facts, but it intentionally cannot emit a damage
|
||||||
|
score. Leaving the Builds page locked forever would strand the validated
|
||||||
|
evidence, while turning Roll-Efficiency, raw OCR confidence, or arbitrary
|
||||||
|
substat weights into a fit score would overstate what the data proves.
|
||||||
|
|
||||||
|
Guide advice also changes with game versions. A version label and review date
|
||||||
|
alone do not prevent an old profile from silently continuing to rank an account.
|
||||||
|
Finally, native results are the only renderer-visible data source that preserves
|
||||||
|
both canonical IK Set IDs and per-field confidence; store records do not.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Keep `build-fit-assessment-v1` and
|
||||||
|
`build-fit-combination-assessment-v1` score-free (`score: null`).
|
||||||
|
- Add `build-fit-evidence-ranking-v1` as a separate, bounded, deterministic,
|
||||||
|
read-only ranker. It considers only complete eligible five-piece combinations
|
||||||
|
and returns at most three non-overlapping profile suggestions.
|
||||||
|
- Make the visible `evidenceCoverage` a product-rule coverage measure: sourced
|
||||||
|
Set-plan tier, variable Main-Stat priority, and ordinal Substat presence. It
|
||||||
|
is not a DPS, optimizer, Roll-Efficiency, or OCR-confidence score.
|
||||||
|
- Treat OCR confidence strictly as a hard gate. Do not use roll values, roll
|
||||||
|
efficiency, character ownership, team, weapon, rotation, or unrecorded
|
||||||
|
constants as ranking inputs.
|
||||||
|
- Require `provenance.validUntil` on every profile. Expired profiles fail closed
|
||||||
|
until a maintainer refreshes their direct source review.
|
||||||
|
- Permit required aggregate targets only from explicit, user-confirmed,
|
||||||
|
session-only non-Artifact context that references a declared local/user
|
||||||
|
source. Missing context keeps the profile deferred; it is never guessed or
|
||||||
|
persisted.
|
||||||
|
- Read Builds data only from the newest complete native result run. The Builds
|
||||||
|
surface does not scan, mutate review/store data, or send game input.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The app can expose useful, inspectable profile candidates without pretending
|
||||||
|
that it is a complete optimizer.
|
||||||
|
- Users see why a candidate ranked and why a profile is deferred, including
|
||||||
|
missing full-build context or conflicts.
|
||||||
|
- Profile maintenance has an explicit renewal deadline and source fixture cost.
|
||||||
|
- A renderer/package acceptance is still required before this UI is called
|
||||||
|
live-accepted; scanner live evidence is not reused as UI evidence.
|
||||||
|
|
||||||
|
## ADR-023: Visuelle Seltenheit fail-closed und Nicht-5★-Ansicht ohne Spielaktion
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Die bisherige Roll-Efficiency-Auswertung kennt rarity-spezifische Regeln, hatte
|
||||||
|
bei neuen nativen Kartencrops aber keine direkte, persistierte Evidenz für die
|
||||||
|
sichtbare Sternreihe. Eine aus Level, Main-Value oder Substats abgeleitete
|
||||||
|
Seltenheit ist für einzelne etablierte Legacy-Regeln nützlich, darf aber eine
|
||||||
|
fehlende oder widersprüchliche aktuelle Sternreihe nicht stillschweigend in ein
|
||||||
|
5★-Ergebnis verwandeln.
|
||||||
|
|
||||||
|
Gleichzeitig sind explizit erkannte 1–4★-Artefakte für die Sammlung und
|
||||||
|
manuelle Sichtung relevant. Daraus folgt jedoch keine sichere
|
||||||
|
Bergungsentscheidung: Sperr-/Ausgerüstet-Status, Crop, Detaildaten und die
|
||||||
|
Spieloberfläche müssen weiterhin durch den Nutzer geprüft werden. Automatische
|
||||||
|
Bergung oder Quick Select wäre eine irreversible Spielaktion und widerspricht
|
||||||
|
dem Scanner-Sicherheitsrahmen aus ADR-004, ADR-010 und ADR-013.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Aktuelle native Capture-Jobs schreiben die direkte visuelle Stern-Evidenz als
|
||||||
|
`starCount`, `starConfidence` und `starSource` in den dauerhaften
|
||||||
|
Ergebnispfad.
|
||||||
|
- Nur explizit bestätigte 5★-Evidenz darf in `roll-efficiency-v1` eingehen.
|
||||||
|
Explizit bestätigte 1–4★-Ergebnisse erhalten `valueStatus: "excluded"` und
|
||||||
|
keinen Wert, keine Projektion und keine Store-Promotion.
|
||||||
|
- Wenn visuelle Stern-Evidenz vorliegt, aber `starCount` fehlt, ungültig oder
|
||||||
|
widersprüchlich ist, wird nicht auf 5★ geraten. Das Ergebnis bleibt `Review`
|
||||||
|
und seine Evidenz bleibt sichtbar.
|
||||||
|
- Legacy-Ergebnisse ohne das neue visuelle Sternfeld werden durch diese
|
||||||
|
Entscheidung nicht rückwirkend als visueller Beweis ausgegeben; für sie
|
||||||
|
gelten bis zum Rescan die bestehenden strukturellen Regeln.
|
||||||
|
- Die Inventory-Ansicht bietet einen separaten schreibgeschützten Filter
|
||||||
|
`Nicht 5★` nur für explizit erkannte 1–4★-Ergebnisse. Er zeigt Crop/Bild und
|
||||||
|
Detaildaten zur manuellen Prüfung, nicht die Behauptung "sicher bergbar".
|
||||||
|
- Es gibt weder eine Scan-Checkbox noch einen Ablauf für automatische Bergung
|
||||||
|
oder Quick Select. Diese Aktion bleibt verboten und aufgeschoben; ein
|
||||||
|
zukünftiger Vorschlag bräuchte eine neue explizite Sicherheits- und
|
||||||
|
Produktentscheidung und dürfte den bestehenden Nicht-Mutationsrahmen nicht
|
||||||
|
umgehen.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Nicht-5★-Artefakte bleiben als vollständige, nachvollziehbare
|
||||||
|
Scan-Ergebnisse sichtbar, ohne die Wertung oder Empfehlungen zu verzerren.
|
||||||
|
- Die UI trennt klar zwischen `excluded` (direkt erkannte 1–4★) und `Review`
|
||||||
|
(unsichere Evidenz), statt beide als schwache 5★-Artefakte darzustellen.
|
||||||
|
- Replay-, Detail- und Promotion-Pfade müssen Stern-Evidenz, Ausschluss und
|
||||||
|
Review deterministisch erhalten.
|
||||||
|
- Der einzige zulässige nächste Schritt für einen Nutzer in der `Nicht 5★`-
|
||||||
|
Ansicht ist die manuelle Prüfung im Spiel; die App sendet dafür keine
|
||||||
|
Bergungs-, Auswahl- oder sonstige mutierende Eingabe.
|
||||||
|
|
||||||
|
## ADR-024: Default Renderer Copy To English While Preserving The English Scanner Boundary
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
The application previously exposed a largely German renderer while its only
|
||||||
|
validated OCR/crop profile is the English Genshin UI. Treating the two language
|
||||||
|
concerns as one setting would either make a German-speaking user unable to
|
||||||
|
choose their application language or incorrectly imply that German Genshin OCR
|
||||||
|
is supported.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Make English the default renderer locale for a fresh, missing, or invalid
|
||||||
|
local preference.
|
||||||
|
- Offer German as a user-selectable Settings preference and persist it locally
|
||||||
|
under one application-scoped key.
|
||||||
|
- Keep message catalogs structurally aligned and route renderer copy through the
|
||||||
|
locale provider rather than hard-coded feature strings.
|
||||||
|
- Keep the scanner contract explicit: changing the application language changes
|
||||||
|
application copy only; Smart Capture remains validated for English Genshin.
|
||||||
|
- Preserve stable semantic UI hooks for renderer acceptance so tests do not
|
||||||
|
depend on a particular translated label.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- New users receive an English interface by default while German remains
|
||||||
|
available without a restart.
|
||||||
|
- The document language and accessible renderer labels track the selected
|
||||||
|
locale.
|
||||||
|
- Future Genshin OCR-language support requires separate crop, parser, corpus,
|
||||||
|
and live-acceptance evidence; it cannot be inferred from this setting.
|
||||||
|
|
||||||
|
## ADR-025: Make Artifact Deletion An App-Local Tombstone Workflow
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
A wrong OCR read or wrong screenshot needs a way to disappear from the local
|
||||||
|
Artifact inventory. Directly mutating the game's inventory is prohibited, and
|
||||||
|
rewriting native `scan-results.json` would destroy the raw evidence needed for
|
||||||
|
replay, debugging, and corpus review. A delete control also needs to prevent an
|
||||||
|
active scan from racing a local cleanup.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Expose a confirmed local-delete control only in artifact detail.
|
||||||
|
- For native results, append an immutable entry to `deleted-results.jsonl` and
|
||||||
|
filter it at load time instead of rewriting `scan-results.json`.
|
||||||
|
- Delete a crop only after its resolved path is proved to be a PNG contained by
|
||||||
|
the selected native run directory.
|
||||||
|
- Block deletion while the target native run or its processor is active; make a
|
||||||
|
repeat deletion idempotent.
|
||||||
|
- Allow Store deletion only for one exact local record: either a Store-only row
|
||||||
|
or an explicitly selected record linked to the deleted native result.
|
||||||
|
- Keep the whole workflow behind main-process IPC. It must never focus Genshin,
|
||||||
|
call the input helper, or dispatch a game action.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Users can correct local inventory mistakes without jeopardizing the game or
|
||||||
|
losing raw scan evidence.
|
||||||
|
- Tombstones become part of the local run contract and loaders/replay tooling
|
||||||
|
must apply them deliberately.
|
||||||
|
- The UI must communicate that the action removes app-local data only, and
|
||||||
|
confirmation remains mandatory because local data and screenshots can be
|
||||||
|
removed.
|
||||||
|
|
||||||
|
## ADR-026: Persist First-Observed Native Run Timing On One Shared Clock
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Native capture and downstream OCR/parse work overlap. Earlier reports could
|
||||||
|
show phase rates but did not retain one durable, validated endpoint-to-endpoint
|
||||||
|
clock for the current run. Adding capture and processing durations would double
|
||||||
|
count their overlap and produce a misleading end-to-end claim.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- Write atomic `run-timing.json` records using
|
||||||
|
`native-scanner-run-timing-v1` in each native run directory.
|
||||||
|
- Record the first valid observation of request start, native scanner start,
|
||||||
|
capture start/end, processing start/end, durable result write, and final
|
||||||
|
result reconciliation. Later polling or retries cannot replace a marker.
|
||||||
|
- Derive request-to-durable and request-to-reconciled milliseconds directly from
|
||||||
|
their timestamps and validate marker ordering.
|
||||||
|
- Require a complete valid timing record and final count reconciliation for a
|
||||||
|
live streaming/end-to-end claim.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Capture, processing, and end-to-end rate can be reported as distinct metrics
|
||||||
|
without double counting overlap.
|
||||||
|
- A historical timing observation cannot stand in for current-build live
|
||||||
|
evidence.
|
||||||
|
- Timing files are durable diagnostic evidence and must be included in native
|
||||||
|
smoke validation, not merely displayed in the renderer.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Localization, Local Deletion, Timing, and OCR Corpus Checkpoint
|
||||||
|
|
||||||
|
Date: 2026-07-11
|
||||||
|
|
||||||
|
Status: implementation, offline-validation, and current-build live-smoke
|
||||||
|
checkpoint. The non-persisting 50-artifact development smoke passed on
|
||||||
|
2026-07-11; its durable evidence is in
|
||||||
|
[NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||||
|
An elevated packaged executable remains a separate package-runtime boundary and
|
||||||
|
is not claimed here.
|
||||||
|
|
||||||
|
## Delivered Scope
|
||||||
|
|
||||||
|
### English-default renderer with German Settings option
|
||||||
|
|
||||||
|
- `en` is the default for a fresh, missing, or invalid persisted locale.
|
||||||
|
- Settings exposes English and German; the selected app locale is persisted
|
||||||
|
locally and keeps the document language in sync.
|
||||||
|
- Visible app-shell, navigation, Scanner, Artifacts, Review, Builds, Overlay,
|
||||||
|
Diagnostics, Settings, and local-delete chrome use the renderer translation
|
||||||
|
catalog. Source/domain evidence remains deliberately distinct from translated
|
||||||
|
UI chrome.
|
||||||
|
- The setting changes application copy only. Smart Capture remains validated
|
||||||
|
for the English Genshin UI; it does not claim German Genshin OCR/crop support.
|
||||||
|
- The package UI runner uses stable `data-*` hooks instead of translated text
|
||||||
|
for core interaction checks.
|
||||||
|
|
||||||
|
### Local artifact removal
|
||||||
|
|
||||||
|
The delete control corrects app-local data only. It is not a Genshin action.
|
||||||
|
|
||||||
|
| Selected row | Local effect | Preserved / prohibited behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Native scan result | Appends a `deleted-results.jsonl` tombstone, hides the row, and removes its crop only if the resolved PNG stays inside the selected run directory. | Raw `scan-results.json` remains replay evidence. Active capture/processing blocks removal. No input helper or game action is called. |
|
||||||
|
| Exact Store record linked to native row | Removed only when the user explicitly includes that exact local record in the confirmed removal. | No broad Store deletion and no inferred record matching. |
|
||||||
|
| Store-only row | Removes that one explicit local Store record through main-process IPC. | No native scan evidence or Genshin state is touched. |
|
||||||
|
|
||||||
|
The workflow is idempotent for an already tombstoned native row. It retains the
|
||||||
|
native run's raw result array for audit/replay and limits file deletion to
|
||||||
|
contained PNG paths.
|
||||||
|
|
||||||
|
### Shared-clock native run timing
|
||||||
|
|
||||||
|
Each native run can now write atomic `run-timing.json` evidence with version
|
||||||
|
`native-scanner-run-timing-v1`.
|
||||||
|
|
||||||
|
- Markers are first-observed and immutable after a valid value is written.
|
||||||
|
- The record covers request start, native scanner start, capture start/end,
|
||||||
|
processing start/end, durable results, and final reconciliation.
|
||||||
|
- `requestToResultsDurableMs` and `requestToResultsReconciledMs` are derived
|
||||||
|
directly from their endpoints and validated against timestamp ordering.
|
||||||
|
- Capture and processing remain separate metrics. They may overlap and are
|
||||||
|
never added to create an end-to-end duration.
|
||||||
|
|
||||||
|
### Confirmed OCR corpus changes
|
||||||
|
|
||||||
|
The historical 136-result Review population was inspected against saved crops,
|
||||||
|
not treated as self-labeled training data:
|
||||||
|
|
||||||
|
| Audit result | Count | Current behavior |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Explicit visual 1–4★ | 128 | Remain excluded/manual-inspection entries, not value-scored parser labels. |
|
||||||
|
| Confirmed `HP+4..1%` OCR punctuation defect | 6 | Exact duplicated decimal punctuation is repaired to `HP+4.1%`; the regression cases are in the confirmed corpus. |
|
||||||
|
| Confirmed 5★ partial-name case with direct slot evidence | 1 | The unsafe global alias was removed. Canonicalization is now allowed only when independent high-confidence slot evidence makes one piece valid. |
|
||||||
|
| Incomplete/malformed 5★ crop | 1 | Remains `Review`; no heuristic converts it into a clean artifact. |
|
||||||
|
|
||||||
|
This preserves the fail-closed rule: ambiguous OCR becomes unknown or Review,
|
||||||
|
not a guessed canonical artifact.
|
||||||
|
|
||||||
|
## Offline Evidence Recorded For This Checkpoint
|
||||||
|
|
||||||
|
- Focused localization tests cover English fallback, persisted locale behavior,
|
||||||
|
and English/German catalog-key parity.
|
||||||
|
- Native deletion tests cover tombstone filtering, raw-evidence preservation,
|
||||||
|
active-run blocking, contained crop cleanup, idempotency, and exact Store
|
||||||
|
scope.
|
||||||
|
- Native timing tests cover first-observation behavior, ordering validation, and
|
||||||
|
derived durations.
|
||||||
|
- Parser/corpus evaluation covers the duplicate-decimal repair and slot-safe
|
||||||
|
name behavior. The latest saved 50-artifact evidence validation completed at
|
||||||
|
`50/50` with zero validation issues.
|
||||||
|
- The final current-build offline gate passed after all localization, deletion,
|
||||||
|
timing, corpus, and acceptance-runner changes: `npm run acceptance:offline`
|
||||||
|
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||||
|
validation, package verification (27/27), both dependency audits, and the
|
||||||
|
diff check.
|
||||||
|
- The built-renderer Builds/UI acceptance passed fresh English, German
|
||||||
|
selection, English restoration, Builds context/focus, reduced motion,
|
||||||
|
overflow, and native plus Store local-delete confirmation probes. It did not
|
||||||
|
launch an elevated packaged executable, so it is not
|
||||||
|
UAC/preload/helper/native-runtime evidence.
|
||||||
|
|
||||||
|
## Remaining Release Gates
|
||||||
|
|
||||||
|
1. If a package-runtime/UAC claim is needed, separately launch the elevated
|
||||||
|
packaged executable and verify its bridge/helper/runtime behavior. Do not
|
||||||
|
substitute the passed built-renderer UI acceptance for that evidence.
|
||||||
|
|
||||||
|
2. The required elevated development 50-artifact smoke now passes: 50/50
|
||||||
|
capture, click, queue, parse, durable-result reconciliation, zero Review,
|
||||||
|
zero processing errors, zero Store writes, and a complete valid
|
||||||
|
`run-timing.json`. It measured `28,022 ms` request-to-reconciliation, so it
|
||||||
|
does not claim the separate 3/s performance target.
|
||||||
|
|
||||||
|
3. Remove generated workspace outputs, commit the coherent checkpoint, and
|
||||||
|
push the intended branch.
|
||||||
|
|
||||||
|
## Follow-on Quality Phase
|
||||||
|
|
||||||
|
- Extend the corpus only from visually confirmed crops and preserve ambiguity as
|
||||||
|
`Review`.
|
||||||
|
- Refresh Build-Fit source profiles before their explicit expiry and add Golden
|
||||||
|
plus adversarial fixtures for each profile expansion.
|
||||||
|
- Keep the bounded wheel reset as the production baseline; test any alternate
|
||||||
|
inventory-entry/reset choreography as a separate guarded experiment.
|
||||||
|
- Keep Builds read-only and Artifact-only until a separately sourced product
|
||||||
|
decision introduces character, weapon, team, or rotation inputs.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Native Scanner Live Acceptance - 2026-07-10
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
Die Artifact-first Scanner-Kette ist fuer den validierten Zustand `1920x1080`,
|
||||||
|
16:9, englische Artifact-Inventaransicht mit sichtbarer Detailkarte live
|
||||||
|
abgenommen. Dev- und Packaged-Runtime nutzten denselben Runtime-Vertrag
|
||||||
|
`2026-07-10-packaged-runtime-acceptance`. Alle Scans blieben read-only und
|
||||||
|
liefen mit `persist=false`.
|
||||||
|
|
||||||
|
Die Abnahme umfasst inzwischen auch das komplette OCR-bestaetigte Inventar:
|
||||||
|
Run `20260710-223233` erfasste exakt 2.211 Artifacts ueber 70 Seiten. Der erste
|
||||||
|
Parserstand verfehlte den Review-Grenzwert ehrlich; nach einer strukturell
|
||||||
|
belegten +0-Korrektur reprozessierte das aktuelle Paket denselben vollstaendigen
|
||||||
|
Corpus mit 136 Review (6,15%), 0 Fehlern und 0 Store-Writes. Der gespeicherte
|
||||||
|
Run-Validator meldet 0 Issues.
|
||||||
|
|
||||||
|
Der Scanner-Core darf damit in die naechste Produktphase uebergehen. Der
|
||||||
|
strikte End-to-End-Zielwert von drei Artifacts pro Sekunde ist dagegen nicht
|
||||||
|
erreicht und wird nicht behauptet.
|
||||||
|
|
||||||
|
## Live-Matrix
|
||||||
|
|
||||||
|
| Runtime | Run | Ziel | Seiten | Reset | Capture | Capture/s | Processing | Processing/s | Review | Fehler | Store |
|
||||||
|
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Dev | `20260710-102344` | 5 | 1 | 1.701 ms | 1.123 ms | 4,45 | 1.806 ms | 2,77 | 0 | 0 | 0 |
|
||||||
|
| Dev | `20260710-102421` | 20 | 1 | 1.704 ms | 4.443 ms | 4,50 | 6.430 ms | 3,11 | 0 | 0 | 0 |
|
||||||
|
| Dev | `20260710-102450` | 50 | 2 | 1.744 ms | 11.328 ms | 4,41 | 15.579 ms | 3,21 | 0 | 0 | 0 |
|
||||||
|
| Dev | `20260710-102547` | 100 | 4 | 1.718 ms | 22.877 ms | 4,37 | 31.731 ms | 3,15 | 0 | 0 | 0 |
|
||||||
|
| Packaged | `20260710-103736` | 5 | 1 | 1.702 ms | 1.162 ms | 4,30 | 1.801 ms | 2,78 | 0 | 0 | 0 |
|
||||||
|
| Packaged streaming UI | `20260710-201614` | 20 | 1 | 1.701 ms | 4.486 ms | 4,46 | 8.032 ms | 2,49 | 0 | 0 | 0 |
|
||||||
|
| Packaged full inventory | `20260710-223233` | 2.211 | 70 | 1.739 ms | 522.005 ms | 4,24 | 722.164 ms | 3,06 | 490* | 0 | 0 |
|
||||||
|
|
||||||
|
`*` Der Vollinventar-Runner lehnte diesen ersten Endstand wegen 22,16% Review
|
||||||
|
korrekt ab. Die anschliessende Reprozessierung mit dem reparierten aktuellen
|
||||||
|
Parser verarbeitete 2.211/2.211 in 689.049 ms (3,21/s) und endete bei 136 Review
|
||||||
|
(6,15%), 0 Fehlern, 0 Writes und 2.211 Results. Capture/Streaming und aktuelle
|
||||||
|
Parserqualitaet sind damit als zwei explizite, gespeicherte Evidenzschritte
|
||||||
|
akzeptiert; ein nachtraeglich behaupteter Ein-Pass-Lauf wird nicht erfunden.
|
||||||
|
|
||||||
|
Die fruehere `1,77/s`-Aussage beschreibt ausschliesslich den historischen,
|
||||||
|
seriellen Dev-100-Smoke: `100 / (24.623 ms Reset/Capture + 31.731 ms
|
||||||
|
Batch-Processing)`. `native-live-smoke.ps1` wartete dort bewusst auf das
|
||||||
|
Capture-Ende und startete erst danach den Processor. Die Zahl ist rechnerisch
|
||||||
|
korrekt, aber keine aktuelle End-to-End-Messung der Streaming-Pipeline.
|
||||||
|
|
||||||
|
Der erneute erhoehte Streaming-Livetest am 2026-07-11 (`20260711-090019`)
|
||||||
|
lief dry mit 20/20 Capture, Parse und Resultaten, 0 Review, 0 Fehlern und 0
|
||||||
|
Writes. Das erste Resultat war sichtbar, bevor Capture endete. Die gemessenen
|
||||||
|
Teilwerte waren `4,43 Capture/s` (`4.517 ms` active) und `7.668 ms`
|
||||||
|
Processor-Laufzeit; die beobachtete Zeit vom Startrequest bis zur finalen
|
||||||
|
Result-Reconciliation betrug `8.370 ms` (`2,39/s`). Diese kleine,
|
||||||
|
clientseitige Beobachtung ist kein allgemeiner Durchsatzclaim, weil der
|
||||||
|
gemeinsame Clock-Contract noch nicht als dauerhafter Run-Wert gespeichert wird.
|
||||||
|
|
||||||
|
Bei ueberlappenden Phasen duerfen Capture- und Processing-Dauer nie addiert
|
||||||
|
werden. Kuenftige End-to-End-Claims muessen Start, Capture-Start/-Ende,
|
||||||
|
Processing-Start/-Ende und finale Result-Reconciliation auf einer gemeinsamen
|
||||||
|
Wall-Clock belegen; Capture- und Processing-Raten bleiben getrennte Werte. Das
|
||||||
|
3/s-Gesamtziel ist weiterhin nicht bewiesen.
|
||||||
|
|
||||||
|
## Qualitaets- und Dateivertraege
|
||||||
|
|
||||||
|
- Alle neuen 5/20/50/100-Runs hatten exakt so viele Jobs, PNGs, Klicks,
|
||||||
|
verarbeitete Zeilen und geladene Results wie angefordert.
|
||||||
|
- Jeder Job war an genau eine nichtleere `artifact-NNNN.png`, eine eindeutige
|
||||||
|
Sequenz, den erwarteten absoluten Pfad und genau zwei Click-Events gebunden.
|
||||||
|
- Der 100er verteilte sich auf `32/32/32/4` Captures ueber vier Seiten.
|
||||||
|
- Der Validator fand im 100er keine PNG-Hash-Duplikatgruppen, keine komplett
|
||||||
|
wiederholten Seiten und keine identischen Seiten-Grenzpaare.
|
||||||
|
- Der Vollinventar-Run enthielt exakt 2.211 Jobs, PNGs und Results. Der letzte
|
||||||
|
Job war Sequenz 2.211 auf Seite 70, Zeile 0, Spalte 2, passend zur partiellen
|
||||||
|
Schlussseite nach 69 vollen 32er-Seiten.
|
||||||
|
- Der gespeicherte Vollinventar-Validator fand zwar vier Gruppen identischer
|
||||||
|
Einzel-Crops, aber keine wiederholten Seitenpaare und keine identischen
|
||||||
|
Seiten-Grenzpaare; sein gesamter Run-Vertrag bestand mit 0 Issues. Identische
|
||||||
|
Einzelkarten werden daher nicht mit einem haengenden Scroll verwechselt.
|
||||||
|
- Der aktuelle Vollinventar-Replay ist ueber fuenf Wiederholungen deterministisch:
|
||||||
|
2.075 Results sind `evaluated`, 136 bleiben `review`, 0 sind `unknown`.
|
||||||
|
1.952 unterlevelte Results haben eine Projektion, 123 sind bereits komplett.
|
||||||
|
- Fuenf Offline-Replays des neuen 100ers waren hashidentisch. Alle 100 Results
|
||||||
|
waren `parsed` und `evaluated`; Scorebereich `76.1-92.5`, 0 Review, 0 unknown.
|
||||||
|
- Die neuen 100 Artifacts waren alle +20, daher korrekt 0 offene Projektionen.
|
||||||
|
Die Projektionslogik bleibt durch die vorhandene Offline-Evidenz fuer
|
||||||
|
unterlevelte Artifacts abgedeckt.
|
||||||
|
- Artifact-Store und `review-samples.jsonl` waren innerhalb jedes Dry-Runs vor
|
||||||
|
und nach dem Scan bytegleich. Die Review-Datei blieb bei
|
||||||
|
`83A222F49E7246054B16363C0CA7083168F7CE0AB595916313B44CB279BF9B8C`.
|
||||||
|
|
||||||
|
## Safety-Nachweise
|
||||||
|
|
||||||
|
- Probe-Klick: Genshin-Fenster, erkanntes 4x8-Grid, sichtbare Detailkarte und
|
||||||
|
geaenderter Detail-Fingerprint wurden vor dem breiten Lauf bewiesen.
|
||||||
|
- Fokusverlust-Run `20260710-101820`: `blocked`, 0 Klicks, 0 Captures; der
|
||||||
|
Helper refokussierte nicht automatisch.
|
||||||
|
- Stop-Run `20260710-102735`: Stop waehrend des Initial-Resets nach `87 ms`,
|
||||||
|
Status `stopped`, 0 Klicks, 0 Captures.
|
||||||
|
- Foreground-HWND wird vor jedem Reset-/Scroll-Event, Klick und Crop-Capture
|
||||||
|
geprueft. ESC, Enter, F9 und expliziter Stop bleiben auch im Reset aktiv.
|
||||||
|
- Der vollstaendige Top-Reset bleibt auf 3.200 Events begrenzt, pausiert aber
|
||||||
|
nur noch nach jeweils 39 Events. Live sank er dadurch von `50.698 ms` auf
|
||||||
|
stabil rund `1.7 s`, ohne Guard oder Reichweite zu lockern.
|
||||||
|
- Native Helper und PowerShell-Fallback erkennen nur die exakten Prozessnamen
|
||||||
|
`GenshinImpact` oder `YuanShen` und validieren gecachte HWNDs erneut. Nachdem
|
||||||
|
das Spiel waehrend der Reprozessierung extern geschlossen war, konnte die App
|
||||||
|
sich dadurch nicht mehr selbst als Genshin-Ziel erfassen. Der finale
|
||||||
|
No-Genshin-Smoke meldete `genshinFound=false`, HWND 0, leeren Zielprozess,
|
||||||
|
keine Capture-Quelle und 0 Renderer-/Konsolenfehler.
|
||||||
|
|
||||||
|
## Packaged-Runtime
|
||||||
|
|
||||||
|
- Start aus `%TEMP%`, `isDev=false`, Runtime und Helper-Guard beide
|
||||||
|
`isElevated=true`.
|
||||||
|
- Renderer wurde aus `resources/app.asar` geladen; relative Vite-Assets waren
|
||||||
|
vorhanden.
|
||||||
|
- Helper lief aus `resources/input-helper/InputHelper.exe`.
|
||||||
|
- IK 6.7.0 wurde aus `resources/ik-inventorylists` geladen: 61 Sets,
|
||||||
|
289 Pieces.
|
||||||
|
- Die Preload-Bridge stellte 36 erwartete Methoden bereit.
|
||||||
|
- Vor dem neuen Scan wurde automatisch der neueste vollstaendige 100er geladen;
|
||||||
|
neuere gestoppte oder unprozessierte Runs wurden korrekt ignoriert.
|
||||||
|
- Der paketierte 5er lief ueber Renderer -> Preload -> IPC -> C# Helper und
|
||||||
|
bestand Capture, Processing, Result-Load und Non-Persistence.
|
||||||
|
- Finale Inventory-Ansicht: 5/5 geparst, 0 Review, 5 bewertet,
|
||||||
|
`0 speicherbar - 5 im Store - 0 Review - 0 blockiert`.
|
||||||
|
- Der aktuelle paketierte Renderer bestand zusaetzlich den echten
|
||||||
|
Settings-UI-Pfad `Eigenes Limit -> Reihen -> 1`: das erkannte 8-Spalten-Raster
|
||||||
|
ergab Ziel 8; Run `20260710-180851` endete mit 8/8 Captures, 8/8 Processing,
|
||||||
|
8/8 Parsing, 0 Review, 0 Fehlern und `persist=false`. Der Top-Reset brauchte
|
||||||
|
1.715 ms. Zwei vorherige Vordergrundverluste blockierten sicher mit 0 Klicks.
|
||||||
|
- Der echte Settings-UI-Streamingpfad mit Artifact-Limit 20 bewies die
|
||||||
|
Phasenueberlappung: waehrend der Helper noch `running` meldete, waren bereits
|
||||||
|
11 Ergebnisse komplett geparst, bewertet und in der Rail sichtbar. Neue
|
||||||
|
Karten verwendeten die konfigurierte Slide-in-Mountanimation. Final standen
|
||||||
|
20 eindeutige Sequenzen 1-20, 20 evaluierte Results, 0 Review, 0 Fehler und
|
||||||
|
0 Persistenzwrites. Der Dokument-Viewport blieb 1304x821 ohne Overflow; die
|
||||||
|
Ergebnis-Rail scrollte intern.
|
||||||
|
- Der Vollinventar-Settings-Pfad las als Ziel ausschliesslich den aktuellen
|
||||||
|
OCR-Besitzwert 2.211, nie die Kapazitaet. Capture erreichte 2.211/2.211 auf 70
|
||||||
|
Seiten, waehrend der laufenden Erfassung waren bereits bis zu 1.656 Result-
|
||||||
|
Rows und 1.659 Auswertungen sichtbar, alle 2.211 Zeilen standen am Ende in der
|
||||||
|
Rail, die Slide-in-Mountanimation wurde beobachtet und der 1304x821-Dokument-
|
||||||
|
Viewport blieb ohne Overflow.
|
||||||
|
- Das paketierte `reprocess-run`-Gate reprozessierte denselben Corpus mit dem
|
||||||
|
aktuellen Parser vollstaendig und non-persistent: 2.211 processed/parsed,
|
||||||
|
136 Review, 0 Fehler, 0 gespeichert, 2.211 Result-Eintraege.
|
||||||
|
|
||||||
|
Lokale Evidenz zum Abnahmezeitpunkt:
|
||||||
|
|
||||||
|
Die folgenden Pfade bezeichneten temporaere lokale Testartefakte. Sie wurden
|
||||||
|
bei der angeforderten Workspace-Bereinigung am 2026-07-11 entfernt; die oben
|
||||||
|
dokumentierten Run-IDs, Messwerte und Vertragspruefungen bleiben der dauerhafte
|
||||||
|
Nachweis.
|
||||||
|
|
||||||
|
- `outputs/offline-acceptance/offline-acceptance-report.json`
|
||||||
|
- `outputs/packaging/offline-package-report.json`
|
||||||
|
- `outputs/native-live-smoke/20260710-102341/`
|
||||||
|
- `outputs/native-live-smoke/20260710-102418/`
|
||||||
|
- `outputs/native-live-smoke/20260710-102447/`
|
||||||
|
- `outputs/native-live-smoke/20260710-102544/`
|
||||||
|
- `outputs/native-replay/saved-native-validation-report.json`
|
||||||
|
- `outputs/packaged-live/packaged-final-scan5.json`
|
||||||
|
- `outputs/packaged-live/packaged-final-inventory-fixed.json`
|
||||||
|
- `outputs/packaged-live/packaged-final-inventory-fixed.png`
|
||||||
|
- `outputs/packaged-live/final-artifact-probe.json`
|
||||||
|
- `outputs/packaged-live/final-ui-row1-pass.json`
|
||||||
|
- `outputs/packaged-live/final-ui-row1-pass.png`
|
||||||
|
- `outputs/packaged-live/streaming-ui-20.json`
|
||||||
|
- `outputs/packaged-live/streaming-ui-20.png`
|
||||||
|
- `outputs/packaged-live/streaming-ui-20-results-view.json`
|
||||||
|
- `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||||
|
- `outputs/full-inventory-phase/packaged-preflight-restored.json`
|
||||||
|
- `outputs/full-inventory-phase/packaged-ui-full-inventory.json`
|
||||||
|
- `outputs/full-inventory-phase/packaged-ui-full-inventory.png`
|
||||||
|
- `outputs/native-review-analysis/20260710-223233/native-review-analysis.json`
|
||||||
|
- `outputs/full-inventory-phase/packaged-reprocess-2211.json`
|
||||||
|
- `outputs/full-inventory-phase/packaged-reprocess-2211.png`
|
||||||
|
- `outputs/full-inventory-phase/packaged-no-genshin-safety.json`
|
||||||
|
- `outputs/full-inventory-phase/packaged-no-genshin-safety.png`
|
||||||
|
|
||||||
|
## Waehrend der Abnahme behobene Fehler
|
||||||
|
|
||||||
|
1. Eigene App-Titel mit `Genshin` konnten als Capture-Quelle erscheinen.
|
||||||
|
2. Dev-Cleanup konnte den Codex-CUA-Node allein wegen seines Working Directory
|
||||||
|
als alte App-Instanz klassifizieren.
|
||||||
|
3. Native Runs starteten nicht garantiert am Inventaranfang und prueften den
|
||||||
|
Foreground nicht waehrend jedes Input-Schritts.
|
||||||
|
4. Der sichere Initial-Reset dauerte wegen Windows-Timer-Rundung etwa 50 s.
|
||||||
|
5. Smoke-Gates akzeptierten zu schwache Summen-/Cleanup-Vertraege.
|
||||||
|
6. Run-Aufloesung konnte ohne explizites `runDir` unvollstaendige Runs waehlen.
|
||||||
|
7. Packaged Runtime-Pfade und absolute Vite-Assets konnten durch das Repo-CWD
|
||||||
|
kaschiert werden.
|
||||||
|
8. Helper-Guard verlor das echte Elevation-Signal.
|
||||||
|
9. Inventory-Metriken lagen wegen einer impliziten dritten Grid-Zeile ueber der
|
||||||
|
View; ein Store-Duplikat mit unsicherem optionalem Feld erschien irrefuehrend
|
||||||
|
als Promotion-Review.
|
||||||
|
10. Capture und Auswertung liefen seriell; die Result-Rail blieb bis zum Ende
|
||||||
|
leer. Der Processor tailt jetzt JSONL, ist pro Run single-flight, liefert
|
||||||
|
Sequenz-Deltas und reconciled vor dem Abschluss den Vollbestand.
|
||||||
|
11. Vier-Substat-+0-Fuenfsterne wurden wegen OCR-unsicherem Main Value
|
||||||
|
uebermaessig in Review geschickt. Nur die strukturell eindeutige Kombination
|
||||||
|
`level === 0` plus vier Substats erhaelt jetzt den kanonischen Fuenfsterne-
|
||||||
|
Startwert; Drei-Substat-Faelle bleiben absichtlich unsicher.
|
||||||
|
12. Eine breite Prozessnamensuche konnte die eigene App als Genshin erkennen.
|
||||||
|
Exakte Allowlist und gecachte-HWND-Revalidierung schliessen dieses Ziel aus.
|
||||||
|
13. Die Scan-Zusammenfassung addierte Capture- und Processing-Zeit trotz echter
|
||||||
|
Ueberlappung. Sie verwendet jetzt die groessere Wall-Clock-Zeit.
|
||||||
|
|
||||||
|
## Offene Grenzen und naechste Phase
|
||||||
|
|
||||||
|
- Aktiv bleibt nur Artifact-Scanning. Weapons, Materials und Character Details
|
||||||
|
sind weiterhin Katalogdaten, keine Scannerfeatures.
|
||||||
|
- Nicht-16:9, andere Sprachen und ungewoehnliche UI-Zustaende bleiben bewusst
|
||||||
|
blockiert, bis eigene Profile live validiert sind.
|
||||||
|
- Der Renderer-Chunk von rund 586 kB und das Default-Electron-Icon sind
|
||||||
|
nicht-blockierende technische/cosmetische Arbeit.
|
||||||
|
- Eine echte Review-Korrektur oder Promotion wurde nicht absichtlich in den
|
||||||
|
Store geschrieben. Die statischen und Main-Process-Gates bestehen; der
|
||||||
|
Mutation-Pfad bleibt ein separater, explizit bestaetigter QA-Fall.
|
||||||
|
- Die verbliebenen 136 Review-Faelle des kompletten Corpus sind der naechste
|
||||||
|
Scanner-Qualitaetsbestand: 128 tragen `substats_implausible`, 65
|
||||||
|
`substats_incomplete`, 28 eine niedrige Main-Value-Konfidenz und 4 einen
|
||||||
|
IK-Mismatch; Gruende koennen sich ueberlappen. Sie werden nicht automatisch
|
||||||
|
als sauber umetikettiert.
|
||||||
|
|
||||||
|
Die score-freie Grundlage von `build-fit-contract-v1` ist inzwischen
|
||||||
|
implementiert: gesourcte Eingaben, OCR-/Review-Gates, Set-/Mainstat-/Substat-
|
||||||
|
Regeln, Gesamtziele und Konflikte sind versioniert, waehrend echte lokale
|
||||||
|
Snapshots keine Legacy-Demo-Empfehlungen mehr erzeugen. Der deterministische
|
||||||
|
Fuenfer-Verifier prueft inzwischen Slot-/Set-Form, verschachtelte Gates,
|
||||||
|
gesourcte Gesamtwerte und Konflikte, bleibt aber score-frei. Ein erster enger
|
||||||
|
Furina-`Luna II`-Candidate ist samt IK-Key- und Kontext-Fixtures versioniert.
|
||||||
|
Vor sichtbaren charakterbezogenen Empfehlungen fehlen weiterhin breitere
|
||||||
|
Profile, eine validierte Rangfolge und weitere Golden Fixtures fuer sichere
|
||||||
|
sowie adversarielle Eingaben. Das ist jetzt die aktive Produktphase.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Native Scanner Current-Build Live Smoke - 2026-07-11
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
The required current-build, elevated development smoke passed against the
|
||||||
|
accepted English 16:9 Genshin Artifact inventory with a visible detail card.
|
||||||
|
The scanner remained read-only and ran with `persist=false`.
|
||||||
|
|
||||||
|
| Contract | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Run ID | `20260711-154827` |
|
||||||
|
| Requested / target / captured / clicked / queued | `50 / 50 / 50 / 50 / 50` |
|
||||||
|
| Processed / parsed / loaded | `50 / 50 / 50` |
|
||||||
|
| Pages | `2` |
|
||||||
|
| Review / processing errors | `0 / 0` |
|
||||||
|
| Store writes / persisted | `0 / false` |
|
||||||
|
| Jobs / non-empty PNGs | `50 / 50` |
|
||||||
|
| Visual preflight | Genshin found, 16:9, 4x8 grid, visible Artifact inventory |
|
||||||
|
| Read-only probe | Detail fingerprint changed before the run |
|
||||||
|
|
||||||
|
The run wrote a complete `native-scanner-run-timing-v1` record. Its
|
||||||
|
request-to-durable-results duration was `27,916 ms`; final reconciliation
|
||||||
|
completed at `28,022 ms` from the request start. That is approximately
|
||||||
|
`1.78 Artifacts/s` end-to-end for this exact smoke. Capture and processing are
|
||||||
|
reported separately; they are not added together.
|
||||||
|
|
||||||
|
## Scope And Non-Claims
|
||||||
|
|
||||||
|
- This is an elevated **development** runtime smoke, not an elevated packaged
|
||||||
|
executable acceptance.
|
||||||
|
- It proves the current 50-item functional/safety/reconciliation gate, not the
|
||||||
|
three-Artifacts-per-second product target.
|
||||||
|
- It did not write to the local Artifact Store and did not modify Genshin.
|
||||||
|
- The raw run remains local runtime evidence. The generated workspace summary
|
||||||
|
is intentionally disposable after this durable record is updated.
|
||||||
|
|
||||||
|
## Follow-Up
|
||||||
|
|
||||||
|
The required scanner release gate is complete. The next approved actions are
|
||||||
|
workspace cleanup, a coherent repository commit, and push of the current
|
||||||
|
checkpoint. The remaining quality work is visually confirmed corpus growth,
|
||||||
|
source-profile refresh, and a separate elevated packaged-runtime check only if
|
||||||
|
that specific binary-runtime claim is needed.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Native Artifact Scanner Validation - 2026-07-09
|
# Native Artifact Scanner Validation - 2026-07-09
|
||||||
|
|
||||||
|
Historical first-session evidence. The later-session and packaged acceptance
|
||||||
|
that was still deferred here passed on 2026-07-10; see
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
This report records the first bounded 20/50/100-item validation of the native
|
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
|
Artifact capture and post-capture processing path. All runs used the visible
|
||||||
1920x1080 16:9 Artifact inventory, category `artifacts`, and `persist=0`.
|
1920x1080 16:9 Artifact inventory, category `artifacts`, and `persist=0`.
|
||||||
@@ -82,3 +86,21 @@ next implementation focus from capture expansion, promotion, and single-result
|
|||||||
review to deterministic Artifact value evaluation. The visible-inventory path
|
review to deterministic Artifact value evaluation. The visible-inventory path
|
||||||
remains the production baseline until packaged behavior and later-session
|
remains the production baseline until packaged behavior and later-session
|
||||||
native repeatability are also proven.
|
native repeatability are also proven.
|
||||||
|
|
||||||
|
## Offline Follow-Up - 2026-07-10
|
||||||
|
|
||||||
|
The saved 20/50/100 native runs were replayed through the new deterministic
|
||||||
|
roll-efficiency evaluator without opening Genshin or sending input:
|
||||||
|
|
||||||
|
| Saved run | Results | Evaluated | Review | Projection | Deterministic repeats |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| `20260709-223006` | 20 | 20 | 0 | 0 | 5/5 |
|
||||||
|
| `20260709-223041` | 50 | 49 | 1 historical | 0 | 5/5 |
|
||||||
|
| `20260709-223441` | 100 | 100 | 0 | 9 | 5/5 |
|
||||||
|
|
||||||
|
This proves deterministic offline processing of the saved evidence, not a new
|
||||||
|
live scanner run. The complete implementation and package evidence is recorded
|
||||||
|
in
|
||||||
|
[ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md).
|
||||||
|
The next live and packaged-app acceptance pass is intentionally deferred to
|
||||||
|
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||||
|
|||||||
+221
-72
@@ -8,13 +8,22 @@ For the next scan-result and artifact-inventory product phase, see
|
|||||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md).
|
||||||
For the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md).
|
For the 2026-07-09 scanner merge evidence, see [MERGE_READINESS.md](MERGE_READINESS.md).
|
||||||
For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md).
|
For Gitea push/authentication setup, see [GITEA_AUTH.md](GITEA_AUTH.md).
|
||||||
|
For the completed evaluation/offline phases and executed live acceptance, see
|
||||||
|
[ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md](ARTIFACT_EVALUATION_OFFLINE_VALIDATION_2026-07-10.md)
|
||||||
|
and [NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md),
|
||||||
|
plus the current-build [NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md](NATIVE_SCANNER_LIVE_SMOKE_2026-07-11.md).
|
||||||
|
For the score-free Build-Fit V1 data and safety contract, see
|
||||||
|
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||||
|
For the current English-default UI, local-result removal, timing, and reviewed
|
||||||
|
OCR-corpus checkpoint, see
|
||||||
|
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md).
|
||||||
|
|
||||||
## Project Identity
|
## Project Identity
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Project name | Genshin Artifact Assistant |
|
| Project name | Genshin Artifact Assistant |
|
||||||
| 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 |
|
| Status | Artifact-first native scanner with historical live-accepted dev 5/20/50/100, packaged bounded/UI paths, and a complete packaged 2,211-Artifact inventory soak; scanner-first Galaxy UI, explicit review/promotion gates, deterministic roll-efficiency, an English-default/Deutsch-selectable app UI, local-only artifact removal, durable timing evidence, and an offline-tested source-bound Build-Fit evidence-ranking layer. The current-build elevated-dev 50-artifact smoke now passes its functional/safety gate; the 3/s performance target remains separate and unproven. |
|
||||||
| Platform | Windows desktop |
|
| Platform | Windows desktop |
|
||||||
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
|
| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup |
|
||||||
| Runtime | Electron app with React UI and TypeScript |
|
| Runtime | Electron app with React UI and TypeScript |
|
||||||
@@ -32,8 +41,14 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
- Parse artifact name, slot, main stat, substats, set, equipped state, and confidence deterministically against that package.
|
- Parse artifact name, slot, main stat, substats, set, equipped state, and confidence deterministically against that package.
|
||||||
- Save weak or failed reads automatically as review samples and turn corrections into reusable local fixes.
|
- Save weak or failed reads automatically as review samples and turn corrections into reusable local fixes.
|
||||||
- Present finished scan results as a compact artifact list instead of a debug-heavy live stats surface.
|
- Present finished scan results as a compact artifact list instead of a debug-heavy live stats surface.
|
||||||
|
- Make scan readiness, the primary scan action, progress, and safe next steps understandable without opening technical views.
|
||||||
|
- Make the primary scan cover the recognized owned Artifact inventory by
|
||||||
|
default, while allowing an explicit limit by Artifact count or grid rows.
|
||||||
- Keep extraction confidence separate from artifact value so uncertain OCR becomes review, not a misleading low score.
|
- Keep extraction confidence separate from artifact value so uncertain OCR becomes review, not a misleading low score.
|
||||||
- Provide a browsable local artifact inventory with detail views before promoting broader recommendations.
|
- Provide a browsable local artifact inventory with detail views before promoting broader recommendations.
|
||||||
|
- Die sichtbare Sternreihe einer nativen Artefaktkarte direkt erfassen: Nur
|
||||||
|
explizit bestätigte 5★-Artefakte erhalten eine Roll-Efficiency-Bewertung;
|
||||||
|
bestätigte 1–4★-Artefakte bleiben als manuell prüfbare Ergebnisse erhalten.
|
||||||
- Keep the app offline-first and usable without optimizer imports, Enka, HoYoLAB,
|
- Keep the app offline-first and usable without optimizer imports, Enka, HoYoLAB,
|
||||||
or any external scanner as a core dependency.
|
or any external scanner as a core dependency.
|
||||||
- Re-introduce recommendations only after the scanner base is trustworthy.
|
- Re-introduce recommendations only after the scanner base is trustworthy.
|
||||||
@@ -41,7 +56,9 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
- No memory reads, process hooks, game modification, packet inspection, or anti-cheat bypassing.
|
- No memory reads, process hooks, game modification, packet inspection, or anti-cheat bypassing.
|
||||||
- No automatic deleting, feeding, enhancing, or spending resources.
|
- Keine automatische Bergung, Quick-Select-Auswahl, Löschung, Fütterung,
|
||||||
|
Verbesserung, Sperr-/Entsperraktionen oder Ressourcenverbrauch — auch nicht
|
||||||
|
für erkannte 1–4★-Artefakte.
|
||||||
- No advanced formula editor or full power-user optimizer in the MVP.
|
- No advanced formula editor or full power-user optimizer in the MVP.
|
||||||
- No cloud sync by default.
|
- No cloud sync by default.
|
||||||
|
|
||||||
@@ -56,19 +73,24 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Implemented for visible-inventory baseline |
|
| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Implemented for visible-inventory baseline |
|
||||||
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Implemented baseline |
|
| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Implemented baseline |
|
||||||
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Implemented baseline |
|
| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Implemented baseline |
|
||||||
| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | Implemented baseline |
|
| FR-008 | Keep the scan UI operator-friendly: readiness and primary action first, preview plus result rail during scanning, debug in collapsed or secondary surfaces. | Must | Implemented scanner-first baseline |
|
||||||
| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending |
|
| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending |
|
||||||
| FR-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending |
|
| FR-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending |
|
||||||
| FR-011 | Farming overlay for reward scans. | Later | Prototype shell |
|
| FR-011 | Farming overlay for reward scans. | Later | Prototype shell |
|
||||||
| FR-012 | Show active scan results as a minimal right-side rail with artifact number, name or compact fallback, value score, and status pill. | Should | Partial foundation |
|
| FR-012 | Show active scan results as a minimal right-side rail with artifact number, name or compact fallback, value score, and status pill. | Should | Implemented baseline |
|
||||||
| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | Should | Partial foundation |
|
| FR-013 | Provide a scanned artifact inventory view with compact score pills, filters, sorting, and click-through detail. | Should | Implemented baseline |
|
||||||
| FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Planned |
|
| FR-014 | Provide artifact detail evaluation with screenshot/crops, parsed fields, OCR confidence, value reasons, and optional upgrade projection. | Should | Implemented; packaged visual acceptance passed, under-level projection remains corpus-backed |
|
||||||
|
| FR-015 | Provide clear loading, success, review, blocked, and error feedback through progress, spinners, skeletons, and mutation toasts without hiding durable state. | Should | Implemented UI baseline |
|
||||||
|
| FR-016 | Die sichtbare Sternreihe je nativer Artefaktkarte erfassen; nur explizit bestätigte 5★-Artefakte bewerten, bestätigte 1–4★-Artefakte ohne Wertung mit Bild und Details in einer separaten schreibgeschützten `Nicht 5★`-Ansicht zeigen. | Must | Implementiert; aktueller 50er-Smoke bestanden |
|
||||||
|
| FR-017 | Provide an English-default application interface and let the user switch the renderer to German in Settings without changing the scanner contract. | Should | Implemented; built-renderer acceptance passed, elevated packaged-executable runtime remains separate |
|
||||||
|
| FR-018 | Let the user remove an incorrectly scanned artifact from local app data only, including its local crop when safe, without any Genshin mutation. | Should | Implemented, unit tested, and confirmation paths exercised; no data-removing test was run |
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
| Category | Requirement | Measurement |
|
| Category | Requirement | Measurement |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
|
| Safety | Never perform irreversible in-game actions. | Code review and manual test |
|
||||||
|
| Evaluation safety | Explizit visuelle 1–4★-Evidenz wird ausgeschlossen; fehlende, widersprüchliche oder unsichere Stern-Evidenz geht in `Review`. | Result-/Replay-Tests und Detailprüfung |
|
||||||
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
|
| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification |
|
||||||
| Performance | A 100-artifact visible-inventory run should finish cleanly with low review/miss rates and report timing evidence. | `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` quality-gated report |
|
| Performance | A 100-artifact visible-inventory run should finish cleanly with low review/miss rates and report timing evidence. | `npm run scan:goal:validated` or `npm run scan:goal:validated:wait` quality-gated report |
|
||||||
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
|
| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path |
|
||||||
@@ -76,18 +98,20 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
| Score integrity | Extraction confidence and artifact value are separate concepts. | Review state can block or qualify a value score |
|
| Score integrity | Extraction confidence and artifact value are separate concepts. | Review state can block or qualify a value score |
|
||||||
| Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` |
|
| Learning loop | Scanner mistakes should become reusable local review samples. | `review-samples.jsonl` |
|
||||||
| Maintainability | Scanner heuristics must be isolated and documented. | Parser tests, scan-loop tests, data generator, review sample pipeline |
|
| Maintainability | Scanner heuristics must be isolated and documented. | Parser tests, scan-loop tests, data generator, review sample pipeline |
|
||||||
|
| Usability | The default path should expose one primary next action and keep advanced controls secondary. | Scanner-first navigation and desktop visual acceptance |
|
||||||
|
| Accessibility | Focus, status, loading, and motion must remain understandable without relying on animation or color alone. | Keyboard review, ARIA status semantics, and `prefers-reduced-motion` |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Area | Choice | Notes |
|
| Area | Choice | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Desktop shell | Electron | Windows local app and overlay windows |
|
| Desktop shell | Electron 43 | Windows local app and overlay windows |
|
||||||
| Frontend | React + TypeScript + Vite | UI and client state |
|
| Frontend | React + TypeScript + Vite | UI and client state |
|
||||||
| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction |
|
| Styling | CSS design tokens with dark Galaxy glass surfaces | Cinematic depth with restrained violet, cyan, gold, and semantic state accents |
|
||||||
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source |
|
||||||
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability |
|
||||||
| Input automation | C# sidecar with elevated dev runner when needed | Live-validated for read-only inventory selection clicks; see `docs/AUTOMATION_LIVE_SCAN.md` |
|
| Input automation | C# sidecar with elevated dev runner when needed | Live-validated for read-only inventory selection clicks; see `docs/AUTOMATION_LIVE_SCAN.md` |
|
||||||
| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand |
|
| Tests | Vitest 4 + TypeScript checks | OCR eval, helper/cleanup safety, Build-Fit contract/combination/profile/ranking/context gates, localization/deletion/timing tests, packaged acceptance runners, and saved-run replay |
|
||||||
| Packaging | electron-builder | Configured in `package.json` |
|
| Packaging | electron-builder | Configured in `package.json` |
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
@@ -97,6 +121,10 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
| Local dev | `npm run dev` | Starts Vite and Electron |
|
| Local dev | `npm run dev` | Starts Vite and Electron |
|
||||||
| Local dev with automation | `npm run dev:admin` | Required when `GenshinImpact.exe` is elevated; Windows blocks lower-integrity cursor/click input |
|
| Local dev with automation | `npm run dev:admin` | Required when `GenshinImpact.exe` is elevated; Windows blocks lower-integrity cursor/click input |
|
||||||
| Production build | `npm run build` | Builds React and Electron main process |
|
| Production build | `npm run build` | Builds React and Electron main process |
|
||||||
|
| Complete non-live gate | `npm run acceptance:offline` | Runs tests, OCR/assessment gates, saved-run validation, fresh package build/verification, audits, and diff checks |
|
||||||
|
| Unpacked package | `npm run package:offline-check` | Builds helper/app and verifies ASAR/resources without launching Genshin |
|
||||||
|
| Saved-run replay | `npm run scan:native:replay` | Re-evaluates the newest existing native scan without capture or input |
|
||||||
|
| Saved-run evidence set | `npm run scan:native:validate:saved` | Validates requested accepted targets (bounded 20/50/100 or full 2,211), files, review provenance, replay, and projection boundaries without capture or input |
|
||||||
| Preview | `npm run preview` | Browser preview only; capture bridge is unavailable |
|
| Preview | `npm run preview` | Browser preview only; capture bridge is unavailable |
|
||||||
|
|
||||||
## Current State Review
|
## Current State Review
|
||||||
@@ -108,8 +136,8 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
- A local canonical data package already exists in `src/data/genshinGameData.json`, generated from `genshin-db`.
|
- A local canonical data package already exists in `src/data/genshinGameData.json`, generated from `genshin-db`.
|
||||||
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping.
|
||||||
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
- Review samples, learned replacements, parser notes, and stored artifacts already persist locally.
|
||||||
- The scan surface has an initial recent-results rail backed by the newest
|
- The scan surface has a current-session result rail that starts empty; older
|
||||||
stored artifacts.
|
persisted Artifacts remain available in the dedicated inventory view.
|
||||||
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions.
|
||||||
- The scanner now has a validated visible-inventory path: 32 safe artifact
|
- The scanner now has a validated visible-inventory path: 32 safe artifact
|
||||||
targets per page, lookup-derived fields, fast OCR crop profile, and a
|
targets per page, lookup-derived fields, fast OCR crop profile, and a
|
||||||
@@ -133,16 +161,56 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
- Native IK-style Artifact capture is wired through the C# helper. It writes
|
- 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
|
card crops and run artifacts for downstream OCR/parse processing instead of
|
||||||
making React do per-artifact work in the hot capture loop.
|
making React do per-artifact work in the hot capture loop.
|
||||||
- The native post-capture processor can write `scan-results.json` and
|
- The native result processor tails flushed capture jobs while the helper is
|
||||||
`processing-report.json`, preserve parser field confidence, match Artifact
|
still scanning, evaluates them with four bounded workers, publishes ordered
|
||||||
results against IK artifact set/piece/slot data, and keep store persistence
|
live deltas, and writes incremental `scan-results.json` plus
|
||||||
opt-in.
|
`processing-report.json`. Parser confidence and IK set/piece/slot conflicts
|
||||||
|
remain reviewable, and store persistence stays opt-in.
|
||||||
- The Inventory view now has an Artifact-only pipeline surface for scope,
|
- The Inventory view now has an Artifact-only pipeline surface for scope,
|
||||||
native capture, OCR queue, review gate, promotion, and evidence. Native rows
|
native capture, OCR queue, review gate, promotion, and evidence. Native rows
|
||||||
expose crop previews, IK/GOOD metadata, dry-run promotion state, and a
|
expose crop previews, IK/GOOD metadata, dry-run promotion state, and a
|
||||||
`Naechster Schritt` card.
|
`Naechster Schritt` card.
|
||||||
- The active UI intentionally hides weapon, material, and character-detail IK
|
- The active UI intentionally hides weapon, material, and character-detail IK
|
||||||
catalog coverage until those values are actually scanned.
|
catalog coverage until those values are actually scanned.
|
||||||
|
- Clean Artifact results now receive deterministic `roll-efficiency-v1`
|
||||||
|
evaluations. Inventory/detail show score reasons and legal roll breakdown;
|
||||||
|
build fit remains explicitly deferred.
|
||||||
|
- Under-leveled, unambiguously identified 5-star Artifacts with four known
|
||||||
|
substats receive a detail-only Worst/Middle/Best projection. 4-star,
|
||||||
|
rarity-ambiguous, incomplete, or review data receives none.
|
||||||
|
- Existing 20/50/100 native results passed five-repeat offline determinism
|
||||||
|
replay; the approved 100-result run evaluated 100/100 with zero unknown.
|
||||||
|
- An Electron 43 unpacked app passed 27/27 static package checks and live launch
|
||||||
|
from `%TEMP%`. ASAR renderer, preload, IPC, packaged C# helper, IK resources,
|
||||||
|
elevation telemetry, Inventory UI, and a packaged 5-result dry run passed.
|
||||||
|
- The renderer now uses a scanner-first information architecture: Scanner,
|
||||||
|
Artifacts, Review, and the clearly labeled Builds preview form the workspace;
|
||||||
|
Overlay and Diagnostics remain secondary tools.
|
||||||
|
- The Scanner surface leads with Genshin/app readiness, one primary start/stop
|
||||||
|
action, a three-step empty state, explicit scan phases, and one modal for
|
||||||
|
scan scope, capture source, and manual tools. Inventory loading uses
|
||||||
|
skeletons/spinners, and confirmed
|
||||||
|
promotion, review, import, and export mutations report through accessible
|
||||||
|
toasts.
|
||||||
|
- Shared Galaxy tokens, focus-visible treatment, micro-motion, and a
|
||||||
|
`prefers-reduced-motion` fallback now define the visual/feedback baseline.
|
||||||
|
- The renderer now defaults to English on a fresh, missing, or invalid locale
|
||||||
|
setting. Users can choose German from Settings; the preference is local to the
|
||||||
|
app and updates the document language. This changes application copy only:
|
||||||
|
Smart Capture remains validated for the English Genshin UI.
|
||||||
|
- Artifact detail now exposes a small, confirmed local-delete action. Native
|
||||||
|
scan results are tombstoned rather than rewritten, their crop is removed only
|
||||||
|
when it is contained in the selected run directory, and an exact linked Store
|
||||||
|
record is removable only through an explicit local confirmation. No delete
|
||||||
|
path calls the input helper or changes Genshin.
|
||||||
|
- Native runs now retain an atomic `run-timing.json` record from request start
|
||||||
|
through durable result reconciliation. It measures capture and processing
|
||||||
|
separately and derives end-to-end duration from one wall-clock interval
|
||||||
|
instead of adding overlapping phases.
|
||||||
|
- The 136 historical full-inventory Review samples were audited against their
|
||||||
|
crops. The confirmed corpus now distinguishes 128 explicit non-five-star
|
||||||
|
entries, six exact duplicated-decimal repairs, one slot-safe artifact-name
|
||||||
|
correction, and one incomplete entry that intentionally remains `Review`.
|
||||||
|
|
||||||
### What is still structurally weak
|
### What is still structurally weak
|
||||||
|
|
||||||
@@ -154,20 +222,28 @@ The app is not intended to replace deep min-max tools. It prioritizes time savin
|
|||||||
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way.
|
||||||
- The scan page now has a minimalist recent-results rail, and the Inventory view
|
- 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
|
can inspect native Artifact results, crop previews, IK match state, promotion
|
||||||
dry-runs, and pipeline risk/status. Value scoring remains incomplete;
|
dry-runs, pipeline risk/status, roll-efficiency reasons, and upgrade
|
||||||
single-result review/edit/approve is implemented and batch review is
|
projection. Single-result review/edit/approve is implemented and batch
|
||||||
intentionally unavailable.
|
review is intentionally unavailable.
|
||||||
- The new native IK-style path has same-session 20/50/100 live scale evidence
|
- The native IK-style path has fresh later-session 5/20/50/100 scale evidence,
|
||||||
with complete capture/result counts and safe Review gating. Later-session
|
deterministic replay, focus-loss and stop evidence, plus a packaged 5-result
|
||||||
repeatability and packaged behavior remain open.
|
runtime smoke. Non-16:9 and other UI-language profiles remain open.
|
||||||
- Store promotion from native `scan-results.json` now supports one selected,
|
- Store promotion from native `scan-results.json` now supports one selected,
|
||||||
confirmed result at a time with main-process revalidation and a durable log.
|
confirmed result at a time with main-process revalidation and a durable log.
|
||||||
Batch promotion intentionally remains unavailable.
|
Batch promotion intentionally remains unavailable.
|
||||||
- Native Review results can be corrected and approved or rejected one at a
|
- Native Review results can be corrected and approved or rejected one at a
|
||||||
time. Approval revalidates IK identity, canonical main values, and legal
|
time. Approval revalidates IK identity, canonical main values, and legal
|
||||||
substat rolls, then feeds the existing review-to-eval candidate pipeline.
|
substat rolls, then feeds the existing review-to-eval candidate pipeline.
|
||||||
- Recommendations and build logic exist, but artifact inventory, detail review,
|
- Recommendations and build logic exist, but build-aware fit must be designed
|
||||||
and value scoring should land first so recommendations have trustworthy inputs.
|
separately from the now-implemented context-free roll-efficiency score.
|
||||||
|
- The Builds navigation item now reads only the newest complete native run and
|
||||||
|
can surface up to three source-bound, read-only profile suggestions. It must
|
||||||
|
not imply character ownership, team, weapon, rotation, DPS, or optimizer
|
||||||
|
certainty; the built-renderer acceptance now passes, while an elevated
|
||||||
|
packaged-executable runtime remains a separate packaging check.
|
||||||
|
- The historical 136-item Review queue is not a source of labels by itself.
|
||||||
|
Its audit added only visually confirmed parser/corpus cases; the one
|
||||||
|
incomplete crop remains fail-closed in `Review`.
|
||||||
- Repeatability and 3 artifacts/second are still open; speed work should not
|
- Repeatability and 3 artifacts/second are still open; speed work should not
|
||||||
outrank result clarity, inventory UX, or corpus growth while the current path
|
outrank result clarity, inventory UX, or corpus growth while the current path
|
||||||
is stable.
|
is stable.
|
||||||
@@ -183,10 +259,30 @@ the helper captures Artifact card crops quickly, while OCR, parsing, review,
|
|||||||
promotion, and value evaluation run downstream.
|
promotion, and value evaluation run downstream.
|
||||||
|
|
||||||
The next product phase is not broad category expansion. Weapons, materials, and
|
The next product phase is not broad category expansion. Weapons, materials, and
|
||||||
character details stay out of active scope. The priority is later-session
|
character details stay out of active scope. Packaged/live acceptance passed on
|
||||||
native repeatability, value
|
2026-07-10 for the bounded 5/20/50/100 scanner and packaged 5er. A later real
|
||||||
evaluation, and detail explanations before recommendations become the core
|
Settings-UI run also accepted the revised Artifact-limit path at 5/5 through
|
||||||
product surface.
|
preflight, capture, processing, parsing, and the session result rail with no
|
||||||
|
review, processing error, or persistence. The packaged Settings UI then
|
||||||
|
accepted a one-row limit as an eight-Artifact target at 8/8 with the same safe
|
||||||
|
outcome. The complete Settings-UI full-owned-inventory soak then accepted the
|
||||||
|
OCR-owned target 2,211 with exact 2,211 captures/results across 70 pages. After
|
||||||
|
one evidence-driven parser correction, the current packaged reprocess completed
|
||||||
|
2,211/2,211 with 136 Review (6.15%), zero errors/writes, and zero saved-run
|
||||||
|
validation issues. The current Build-Fit checkpoint now has four sourced
|
||||||
|
profiles, profile-expiry gates, a deterministic non-Roll-Efficiency evidence
|
||||||
|
ranker, explicit session-only Furina context, and a read-only Builds surface.
|
||||||
|
The renderer now defaults to English with a German Settings option, and local
|
||||||
|
artifact removal is deliberately confined to app-local result/store records and
|
||||||
|
contained crops. The current scanner also writes durable shared-clock timing
|
||||||
|
evidence and has a visually reviewed corpus correction for the audited
|
||||||
|
136-entry Review set. These current-build changes are offline/unit validated,
|
||||||
|
and the built-renderer Builds/UI acceptance passed, including the local-delete
|
||||||
|
confirmation probes. That is not an elevated packaged-executable claim. The
|
||||||
|
current elevated development 50-artifact native smoke now passes with complete
|
||||||
|
timing evidence and zero writes; its `28,022 ms` end-to-end result does not
|
||||||
|
prove the separate 3/s target. The remaining Review corpus continues to grow
|
||||||
|
only from visually confirmed labels.
|
||||||
|
|
||||||
## Product Direction
|
## Product Direction
|
||||||
|
|
||||||
@@ -196,11 +292,23 @@ product surface.
|
|||||||
- External scanners are not runtime dependencies. The app owns its scan
|
- External scanners are not runtime dependencies. The app owns its scan
|
||||||
choreography, OCR, and quality gates.
|
choreography, OCR, and quality gates.
|
||||||
- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion.
|
- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion.
|
||||||
- The scan workspace should be an operator surface: preview, live result rail,
|
- The scan workspace should be an operator surface: the left area shows preview
|
||||||
Stop, status, and review access. Detailed stats and debug evidence belong in
|
when idle and owns capture-versus-target progress during a run; the right
|
||||||
diagnostics, summaries, or artifact detail.
|
rail contains current-session results plus independent evaluation progress.
|
||||||
|
Stop, status, and review access remain visible. Detailed debug evidence
|
||||||
|
belongs in diagnostics, summaries, or artifact detail.
|
||||||
|
- Primary navigation should follow the user journey: scan, inspect artifacts,
|
||||||
|
review uncertainty, then enter future build guidance. Overlay and diagnostics
|
||||||
|
remain tools instead of competing with that journey.
|
||||||
|
- Feedback should match the action: progress for long-running scans, skeletons
|
||||||
|
for content loading, inline state for durable blockers, and toasts for the
|
||||||
|
outcome of explicit mutations.
|
||||||
- Artifact value scoring must not hide OCR uncertainty. `Review` is a distinct
|
- Artifact value scoring must not hide OCR uncertainty. `Review` is a distinct
|
||||||
outcome, not just a weak artifact score.
|
outcome, not just a weak artifact score.
|
||||||
|
- Explizit bestätigte visuelle 1–4★-Artefakte sind keine
|
||||||
|
Bergungs-/Lösch-Empfehlung: Sie erscheinen nur in der schreibgeschützten
|
||||||
|
`Nicht 5★`-Ansicht mit Bild und Details zur manuellen Prüfung. Eine
|
||||||
|
fehlende oder widersprüchliche Sternreihe bleibt `Review`.
|
||||||
|
|
||||||
## Execution Plan
|
## Execution Plan
|
||||||
|
|
||||||
@@ -212,9 +320,15 @@ Outcome:
|
|||||||
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
|
- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time.
|
||||||
|
|
||||||
Status:
|
Status:
|
||||||
- Mostly done for the scanner baseline. The Diagnose/dev surface is separated
|
- Implemented as the scanner-first Galaxy baseline. Readiness and the primary
|
||||||
and scrollable, scan summaries are compact, and the normal Auto-Scan path is
|
scan action lead the page, the left surface owns capture progress, and the
|
||||||
guarded. Further UI polish remains useful but no longer blocks scanner merge.
|
larger right surface owns ordered live results plus compact independent
|
||||||
|
evaluation progress. The current-session result rail starts empty, advanced
|
||||||
|
controls open in a modal without shifting the workspace, and Diagnose remains
|
||||||
|
a separate scrollable tool. The
|
||||||
|
Artifact-limit path and consolidated progress/result layout passed a real
|
||||||
|
five-result Settings-UI run, the one-row path at 8/8, and the full-owned path
|
||||||
|
at 2,211/2,211 with current-package Review below the 15% gate.
|
||||||
|
|
||||||
### Phase 1 - Canonical game data package
|
### Phase 1 - Canonical game data package
|
||||||
|
|
||||||
@@ -318,7 +432,11 @@ Outcome:
|
|||||||
Status:
|
Status:
|
||||||
- Prepared in code for text replacements, field aliases, constrained fixes,
|
- Prepared in code for text replacements, field aliases, constrained fixes,
|
||||||
crop adjustment proposals, and UI-profile adjustment proposals. Crop/profile
|
crop adjustment proposals, and UI-profile adjustment proposals. Crop/profile
|
||||||
changes still require live review before being auto-applied.
|
changes still require live review before being auto-applied. The current
|
||||||
|
review-corpus checkpoint adds only visually confirmed cases: exact duplicate
|
||||||
|
decimal punctuation is repaired, and a partial artifact name is canonicalized
|
||||||
|
only when independent, high-confidence slot evidence leaves one safe match.
|
||||||
|
Ambiguous or incomplete crops remain `Review`.
|
||||||
|
|
||||||
### Phase 6 - Minimal scan result rail and artifact inventory
|
### Phase 6 - Minimal scan result rail and artifact inventory
|
||||||
|
|
||||||
@@ -334,31 +452,46 @@ Outcome:
|
|||||||
|
|
||||||
Status:
|
Status:
|
||||||
- Started with a scan result rail in the scan surface and an `Inventory` view.
|
- 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
|
The rail shows native `scan-results.json` entries from the current app scan
|
||||||
and falls back to recent stored artifacts. The inventory browser can filter,
|
session after post-processing and does not preload older stored/native rows
|
||||||
|
at startup. The inventory browser can filter,
|
||||||
sort, inspect native/store/snapshot rows, and preview native card crops from
|
sort, inspect native/store/snapshot rows, and preview native card crops from
|
||||||
the selected run directory. It also exposes the current IK inventorylist
|
the selected run directory. It also exposes the current IK inventorylist
|
||||||
version, active Artifact-only scope, and pipeline status for capture,
|
version, active Artifact-only scope, and pipeline status for capture,
|
||||||
post-processing, review, promotion, and evidence. Native Artifact rows carry
|
post-processing, review, promotion, and evidence. Native Artifact rows carry
|
||||||
IK/GOOD match status from the post-capture processor and a dry-run promotion
|
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,
|
decision that shows whether a native result is speicherbar, already stored,
|
||||||
review-only, or blocked without writing to the store. Detail review exists;
|
review-only, or blocked without writing to the store. Detail review,
|
||||||
deterministic value score and value reasons are still pending. See
|
deterministic roll-efficiency reasons, evaluated/open filters, score sorting,
|
||||||
|
and under-level projection are implemented. The browser prioritizes the
|
||||||
|
collection, filters, selected detail, and explicit next actions; technical
|
||||||
|
pipeline evidence remains available in collapsible detail. See
|
||||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
|
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md)
|
||||||
for the implementation phases and acceptance criteria.
|
for the implementation phases and acceptance criteria. The separate,
|
||||||
|
read-only `Nicht 5★` filter retains explicitly recognized 1–4★ native
|
||||||
|
results with their crop and details, excludes them from roll evaluation, and
|
||||||
|
exposes no in-game action. A confirmed local-delete action can remove a
|
||||||
|
wrong native result from the visible local inventory through an append-only
|
||||||
|
tombstone and safely contained crop cleanup; it can remove a Store-only row
|
||||||
|
or exact linked Store record only locally and never changes Genshin.
|
||||||
|
|
||||||
### Phase 7 - Artifact detail evaluation and upgrade projection
|
### Phase 7 - Artifact detail evaluation and upgrade projection
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
- Artifact detail explains parsed fields, OCR confidence, scoring reasons, and
|
- Artifact detail explains parsed fields, OCR confidence, scoring reasons, and
|
||||||
review needs.
|
review needs.
|
||||||
- Upgrade projection is available only when enough data is known and is labeled
|
- Upgrade projection is available only for unambiguously identified 5-star data
|
||||||
as probabilistic, with worst/middle/best projected value scores.
|
with four known legal substats and is labeled as probabilistic, with
|
||||||
|
worst/middle/best projected value scores.
|
||||||
- Low-confidence OCR disables or qualifies value conclusions instead of showing
|
- Low-confidence OCR disables or qualifies value conclusions instead of showing
|
||||||
false certainty.
|
false certainty.
|
||||||
|
|
||||||
Status:
|
Status:
|
||||||
- Planned after the scan result rail and inventory data contracts.
|
- Implemented as `roll-efficiency-v1` plus detail-only
|
||||||
|
`roll-efficiency-projection-v1`. Static/build/replay, fresh dev 5/20/50/100,
|
||||||
|
packaged runtime, and final Inventory visual acceptance pass. The new live
|
||||||
|
100 contained only +20 Artifacts, so under-level projections remain covered
|
||||||
|
by the confirmed saved-run corpus rather than a new live sample.
|
||||||
|
|
||||||
### Phase 8 - Recommendations come back on top of a trusted scanner
|
### Phase 8 - Recommendations come back on top of a trusted scanner
|
||||||
|
|
||||||
@@ -367,35 +500,51 @@ Outcome:
|
|||||||
- Recommendations explain uncertainty and surface conflicts instead of pretending perfect certainty.
|
- Recommendations explain uncertainty and surface conflicts instead of pretending perfect certainty.
|
||||||
|
|
||||||
Status:
|
Status:
|
||||||
- Next major product area after inventory, detail evaluation, repeatability, and
|
- `build-fit-contract-v1` continues to keep per-Artifact and five-piece
|
||||||
corpus work. Do not promote recommendation UX until stored artifact quality is
|
assessments score-free. On top of it, the offline-tested
|
||||||
backed by more confirmed review samples and repeat live scan runs.
|
`build-fit-evidence-ranking-v1` selects at most three non-overlapping safe
|
||||||
|
combinations from the newest complete native run. The sourced corpus now
|
||||||
|
covers Furina Solo-Hydro C0-C1, Neuvillette Charged Attack, Raiden off-field
|
||||||
|
Hyperbloom, and Nahida off-field Deepwood. Profiles expire explicitly;
|
||||||
|
Roll-Efficiency and OCR confidence are not ranking inputs. Furina's required
|
||||||
|
whole-build ER target accepts only explicit, session-only user context and
|
||||||
|
otherwise stays deferred. The Builds surface explains Set/Main-Stat/Substat
|
||||||
|
coverage, sources, warnings, and uncertainty without writing data or sending
|
||||||
|
game input. A built-renderer acceptance now covers fresh English locale,
|
||||||
|
German switching/restoration, Builds loading/context/focus, reduced motion,
|
||||||
|
overflow, and native plus Store local-delete confirmation probes. It is not evidence of an
|
||||||
|
elevated packaged-executable launch; that runtime boundary remains separate.
|
||||||
|
See
|
||||||
|
[BUILD_FIT_CONTRACT_V1.md](BUILD_FIT_CONTRACT_V1.md).
|
||||||
|
|
||||||
## Immediate Next Implementation Order
|
## Immediate Next Validation Order
|
||||||
|
|
||||||
1. Add deterministic Artifact value evaluation with explainable reasons.
|
1. The final current-build offline gate passed after the localization, deletion,
|
||||||
2. Keep the visible-inventory scanner path as the production baseline and avoid
|
timing, corpus, and acceptance-runner changes: `npm run acceptance:offline`
|
||||||
promoting `auto-entry`, `direct-inventory`, or `paimon-menu` until they pass
|
completed TypeScript, 72 test files / 444 tests, OCR evaluation, saved-run
|
||||||
their own low-limit live validations.
|
validation, package verification (27/27), both dependency audits, and the
|
||||||
3. Keep the active native scope artifact-only until weapons, materials, and
|
diff check.
|
||||||
character details are actually scanned.
|
2. Preserve the passing built-renderer acceptance as renderer evidence, but
|
||||||
4. Finish the scan result and inventory detail contracts from
|
separately verify an elevated packaged-executable launch/UAC bridge before a
|
||||||
[scanner-results-inventory-roadmap.md](scanner-results-inventory-roadmap.md),
|
binary release claim. It is not a substitute for scanner smoke evidence.
|
||||||
preserving separate extraction confidence and artifact value.
|
3. The current elevated development 50-artifact smoke passed with full
|
||||||
5. Finish value reasons in the existing Artifact inventory detail view before
|
reconciliation, zero Review/errors/writes, and
|
||||||
expanding broad build recommendations.
|
`requestToResultsReconciledMs=28,022`. This is a functional/safety gate;
|
||||||
6. Keep the existing scan preview/result rail and Inventory workflow compact;
|
it does not claim 3 Artifacts/s.
|
||||||
move new diagnostics behind the dedicated diagnostics surface.
|
4. Clean generated workspace output, commit the coherent checkpoint, and push
|
||||||
7. Continue growing the confirmed OCR corpus from review samples exported by
|
the intended branch. A package-runtime/UAC test is required only for that
|
||||||
`npm run eval:review-candidates` and prepared through
|
separate claim.
|
||||||
`npm run eval:prepare-confirmed`.
|
5. Continue review-corpus growth only from visually confirmed crops. Keep
|
||||||
8. Repeat live scanner runs in later sessions to prove repeatability across
|
explicit 1–4★ evidence excluded, ambiguous evidence in `Review`, and reject
|
||||||
pages, locked/unlocked artifacts, equipped footers, and duplicate handling.
|
aliases that cannot be justified by independent field evidence.
|
||||||
9. Continue the optional `3 artifacts/second` work only if the next change can
|
6. Refresh source-bound profiles before expiry and add Golden plus
|
||||||
reduce OCR/capture transport time without weakening quality gates.
|
Review/low-confidence adversarial fixtures for each additional profile.
|
||||||
10. Start recommendation/product UX work only after inventory/detail evaluation,
|
7. Keep the bounded wheel reset as the production baseline. Benchmark a `B`
|
||||||
repeat scan quality, and confirmed corpus coverage are strong enough to trust
|
plus Artifact-tab reopen only as a separate guarded experiment before any
|
||||||
stored artifacts.
|
reset strategy change.
|
||||||
|
8. Keep Build-Fit profile suggestions read-only and Artifact-only until an
|
||||||
|
explicit product decision adds owned-character, weapon, team, and rotation
|
||||||
|
inputs with their own sources.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -403,7 +552,7 @@ Status:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
|
| Is the current C# helper sufficient for production packaging, or does a later Rust/C++ sidecar still materially reduce latency or packaging risk? | Open |
|
||||||
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
| When should UI-profile learning be allowed to change crop geometry automatically versus requiring review approval? | Open |
|
||||||
| What scan-quality threshold is high enough before recommendations should be considered user-facing again? | Open |
|
| What scan-quality threshold is high enough before profile suggestions can be called live-accepted? | Scanner gate passed for accepted 16:9 English Artifact scope; Build-Fit unit/repository gate and built-renderer acceptance passed, while elevated packaged-executable runtime and current scanner smoke remain separate |
|
||||||
| What exact first-pass value formula should drive the `0-100` artifact score before build-aware recommendations exist? | Open |
|
| What exact first-pass value formula should drive the `0-100` artifact score before build-aware recommendations exist? | Resolved for V1: legal roll-tier efficiency only; build fit remains separate |
|
||||||
| Which upgrade projection model is honest enough for early UX: deterministic roll buckets, probability-weighted outcomes, or a deliberately simple best/middle/worst estimate? | Open |
|
| Which upgrade projection model is honest enough for early UX: deterministic roll buckets, probability-weighted outcomes, or a deliberately simple best/middle/worst estimate? | Resolved for V1: legal Worst/Middle/Best roll-efficiency bounds with explicit uncertainty |
|
||||||
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open |
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Tomorrow Live Test Plan - 2026-07-10
|
||||||
|
|
||||||
|
> Historical execution plan. Its bounded acceptance matrix was completed and
|
||||||
|
> later extended by the packaged 2,211-Artifact full-owned-inventory baseline.
|
||||||
|
> The current authoritative result is
|
||||||
|
> [NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
|
This is the ordered acceptance checklist for the first live session after the
|
||||||
|
five evaluation phases. It intentionally separates packaged-app, UI, scanner,
|
||||||
|
safety, quality, and performance evidence. Do not skip directly to a 100-item
|
||||||
|
run.
|
||||||
|
|
||||||
|
Executed on 2026-07-10. The result table below and
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md)
|
||||||
|
are authoritative; the unchecked boxes above remain the original reusable
|
||||||
|
operator checklist rather than a claim that the session is still pending.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- [ ] PC started fresh; no stale Electron, Vite, helper, or old dev-server process.
|
||||||
|
- [ ] Genshin uses borderless `1920x1080` 16:9.
|
||||||
|
- [ ] Artifact inventory is open and one Artifact detail card is visible.
|
||||||
|
- [ ] No enhancement, feeding, deletion, locking, or resource-spending UI is involved.
|
||||||
|
- [ ] Current source worktree and commit are recorded before testing.
|
||||||
|
- [ ] `outputs/dist/win-unpacked/Genshin Artifact Assistant.exe` exists.
|
||||||
|
- [ ] Existing `%APPDATA%/genshin-artifact-assistant/native-scans` data stays intact.
|
||||||
|
|
||||||
|
## 1. Static Recheck After Boot
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm run eval
|
||||||
|
npm run package:verify
|
||||||
|
npm audit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- [ ] `262/262` or more tests pass.
|
||||||
|
- [ ] OCR eval remains `26/26` exact and `77/77` fields.
|
||||||
|
- [ ] Package verifier remains `27/27`.
|
||||||
|
- [ ] Dependency audit remains at zero.
|
||||||
|
|
||||||
|
## 2. Packaged-App Launch
|
||||||
|
|
||||||
|
Launch the unpacked executable and approve its UAC prompt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "outputs\dist\win-unpacked\Genshin Artifact Assistant.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] App opens without a blank window, crash, or native menu bar.
|
||||||
|
- [ ] Scanner, Inventory, Diagnose, Triage, Builds, and Overlay navigation render.
|
||||||
|
- [ ] Runtime reports Electron `43.1.0` and the current app signature.
|
||||||
|
- [ ] Elevation/automation guard reports the packaged app as elevated.
|
||||||
|
- [ ] IK data reports version `6.7.0`, 61 sets, and 289 pieces.
|
||||||
|
- [ ] Packaged C# helper resolves from `resources/input-helper`.
|
||||||
|
- [ ] Browser-preview-only bridge warnings do not appear in the packaged app.
|
||||||
|
|
||||||
|
Stop immediately if helper resolution, preload, IK data, or elevation is broken.
|
||||||
|
|
||||||
|
## 3. Existing Scanned-Artifact UI
|
||||||
|
|
||||||
|
Open Inventory before starting any new scan.
|
||||||
|
|
||||||
|
- [ ] Existing native results load without changing Genshin.
|
||||||
|
- [ ] Latest approved 100-result run shows 100 parsed/evaluated and zero Review.
|
||||||
|
- [ ] Result rows show `xx% Roll`, extraction status, and source separately.
|
||||||
|
- [ ] `Bewertet` filter returns evaluated rows.
|
||||||
|
- [ ] `Wert offen` does not include normally evaluated clean rows.
|
||||||
|
- [ ] `Roll-Wert` sorting orders numeric scores descending and leaves unknown last.
|
||||||
|
- [ ] A +20 detail shows score reasons and per-substat roll/efficiency breakdown.
|
||||||
|
- [ ] Detail explicitly says build fit is still open.
|
||||||
|
- [ ] Locked/equipped state does not change the same Artifact's roll score.
|
||||||
|
- [ ] Native PNG crop preview loads only from the selected run directory.
|
||||||
|
|
||||||
|
Projection samples from run `20260709-223441`:
|
||||||
|
|
||||||
|
- [ ] Select an under-leveled Artifact such as sequence `#96` (+16) or `#100` (+4).
|
||||||
|
- [ ] Projection appears only in detail, not as a guaranteed result in the list.
|
||||||
|
- [ ] `Worst <= Middle <= Best`.
|
||||||
|
- [ ] Remaining roll count matches the current level.
|
||||||
|
- [ ] Disclaimer states random target/roll tier and no guarantee.
|
||||||
|
- [ ] +20 Artifacts show no future projection card.
|
||||||
|
|
||||||
|
## 4. Review And Promotion Safety
|
||||||
|
|
||||||
|
- [ ] A Review result has no normal numeric value score.
|
||||||
|
- [ ] Invalid manual substat totals are rejected even if each value is individually plausible.
|
||||||
|
- [ ] A 4-star-only roll value cannot be approved on a +20 Artifact.
|
||||||
|
- [ ] Reject keeps the result blocked.
|
||||||
|
- [ ] Approve requires IK identity, canonical main value, and legal total roll count.
|
||||||
|
- [ ] Promotion still requires explicit selection and second confirmation.
|
||||||
|
- [ ] Canceling promotion writes nothing.
|
||||||
|
- [ ] If one promotion is deliberately tested, store count changes exactly once and the durable log is written.
|
||||||
|
- [ ] No action changes anything inside the Genshin inventory.
|
||||||
|
|
||||||
|
## 5. Manual Smart Capture Smoke
|
||||||
|
|
||||||
|
This is the first live capture of the session.
|
||||||
|
|
||||||
|
- [ ] Genshin focus/preflight succeeds with the visible detail card.
|
||||||
|
- [ ] One Artifact is captured and parsed.
|
||||||
|
- [ ] Crop preview matches the selected Artifact.
|
||||||
|
- [ ] Name, set, slot, level, main stat/value, substats, equipped, and locked state are checked visually.
|
||||||
|
- [ ] Clean extraction receives a roll-efficiency score.
|
||||||
|
- [ ] Uncertain extraction goes to Review and receives no normal score.
|
||||||
|
- [ ] Stop/failsafe remains responsive.
|
||||||
|
|
||||||
|
## 6. Native Five-Artifact Smoke
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run scan:native:smoke:5
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Exactly 5 capture jobs, PNG crops, and durable results.
|
||||||
|
- [ ] Zero processing errors and zero automatic store writes.
|
||||||
|
- [ ] Result order and capture timestamps are preserved.
|
||||||
|
- [ ] Evaluation is present for every clean parsed result.
|
||||||
|
- [ ] Run the offline gate against the new run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run scan:native:replay -- --run-dir="<new-run-dir>" --repeats=5
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Replay is deterministic and has zero clean unevaluated rows.
|
||||||
|
|
||||||
|
## 7. Bounded 20/50/100 Progression
|
||||||
|
|
||||||
|
Only continue when the previous size passes.
|
||||||
|
|
||||||
|
For each run:
|
||||||
|
|
||||||
|
- [ ] Requested crop/job/result count is complete.
|
||||||
|
- [ ] Processing errors are zero.
|
||||||
|
- [ ] Store writes remain zero unless explicitly requested separately.
|
||||||
|
- [ ] Review reason is inspectable for every Review row.
|
||||||
|
- [ ] Miss rate is `<=2%` and Review rate is `<=15%`.
|
||||||
|
- [ ] Stop works during capture and processing.
|
||||||
|
- [ ] Offline replay with five repeats is deterministic.
|
||||||
|
- [ ] Evidence directory and timings are recorded.
|
||||||
|
|
||||||
|
Final 100-item checks:
|
||||||
|
|
||||||
|
- [ ] Pages and scrolling advance without duplicates or skipped visible tiles.
|
||||||
|
- [ ] Previously confirmed decimal-loss cases are repaired only when unique.
|
||||||
|
- [ ] Ambiguous OCR stays Review.
|
||||||
|
- [ ] Eligible under-leveled 5-star Artifacts receive projection; 4-star or
|
||||||
|
ambiguous-rarity data does not; +20 Artifacts are complete.
|
||||||
|
- [ ] Capture, processing, and end-to-end rates are reported separately.
|
||||||
|
- [ ] Do not claim 3 artifacts/second unless measured end to end with quality gates.
|
||||||
|
|
||||||
|
## 8. Packaged Runtime Regression
|
||||||
|
|
||||||
|
Repeat the key five-item smoke from the packaged executable, not only dev mode:
|
||||||
|
|
||||||
|
- [ ] Packaged preload bridge exposes all native scanner methods.
|
||||||
|
- [ ] Packaged helper can focus/click/capture under UAC.
|
||||||
|
- [ ] Packaged IK resource path resolves.
|
||||||
|
- [ ] Packaged crop preview bridge accepts valid PNG and blocks outside paths/non-PNG.
|
||||||
|
- [ ] Close/reopen preserves saved Inventory and review/promotion state.
|
||||||
|
|
||||||
|
## Abort Criteria
|
||||||
|
|
||||||
|
Stop the current run immediately when any of these occurs:
|
||||||
|
|
||||||
|
- Wrong Genshin category or no visible Artifact detail card.
|
||||||
|
- Blank/too-uniform capture passes preflight.
|
||||||
|
- Click lands outside the validated Artifact grid.
|
||||||
|
- Genshin loses focus and the worker continues clicking.
|
||||||
|
- Result count exceeds requested limit.
|
||||||
|
- Any automatic store write occurs during a dry run.
|
||||||
|
- Review data receives a confident normal score.
|
||||||
|
- Projection appears with missing fourth substat or claims certainty.
|
||||||
|
- Helper/preload/IK packaged resource is missing.
|
||||||
|
- App attempts delete, feed, enhance, lock/unlock, or resource spending.
|
||||||
|
|
||||||
|
## Result Record
|
||||||
|
|
||||||
|
Recorded after the session:
|
||||||
|
|
||||||
|
| Check | Result | Evidence path / note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Static suite | passed | Final `npm run acceptance:offline`: 296/296 tests, 27/27 package checks, both audits clean |
|
||||||
|
| Packaged launch | passed | Started elevated from `%TEMP%`; ASAR renderer, preload, helper and IK resources resolved |
|
||||||
|
| Existing Inventory UI | passed | `outputs/packaged-live/packaged-final-inventory-fixed.png`; layout and latest complete-run discovery accepted |
|
||||||
|
| Evaluation reasons | passed | New 20/50/100 five-repeat replay; 100/100 evaluated, 0 unknown |
|
||||||
|
| Upgrade projection | passed offline, no new live sample | Existing under-level corpus passes; new live 100 contained only +20 Artifacts |
|
||||||
|
| Review/promotion safety | passed non-mutating boundary | Main-process/tests pass; duplicates show `5 im Store, 0 Review`; no deliberate store mutation performed |
|
||||||
|
| Single Smart Capture | passed | Real source `Genshin Impact`, `captureTarget=genshin-client`, 4x8 detected grid |
|
||||||
|
| Native 5 | passed | Dev `20260710-102344`: 5/5, 0 Review/errors/writes |
|
||||||
|
| Native 20 | passed | `20260710-102421`: 20/20, capture 4.50/s, processing 3.11/s |
|
||||||
|
| Native 50 | passed | `20260710-102450`: 50/50, capture 4.41/s, processing 3.21/s |
|
||||||
|
| Native 100 | passed | `20260710-102547`: 100/100, four pages, no duplicate page/hash evidence |
|
||||||
|
| Focus/stop safety | passed | Focus-loss `20260710-101820` blocked at 0; stop `20260710-102735` stopped at 0 |
|
||||||
|
| Packaged native smoke | passed | `20260710-103736`: 5/5 via renderer/preload/IPC/packaged helper, persist=false |
|
||||||
|
|
||||||
|
Full evidence and the next-phase decision are in
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
@@ -57,6 +57,22 @@ marks missing fast-profile fields so stale/partial captures do not crowd out
|
|||||||
useful cases, and surfaces ownership/lock evidence (`artifact-footer`,
|
useful cases, and surfaces ownership/lock evidence (`artifact-footer`,
|
||||||
`equipped`, and `locked=true/false`) for the next validation pass.
|
`equipped`, and `locked=true/false`) for the next validation pass.
|
||||||
|
|
||||||
|
Current local snapshot from 2026-07-10:
|
||||||
|
|
||||||
|
- 177 records read, 0 invalid
|
||||||
|
- 138 unique candidates
|
||||||
|
- 80 exported candidates
|
||||||
|
- 36 candidates with complete fast-profile fields
|
||||||
|
- 41 likely stale or partial captures
|
||||||
|
- 14 equipped-footer candidates
|
||||||
|
- 3 `native-review-approved` cases already represented in
|
||||||
|
`confirmedReviewCorpus.ts`; the remaining 77 exported cases still require
|
||||||
|
explicit human labels
|
||||||
|
|
||||||
|
These counts describe the current local queue and may grow after later live
|
||||||
|
sessions. Do not treat the 36 complete-field candidates as automatically
|
||||||
|
correct; complete OCR is still only a review candidate until visually checked.
|
||||||
|
|
||||||
After manually checking one candidate against the real artifact, create a
|
After manually checking one candidate against the real artifact, create a
|
||||||
confirmed corpus snippet with explicit expected labels:
|
confirmed corpus snippet with explicit expected labels:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ downstream work so the Electron app remains a visual/status surface instead of
|
|||||||
the worker.
|
the worker.
|
||||||
|
|
||||||
For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||||
|
For the current localization, local-deletion, timing, and reviewed-corpus
|
||||||
|
checkpoint, see
|
||||||
|
[LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md](LOCALIZATION_LOCAL_DELETION_TIMING_CHECKPOINT_2026-07-11.md).
|
||||||
|
|
||||||
## Product Stance
|
## Product Stance
|
||||||
|
|
||||||
@@ -21,24 +24,46 @@ For the short current status, see [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
|||||||
diagnostics, or the inventory view.
|
diagnostics, or the inventory view.
|
||||||
- Do not merge scan confidence and artifact value into one ambiguous score.
|
- Do not merge scan confidence and artifact value into one ambiguous score.
|
||||||
- Treat uncertain OCR as review, not as a low-quality artifact.
|
- Treat uncertain OCR as review, not as a low-quality artifact.
|
||||||
|
- Treat direct visual star evidence as an evaluation gate: explicitly confirmed
|
||||||
|
1–4★ results remain visible but are excluded from roll evaluation; missing or
|
||||||
|
conflicting star evidence fails closed to `Review`.
|
||||||
|
- The `Nicht 5★` inventory filter is a read-only manual-inspection view, not a
|
||||||
|
salvage decision. It must never expose automatic salvage or Quick Select.
|
||||||
|
- The app renderer defaults to English; German is a Settings preference only.
|
||||||
|
The setting must not be presented as German Genshin OCR support.
|
||||||
|
- A wrong local artifact may be removed only through a confirmed app-local
|
||||||
|
workflow. It must preserve raw scan evidence, restrict crop cleanup to the
|
||||||
|
selected run directory, and never send game input.
|
||||||
- Prioritize extracting correct artifact content over another speed pass unless
|
- Prioritize extracting correct artifact content over another speed pass unless
|
||||||
live timings regress materially.
|
live timings regress materially.
|
||||||
|
|
||||||
|
## Current 2026-07-11 Checkpoint
|
||||||
|
|
||||||
|
The implementation now has an English-default/Deutsch-selectable renderer,
|
||||||
|
confirmed local artifact removal, a shared-clock native run timing record, and
|
||||||
|
new visually reviewed OCR corpus cases. These are implementation and offline
|
||||||
|
validation results, not a new live-release claim. The remaining release gates
|
||||||
|
include the non-persisting 50-artifact native smoke that produces a complete
|
||||||
|
`run-timing.json`. The built-renderer Builds/UI acceptance already passed; an
|
||||||
|
elevated packaged-executable runtime remains a separate packaging boundary.
|
||||||
|
|
||||||
## Target User Flow
|
## Target User Flow
|
||||||
|
|
||||||
1. The user opens Artifact inventory in Genshin with a visible detail card.
|
1. The user opens Artifact inventory in Genshin with a visible detail card.
|
||||||
2. The scanner runs the existing read-only visible-inventory flow.
|
2. The scanner runs the existing read-only visible-inventory flow.
|
||||||
3. The scan view shows the latest screenshot/preview on the left.
|
3. The scan view shows the latest screenshot/preview on the left.
|
||||||
4. A compact live result rail on the right receives one row per finished
|
4. A compact live result rail on the right receives one row per finished safe
|
||||||
artifact evaluation.
|
artifact result.
|
||||||
5. Each row shows only:
|
5. Each evaluated 5★ row shows only:
|
||||||
- scan number,
|
- scan number,
|
||||||
- artifact name or compact slot/set fallback,
|
- artifact name or compact slot/set fallback,
|
||||||
- artifact value score from `0` to `100`,
|
- artifact value score from `0` to `100`,
|
||||||
- a colored result pill.
|
- a colored result pill.
|
||||||
6. After the scan, the user opens the inventory menu to browse all scanned
|
6. An explicitly recognized 1–4★ row shows `Nicht bewertet`; an unconfirmed or
|
||||||
artifacts.
|
conflicting star row stays `Review` rather than assuming 5★.
|
||||||
7. Clicking an artifact opens a detail view with screenshot, parsed fields,
|
7. After the scan, the user opens the inventory menu to browse all scanned
|
||||||
|
artifacts, including the separate read-only `Nicht 5★` filter.
|
||||||
|
8. Clicking an artifact opens a detail view with screenshot, parsed fields,
|
||||||
OCR confidence, scoring reasons, and optional upgrade projection.
|
OCR confidence, scoring reasons, and optional upgrade projection.
|
||||||
|
|
||||||
## Score Contract
|
## Score Contract
|
||||||
@@ -48,12 +73,17 @@ The UI must keep two concepts separate:
|
|||||||
| Concept | Meaning | UI behavior |
|
| Concept | Meaning | UI behavior |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Extraction confidence | How reliable the scan/OCR/parser result is. | Drives `Review`, warnings, and detail confidence rows. |
|
| Extraction confidence | How reliable the scan/OCR/parser result is. | Drives `Review`, warnings, and detail confidence rows. |
|
||||||
|
| Visual star evidence | Direct `starCount`, confidence, and source from the native card crop. | Confirmed 1–4★ is `excluded`; missing, invalid, or conflicting evidence is `Review`; only confirmed 5★ proceeds to evaluation. |
|
||||||
| Artifact value score | How useful the artifact appears for builds. | Drives the `0-100` value and good/mid/weak pill. |
|
| Artifact value score | How useful the artifact appears for builds. | Drives the `0-100` value and good/mid/weak pill. |
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- If extraction confidence is too low, show `Review` instead of a normal value
|
- If extraction confidence is too low, show `Review` instead of a normal value
|
||||||
decision, even when a tentative value score exists.
|
decision, even when a tentative value score exists.
|
||||||
|
- An explicit 1–4★ result gets no value score, projection, or promotion. It is
|
||||||
|
visible only as an `excluded` manual-review item with its crop and details.
|
||||||
|
- Never infer a 5★ result from an absent, invalid, or conflicting direct visual
|
||||||
|
star field in a current native job; it must stay `Review`.
|
||||||
- If the artifact is a duplicate, show duplicate state separately from value.
|
- If the artifact is a duplicate, show duplicate state separately from value.
|
||||||
- The live rail may show one compact pill, but the data model should preserve
|
- The live rail may show one compact pill, but the data model should preserve
|
||||||
separate `extractionStatus` and `valueStatus` fields.
|
separate `extractionStatus` and `valueStatus` fields.
|
||||||
@@ -137,9 +167,13 @@ Outcome:
|
|||||||
|
|
||||||
- Add a durable scan result entry model with sequence number, capture metadata,
|
- Add a durable scan result entry model with sequence number, capture metadata,
|
||||||
parsed artifact identity, extraction status, artifact value score, value
|
parsed artifact identity, extraction status, artifact value score, value
|
||||||
status, duplicate/review flags, and timestamps. Initial native entries use
|
status, direct visual rarity evidence, duplicate/review flags, and timestamps. Initial native entries use
|
||||||
`valueStatus: "deferred"` for clean parses and `valueStatus: "review"` for
|
`valueStatus: "deferred"` for clean parses, `valueStatus: "review"` for
|
||||||
uncertain extraction.
|
uncertain extraction, and `valueStatus: "excluded"` for explicit direct
|
||||||
|
1–4★ evidence.
|
||||||
|
- Preserve `starCount`, `starConfidence`, and `starSource` for current native
|
||||||
|
jobs so the downstream gate can distinguish explicit 1–4★ exclusion from an
|
||||||
|
ambiguous result that must remain `Review`.
|
||||||
- Keep existing stored artifact records compatible.
|
- Keep existing stored artifact records compatible.
|
||||||
- Add tests for status derivation so low-confidence OCR cannot become a normal
|
- Add tests for status derivation so low-confidence OCR cannot become a normal
|
||||||
`Good` or `Strong` result.
|
`Good` or `Strong` result.
|
||||||
@@ -154,16 +188,24 @@ Likely files:
|
|||||||
|
|
||||||
### Phase 2 - Minimal live result rail
|
### Phase 2 - Minimal live result rail
|
||||||
|
|
||||||
Status: foundation implemented for native post-capture results. The scan main
|
Status: implemented for current-session native post-capture results. The rail
|
||||||
section shows newest stored artifacts as fallback and can display the latest
|
starts empty on app launch and receives only results loaded for a scan started
|
||||||
native `scan-results.json` entries after post-processing. Result rail rows can
|
in the current app session. Older stored/native history stays in the Inventory
|
||||||
open the Inventory surface for crop/IK/detail inspection. Value scores are still
|
surface. Result rail rows can open that surface for crop/IK/detail inspection.
|
||||||
pending.
|
A real Settings-UI Artifact-limit-5 run completed with 5 captured, processed,
|
||||||
|
and parsed results, 0 review/errors, `persist=false`, and five rail rows. A
|
||||||
|
real row-limit run completed at 8/8. The packaged full-owned-inventory baseline
|
||||||
|
then captured 2,211/2,211 over 70 pages and showed all 2,211 live-session rows;
|
||||||
|
current-package reprocessing passed at 136 Review (6.15%), 0 errors, and 0
|
||||||
|
store writes.
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
|
|
||||||
- Rework the scan main section into preview plus right-side result rail.
|
- Rework the scan main section into a left preview/progress work surface plus a
|
||||||
- Remove live evaluation cards and noisy stats from the primary scan area.
|
right-side current-session result rail.
|
||||||
|
- Keep phase and outcome counts in that left work surface during a run; remove
|
||||||
|
the duplicate standalone progress card and noisy debug stats from the primary
|
||||||
|
scan area.
|
||||||
- Append rows only after an artifact has finished parse/evaluation.
|
- Append rows only after an artifact has finished parse/evaluation.
|
||||||
- Keep Stop, scan status, and review access available.
|
- Keep Stop, scan status, and review access available.
|
||||||
- Keep debug stats in diagnostics or summary modals.
|
- Keep debug stats in diagnostics or summary modals.
|
||||||
@@ -186,20 +228,26 @@ crop preview loading are present. The view now surfaces the vendored IK
|
|||||||
Artifact version/counts, active Artifact-only scope, compact pipeline state for
|
Artifact version/counts, active Artifact-only scope, compact pipeline state for
|
||||||
native capture, OCR queue, review, promotion, and evidence, plus per-result
|
native capture, OCR queue, review, promotion, and evidence, plus per-result
|
||||||
IK/GOOD match status for native artifacts. The inventory view also computes a
|
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
|
dry-run promotion summary from `scan-results.json` plus the local artifact
|
||||||
store, separating `speicherbar`, already stored, review, and blocked native
|
store, separating `speicherbar`, already stored, review, and blocked native
|
||||||
results. One selected clean result can now be promoted after a second UI
|
results. One selected clean result can now be promoted after a second UI
|
||||||
confirmation; the main process revalidates the run result, writes the store,
|
confirmation; the main process revalidates the run result, writes the store,
|
||||||
updates `scan-results.json`, and appends `promotion-log.jsonl`. Weapons,
|
updates `scan-results.json`, and appends `promotion-log.jsonl`. Weapons,
|
||||||
materials, and character details
|
materials, and character details
|
||||||
remain hidden from the active feature UI while they are not scanned. The native
|
remain hidden from the active feature UI while they are not scanned. The native
|
||||||
helper still reports the category distinction in
|
helper still reports the category distinction in
|
||||||
`supportedCategories` via `catalogAvailable`, `nativeCaptureSupported`, and
|
`supportedCategories` via `catalogAvailable`, `nativeCaptureSupported`, and
|
||||||
`scanStatus`, so dev-control evidence cannot accidentally claim that every IK
|
`scanStatus`, so dev-control evidence cannot accidentally claim that every IK
|
||||||
catalog has an implemented scanner. Native scan start, status, manifest, and
|
catalog has an implemented scanner. Native scan start, status, manifest, and
|
||||||
capture jobs now carry an explicit scan category; only `artifacts` can currently
|
capture jobs now carry an explicit scan category; only `artifacts` can currently
|
||||||
produce native capture jobs. Value scoring is still pending; single-result
|
produce native capture jobs. Deterministic roll-efficiency evaluation and
|
||||||
review/edit/approve is implemented in the detail phase below.
|
single-result review/edit/approve are implemented in the detail phases below.
|
||||||
|
The current checkpoint also adds a confirmed local-delete path. A native
|
||||||
|
result is hidden by an append-only tombstone rather than an in-place result
|
||||||
|
rewrite; its PNG crop is removed only after run-directory containment
|
||||||
|
succeeds. A Store-only row, or the exact Store record linked to a native row,
|
||||||
|
can be removed locally after confirmation. Active scans/processors block the
|
||||||
|
action, and no delete path sends Genshin input.
|
||||||
|
|
||||||
Support code for simple IK weapon, character, and material name/GOOD-key
|
Support code for simple IK weapon, character, and material name/GOOD-key
|
||||||
matching exists, but those categories still need their own capture flows before
|
matching exists, but those categories still need their own capture flows before
|
||||||
@@ -212,6 +260,13 @@ Outcome:
|
|||||||
- Each entry shows the same score/pill language as the live rail.
|
- Each entry shows the same score/pill language as the live rail.
|
||||||
- Provide filters and sorting for review, score, set, slot, equipped, locked,
|
- Provide filters and sorting for review, score, set, slot, equipped, locked,
|
||||||
and newest scan.
|
and newest scan.
|
||||||
|
- Provide a separate read-only `Nicht 5★` filter for explicitly recognized
|
||||||
|
1–4★ native results. It must retain screenshot/crop and parsed detail for
|
||||||
|
manual inspection, use no "safe to salvage" language, and offer no automatic
|
||||||
|
salvage or Quick Select action.
|
||||||
|
- Provide a small confirmed local-delete action in artifact detail. It must
|
||||||
|
describe its app-local scope, preserve raw native result evidence, limit crop
|
||||||
|
removal to a contained PNG, and keep Store removal exact and opt-in.
|
||||||
- Avoid a marketing/landing layout; the first screen is the actual inventory.
|
- Avoid a marketing/landing layout; the first screen is the actual inventory.
|
||||||
|
|
||||||
Likely files:
|
Likely files:
|
||||||
@@ -224,13 +279,15 @@ Likely files:
|
|||||||
|
|
||||||
### Phase 4 - Artifact detail view
|
### Phase 4 - Artifact detail view
|
||||||
|
|
||||||
Status: started for native scan results. Inventory detail can show the native
|
Status: implemented baseline for native scan results. Inventory detail shows the native
|
||||||
card crop from the run directory through the Electron bridge, constrained to
|
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
|
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
|
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
|
promotion state, and a `Naechster Schritt` card. Native Review results now have
|
||||||
an inline field editor with approve/reject, authoritative validation, run logs,
|
an inline field editor with approve/reject, authoritative validation, run logs,
|
||||||
and review-to-eval export. Value reasons are still pending.
|
and review-to-eval export. It now also shows roll-efficiency reasons,
|
||||||
|
per-substat legal roll breakdown, build-fit deferral, and under-level
|
||||||
|
projection when safe.
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
|
|
||||||
@@ -250,17 +307,24 @@ Likely files:
|
|||||||
|
|
||||||
### Phase 5 - Artifact value evaluation
|
### Phase 5 - Artifact value evaluation
|
||||||
|
|
||||||
Status: next product feature after the now-completed same-session native scale,
|
Status: implemented as `roll-efficiency-v1`. Clean legal Artifacts are
|
||||||
selected promotion, and review/edit/approve workflow.
|
evaluated; Review and unknown data receive no confident score. For current
|
||||||
|
native jobs, only explicitly confirmed 5★ evidence enters evaluation;
|
||||||
|
explicit 1–4★ evidence becomes `excluded` with no score or projection, while
|
||||||
|
ambiguous direct star evidence remains `Review`. Existing saved results are
|
||||||
|
enriched on load and replayed offline without rescanning.
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
|
|
||||||
- Add a deterministic artifact value evaluator before promoting build
|
- Add a deterministic artifact value evaluator before promoting build
|
||||||
recommendations.
|
recommendations.
|
||||||
- Explain the score through factors such as set, slot, main stat, substat
|
- Explain the score through legal roll-tier quality and total roll counts.
|
||||||
quality, level, locked/equipped state, and available character/build context.
|
- Keep set/main-stat desirability and character/build context separate.
|
||||||
|
- Keep locked/equipped state out of intrinsic roll quality.
|
||||||
- The evaluator must accept incomplete data and return review/unknown instead
|
- The evaluator must accept incomplete data and return review/unknown instead
|
||||||
of confident nonsense.
|
of confident nonsense.
|
||||||
|
- Do not turn an `excluded` 1–4★ result into an evaluation, store-promotion,
|
||||||
|
projection, or automatic in-game action.
|
||||||
|
|
||||||
Likely files:
|
Likely files:
|
||||||
|
|
||||||
@@ -272,11 +336,16 @@ Likely files:
|
|||||||
|
|
||||||
### Phase 6 - Upgrade projection
|
### Phase 6 - Upgrade projection
|
||||||
|
|
||||||
Status: later detail-level feature after artifact value evaluation.
|
Status: implemented as detail-only `roll-efficiency-projection-v1` for
|
||||||
|
under-leveled, unambiguously identified 5-star Artifacts with four known legal
|
||||||
|
substats. Packaged visual/live acceptance passed on 2026-07-10; the new live
|
||||||
|
100er contained only +20 Artifacts, so under-level behavior remains covered by
|
||||||
|
the confirmed saved-run corpus.
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
|
|
||||||
- For artifacts below max level, show optional projection only in detail.
|
- For unambiguously identified 5-star artifacts below max level, show optional
|
||||||
|
projection only in detail.
|
||||||
- Provide `worst`, `middle`, and `best` projected value scores.
|
- Provide `worst`, `middle`, and `best` projected value scores.
|
||||||
- Label projection as probabilistic and not a guaranteed result.
|
- Label projection as probabilistic and not a guaranteed result.
|
||||||
- Use known Genshin upgrade constraints and current substats; unknown or
|
- Use known Genshin upgrade constraints and current substats; unknown or
|
||||||
@@ -284,7 +353,7 @@ Outcome:
|
|||||||
|
|
||||||
Likely files:
|
Likely files:
|
||||||
|
|
||||||
- `src/lib/upgradeProjection.ts`
|
- `src/lib/artifactEvaluation.ts`
|
||||||
- `src/lib/substatRolls.ts`
|
- `src/lib/substatRolls.ts`
|
||||||
- `src/features/inventory/components/*`
|
- `src/features/inventory/components/*`
|
||||||
- parser/scoring tests
|
- parser/scoring tests
|
||||||
@@ -300,10 +369,15 @@ Outcome:
|
|||||||
- Preserve stop/failsafe behavior and review decisions.
|
- Preserve stop/failsafe behavior and review decisions.
|
||||||
- Compare throughput against the current baseline without weakening accuracy.
|
- Compare throughput against the current baseline without weakening accuracy.
|
||||||
|
|
||||||
Status: started for post-capture processing. Native capture already writes
|
Status: implemented and live accepted. Native capture writes crop jobs without
|
||||||
crop jobs without waiting for OCR. The downstream processor now consumes those
|
waiting for OCR. The downstream processor tails those jobs with bounded
|
||||||
jobs with bounded parallelism and writes stable ordered reports. Live throughput
|
parallelism, streams stable ordered deltas into the result rail, and writes
|
||||||
comparison remains a final validation item.
|
durable reports. Packaged 20-result and 2,211-result runs proved live capture/
|
||||||
|
evaluation overlap; the full corpus also passed five-repeat deterministic replay
|
||||||
|
and saved-run validation after current-package reprocessing. The current build
|
||||||
|
also writes immutable first-observed lifecycle markers to `run-timing.json`.
|
||||||
|
It derives end-to-end time directly from request to result reconciliation;
|
||||||
|
capture and processing durations remain separate and must not be added.
|
||||||
|
|
||||||
Likely files:
|
Likely files:
|
||||||
|
|
||||||
@@ -314,14 +388,28 @@ Likely files:
|
|||||||
|
|
||||||
### Phase 8 - Recommendation promotion
|
### Phase 8 - Recommendation promotion
|
||||||
|
|
||||||
Status: intentionally delayed until native artifact ingestion, review,
|
Status: offline implementation checkpoint completed. The Builds surface reads
|
||||||
promotion, value scoring, and repeatability are trustworthy.
|
the newest complete native result run, validates source-bound profiles and
|
||||||
|
five-piece evidence, and exposes up to three read-only, non-overlapping
|
||||||
|
suggestions. The current package acceptance runner covers the fresh
|
||||||
|
English/German locale flow, Builds context/focus, reduced motion, and overflow,
|
||||||
|
and its built-renderer execution passed together with the local-delete
|
||||||
|
confirmation probe. It is not evidence of an elevated packaged-executable
|
||||||
|
launch, UAC, preload bridge, or native runtime; those remain separate from the
|
||||||
|
renderer result and the required current-build scanner smoke.
|
||||||
|
|
||||||
Outcome:
|
Outcome:
|
||||||
|
|
||||||
- Promote account-level recommendations only after scan result quality,
|
- Promote only source-bound profile suggestions after scan result quality,
|
||||||
inventory browsing, detail review, and value scoring are trustworthy.
|
inventory browsing, detail review, and value scoring are trustworthy.
|
||||||
- Recommendations must reference stored artifact quality and uncertainty.
|
- Read Build-Fit inputs from complete native results, not metadata-poor store
|
||||||
|
records, because canonical IK Set keys and field-confidence evidence are
|
||||||
|
required.
|
||||||
|
- Keep `roll-efficiency-v1` and OCR confidence out of ranking. The visible
|
||||||
|
rule-coverage value may use only documented Set/Main-Stat/Substat evidence;
|
||||||
|
required full-build context must be explicit.
|
||||||
|
- Keep suggestions read-only, explain uncertainty/conflicts, and never infer
|
||||||
|
character ownership, team, weapon, rotation, or an in-game action.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
@@ -333,7 +421,22 @@ Outcome:
|
|||||||
- Artifact value score and extraction confidence remain separate in data.
|
- Artifact value score and extraction confidence remain separate in data.
|
||||||
- Inventory view can browse stored scan results without opening diagnostics.
|
- Inventory view can browse stored scan results without opening diagnostics.
|
||||||
- Detail view explains why an artifact received its score.
|
- Detail view explains why an artifact received its score.
|
||||||
|
- Explicit 1–4★ results are visibly excluded rather than scored; ambiguous
|
||||||
|
visual rarity is visibly `Review`.
|
||||||
|
- The `Nicht 5★` inventory filter presents only manual review evidence
|
||||||
|
(screenshot/crop and details), never a salvage or Quick Select control.
|
||||||
|
- A freshly initialized renderer is English; the Settings locale control can
|
||||||
|
select German and restore English without implying German Genshin OCR support.
|
||||||
|
- Artifact detail can remove a wrong local row only after confirmation. Native
|
||||||
|
removal preserves raw result evidence through a tombstone, constrains crop
|
||||||
|
cleanup to the run directory, and never sends game input.
|
||||||
|
- Streaming/end-to-end reports use a complete, ordered shared-clock
|
||||||
|
`run-timing.json` rather than summed capture and processing durations.
|
||||||
- Upgrade projection never implies a guaranteed future roll.
|
- Upgrade projection never implies a guaranteed future roll.
|
||||||
|
- Build suggestions use only complete native evidence, source-valid profiles,
|
||||||
|
complete Set shapes, and explicit required aggregate context.
|
||||||
|
- Builds explains profile, sources, chosen five Artifacts, rule coverage,
|
||||||
|
conflicts, and deferrals without presenting an optimizer or DPS score.
|
||||||
- Existing safety constraints and scan quality gates remain intact.
|
- Existing safety constraints and scan quality gates remain intact.
|
||||||
|
|
||||||
## Validation Plan
|
## Validation Plan
|
||||||
@@ -342,12 +445,34 @@ Outcome:
|
|||||||
- `npm test`
|
- `npm test`
|
||||||
- `npm run build`
|
- `npm run build`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
- `npm run scan:native:replay` against accepted bounded runs and the 2,211 corpus
|
||||||
|
- `npm run package:offline-check`
|
||||||
|
- Preserve the passing built-renderer `npm run package:live:builds` evidence
|
||||||
|
for locale/Builds/delete UI behavior. Separately launch an elevated packaged
|
||||||
|
executable before making a package-runtime/UAC claim.
|
||||||
|
- `npm audit`
|
||||||
- `npm run scan:native:smoke` before claiming native capture plus
|
- `npm run scan:native:smoke` before claiming native capture plus
|
||||||
post-capture processing on live Genshin data.
|
post-capture processing on live Genshin data.
|
||||||
- Broader native Artifact runs with 20/50/100 items before claiming IK-style
|
- For this checkpoint, run the smoke at exactly 50 artifacts with
|
||||||
speed or stability.
|
`scripts/native-live-smoke.ps1 -Limit 50`; require reconciliation, zero
|
||||||
|
processing errors, the review-rate gate, and a complete valid
|
||||||
|
`run-timing.json` before cleanup, commit, or push.
|
||||||
|
- Test local deletion offline for active-run blocking, raw-result preservation,
|
||||||
|
tombstone filtering, crop containment, and exact Store scope. The built
|
||||||
|
renderer already passed the visible confirmation probe; repeat it only for a
|
||||||
|
separate elevated packaged-executable runtime claim.
|
||||||
|
- Test fresh English/default, German selection, and English restoration through
|
||||||
|
the app Settings modal; do not use translated text as the primary automation
|
||||||
|
selector.
|
||||||
|
- Preserve the accepted 20/50/100 and 2,211 evidence before changing capture,
|
||||||
|
parser, Review, or evaluation contracts.
|
||||||
- Add unit tests for score/status derivation and upgrade projection.
|
- Add unit tests for score/status derivation and upgrade projection.
|
||||||
|
- Add direct visual 5★/4★/3★ and ambiguous-star fixtures. Verify that 1–4★ is
|
||||||
|
stable `excluded`, ambiguity is `Review`, and neither path can be promoted or
|
||||||
|
expose an in-game mutation.
|
||||||
- For scanner-facing changes, run a low-limit visible-inventory live scan before
|
- For scanner-facing changes, run a low-limit visible-inventory live scan before
|
||||||
wider validation.
|
broad validation, then use the saved full corpus whenever new input is not
|
||||||
|
required. The original execution plan is retained in
|
||||||
|
[TOMORROW_LIVE_TEST_PLAN_2026-07-10.md](TOMORROW_LIVE_TEST_PLAN_2026-07-10.md).
|
||||||
- Keep `npm run scan:repeatability:wait` for later regression checks, not for
|
- Keep `npm run scan:repeatability:wait` for later regression checks, not for
|
||||||
every UI-only pass.
|
every UI-only pass.
|
||||||
|
|||||||
+174
-152
@@ -1,173 +1,195 @@
|
|||||||
# Scanner Rework Status
|
# Scanner Rework Status
|
||||||
|
|
||||||
Updated: 2026-07-09
|
Updated: 2026-07-11 after the 2,211-Artifact full-inventory acceptance,
|
||||||
|
review-gate correction, strict game-process targeting, and the offline-tested
|
||||||
|
Build-Fit evidence-ranking checkpoint
|
||||||
|
|
||||||
Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
Short current-state entry point: [CURRENT_STATUS.md](CURRENT_STATUS.md).
|
||||||
|
Detailed evidence:
|
||||||
|
[NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md](NATIVE_SCANNER_LIVE_ACCEPTANCE_2026-07-10.md).
|
||||||
|
|
||||||
## Was Es Kann
|
## Was Es Kann
|
||||||
|
|
||||||
- Native IK-Erfassung ist als neuer schneller Pfad verdrahtet: Electron/React
|
- Der native Artifact-Scanner setzt das 2.400-Slot-Inventar begrenzt an den
|
||||||
starten und zeigen Status, der C# Helper prueft IK-Listen, fokussiert Genshin,
|
Anfang, erfasst ein validiertes 4x8-Grid ueber mehrere Seiten und streamt
|
||||||
klickt das 16:9 8x4 Grid, scrollt und schreibt Detailkarten-Crops.
|
jeden Crop direkt in vier begrenzte OCR-/Parse-/Evaluierungsworker.
|
||||||
- IK `inventorylists` aus Inventory Kamera 1.4.4 sind 1:1 unter
|
- Genshin-HWND, Stop-Signale und Input-Erfolg werden vor jedem relevanten
|
||||||
`data/ik-inventorylists` uebernommen und werden in Builds als Resource
|
Wheel-, Klick- und Capture-Schritt geprueft. Fokusverlust refokussiert nicht.
|
||||||
mitgeliefert. Der Helper meldet Version `6.7.0` mit `61` Artifact-Sets,
|
- Die frische Live-Kette `5/20/50/100` bestand mit 0 Review, 0 Fehlern und
|
||||||
`289` Artifact-Pieces, `247` Waffen, `119` Charakteren und `715` Materialien.
|
0 Store-Writes. Der 100er `20260710-102547` erreichte 4,37 Captures/s und
|
||||||
- Jeder native Run legt `manifest.json`, `capture-jobs.jsonl` und `status.json`
|
3,15 verarbeitete Results/s.
|
||||||
im Run-Ordner an. Damit kann OCR/Parsing als nachgelagerte Queue laufen,
|
- Gespeicherte Runs werden auf exakte Jobs/PNGs/Results, Sequenzen, Pfade,
|
||||||
ohne den Capture-Loop wieder in React/Electron zu ziehen.
|
Click-Events, Seitenwiederholung, PNG-Hashes und Replay-Determinismus geprueft.
|
||||||
- Nach einem nativen Run kann die App `capture-jobs.jsonl` nachgelagert
|
- Inventory zeigt Native- und Store-Zeilen mit getrennten Signalen fuer
|
||||||
verarbeiten und `scan-results.json` plus `processing-report.json` schreiben.
|
Extraktion, Review, IK, Roll-Efficiency, Promotion und spaeteren Build-Fit.
|
||||||
`scan-results.json` enthaelt dauerhafte Ergebnis-Eintraege mit
|
- Die unpacked App ist aus `%TEMP%` live gestartet. Renderer, Preload, IPC,
|
||||||
Extraction-Status, Review-Zustand und bewusst deferierter Value-Auswertung.
|
paketierter C#-Helper und IK 6.7.0 funktionierten; der paketierte 5er
|
||||||
Diese Stufe OCRt/parst nach der Erfassung, matched native Artifact-Ergebnisse
|
`20260710-103736` bestand ohne Persistenz.
|
||||||
gegen IK `inventorylists`, zwingt IK-Konflikte in Review, verarbeitet Crops
|
|
||||||
ueber eine bounded Queue, schreibt Parser-Feldconfidence in die Resultate und
|
|
||||||
persistiert standardmaessig noch nicht in die DB.
|
|
||||||
- Smart Capture reads the current artifact detail view through focused 16:9
|
|
||||||
crops, OCR preprocessing, deterministic parser matching, and local lookup data.
|
|
||||||
- Visible-inventory auto-scan is the production baseline: preflight, grid
|
|
||||||
detection, focus, click, detail verification, OCR, parse, store/review, scroll,
|
|
||||||
and summary.
|
|
||||||
- The current live path has completed a 100-artifact run with `100/100` verified
|
|
||||||
and parsed, `98` stored, `2` duplicates, `0` review samples, `0` misses, and
|
|
||||||
`393 ms/artifact`.
|
|
||||||
- Native IK live smoke passed on 2026-07-09 with runtime signature
|
|
||||||
`2026-07-09-native-ik-visual-probe`: visual preflight ready, guarded probe
|
|
||||||
changed the detail panel, `2/2` native artifact card crops captured,
|
|
||||||
post-capture processing parsed `2/2`, `0` review, `0` errors, and
|
|
||||||
`queueConcurrency: 2` without persisting to the artifact store.
|
|
||||||
- Native 20/50/100 dry validation also passed in the same live session. The
|
|
||||||
100-item run captured and parsed `100/100` across 4 pages with `0` errors,
|
|
||||||
`3` correctly gated Review results, and `0` store writes. See
|
|
||||||
`docs/NATIVE_SCANNER_VALIDATION_2026-07-09.md`.
|
|
||||||
- Stored artifacts, review samples, local text replacements, GOOD-compatible
|
|
||||||
import/export, scanner diagnostics, and OCR eval are implemented.
|
|
||||||
- The scan page now has a compact `Letzte Ergebnisse` rail. It uses the latest
|
|
||||||
native `scan-results.json` entries after post-processing and falls back to
|
|
||||||
newest stored artifacts when no native run result is loaded. Rail rows open
|
|
||||||
the Inventory surface for detail inspection.
|
|
||||||
- The app now has an `Inventory` navigation view for browsing native scan
|
|
||||||
result entries, stored artifacts, and snapshot fallback rows with filters,
|
|
||||||
sorting, a detail panel, and native crop previews loaded from the scan run
|
|
||||||
directory. The same view is now scoped to Artifact scanning: it shows IK
|
|
||||||
Artifact set/piece coverage, a Native Artifact pipeline strip for capture,
|
|
||||||
post-processing, review, promotion, and evidence, plus a per-result
|
|
||||||
`Naechster Schritt` panel. Weapons, materials, and character details remain
|
|
||||||
loaded data only and are intentionally hidden from the active feature UI while
|
|
||||||
they are not scanned.
|
|
||||||
- The live-soak runner writes JSON/CSV evidence bundles and validates quality via
|
|
||||||
`scan-performance-assessment.json`.
|
|
||||||
|
|
||||||
## Was Zuletzt Gemacht Wurde
|
## Was Zuletzt Gemacht Wurde
|
||||||
|
|
||||||
- Der aktive UI-Scope wurde auf Artifact scanning begrenzt.
|
- Capture-Quellenklassifizierung schliesst die eigene App trotz `Genshin` im
|
||||||
- Waffen, Materialien und Charakterdetails bleiben als vendored IK-Daten
|
Titel aus.
|
||||||
vorhanden, werden aber nicht mehr als aktive Scanner-Features in der
|
- Dev-Cleanup beendet nur echte Repo-Vite/Electron/Helper-Prozesse und nicht
|
||||||
Inventory UI dargestellt.
|
mehr den Codex-CUA-Node.
|
||||||
- Die Inventory UI zeigt jetzt eine Native Artifact Pipeline:
|
- Native Runs starten immer mit einem begrenzten Top-Reset und pruefen den
|
||||||
Scope, Native Capture, OCR Queue, Review Gate, Promotion und Evidenz.
|
Foreground vor jedem Input/Capture. Reset-Pacing senkte die Live-Zeit von
|
||||||
- Native Ergebnislabels wurden geschaerft:
|
50,7 s auf rund 1,7 s.
|
||||||
`Geparst` bedeutet extrahiert, `Stored` bedeutet persistiert, `Review`
|
- Der Smoke-Gate prueft exakte Counts, Dateivertraege, Reviewrate,
|
||||||
bedeutet unsicher und `Wert offen` bedeutet bewusst noch nicht bewertet.
|
Non-Persistence und bestaetigten Stop-Cleanup.
|
||||||
- Native Artifact Details zeigen jetzt einen `Naechster Schritt`:
|
- Run-Aufloesung waehlt nur den neuesten vollstaendigen Run; gestoppte oder
|
||||||
Promotion bereit, Review zuerst, bereits im Store, blockiert oder Wert spaeter.
|
unprozessierte Runs ersetzen die Inventory-Evidenz nicht.
|
||||||
- Inventory Filter wurden um native Ergebnisse und speicherbare Ergebnisse
|
- Packaged Pfade verwenden `resourcesPath`, Vite relative Assets und eine
|
||||||
erweitert.
|
verifizierte Runtime-Signatur.
|
||||||
- Ein ausgewaehltes, sauberes natives Ergebnis kann jetzt nach einer zweiten
|
- Elevation wird sowohl im Runtime- als auch im Helper-Guard korrekt angezeigt.
|
||||||
UI-Bestaetigung promotet werden. Der Main-Prozess validiert die autoritative
|
- Inventory-Layout und Promotion-Duplikatstatus wurden im finalen Paket visuell
|
||||||
Run-Datei erneut, prueft Store-Duplikate, schreibt den Store, aktualisiert
|
korrigiert und abgenommen.
|
||||||
`scan-results.json` und protokolliert in `promotion-log.jsonl`.
|
- Der aktuelle Renderer-Stand setzt den Scanumfang standardmaessig auf die
|
||||||
- Native Review-Ergebnisse koennen direkt im Inventory anhand des Crops editiert,
|
erkannte Besitzanzahl und bietet optional ein Artifact- oder Reihenlimit. Die
|
||||||
gegen IK, kanonische Main-Werte und legale Substat-Rolls validiert sowie
|
linke Hauptflaeche uebernimmt den Lauf-Fortschritt; die separate
|
||||||
freigegeben oder abgelehnt werden. Freigaben aktualisieren `scan-results.json`,
|
Fortschrittskarte entfaellt und die Ergebnisleiste startet sitzungsleer.
|
||||||
schreiben `review-log.jsonl` und erzeugen einen normalen Eval-Review-Sample.
|
- Der bestehende gebundene Wheel-Top-Reset bleibt Produktionsstandard. Ein
|
||||||
- Projekt-, Roadmap-, Architektur-, Runbook- und Checklist-Doku wurden auf
|
`B`-Reopen mit Artifact-Tab ist nur eine spaeter live zu benchmarkende
|
||||||
Artifact-only Scope nachgezogen.
|
Alternative, nicht Teil des aktuellen Produktionspfads.
|
||||||
|
- Ein realer Settings-UI-Lauf mit Artifact-Limit 5 bestand Genshin-, Detail-
|
||||||
|
und Grid-Preflight. Ziel/Capture/Processing/Parsing endeten bei 5/5,
|
||||||
|
Review/Fehler bei 0, `persist=false`; die sitzungsgebundene Ergebnisleiste
|
||||||
|
zeigte alle 5 Resultate.
|
||||||
|
- Der paketierte Settings-UI-Reihenlauf `20260710-180851` loeste eine Reihe
|
||||||
|
ueber das erkannte 8-Spalten-Raster zu Ziel 8 auf. Capture, Processing und
|
||||||
|
Parsing endeten bei 8/8, Review/Fehler bei 0 und der Top-Reset bei 1.715 ms.
|
||||||
|
Zwei Fokusverlust-Versuche blockierten vorher korrekt mit 0 Klicks.
|
||||||
|
- Der paketierte Streaming-Lauf `20260710-201614` startete Processing beim
|
||||||
|
ersten verfuegbaren Run-Verzeichnis. Noch waehrend Capture `running` war,
|
||||||
|
zeigte die Session-Rail bereits 11 ausgewertete Ergebnisse. Final waren
|
||||||
|
Capture/Processing/Parsing/Evaluation 20/20, Review/Fehler/Store-Writes 0;
|
||||||
|
alle Sequenzen 1-20 waren eindeutig und chronologisch sichtbar.
|
||||||
|
- Die Scanner-Hierarchie nutzt jetzt mehr Breite fuer die sitzungsgebundenen
|
||||||
|
Ergebnisse als fuer Guide/Capture. Scanbereitschaft und Live-Auswertung sind
|
||||||
|
kompakte Statusleisten statt verschachtelter Karten.
|
||||||
|
- Scanumfang, Aufnahmequelle und manuelle Werkzeuge liegen in einem
|
||||||
|
fokusgefangenen `Scanoptionen`-Modal und verschieben den Arbeitsbereich nicht
|
||||||
|
mehr. Collection-Pipeline-Details im Inventory liegen ebenfalls in einem
|
||||||
|
Modal; Diagnose-Import/-Export ist standardmaessig eingeklappt.
|
||||||
|
- Der frisch paketierte UI-Lauf `20260710-214406` bestaetigte die neue
|
||||||
|
Hierarchie erneut live: 20/20 Capture, Processing, Parsing und Evaluation,
|
||||||
|
0 Review/Fehler/Store-Writes, bis zu 9 sichtbare Ergebniszeilen und 12 bereits
|
||||||
|
ausgewertete Resultate waehrend Capture noch lief. Der erste Startversuch
|
||||||
|
blockierte korrekt an der doppelten Detailkarten-Pruefung; nach stabiler
|
||||||
|
Detailkarte lief derselbe begrenzte Pfad durch.
|
||||||
|
- `build-fit-contract-v1` trennt gesourcte Character-/Zielregeln,
|
||||||
|
Set-Pfade, Main-Stat-Prioritaeten, Substat-Gewichte, Gesamtziele, Konflikte
|
||||||
|
und Profil-Ablaufdaten von OCR und Roll-Efficiency. Unsichere oder nicht
|
||||||
|
kanonische Scanergebnisse blockieren; V1 gibt absichtlich noch keinen
|
||||||
|
Fit-Score aus.
|
||||||
|
- Reale lokale Account-Snapshots rufen den alten Demo-Scorer nicht mehr auf.
|
||||||
|
Der getrennte `build-fit-evidence-ranking-v1` liest stattdessen nur den
|
||||||
|
neuesten vollstaendigen nativen Run und kann bis zu drei read-only
|
||||||
|
Profilvorschlaege ausgeben.
|
||||||
|
- Der score-freie Fuenfer-Verifier prueft genau einen Eintrag pro Slot,
|
||||||
|
vollstaendige Set-Formen, verschachtelte OCR-/Review-Gates, Konflikte und
|
||||||
|
optional gesourcte Gesamtwerte. Fehlender Nicht-Artifact-Kontext bleibt
|
||||||
|
`pending` und wird nicht geraten.
|
||||||
|
- Der Profil-Corpus umfasst Furina Off-field/Solo Hydro C0-C1, Neuvillette
|
||||||
|
Charged Attack, Raiden Off-field Hyperbloom und Nahida Off-field Deepwood.
|
||||||
|
Jede Regel ist quellen-/versions-/ablaufgebunden, gegen reale IK-6.7.0-Keys
|
||||||
|
geprueft und durch Golden plus adversarial Fixture abgesichert.
|
||||||
|
- Die sichtbare Regelabdeckung kombiniert nur Set-Form, Main-Stat-Prioritaet
|
||||||
|
und dokumentierte Substat-Praesenz. Roll-Efficiency, Rollhoehe und OCR-
|
||||||
|
Confidence sind keine Rankinggewichte; Charakterbesitz, Team, Waffe und
|
||||||
|
Rotation werden nicht geraten. Furinas ER-Kontext ist nur explizit und
|
||||||
|
sitzungsgebunden bestaetigbar.
|
||||||
|
- Der reale Vollinventar-Lauf `20260710-223233` nahm den OCR-Besitzwert 2.211,
|
||||||
|
erfasste exakt 2.211 Karten auf 70 Seiten mit 4,24/s und schrieb nichts in
|
||||||
|
den Store. Die aktuelle paketierte Reprozessierung erreichte 2.211/2.211,
|
||||||
|
136 Review (6,15%), 0 Fehler und 0 Writes; der Saved-Run-Validator meldet
|
||||||
|
`issues=0`.
|
||||||
|
- Der erste Parse desselben Laufs wurde bei 490 Review (22,16%) korrekt nicht
|
||||||
|
akzeptiert. 360 widerspruechliche `+0`-Main-Values werden jetzt nur dann aus
|
||||||
|
der kanonischen 5-Sterne-Tabelle repariert, wenn vier Anfangs-Substats die
|
||||||
|
Seltenheit strukturell beweisen; Drei-Substat-Faelle bleiben Review.
|
||||||
|
- Native Helper und PowerShell-Fallback akzeptieren nur noch die exakten
|
||||||
|
Prozessnamen `GenshinImpact` oder `YuanShen`. Bei geschlossenem Spiel bestand
|
||||||
|
der Package-Smoke mit `genshinFound=false`, HWND 0 und leerem Zielprozess.
|
||||||
|
|
||||||
## Was Noch Nicht
|
## Was Noch Nicht
|
||||||
|
|
||||||
- Native IK-Erfassung captured aktuell Karten-Crops; OCR/Parser laufen erst
|
- Roll-Efficiency ist weiterhin kein Build-Fit. Die offline getestete Builds-
|
||||||
nachgelagert ueber den Processor. GOOD-Speichern und Evaluierung laufen noch
|
Oberflaeche ist keine Optimizer- oder DPS-Aussage und wurde noch nicht als
|
||||||
nicht automatisch im nativen Pipeline-Nachgang.
|
frischer paketierter Renderer-Fall live abgenommen.
|
||||||
- Native IK-Erfassung ist aktuell auf das sichtbare 16:9 Artifact-Inventar
|
- Weapons, Materials und Character Details bleiben ausserhalb des aktiven
|
||||||
begrenzt.
|
Scanner-Scope.
|
||||||
- The result rail is still a foundation: it can show native `scan-results.json`
|
- Andere Aufloesungen, Seitenverhaeltnisse und UI-Sprachen haben noch kein
|
||||||
entries after post-processing, but does not yet calculate or show real value
|
live validiertes Profil.
|
||||||
scores.
|
- Batch-Review/-Promotion und automatische Store-Persistenz bleiben aus.
|
||||||
- The inventory browser is still a foundation: value scoring and richer
|
- Der absichtlich schreibende Review-/Promotion-Pfad wurde nicht live
|
||||||
review/edit flows are not complete.
|
ausgefuehrt; er bleibt zustimmungspflichtige separate QA.
|
||||||
- Upgrade projection and build recommendations should wait until inventory and
|
|
||||||
detail evaluation have trustworthy stored artifacts.
|
|
||||||
- Repeatability across later sessions, more accounts, more locked/equipped
|
|
||||||
combinations, and more confirmed OCR corpus cases still needs growth.
|
|
||||||
- The strict `333 ms/artifact` budget for 3 artifacts/second is not proven.
|
|
||||||
- Weapons, materials, and character details are intentionally out of active
|
|
||||||
scope until their values are actually scanned.
|
|
||||||
|
|
||||||
## Wo Es Noch Probleme Macht
|
## Wo Es Noch Probleme Macht
|
||||||
|
|
||||||
- Die native Pipeline hat jetzt 20/50/100-Skalenevidenz aus einer Live-Session.
|
- Die fruehere `1,77/s`-Zahl ist nur der historische serielle 100er-Smoke:
|
||||||
Spaetere Session-Repeatability und Packaged-App-Evidenz stehen noch aus.
|
Capture/Reset und danach Batch-Processing wurden addiert. Sie darf nicht als
|
||||||
- Capture roundtrip and OCR remain the main timing costs in the 100-artifact
|
aktuelle Streaming-End-to-End-Rate gelesen werden. Der dry Streaming-20er
|
||||||
path.
|
`20260711-090019` lief 20/20 mit 0 Review/Fehlern/Writes, lieferte das erste
|
||||||
- Auto-scan still requires a visible artifact inventory detail card for the
|
Ergebnis vor Capture-Ende, mass `4,43 Capture/s` und beobachtet `2,39/s`
|
||||||
production path; broader auto-entry modes remain dev experiments.
|
vom Startrequest bis zur Result-Reconciliation. Der gemeinsame Clock-Contract
|
||||||
- OCR/parser quality is good on the confirmed corpus, but review samples must be
|
ist jetzt persistiert: der aktuelle 50er `20260711-154827` bestand mit
|
||||||
manually labeled before they can become permanent eval ground truth.
|
50/50 Capture/Parse/Results, 0 Review/Fehler/Writes und `28.022 ms`
|
||||||
- Non-16:9 or unusual game layouts are intentionally higher risk and should
|
Request-to-Reconciliation (`~1,78/s`). Der 3/s-Gesamtclaim bleibt offen.
|
||||||
block or fall into review instead of silently scanning.
|
- Neue OCR-Zustaende koennen weiterhin Review ausloesen; das ist ein sicherer
|
||||||
- The UI now labels these risks in the Artifact Inventory pipeline. Same-session
|
Fallback, kein Grund die Plausibilitaetsgates zu lockern.
|
||||||
scale is proven, but external-tool parity and later-session repeatability are
|
- Der Renderer hat weiter einen nicht-blockierenden ~630-kB-Chunk.
|
||||||
intentionally not claimed.
|
- Die neuen Live-Artifacts waren alle +20, deshalb entstand kein neuer echter
|
||||||
|
Under-Level-Projektionsfall.
|
||||||
|
- Ohne plausiblen OCR-Wert fuer die aktuelle Besitzanzahl blockiert der
|
||||||
|
Full-Inventory-Lauf absichtlich; Kapazitaet darf das Ziel nicht ersetzen und
|
||||||
|
der native Helper erkennt das Listenende noch nicht verlaesslich selbst.
|
||||||
|
|
||||||
## Was Noch Verbesserungsfaehig Ist
|
## Was Noch Verbesserungsfaehig Ist
|
||||||
|
|
||||||
- Repeat the bounded 20/50/100 native runs in a later session and in a packaged
|
1. Die Builds-Oberflaeche hat nun eine gebaute Renderer-Abnahme fuer frisches
|
||||||
app to verify that the current post-capture queue remains repeatable.
|
Englisch, Deutsch-Umschaltung, Kontext, Fokus und Overflow bestanden. Ein
|
||||||
- Keep native persistence explicit: clean selected results can be promoted
|
elevated Paket-Start bleibt nur fuer einen gesonderten Runtime/UAC-Claim
|
||||||
after confirmation, while uncertain results must pass single-item review.
|
offen; der frische native 50er-Smoke bestand als Scanner-Release-Gate.
|
||||||
- Finish value scoring and value-reason contracts in the existing detail view from
|
2. Profilquellen vor Ablauf erneuern und jedes weitere Profil wieder mit
|
||||||
`docs/scanner-results-inventory-roadmap.md`.
|
Golden-/adversarial Fixtures absichern.
|
||||||
- Keep diagnostics available but out of the primary scan surface.
|
3. Die verbleibenden 136 Review-Faelle nur nach visueller Crop-Bestaetigung in
|
||||||
- Continue expanding confirmed OCR eval cases from real review samples. The
|
Parser-Corpus oder Lernregeln uebernehmen.
|
||||||
first three native Review cases are corrected, approved, and permanent
|
4. Result-Klarheit und explizite Mutation-QA weiter staerken.
|
||||||
regression cases.
|
5. Erst nach eigenen Quellen fuer Charakterbesitz, Team, Waffe und Rotation
|
||||||
- Add artifact value/detail evaluation without collapsing extraction confidence
|
eine breitere Optimizer- oder Empfehlungsphase erwägen.
|
||||||
and artifact value into one ambiguous status.
|
|
||||||
- Continue performance work only when it reduces capture/OCR overhead without
|
|
||||||
weakening review, miss, duplicate, or safety gates.
|
|
||||||
|
|
||||||
## Was Als Naechstes Ansteht
|
## Aktueller Nachweis
|
||||||
|
|
||||||
1. Artifact Value Evaluation ergaenzen, aber erst nach sauberer Extraktion und
|
- `npm run acceptance:offline`: PASS
|
||||||
weiterhin getrennt von OCR/Parser-Confidence.
|
- `npm test`: 444/444 in 72 Dateien
|
||||||
2. Detail-Ansicht um Value-Gruende und optionale Upgrade-Projektion erweitern.
|
- `npm run acceptance:offline`: final PASS nach Lokalisierung, lokaler
|
||||||
3. Packaged-App-Verhalten fuer Helper, IK-Listen, Preload-Bridge,
|
Loeschung, Timing, Korpus und UI-Acceptance-Runner; Package-Verifier 27/27
|
||||||
Crop-Preview und Smoke-Kommandos pruefen.
|
- Build-Fit-Ranker/Kontext/Profile: offline PASS; gebaute Renderer-UI PASS,
|
||||||
4. Empfehlungen und Build-UX erst danach wieder nach vorne ziehen.
|
aber kein elevated Paket- oder neuer nativer Live-Claim
|
||||||
|
- `npm run package:verify`: 27/27
|
||||||
## Current Validation Commands
|
- `npm audit` und `npm audit --omit=dev`: 0 Findings
|
||||||
|
- Dev live: 5/20/50/100 PASS
|
||||||
```powershell
|
- Settings UI live: Artifact-Limit 5, 5/5 Capture/Processing/Parsing,
|
||||||
npm run scan:live:preflight
|
0 Review/Fehler, `persist=false`, Ergebnisleiste 5 PASS
|
||||||
npm run scan:iterate:validated
|
- Packaged Settings UI live: Reihenlimit 1 -> Ziel 8, 8/8
|
||||||
npm run scan:goal:validated
|
Capture/Processing/Parsing, 0 Review/Fehler, `persist=false` PASS
|
||||||
npm run scan:repeatability:wait
|
- Packaged Settings UI streaming: Artifact-Limit 20, maximal 11 Live-Resultate
|
||||||
npm run scan:assessment:validate -- --latest --summary
|
waehrend Capture, final 20/20, 0 Review/Fehler/Writes PASS
|
||||||
npm run eval
|
- Packaged UI-Dichteabnahme `20260710-214406`: maximal 9 sichtbare Resultate
|
||||||
npm test
|
und 12 Evaluierungen waehrend Capture, final 20/20, 0 Review/Fehler/Writes;
|
||||||
npm run build
|
Ergebnisflaeche 634 px bei 1304x821 ohne Dokument-Overflow PASS
|
||||||
```
|
- Packaged Full Inventory `20260710-223233`: OCR-Ziel/Capture/Results
|
||||||
|
2.211/2.211, 70 Seiten, 4,24 Capture/s, aktueller Package-Reprocess
|
||||||
Latest static validation after the Artifact-only Inventory UI update:
|
2.211/2.211 mit 136 Review (6,15%), 0 Fehler/Writes; Saved-Run-Validator
|
||||||
|
2.075 evaluated, 0 unknown, `issues=0` PASS
|
||||||
```powershell
|
- Packaged No-Genshin Safety: `genshinFound=false`, HWND 0, leerer
|
||||||
npm run lint # passed
|
Zielprozess, keine Capture-Quelle und 0 Renderer-Fehler PASS
|
||||||
npm test # passed, 252 tests
|
- Packaged live: Start/Bridge/Resources/Inventory und nativer 5er PASS
|
||||||
npm run build # passed
|
- Finale visuelle Evidenz:
|
||||||
git diff --check
|
`outputs/ui-qa/final-scan.png`, `outputs/ui-qa/final-scan-settings.png`,
|
||||||
```
|
`outputs/ui-qa/final-inventory.png` und die weiteren `final-*.png`-Ansichten
|
||||||
|
unter `outputs/ui-qa/`.
|
||||||
`git diff --check` passed with existing CRLF warnings for
|
sowie `outputs/packaged-live/streaming-ui-20-results-view.png`
|
||||||
`electron/services/inputHelperPowerShellFallback.ts` and `src/styles/base.css`.
|
(temporäre lokale Dateien zum Abnahmezeitpunkt; bei der angeforderten
|
||||||
|
Workspace-Bereinigung am 2026-07-11 entfernt).
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export function createAppWindowManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hideOverlayWindow() {
|
function hideOverlayWindow() {
|
||||||
overlayWindow?.close();
|
if (!overlayWindow || overlayWindow.isDestroyed()) return { ok: false };
|
||||||
|
overlayWindow.close();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ import type {
|
|||||||
GoodImportFileResult,
|
GoodImportFileResult,
|
||||||
NativeScannerCatalogStatus,
|
NativeScannerCatalogStatus,
|
||||||
NativeScannerDataStatus,
|
NativeScannerDataStatus,
|
||||||
|
NativeScannerDeleteResultStatus,
|
||||||
NativeScannerImageLoadStatus,
|
NativeScannerImageLoadStatus,
|
||||||
NativeScannerPreflightStatus,
|
NativeScannerPreflightStatus,
|
||||||
|
NativeScannerProcessOptions,
|
||||||
NativeScannerProcessStatus,
|
NativeScannerProcessStatus,
|
||||||
|
NativeScannerProcessingProgressStatus,
|
||||||
NativeScannerPromotionStatus,
|
NativeScannerPromotionStatus,
|
||||||
NativeScannerReviewArtifactInput,
|
NativeScannerReviewArtifactInput,
|
||||||
NativeScannerReviewStatus,
|
NativeScannerReviewStatus,
|
||||||
|
NativeScannerResultsLoadOptions,
|
||||||
NativeScannerResultsLoadStatus,
|
NativeScannerResultsLoadStatus,
|
||||||
NativeScannerRunStatus,
|
NativeScannerRunStatus,
|
||||||
NativeScannerStartCategory,
|
NativeScannerStartCategory,
|
||||||
@@ -50,10 +54,12 @@ interface AppHandlersDependencies {
|
|||||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
nativeScannerProcessingStatus: () => Promise<NativeScannerProcessingProgressStatus>;
|
||||||
|
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => Promise<NativeScannerResultsLoadStatus>;
|
||||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||||
|
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => Promise<NativeScannerDeleteResultStatus>;
|
||||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||||
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
readRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||||
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
|
||||||
@@ -102,9 +108,11 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
|||||||
nativeScannerStop: dependencies.nativeScannerStop,
|
nativeScannerStop: dependencies.nativeScannerStop,
|
||||||
nativeScannerStatus: dependencies.nativeScannerStatus,
|
nativeScannerStatus: dependencies.nativeScannerStatus,
|
||||||
nativeScannerProcessRun: dependencies.nativeScannerProcessRun,
|
nativeScannerProcessRun: dependencies.nativeScannerProcessRun,
|
||||||
|
nativeScannerProcessingStatus: dependencies.nativeScannerProcessingStatus,
|
||||||
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
|
nativeScannerLoadResults: dependencies.nativeScannerLoadResults,
|
||||||
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
|
nativeScannerPromoteResults: dependencies.nativeScannerPromoteResults,
|
||||||
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
|
nativeScannerReviewResult: dependencies.nativeScannerReviewResult,
|
||||||
|
nativeScannerDeleteResult: dependencies.nativeScannerDeleteResult,
|
||||||
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
|
nativeScannerLoadImage: dependencies.nativeScannerLoadImage,
|
||||||
getRuntimeInfo: dependencies.readRuntimeInfo,
|
getRuntimeInfo: dependencies.readRuntimeInfo,
|
||||||
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
loadSnapshot: dependencies.loadSnapshotFromDisk,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import type {
|
|||||||
NativeScannerDataStatus,
|
NativeScannerDataStatus,
|
||||||
NativeScannerImageLoadStatus,
|
NativeScannerImageLoadStatus,
|
||||||
NativeScannerPreflightStatus,
|
NativeScannerPreflightStatus,
|
||||||
|
NativeScannerProcessOptions,
|
||||||
NativeScannerProcessStatus,
|
NativeScannerProcessStatus,
|
||||||
|
NativeScannerResultsLoadOptions,
|
||||||
NativeScannerResultsLoadStatus,
|
NativeScannerResultsLoadStatus,
|
||||||
NativeScannerRunStatus,
|
NativeScannerRunStatus,
|
||||||
ScannerCommand,
|
ScannerCommand,
|
||||||
@@ -37,8 +39,8 @@ interface DevControlServerDependencies {
|
|||||||
nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise<NativeScannerRunStatus>;
|
nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => Promise<NativeScannerResultsLoadStatus>;
|
||||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||||
warmOcr: (engine: "current") => Promise<unknown>;
|
warmOcr: (engine: "current") => Promise<unknown>;
|
||||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||||
@@ -235,11 +237,15 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
|||||||
if (url.pathname === "/scanner/native/process") {
|
if (url.pathname === "/scanner/native/process") {
|
||||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||||
const persist = url.searchParams.get("persist") === "1";
|
const persist = url.searchParams.get("persist") === "1";
|
||||||
|
const stream = url.searchParams.get("stream") === "1";
|
||||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||||
|
const expectedTotal = Number(url.searchParams.get("expectedTotal") ?? Number.NaN);
|
||||||
deps.nativeScannerProcessRun({
|
deps.nativeScannerProcessRun({
|
||||||
runDir,
|
runDir,
|
||||||
persist,
|
persist,
|
||||||
|
stream,
|
||||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||||
|
expectedTotal: Number.isFinite(expectedTotal) && expectedTotal > 0 ? expectedTotal : undefined,
|
||||||
})
|
})
|
||||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
.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) }));
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
@@ -248,9 +254,11 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
|||||||
if (url.pathname === "/scanner/native/results") {
|
if (url.pathname === "/scanner/native/results") {
|
||||||
const runDir = url.searchParams.get("runDir") ?? undefined;
|
const runDir = url.searchParams.get("runDir") ?? undefined;
|
||||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||||
|
const afterSequence = Number(url.searchParams.get("afterSequence") ?? Number.NaN);
|
||||||
deps.nativeScannerLoadResults({
|
deps.nativeScannerLoadResults({
|
||||||
runDir,
|
runDir,
|
||||||
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
|
||||||
|
afterSequence: Number.isFinite(afterSequence) && afterSequence >= 0 ? afterSequence : undefined,
|
||||||
})
|
})
|
||||||
.then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status }))
|
.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) }));
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import type {
|
|||||||
FocusGenshinResult,
|
FocusGenshinResult,
|
||||||
NativeScannerCatalogStatus,
|
NativeScannerCatalogStatus,
|
||||||
NativeScannerDataStatus,
|
NativeScannerDataStatus,
|
||||||
|
NativeScannerDeleteResultStatus,
|
||||||
NativeScannerImageLoadStatus,
|
NativeScannerImageLoadStatus,
|
||||||
NativeScannerPreflightStatus,
|
NativeScannerPreflightStatus,
|
||||||
|
NativeScannerProcessOptions,
|
||||||
NativeScannerProcessStatus,
|
NativeScannerProcessStatus,
|
||||||
|
NativeScannerProcessingProgressStatus,
|
||||||
NativeScannerPromotionStatus,
|
NativeScannerPromotionStatus,
|
||||||
NativeScannerReviewArtifactInput,
|
NativeScannerReviewArtifactInput,
|
||||||
NativeScannerReviewStatus,
|
NativeScannerReviewStatus,
|
||||||
|
NativeScannerResultsLoadOptions,
|
||||||
NativeScannerResultsLoadStatus,
|
NativeScannerResultsLoadStatus,
|
||||||
NativeScannerRunStatus,
|
NativeScannerRunStatus,
|
||||||
NativeScannerStartCategory,
|
NativeScannerStartCategory,
|
||||||
@@ -30,10 +34,12 @@ interface AppCommandDependencies {
|
|||||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStop: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
nativeScannerStatus: () => Promise<NativeScannerRunStatus>;
|
||||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => Promise<NativeScannerProcessStatus>;
|
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => Promise<NativeScannerProcessStatus>;
|
||||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => Promise<NativeScannerResultsLoadStatus>;
|
nativeScannerProcessingStatus: () => Promise<NativeScannerProcessingProgressStatus>;
|
||||||
|
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => Promise<NativeScannerResultsLoadStatus>;
|
||||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => Promise<NativeScannerPromotionStatus>;
|
||||||
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => Promise<NativeScannerReviewStatus>;
|
||||||
|
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => Promise<NativeScannerDeleteResultStatus>;
|
||||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise<NativeScannerImageLoadStatus>;
|
||||||
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
getRuntimeInfo: () => Promise<RuntimeInfo>;
|
||||||
loadSnapshot: () => Promise<AppSnapshot | null>;
|
loadSnapshot: () => Promise<AppSnapshot | null>;
|
||||||
@@ -55,9 +61,11 @@ export function registerAppHandlers({
|
|||||||
nativeScannerStop,
|
nativeScannerStop,
|
||||||
nativeScannerStatus,
|
nativeScannerStatus,
|
||||||
nativeScannerProcessRun,
|
nativeScannerProcessRun,
|
||||||
|
nativeScannerProcessingStatus,
|
||||||
nativeScannerLoadResults,
|
nativeScannerLoadResults,
|
||||||
nativeScannerPromoteResults,
|
nativeScannerPromoteResults,
|
||||||
nativeScannerReviewResult,
|
nativeScannerReviewResult,
|
||||||
|
nativeScannerDeleteResult,
|
||||||
nativeScannerLoadImage,
|
nativeScannerLoadImage,
|
||||||
getRuntimeInfo,
|
getRuntimeInfo,
|
||||||
loadSnapshot,
|
loadSnapshot,
|
||||||
@@ -81,10 +89,12 @@ export function registerAppHandlers({
|
|||||||
ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options));
|
ipcMain.handle("scanner:nativeStart", async (_event, options?: { limit?: number; category?: NativeScannerStartCategory }) => nativeScannerStart(options));
|
||||||
ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop());
|
ipcMain.handle("scanner:nativeStop", async () => nativeScannerStop());
|
||||||
ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus());
|
ipcMain.handle("scanner:nativeStatus", async () => nativeScannerStatus());
|
||||||
ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options));
|
ipcMain.handle("scanner:nativeProcessRun", async (_event, options?: NativeScannerProcessOptions) => nativeScannerProcessRun(options));
|
||||||
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options));
|
ipcMain.handle("scanner:nativeProcessingStatus", async () => nativeScannerProcessingStatus());
|
||||||
|
ipcMain.handle("scanner:nativeLoadResults", async (_event, options?: NativeScannerResultsLoadOptions) => nativeScannerLoadResults(options));
|
||||||
ipcMain.handle("scanner:nativePromoteResults", async (_event, options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options));
|
ipcMain.handle("scanner: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:nativeReviewResult", async (_event, options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => nativeScannerReviewResult(options));
|
||||||
|
ipcMain.handle("scanner:nativeDeleteResult", async (_event, options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => nativeScannerDeleteResult(options));
|
||||||
ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options));
|
ipcMain.handle("scanner:nativeLoadImage", async (_event, options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options));
|
||||||
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
|
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
|
||||||
ipcMain.handle("snapshot:load", async () => loadSnapshot());
|
ipcMain.handle("snapshot:load", async () => loadSnapshot());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import type {
|
import type {
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreRemoveResult,
|
||||||
ArtifactStoreRepositoryPort,
|
ArtifactStoreRepositoryPort,
|
||||||
ArtifactStoreSaveResult,
|
ArtifactStoreSaveResult,
|
||||||
ReviewSampleListResult,
|
ReviewSampleListResult,
|
||||||
@@ -81,6 +82,33 @@ export function registerPersistenceHandlers({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intentionally accepts one exact local Store id only. Native result/crop
|
||||||
|
// tombstones use their separate scanner workflow, and this handler never
|
||||||
|
// invokes the input helper or sends any action to Genshin.
|
||||||
|
ipcMain.handle("artifacts:removeOne", async (_event, id: unknown): Promise<ArtifactStoreRemoveResult> => {
|
||||||
|
const artifactId = typeof id === "string" ? id.trim() : "";
|
||||||
|
if (!artifactId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
removed: 0,
|
||||||
|
total: 0,
|
||||||
|
path: artifactStorePath(),
|
||||||
|
error: "A local artifact Store id is required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await getArtifactStoreRepository().removeByIds([artifactId])) as ArtifactStoreRemoveResult;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
removed: 0,
|
||||||
|
total: 0,
|
||||||
|
path: artifactStorePath(),
|
||||||
|
error: "The local artifact Store record could not be removed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||||
return exportGood(payload);
|
return exportGood(payload);
|
||||||
});
|
});
|
||||||
|
|||||||
+159
-58
@@ -17,6 +17,10 @@ import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
|||||||
import { createDevControlServer } from "./devControlServer.js";
|
import { createDevControlServer } from "./devControlServer.js";
|
||||||
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
|
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
|
||||||
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
|
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
|
||||||
|
import { resolveNativeScannerRunDirectory } from "./services/nativeScannerRunDirectory.js";
|
||||||
|
import { readNativeScannerRunTiming, recordNativeScannerRunTiming } from "./services/nativeScannerRunTiming.js";
|
||||||
|
import { isLikelyGenshinSourceName } from "./services/captureSourceClassifier.js";
|
||||||
|
import { ikInventoryListsPathCandidates, inputHelperPathCandidates } from "./runtimePaths.js";
|
||||||
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
|
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
|
||||||
import type { AppSnapshot } from "../src/types/domain.js";
|
import type { AppSnapshot } from "../src/types/domain.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -25,12 +29,16 @@ import type {
|
|||||||
GoodDatabase,
|
GoodDatabase,
|
||||||
NativeScannerCatalogStatus,
|
NativeScannerCatalogStatus,
|
||||||
NativeScannerDataStatus,
|
NativeScannerDataStatus,
|
||||||
|
NativeScannerDeleteResultStatus,
|
||||||
NativeScannerImageLoadStatus,
|
NativeScannerImageLoadStatus,
|
||||||
NativeScannerPreflightStatus,
|
NativeScannerPreflightStatus,
|
||||||
|
NativeScannerProcessOptions,
|
||||||
NativeScannerProcessStatus,
|
NativeScannerProcessStatus,
|
||||||
|
NativeScannerProcessingProgressStatus,
|
||||||
NativeScannerPromotionStatus,
|
NativeScannerPromotionStatus,
|
||||||
NativeScannerReviewArtifactInput,
|
NativeScannerReviewArtifactInput,
|
||||||
NativeScannerReviewStatus,
|
NativeScannerReviewStatus,
|
||||||
|
NativeScannerResultsLoadOptions,
|
||||||
NativeScannerResultsLoadStatus,
|
NativeScannerResultsLoadStatus,
|
||||||
NativeScannerRunStatus,
|
NativeScannerRunStatus,
|
||||||
OcrResult,
|
OcrResult,
|
||||||
@@ -70,7 +78,7 @@ app.commandLine.appendSwitch("disable-gpu-sandbox");
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
||||||
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
|
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
|
||||||
const APP_RUNTIME_SIGNATURE = "2026-07-09-native-artifact-pipeline";
|
const APP_RUNTIME_SIGNATURE = "2026-07-10-packaged-runtime-acceptance";
|
||||||
|
|
||||||
let registeredHotkeys: Record<string, boolean> = {};
|
let registeredHotkeys: Record<string, boolean> = {};
|
||||||
let devControlServer: Server | null = null;
|
let devControlServer: Server | null = null;
|
||||||
@@ -93,13 +101,26 @@ let scannerDevStatus: ScannerStatusPayload = {
|
|||||||
learningRuleCount: 0,
|
learningRuleCount: 0,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
};
|
};
|
||||||
|
let nativeProcessingProgress: NativeScannerProcessingProgressStatus = {
|
||||||
|
running: false,
|
||||||
|
runDir: "",
|
||||||
|
total: 0,
|
||||||
|
processed: 0,
|
||||||
|
parsed: 0,
|
||||||
|
review: 0,
|
||||||
|
stored: 0,
|
||||||
|
errors: 0,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
let repositoryContext: RepositoryContext | null = null;
|
let repositoryContext: RepositoryContext | null = null;
|
||||||
let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
|
let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
|
||||||
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
|
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
|
||||||
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
|
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
|
||||||
|
let nativeScannerProcessingService: ReturnType<typeof createNativeScannerProcessingService> | null = null;
|
||||||
let inputHelperService: InputHelperService | null = null;
|
let inputHelperService: InputHelperService | null = null;
|
||||||
let appWindowManager: AppWindowManager | null = null;
|
let appWindowManager: AppWindowManager | null = null;
|
||||||
let goodFileService: GoodFileService | null = null;
|
let goodFileService: GoodFileService | null = null;
|
||||||
|
const nativeScannerRequestStartedAtByRunId = new Map<string, string>();
|
||||||
|
|
||||||
function getInputHelperService() {
|
function getInputHelperService() {
|
||||||
if (!inputHelperService) {
|
if (!inputHelperService) {
|
||||||
@@ -111,12 +132,13 @@ function getInputHelperService() {
|
|||||||
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
|
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
|
||||||
// the service uses the embedded PowerShell helper when the exe was never built.
|
// the service uses the embedded PowerShell helper when the exe was never built.
|
||||||
function resolveInputHelperExePath(): string | null {
|
function resolveInputHelperExePath(): string | null {
|
||||||
const candidates = [
|
const candidates = inputHelperPathCandidates({
|
||||||
process.env.INPUT_HELPER_EXE,
|
isPackaged: app.isPackaged,
|
||||||
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
|
resourcesPath: process.resourcesPath,
|
||||||
path.join(process.cwd(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
currentWorkingDirectory: process.cwd(),
|
||||||
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
appPath: app.getAppPath(),
|
||||||
].filter((candidate): candidate is string => Boolean(candidate));
|
overridePath: process.env.INPUT_HELPER_EXE,
|
||||||
|
});
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
@@ -231,12 +253,13 @@ async function publishScannerStatus(status: ScannerStatusPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nativeScannerDataDir() {
|
function nativeScannerDataDir() {
|
||||||
const candidates = [
|
const candidates = ikInventoryListsPathCandidates({
|
||||||
process.env.IK_INVENTORYLISTS_DIR,
|
isPackaged: app.isPackaged,
|
||||||
path.join(process.cwd(), "data", "ik-inventorylists"),
|
resourcesPath: process.resourcesPath,
|
||||||
path.join(app.getAppPath(), "data", "ik-inventorylists"),
|
currentWorkingDirectory: process.cwd(),
|
||||||
path.join(process.resourcesPath, "ik-inventorylists"),
|
appPath: app.getAppPath(),
|
||||||
].filter((candidate): candidate is string => Boolean(candidate));
|
overridePath: process.env.IK_INVENTORYLISTS_DIR,
|
||||||
|
});
|
||||||
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
|
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,37 +342,64 @@ async function nativeScannerPreflight(options: { category?: string } = {}): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
|
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
|
||||||
const status = await getInputHelperService().nativeScannerStart({
|
const requestStartedAt = new Date().toISOString();
|
||||||
|
const rawStatus = await getInputHelperService().nativeScannerStart({
|
||||||
dataDir: nativeScannerDataDir(),
|
dataDir: nativeScannerDataDir(),
|
||||||
outputRoot: nativeScannerOutputRoot(),
|
outputRoot: nativeScannerOutputRoot(),
|
||||||
limit: options.limit ?? 100,
|
limit: options.limit ?? 100,
|
||||||
category: options.category ?? "artifacts",
|
category: options.category ?? "artifacts",
|
||||||
});
|
});
|
||||||
|
const runId = rawStatus.runId?.trim();
|
||||||
|
if (runId && !nativeScannerRequestStartedAtByRunId.has(runId)) {
|
||||||
|
nativeScannerRequestStartedAtByRunId.set(runId, requestStartedAt);
|
||||||
|
}
|
||||||
|
const status = await attachNativeScannerTiming(rawStatus);
|
||||||
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
|
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
|
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
|
||||||
const status = await getInputHelperService().nativeScannerStop();
|
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStop());
|
||||||
publishNativeScannerStatus(status);
|
publishNativeScannerStatus(status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
|
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
|
||||||
const status = await getInputHelperService().nativeScannerStatus();
|
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStatus());
|
||||||
publishNativeScannerStatus(status);
|
publishNativeScannerStatus(status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function attachNativeScannerTiming(status: NativeScannerRunStatus): Promise<NativeScannerRunStatus> {
|
||||||
|
const runDir = status.runDir?.trim();
|
||||||
|
const runId = status.runId?.trim();
|
||||||
|
if (!runDir) return status;
|
||||||
|
const timing = await recordNativeScannerRunTiming(runDir, {
|
||||||
|
...(runId && nativeScannerRequestStartedAtByRunId.has(runId)
|
||||||
|
? { requestStartedAt: nativeScannerRequestStartedAtByRunId.get(runId) }
|
||||||
|
: {}),
|
||||||
|
...(usableRunTimingTimestamp(status.startedAt) ? { scannerStartedAt: status.startedAt } : {}),
|
||||||
|
...(usableRunTimingTimestamp(status.captureStartedAt) ? { captureStartedAt: status.captureStartedAt } : {}),
|
||||||
|
...(usableRunTimingTimestamp(status.captureCompletedAt) ? { captureCompletedAt: status.captureCompletedAt } : {}),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!status.running && runId) nativeScannerRequestStartedAtByRunId.delete(runId);
|
||||||
|
return timing ? { ...status, timing } : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usableRunTimingTimestamp(value: unknown): value is string {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& value.length > 0
|
||||||
|
&& !value.startsWith("0001-")
|
||||||
|
&& Number.isFinite(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
function resolveNativeProcessRunDir(runDir?: string) {
|
function resolveNativeProcessRunDir(runDir?: string) {
|
||||||
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
|
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
|
||||||
const candidate = runDir?.trim() || native?.runDir || "";
|
return resolveNativeScannerRunDirectory({
|
||||||
if (!candidate) return "";
|
outputRoot: nativeScannerOutputRoot(),
|
||||||
const root = path.resolve(nativeScannerOutputRoot());
|
requestedRunDir: runDir,
|
||||||
const resolved = path.resolve(candidate);
|
activeRunDir: native?.runDir,
|
||||||
const relative = path.relative(root, resolved);
|
});
|
||||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return "";
|
|
||||||
return resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildNativeCardCaptureResult(
|
async function buildNativeCardCaptureResult(
|
||||||
@@ -436,19 +486,43 @@ async function buildNativeCardCaptureResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNativeScannerService() {
|
function getNativeScannerService() {
|
||||||
return createNativeScannerProcessingService({
|
if (nativeScannerProcessingService) return nativeScannerProcessingService;
|
||||||
|
nativeScannerProcessingService = createNativeScannerProcessingService({
|
||||||
resolveRunDir: resolveNativeProcessRunDir,
|
resolveRunDir: resolveNativeProcessRunDir,
|
||||||
buildCaptureResult: buildNativeCardCaptureResult,
|
buildCaptureResult: buildNativeCardCaptureResult,
|
||||||
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
|
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
|
||||||
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
|
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
|
||||||
|
removeArtifacts: (ids) => getArtifactStoreRepository().removeByIds(ids),
|
||||||
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
|
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
|
||||||
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
|
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
|
||||||
|
recordRunTiming: recordNativeScannerRunTiming,
|
||||||
|
loadRunTiming: readNativeScannerRunTiming,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
nativeProcessingProgress = { ...progress };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return nativeScannerProcessingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerProcessRun(options: { runDir?: string; persist?: boolean; limit?: number } = {}): Promise<NativeScannerProcessStatus> {
|
async function nativeScannerProcessingStatus() {
|
||||||
const service = createNativeScannerService();
|
return { ...nativeProcessingProgress };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nativeScannerProcessRun(options: NativeScannerProcessOptions = {}): Promise<NativeScannerProcessStatus> {
|
||||||
|
const service = getNativeScannerService();
|
||||||
|
nativeProcessingProgress = {
|
||||||
|
running: true,
|
||||||
|
runDir: options.runDir ?? "",
|
||||||
|
total: Math.max(0, Math.round(options.expectedTotal ?? 0)),
|
||||||
|
processed: 0,
|
||||||
|
parsed: 0,
|
||||||
|
review: 0,
|
||||||
|
stored: 0,
|
||||||
|
errors: 0,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
try {
|
||||||
const status = await service.processRun(options);
|
const status = await service.processRun(options);
|
||||||
scannerDevStatus = {
|
scannerDevStatus = {
|
||||||
...scannerDevStatus,
|
...scannerDevStatus,
|
||||||
@@ -465,10 +539,18 @@ async function nativeScannerProcessRun(options: { runDir?: string; persist?: boo
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
return status;
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
nativeProcessingProgress = {
|
||||||
|
...nativeProcessingProgress,
|
||||||
|
running: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerLoadResults(options: { runDir?: string; limit?: number } = {}): Promise<NativeScannerResultsLoadStatus> {
|
async function nativeScannerLoadResults(options: NativeScannerResultsLoadOptions = {}): Promise<NativeScannerResultsLoadStatus> {
|
||||||
const service = createNativeScannerService();
|
const service = getNativeScannerService();
|
||||||
const loaded = await service.loadResults(options);
|
const loaded = await service.loadResults(options);
|
||||||
scannerDevStatus = {
|
scannerDevStatus = {
|
||||||
...scannerDevStatus,
|
...scannerDevStatus,
|
||||||
@@ -483,7 +565,7 @@ async function nativeScannerLoadResults(options: { runDir?: string; limit?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
|
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
|
||||||
const service = createNativeScannerService();
|
const service = getNativeScannerService();
|
||||||
const status = await service.promoteResults(options);
|
const status = await service.promoteResults(options);
|
||||||
scannerDevStatus = {
|
scannerDevStatus = {
|
||||||
...scannerDevStatus,
|
...scannerDevStatus,
|
||||||
@@ -507,7 +589,7 @@ async function nativeScannerReviewResult(options: {
|
|||||||
artifact?: NativeScannerReviewArtifactInput;
|
artifact?: NativeScannerReviewArtifactInput;
|
||||||
note?: string;
|
note?: string;
|
||||||
}): Promise<NativeScannerReviewStatus> {
|
}): Promise<NativeScannerReviewStatus> {
|
||||||
const service = createNativeScannerService();
|
const service = getNativeScannerService();
|
||||||
const status = await service.reviewResult(options);
|
const status = await service.reviewResult(options);
|
||||||
scannerDevStatus = {
|
scannerDevStatus = {
|
||||||
...scannerDevStatus,
|
...scannerDevStatus,
|
||||||
@@ -524,8 +606,30 @@ async function nativeScannerReviewResult(options: {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nativeScannerDeleteResult(options: {
|
||||||
|
runDir?: string;
|
||||||
|
resultId: string;
|
||||||
|
removeLinkedStoreRecord?: boolean;
|
||||||
|
}): Promise<NativeScannerDeleteResultStatus> {
|
||||||
|
const service = getNativeScannerService();
|
||||||
|
const status = await service.deleteResult(options);
|
||||||
|
scannerDevStatus = {
|
||||||
|
...scannerDevStatus,
|
||||||
|
reviewStatus: status.ok
|
||||||
|
? `Local native result removed: ${status.resultId}${status.deletedStoreRecord ? " and linked store record" : ""}.`
|
||||||
|
: `Local native result removal blocked: ${status.error ?? "unknown error"}`,
|
||||||
|
nativeScannerDeletion: status,
|
||||||
|
automationLog: [
|
||||||
|
...(scannerDevStatus.automationLog ?? []).slice(-10),
|
||||||
|
`native local deletion: ${status.resultId || "unknown"} ${status.ok ? "ok" : status.error ?? "blocked"}`,
|
||||||
|
],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
|
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
|
||||||
const service = createNativeScannerService();
|
const service = getNativeScannerService();
|
||||||
return service.loadImage(options);
|
return service.loadImage(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,16 +805,6 @@ async function getAutomationGuardCommand() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyGenshinSourceName(sourceName: string) {
|
|
||||||
const lowered = sourceName.toLowerCase();
|
|
||||||
return (
|
|
||||||
lowered.includes("genshin")
|
|
||||||
|| lowered.includes("genshinimpact")
|
|
||||||
|| lowered.includes("yuanshen")
|
|
||||||
|| sourceName.includes("\u539f\u795e")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function capturePrimaryScreenViaGdi() {
|
async function capturePrimaryScreenViaGdi() {
|
||||||
return getInputHelperService().capturePrimaryScreenViaGdi();
|
return getInputHelperService().capturePrimaryScreenViaGdi();
|
||||||
}
|
}
|
||||||
@@ -734,6 +828,7 @@ function nativeImageFromGdiCapture(gdi: Awaited<ReturnType<InputHelperService["c
|
|||||||
function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) {
|
function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
options.ocrMode === "artifact" ||
|
options.ocrMode === "artifact" ||
|
||||||
|
options.ocrMode === "inventory-count" ||
|
||||||
options.skipOcrUnlessArtifactDetail ||
|
options.skipOcrUnlessArtifactDetail ||
|
||||||
options.skipOcr ||
|
options.skipOcr ||
|
||||||
options.omitCrops,
|
options.omitCrops,
|
||||||
@@ -1398,7 +1493,7 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
|
|||||||
|
|
||||||
function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
|
function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
|
||||||
const safeRect = clampCaptureRect(rect, imageSize);
|
const safeRect = clampCaptureRect(rect, imageSize);
|
||||||
const bitmap = sourceImage.crop(safeRect).getBitmap();
|
const bitmap = sourceImage.crop(safeRect).toBitmap();
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
const stride = Math.max(4, Math.floor(bitmap.length / 4096) * 4);
|
const stride = Math.max(4, Math.floor(bitmap.length / 4096) * 4);
|
||||||
for (let index = 0; index < bitmap.length; index += stride) {
|
for (let index = 0; index < bitmap.length; index += stride) {
|
||||||
@@ -1424,7 +1519,7 @@ function preprocessedCropPngBuffer(sourceImage: NativeImage, rect: Electron.Rect
|
|||||||
const size = upscaled.getSize();
|
const size = upscaled.getSize();
|
||||||
if (!size.width || !size.height) return upscaled.toPNG();
|
if (!size.width || !size.height) return upscaled.toPNG();
|
||||||
const binarized = binarizeForOcr(
|
const binarized = binarizeForOcr(
|
||||||
{ data: upscaled.getBitmap(), width: size.width, height: size.height },
|
{ data: upscaled.toBitmap(), width: size.width, height: size.height },
|
||||||
cropId === "artifact-level" ? { contrast: 80 } : {},
|
cropId === "artifact-level" ? { contrast: 80 } : {},
|
||||||
);
|
);
|
||||||
return nativeImage
|
return nativeImage
|
||||||
@@ -1442,10 +1537,13 @@ function createCrops(
|
|||||||
cropOptions: { sanctified?: boolean; skipOcr?: boolean } = {},
|
cropOptions: { sanctified?: boolean; skipOcr?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const isArtifactScanMode = options.ocrMode === "artifact";
|
const isArtifactScanMode = options.ocrMode === "artifact";
|
||||||
|
const isInventoryCountMode = options.ocrMode === "inventory-count";
|
||||||
const fastArtifactProfile = isArtifactScanMode && options.ocrProfile === "fast";
|
const fastArtifactProfile = isArtifactScanMode && options.ocrProfile === "fast";
|
||||||
const omitCropImages = Boolean(options.omitCropImages);
|
const omitCropImages = Boolean(options.omitCropImages);
|
||||||
const skipCropOcr = Boolean(cropOptions.skipOcr);
|
const skipCropOcr = Boolean(cropOptions.skipOcr);
|
||||||
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
|
const templates: CropTemplate[] = isInventoryCountMode
|
||||||
|
? []
|
||||||
|
: detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
|
||||||
.filter((template) => {
|
.filter((template) => {
|
||||||
if (fastArtifactProfile && (
|
if (fastArtifactProfile && (
|
||||||
template.id === "artifact-set-effects" ||
|
template.id === "artifact-set-effects" ||
|
||||||
@@ -1544,24 +1642,25 @@ async function buildCaptureResult(
|
|||||||
options: CaptureOptions = {},
|
options: CaptureOptions = {},
|
||||||
) {
|
) {
|
||||||
const buildStartedAt = Date.now();
|
const buildStartedAt = Date.now();
|
||||||
|
const isInventoryCountMode = options.ocrMode === "inventory-count";
|
||||||
const size = sourceImage.getSize();
|
const size = sourceImage.getSize();
|
||||||
if (!size.width || !size.height) {
|
if (!size.width || !size.height) {
|
||||||
throw new Error("Capture produced an empty image.");
|
throw new Error("Capture produced an empty image.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const bitmap = sourceImage.getBitmap();
|
const bitmap = sourceImage.toBitmap();
|
||||||
const detailRect = inferDetailRect(bitmap, size);
|
const detailRect = inferDetailRect(bitmap, size);
|
||||||
const artifactDetail = analyzeArtifactDetailPanel(bitmap, size, detailRect);
|
const artifactDetail = analyzeArtifactDetailPanel(bitmap, size, detailRect);
|
||||||
const paimonMenu = analyzePaimonMenu(bitmap, size);
|
const paimonMenu = isInventoryCountMode ? undefined : analyzePaimonMenu(bitmap, size);
|
||||||
const sanctified = detectSanctifiedArtifactDetail(bitmap, size, detailRect);
|
const sanctified = isInventoryCountMode ? undefined : detectSanctifiedArtifactDetail(bitmap, size, detailRect);
|
||||||
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail.present);
|
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail?.present);
|
||||||
const shouldSkipOcr = Boolean(options.skipOcr || skipOcrForMissingDetail);
|
const shouldSkipOcr = Boolean(options.skipOcr || skipOcrForMissingDetail);
|
||||||
const inventoryRect = inferInventoryRect(size, detailRect);
|
const inventoryRect = inferInventoryRect(size, detailRect);
|
||||||
const omitCrops = Boolean(options.omitCrops);
|
const omitCrops = Boolean(options.omitCrops);
|
||||||
const crops = omitCrops
|
const crops = omitCrops
|
||||||
? []
|
? []
|
||||||
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
|
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
|
||||||
const lockSignal = options.omitLockState
|
const lockSignal = options.omitLockState || isInventoryCountMode
|
||||||
? undefined
|
? undefined
|
||||||
: (() => {
|
: (() => {
|
||||||
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
|
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
|
||||||
@@ -1572,7 +1671,7 @@ async function buildCaptureResult(
|
|||||||
try {
|
try {
|
||||||
return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG()));
|
return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG()));
|
||||||
} catch {
|
} catch {
|
||||||
return lockSignalRatio({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height });
|
return lockSignalRatio({ data: lockImage.toBitmap(), width: lockSize.width, height: lockSize.height });
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1590,11 +1689,11 @@ async function buildCaptureResult(
|
|||||||
: undefined;
|
: undefined;
|
||||||
})();
|
})();
|
||||||
const locked = lockSignal ? isLocked(lockSignal.ratio, lockSignal.threshold) : undefined;
|
const locked = lockSignal ? isLocked(lockSignal.ratio, lockSignal.threshold) : undefined;
|
||||||
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact");
|
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact" || isInventoryCountMode);
|
||||||
const omitDetailPreview = Boolean(options.omitDetailPreview);
|
const omitDetailPreview = Boolean(options.omitDetailPreview || isInventoryCountMode);
|
||||||
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact");
|
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact" || isInventoryCountMode);
|
||||||
const detailFingerprint = imageCropFingerprint(sourceImage, detailRect, size);
|
const detailFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, detailRect, size);
|
||||||
const inventoryFingerprint = imageCropFingerprint(sourceImage, inventoryRect, size);
|
const inventoryFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, inventoryRect, size);
|
||||||
const fastOcrPriority: Record<string, number> = {
|
const fastOcrPriority: Record<string, number> = {
|
||||||
"artifact-substats": 0,
|
"artifact-substats": 0,
|
||||||
"artifact-name": 1,
|
"artifact-name": 1,
|
||||||
@@ -1757,8 +1856,9 @@ function initializeAppLifecycle() {
|
|||||||
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
|
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
|
||||||
nativeScannerStop: () => nativeScannerStop(),
|
nativeScannerStop: () => nativeScannerStop(),
|
||||||
nativeScannerStatus: () => nativeScannerStatus(),
|
nativeScannerStatus: () => nativeScannerStatus(),
|
||||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options),
|
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => nativeScannerProcessRun(options),
|
||||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options),
|
nativeScannerProcessingStatus: () => nativeScannerProcessingStatus(),
|
||||||
|
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => nativeScannerLoadResults(options),
|
||||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
|
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
|
||||||
nativeScannerReviewResult: (options: {
|
nativeScannerReviewResult: (options: {
|
||||||
runDir?: string;
|
runDir?: string;
|
||||||
@@ -1767,6 +1867,7 @@ function initializeAppLifecycle() {
|
|||||||
artifact?: NativeScannerReviewArtifactInput;
|
artifact?: NativeScannerReviewArtifactInput;
|
||||||
note?: string;
|
note?: string;
|
||||||
}) => nativeScannerReviewResult(options),
|
}) => nativeScannerReviewResult(options),
|
||||||
|
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => nativeScannerDeleteResult(options),
|
||||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
|
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
|
||||||
readRuntimeInfo: () => readRuntimeInfo(),
|
readRuntimeInfo: () => readRuntimeInfo(),
|
||||||
loadSnapshotFromDisk: () => loadSnapshotFromDisk(),
|
loadSnapshotFromDisk: () => loadSnapshotFromDisk(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
|
removeArtifact: (id) => ipcRenderer.invoke("artifacts:removeOne", id),
|
||||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
@@ -30,9 +31,11 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||||
nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
nativeScannerProcessRun: (options) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||||
|
nativeScannerProcessingStatus: () => ipcRenderer.invoke("scanner:nativeProcessingStatus"),
|
||||||
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
nativeScannerLoadResults: (options) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||||
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
nativeScannerPromoteResults: (options) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
||||||
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
nativeScannerReviewResult: (options) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||||
|
nativeScannerDeleteResult: (options) => ipcRenderer.invoke("scanner:nativeDeleteResult", options),
|
||||||
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
nativeScannerLoadImage: (options) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
|
|||||||
+6
-3
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { CaptureOptions, GoodDatabase, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
import type { CaptureOptions, GoodDatabase, NativeScannerProcessOptions, NativeScannerResultsLoadOptions, NativeScannerReviewArtifactInput, NativeScannerStartCategory, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||||
import type { AppSnapshot } from "../src/types/domain.js";
|
import type { AppSnapshot } from "../src/types/domain.js";
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
|
||||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||||
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||||
|
removeArtifact: (id: string) => ipcRenderer.invoke("artifacts:removeOne", id),
|
||||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||||
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||||
@@ -32,10 +33,12 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
|||||||
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options),
|
nativeScannerStart: (options?: { limit?: number; category?: NativeScannerStartCategory }) => ipcRenderer.invoke("scanner:nativeStart", options),
|
||||||
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
nativeScannerStop: () => ipcRenderer.invoke("scanner:nativeStop"),
|
||||||
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
nativeScannerStatus: () => ipcRenderer.invoke("scanner:nativeStatus"),
|
||||||
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => ipcRenderer.invoke("scanner:nativeProcessRun", options),
|
||||||
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
nativeScannerProcessingStatus: () => ipcRenderer.invoke("scanner:nativeProcessingStatus"),
|
||||||
|
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => ipcRenderer.invoke("scanner:nativeLoadResults", options),
|
||||||
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => ipcRenderer.invoke("scanner:nativePromoteResults", options),
|
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),
|
nativeScannerReviewResult: (options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string }) => ipcRenderer.invoke("scanner:nativeReviewResult", options),
|
||||||
|
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => ipcRenderer.invoke("scanner:nativeDeleteResult", options),
|
||||||
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => ipcRenderer.invoke("scanner:nativeLoadImage", options),
|
||||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||||
import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js";
|
import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js";
|
||||||
import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js";
|
import type {
|
||||||
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreRemoveResult,
|
||||||
|
ArtifactStoreRepositoryPort,
|
||||||
|
ArtifactStoreSaveResult,
|
||||||
|
} from "./contracts.js";
|
||||||
interface ArtifactStoreFile {
|
interface ArtifactStoreFile {
|
||||||
version?: number;
|
version?: number;
|
||||||
artifacts?: StoredArtifactRecord[];
|
artifacts?: StoredArtifactRecord[];
|
||||||
@@ -79,9 +84,26 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
|
|||||||
return { ok: true, added, updated, total: store.size, path: this.filePath };
|
return { ok: true, added, updated, total: store.size, path: this.filePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeByIds(ids: string[]): Promise<ArtifactStoreRemoveResult> {
|
||||||
|
const requestedIds = new Set((ids ?? []).map((id) => String(id ?? "").trim()).filter(Boolean));
|
||||||
|
const store = await this.loadMap();
|
||||||
|
let removed = 0;
|
||||||
|
for (const id of requestedIds) {
|
||||||
|
if (store.delete(id)) removed += 1;
|
||||||
|
}
|
||||||
|
if (removed > 0) await this.writeRecords([...store.values()]);
|
||||||
|
return { ok: true, removed, total: store.size, path: this.filePath };
|
||||||
|
}
|
||||||
|
|
||||||
private async writeRecords(records: StoredArtifactRecord[]) {
|
private async writeRecords(records: StoredArtifactRecord[]) {
|
||||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
|
const temporaryPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temporaryPath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
|
||||||
|
await fs.rename(temporaryPath, this.filePath);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CaptureSourceInfo,
|
CaptureSourceInfo,
|
||||||
ClickResult,
|
ClickResult,
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreRemoveResult,
|
||||||
ArtifactStoreSaveResult,
|
ArtifactStoreSaveResult,
|
||||||
ReviewSampleListResult,
|
ReviewSampleListResult,
|
||||||
ReviewSamplePayload,
|
ReviewSamplePayload,
|
||||||
@@ -23,6 +24,7 @@ import type { AppSnapshot } from "../../src/types/domain.js";
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
|
ArtifactStoreRemoveResult,
|
||||||
ArtifactStoreSaveResult,
|
ArtifactStoreSaveResult,
|
||||||
ReviewSampleListResult,
|
ReviewSampleListResult,
|
||||||
ReviewSamplePayload,
|
ReviewSamplePayload,
|
||||||
@@ -34,6 +36,7 @@ export interface ArtifactStoreRepositoryPort {
|
|||||||
loadAll(): Promise<ArtifactStoreLoadResult>;
|
loadAll(): Promise<ArtifactStoreLoadResult>;
|
||||||
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
|
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
|
||||||
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
|
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
|
||||||
|
removeByIds(ids: string[]): Promise<ArtifactStoreRemoveResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewSamplesRepositoryPort {
|
export interface ReviewSamplesRepositoryPort {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface RuntimeResourcePathOptions {
|
||||||
|
isPackaged: boolean;
|
||||||
|
resourcesPath: string;
|
||||||
|
currentWorkingDirectory: string;
|
||||||
|
appPath: string;
|
||||||
|
overridePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueCandidates(candidates: Array<string | undefined>) {
|
||||||
|
return candidates.filter((candidate, index, all): candidate is string => (
|
||||||
|
Boolean(candidate) && all.indexOf(candidate) === index
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inputHelperPathCandidates({
|
||||||
|
isPackaged,
|
||||||
|
resourcesPath,
|
||||||
|
currentWorkingDirectory,
|
||||||
|
appPath,
|
||||||
|
overridePath,
|
||||||
|
}: RuntimeResourcePathOptions) {
|
||||||
|
const packagedPath = path.join(resourcesPath, "input-helper", "InputHelper.exe");
|
||||||
|
const developmentPaths = [
|
||||||
|
path.join(currentWorkingDirectory, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||||
|
path.join(appPath, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return uniqueCandidates([
|
||||||
|
overridePath,
|
||||||
|
...(isPackaged ? [packagedPath] : [...developmentPaths, packagedPath]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ikInventoryListsPathCandidates({
|
||||||
|
isPackaged,
|
||||||
|
resourcesPath,
|
||||||
|
currentWorkingDirectory,
|
||||||
|
appPath,
|
||||||
|
overridePath,
|
||||||
|
}: RuntimeResourcePathOptions) {
|
||||||
|
const packagedPath = path.join(resourcesPath, "ik-inventorylists");
|
||||||
|
const developmentPaths = [
|
||||||
|
path.join(currentWorkingDirectory, "data", "ik-inventorylists"),
|
||||||
|
path.join(appPath, "data", "ik-inventorylists"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return uniqueCandidates([
|
||||||
|
overridePath,
|
||||||
|
...(isPackaged ? [packagedPath] : [...developmentPaths, packagedPath]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function isLikelyGenshinSourceName(sourceName: string) {
|
||||||
|
const normalized = sourceName.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (
|
||||||
|
normalized === "genshin"
|
||||||
|
|| /\bgenshin\s*impact\b/.test(normalized)
|
||||||
|
|| normalized.includes("genshinimpact")
|
||||||
|
|| normalized.includes("yuanshen")
|
||||||
|
|| sourceName.includes("\u539f\u795e")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,6 +166,18 @@ export interface InputHelperService {
|
|||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function automationGuardFromHelperResponse(result: HelperOperationResponse): AutomationGuard {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
||||||
|
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
||||||
|
escapePressed: Boolean(result.escapePressed),
|
||||||
|
enterPressed: Boolean(result.enterPressed),
|
||||||
|
f9Pressed: Boolean(result.f9Pressed),
|
||||||
|
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
|
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
|
||||||
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
|
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
|
||||||
|
|
||||||
@@ -273,14 +285,7 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
|||||||
|
|
||||||
async function getAutomationGuard() {
|
async function getAutomationGuard() {
|
||||||
const result = await request("cursor", {}, 4000);
|
const result = await request("cursor", {}, 4000);
|
||||||
return {
|
return automationGuardFromHelperResponse(result);
|
||||||
ok: true,
|
|
||||||
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
|
|
||||||
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
|
|
||||||
escapePressed: Boolean(result.escapePressed),
|
|
||||||
enterPressed: Boolean(result.enterPressed),
|
|
||||||
f9Pressed: Boolean(result.f9Pressed),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function capturePrimaryScreenViaGdi() {
|
async function capturePrimaryScreenViaGdi() {
|
||||||
|
|||||||
@@ -197,8 +197,12 @@ function Get-GenshinClientBounds {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Find-GenshinWindow {
|
function Find-GenshinWindow {
|
||||||
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) {
|
||||||
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
$cachedName = Get-ProcessNameFromHwnd -hwnd $script:genshinHwnd
|
||||||
|
if ($cachedName -in @('GenshinImpact', 'YuanShen')) { return $script:genshinHwnd }
|
||||||
|
$script:genshinHwnd = [IntPtr]::Zero
|
||||||
|
}
|
||||||
|
$proc = Get-Process | Where-Object { $_.ProcessName -in @('GenshinImpact', 'YuanShen') -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||||
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||||
return $script:genshinHwnd
|
return $script:genshinHwnd
|
||||||
}
|
}
|
||||||
@@ -283,6 +287,7 @@ while ($true) {
|
|||||||
$response.escapePressed = $state.escapePressed
|
$response.escapePressed = $state.escapePressed
|
||||||
$response.enterPressed = $state.enterPressed
|
$response.enterPressed = $state.enterPressed
|
||||||
$response.f9Pressed = $state.f9Pressed
|
$response.f9Pressed = $state.f9Pressed
|
||||||
|
$response.isElevated = Get-CurrentProcessElevation
|
||||||
}
|
}
|
||||||
"runtime" {
|
"runtime" {
|
||||||
$response.isElevated = Get-CurrentProcessElevation
|
$response.isElevated = Get-CurrentProcessElevation
|
||||||
|
|||||||
@@ -2,23 +2,30 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pngBufferToBitmap } from "./pngBitmap.js";
|
import { pngBufferToBitmap } from "./pngBitmap.js";
|
||||||
import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js";
|
import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js";
|
||||||
|
import { loadNativeScannerDeletedResultIds } from "./nativeScannerResultTombstones.js";
|
||||||
import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
||||||
|
import { evaluateStoredScanResult } from "../../src/lib/artifactEvaluation.js";
|
||||||
import { toStoredArtifact } from "../../src/lib/artifactStore.js";
|
import { toStoredArtifact } from "../../src/lib/artifactStore.js";
|
||||||
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
||||||
import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js";
|
import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js";
|
||||||
|
import { shouldFlagArtifactForReview } from "../../src/lib/scannerLearning.js";
|
||||||
import type {
|
import type {
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
ArtifactStoreSaveResult,
|
ArtifactStoreSaveResult,
|
||||||
CaptureResult,
|
CaptureResult,
|
||||||
NativeScannerImageLoadStatus,
|
NativeScannerImageLoadStatus,
|
||||||
|
NativeScannerDeleteResultStatus,
|
||||||
NativeScannerProcessStatus,
|
NativeScannerProcessStatus,
|
||||||
|
NativeScannerProcessingProgressStatus,
|
||||||
NativeScannerPromotionStatus,
|
NativeScannerPromotionStatus,
|
||||||
NativeScannerReviewArtifactInput,
|
NativeScannerReviewArtifactInput,
|
||||||
NativeScannerReviewStatus,
|
NativeScannerReviewStatus,
|
||||||
NativeScannerResultsLoadStatus,
|
NativeScannerResultsLoadStatus,
|
||||||
|
NativeScannerRunTiming,
|
||||||
|
NativeScannerRunTimingPatch,
|
||||||
ReviewSamplePayload,
|
ReviewSamplePayload,
|
||||||
} from "../../src/types/global.js";
|
} from "../../src/types/global.js";
|
||||||
import type { ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
import type { ArtifactRarity, ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
||||||
|
|
||||||
export type NativeCaptureJobPayload = {
|
export type NativeCaptureJobPayload = {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
@@ -29,11 +36,21 @@ export type NativeCaptureJobPayload = {
|
|||||||
capturedAt?: string;
|
capturedAt?: string;
|
||||||
relativePath?: string;
|
relativePath?: string;
|
||||||
absolutePath?: string;
|
absolutePath?: string;
|
||||||
|
/** Direct visual evidence from the native card crop; absent on legacy jobs. */
|
||||||
|
starCount?: number | null;
|
||||||
|
starConfidence?: number;
|
||||||
|
starSource?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface NativeScannerProcessingService {
|
export interface NativeScannerProcessingService {
|
||||||
processRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise<NativeScannerProcessStatus>;
|
processRun(options?: {
|
||||||
loadResults(options?: { runDir?: string; limit?: number }): Promise<NativeScannerResultsLoadStatus>;
|
runDir?: string;
|
||||||
|
persist?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
stream?: boolean;
|
||||||
|
expectedTotal?: number;
|
||||||
|
}): Promise<NativeScannerProcessStatus>;
|
||||||
|
loadResults(options?: { runDir?: string; limit?: number; afterSequence?: number }): Promise<NativeScannerResultsLoadStatus>;
|
||||||
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
|
promoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus>;
|
||||||
reviewResult(options: {
|
reviewResult(options: {
|
||||||
runDir?: string;
|
runDir?: string;
|
||||||
@@ -42,6 +59,11 @@ export interface NativeScannerProcessingService {
|
|||||||
artifact?: NativeScannerReviewArtifactInput;
|
artifact?: NativeScannerReviewArtifactInput;
|
||||||
note?: string;
|
note?: string;
|
||||||
}): Promise<NativeScannerReviewStatus>;
|
}): Promise<NativeScannerReviewStatus>;
|
||||||
|
deleteResult(options: {
|
||||||
|
runDir?: string;
|
||||||
|
resultId: string;
|
||||||
|
removeLinkedStoreRecord?: boolean;
|
||||||
|
}): Promise<NativeScannerDeleteResultStatus>;
|
||||||
loadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
|
loadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +72,14 @@ interface NativeScannerProcessingServiceDependencies {
|
|||||||
buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise<CaptureResult>;
|
buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise<CaptureResult>;
|
||||||
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
||||||
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
||||||
|
removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>;
|
||||||
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
||||||
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
||||||
|
/** Durable timing is observational only and must never block OCR/parse work. */
|
||||||
|
recordRunTiming?: (runDir: string, patch: NativeScannerRunTimingPatch) => Promise<NativeScannerRunTiming>;
|
||||||
|
loadRunTiming?: (runDir: string) => Promise<NativeScannerRunTiming | null>;
|
||||||
|
onProgress?: (progress: NativeScannerProcessingProgressStatus) => void;
|
||||||
|
onResult?: (result: StoredScanResultEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProcessedNativeJob = {
|
type ProcessedNativeJob = {
|
||||||
@@ -61,70 +89,55 @@ type ProcessedNativeJob = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const POST_CAPTURE_QUEUE_CONCURRENCY = 4;
|
const POST_CAPTURE_QUEUE_CONCURRENCY = 4;
|
||||||
|
const STREAM_QUEUE_HIGH_WATERMARK = 8;
|
||||||
|
const STREAM_READ_CHUNK_BYTES = 64 * 1024;
|
||||||
|
const STREAM_POLL_INTERVAL_MS = 40;
|
||||||
|
const STREAM_TERMINAL_DRAIN_GRACE_MS = 2_000;
|
||||||
|
const MAX_CACHED_LIVE_RUNS = 8;
|
||||||
|
|
||||||
|
type ProcessRunOptions = {
|
||||||
|
runDir?: string;
|
||||||
|
persist?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
stream?: boolean;
|
||||||
|
expectedTotal?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeCaptureProducerStatus = {
|
||||||
|
running: boolean;
|
||||||
|
status: string;
|
||||||
|
target: number;
|
||||||
|
captured: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeProcessingRuns = new Map<string, Promise<NativeScannerProcessStatus>>();
|
||||||
|
const liveResultsByRunDir = new Map<string, StoredScanResultEntry[]>();
|
||||||
|
|
||||||
export function createNativeScannerProcessingService(
|
export function createNativeScannerProcessingService(
|
||||||
deps: NativeScannerProcessingServiceDependencies,
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
): NativeScannerProcessingService {
|
): NativeScannerProcessingService {
|
||||||
const resultWorkflows = createNativeScannerResultWorkflowService(deps);
|
const resultWorkflows = createNativeScannerResultWorkflowService({
|
||||||
|
...deps,
|
||||||
|
isProcessingRunActive: (runDir) => activeProcessingRuns.has(runDir),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
async processRun(options = {}) {
|
processRun(options: ProcessRunOptions = {}) {
|
||||||
const started = Date.now();
|
|
||||||
const runDir = deps.resolveRunDir(options.runDir);
|
const runDir = deps.resolveRunDir(options.runDir);
|
||||||
if (!runDir) {
|
if (!runDir) {
|
||||||
return emptyProcessStatus("No native scanner runDir available.");
|
return Promise.resolve(emptyProcessStatus("No native scanner runDir available."));
|
||||||
}
|
}
|
||||||
|
const active = activeProcessingRuns.get(runDir);
|
||||||
|
if (active) return active;
|
||||||
|
|
||||||
const { jobsPath, jobs } = await readNativeCaptureJobs(runDir);
|
replaceCachedLiveResults(runDir, []);
|
||||||
const limit = Math.max(1, Math.min(jobs.length, Math.round(options.limit ?? jobs.length)));
|
const runPromise = processNativeScannerRun({ deps, options, runDir })
|
||||||
const selectedJobs = jobs.slice(0, limit);
|
.finally(() => {
|
||||||
const runId = path.basename(runDir);
|
if (activeProcessingRuns.get(runDir) === runPromise) {
|
||||||
const ikCatalog = deps.loadIkArtifactCatalog
|
activeProcessingRuns.delete(runDir);
|
||||||
? 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 = {
|
activeProcessingRuns.set(runDir, runPromise);
|
||||||
ok: true,
|
return runPromise;
|
||||||
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 = {}) {
|
async loadResults(options = {}) {
|
||||||
@@ -135,20 +148,32 @@ export function createNativeScannerProcessingService(
|
|||||||
|
|
||||||
const resultsPath = path.join(runDir, "scan-results.json");
|
const resultsPath = path.join(runDir, "scan-results.json");
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(resultsPath, "utf8");
|
const cached = liveResultsByRunDir.get(runDir);
|
||||||
const parsed = JSON.parse(raw);
|
const rawResults = cached
|
||||||
const results = Array.isArray(parsed)
|
? [...cached]
|
||||||
? parsed.filter(isStoredScanResultEntry)
|
: await readStoredScanResults(resultsPath);
|
||||||
: [];
|
const deletedIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||||
|
const results = rawResults.filter((entry) => !deletedIds.has(entry.id));
|
||||||
|
const afterSequence = Number.isFinite(options.afterSequence)
|
||||||
|
? Math.max(0, Math.round(options.afterSequence ?? 0))
|
||||||
|
: null;
|
||||||
|
const candidates = afterSequence === null
|
||||||
|
? results
|
||||||
|
: results.filter((entry) => entry.sequence > afterSequence);
|
||||||
const limit = Number.isFinite(options.limit)
|
const limit = Number.isFinite(options.limit)
|
||||||
? Math.max(1, Math.min(results.length, Math.round(options.limit ?? results.length)))
|
? Math.max(1, Math.min(candidates.length, Math.round(options.limit ?? candidates.length)))
|
||||||
: results.length;
|
: candidates.length;
|
||||||
|
const selectedResults = afterSequence === null
|
||||||
|
? candidates.slice(-limit)
|
||||||
|
: candidates.slice(0, limit);
|
||||||
|
const timing = await recordResultReconciliationIfComplete(deps, runDir, results);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
runDir,
|
runDir,
|
||||||
path: resultsPath,
|
path: resultsPath,
|
||||||
total: results.length,
|
total: results.length,
|
||||||
results: results.slice(-limit),
|
results: selectedResults,
|
||||||
|
...(timing ? { timing } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -159,8 +184,21 @@ export function createNativeScannerProcessingService(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
promoteResults: resultWorkflows.promoteResults,
|
async promoteResults(options) {
|
||||||
reviewResult: resultWorkflows.reviewResult,
|
const status = await resultWorkflows.promoteResults(options);
|
||||||
|
await refreshCachedResultsAfterMutation(deps, options.runDir);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
async reviewResult(options) {
|
||||||
|
const status = await resultWorkflows.reviewResult(options);
|
||||||
|
await refreshCachedResultsAfterMutation(deps, options.runDir);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
async deleteResult(options) {
|
||||||
|
const status = await resultWorkflows.deleteResult(options);
|
||||||
|
await refreshCachedResultsAfterMutation(deps, options.runDir);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
|
||||||
async loadImage(options) {
|
async loadImage(options) {
|
||||||
const runDir = deps.resolveRunDir(options.runDir);
|
const runDir = deps.resolveRunDir(options.runDir);
|
||||||
@@ -205,6 +243,617 @@ export function createNativeScannerProcessingService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processNativeScannerRun({
|
||||||
|
deps,
|
||||||
|
options,
|
||||||
|
runDir,
|
||||||
|
}: {
|
||||||
|
deps: NativeScannerProcessingServiceDependencies;
|
||||||
|
options: ProcessRunOptions;
|
||||||
|
runDir: string;
|
||||||
|
}) {
|
||||||
|
await updateRunTiming(deps, runDir, { processingStartedAt: new Date().toISOString() });
|
||||||
|
return options.stream
|
||||||
|
? processStreamingNativeScannerRun({ deps, options, runDir })
|
||||||
|
: processBatchNativeScannerRun({ deps, options, runDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processBatchNativeScannerRun({
|
||||||
|
deps,
|
||||||
|
options,
|
||||||
|
runDir,
|
||||||
|
}: {
|
||||||
|
deps: NativeScannerProcessingServiceDependencies;
|
||||||
|
options: ProcessRunOptions;
|
||||||
|
runDir: string;
|
||||||
|
}): Promise<NativeScannerProcessStatus> {
|
||||||
|
const started = Date.now();
|
||||||
|
const { jobsPath, jobs } = await readNativeCaptureJobs(runDir);
|
||||||
|
const requestedLimit = Number.isFinite(options.limit)
|
||||||
|
? Math.max(1, Math.round(options.limit ?? jobs.length))
|
||||||
|
: jobs.length;
|
||||||
|
const selectedJobs = jobs.slice(0, Math.min(jobs.length, requestedLimit));
|
||||||
|
const runId = path.basename(runDir);
|
||||||
|
const ikCatalog = deps.loadIkArtifactCatalog
|
||||||
|
? await deps.loadIkArtifactCatalog().catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
let parsedCount = 0;
|
||||||
|
let reviewCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const publishProgress = (running: boolean, stored = 0) => safePublishProgress(deps, {
|
||||||
|
running,
|
||||||
|
runDir,
|
||||||
|
total: selectedJobs.length,
|
||||||
|
processed: processedCount,
|
||||||
|
parsed: parsedCount,
|
||||||
|
review: reviewCount,
|
||||||
|
stored,
|
||||||
|
errors: errorCount,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
publishProgress(true);
|
||||||
|
|
||||||
|
const processedJobs = await mapWithConcurrency(
|
||||||
|
selectedJobs,
|
||||||
|
POST_CAPTURE_QUEUE_CONCURRENCY,
|
||||||
|
async (job) => {
|
||||||
|
const processedJob = await processNativeCaptureJob({
|
||||||
|
deps,
|
||||||
|
ikCatalog,
|
||||||
|
job,
|
||||||
|
persist: Boolean(options.persist),
|
||||||
|
runDir,
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
processedCount += 1;
|
||||||
|
if (processedJob.result.parsed) parsedCount += 1;
|
||||||
|
if (processedJob.result.needsReview) reviewCount += 1;
|
||||||
|
if (processedJob.result.error) errorCount += 1;
|
||||||
|
publishProgress(true);
|
||||||
|
return processedJob;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const scanResults = processedJobs.map((entry) => entry.scanResult);
|
||||||
|
replaceCachedLiveResults(runDir, scanResults);
|
||||||
|
for (const result of scanResults) safePublishResult(deps, result);
|
||||||
|
return finalizeProcessedRun({
|
||||||
|
deps,
|
||||||
|
jobsPath,
|
||||||
|
ok: true,
|
||||||
|
options,
|
||||||
|
processedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processStreamingNativeScannerRun({
|
||||||
|
deps,
|
||||||
|
options,
|
||||||
|
runDir,
|
||||||
|
}: {
|
||||||
|
deps: NativeScannerProcessingServiceDependencies;
|
||||||
|
options: ProcessRunOptions;
|
||||||
|
runDir: string;
|
||||||
|
}): Promise<NativeScannerProcessStatus> {
|
||||||
|
const started = Date.now();
|
||||||
|
const runId = path.basename(runDir);
|
||||||
|
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
|
||||||
|
const reportPath = path.join(runDir, "processing-report.json");
|
||||||
|
const scanResultsPath = path.join(runDir, "scan-results.json");
|
||||||
|
const streamLimit = Number.isFinite(options.limit)
|
||||||
|
? Math.max(1, Math.round(options.limit ?? 1))
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const configuredTotal = Number.isFinite(options.expectedTotal)
|
||||||
|
? Math.max(0, Math.round(options.expectedTotal ?? 0))
|
||||||
|
: Number.isFinite(options.limit)
|
||||||
|
? streamLimit
|
||||||
|
: 0;
|
||||||
|
const ikCatalog = deps.loadIkArtifactCatalog
|
||||||
|
? await deps.loadIkArtifactCatalog().catch(() => null)
|
||||||
|
: null;
|
||||||
|
const tailState: NativeCaptureJobTailState = { offset: 0 };
|
||||||
|
const pendingJobs: NativeCaptureJobPayload[] = [];
|
||||||
|
const orderedSequences: number[] = [];
|
||||||
|
const seenJobsBySequence = new Map<number, string>();
|
||||||
|
const activeJobs = new Map<number, Promise<void>>();
|
||||||
|
const completedBySequence = new Map<number, ProcessedNativeJob>();
|
||||||
|
const publishedJobs: ProcessedNativeJob[] = [];
|
||||||
|
let nextPublishIndex = 0;
|
||||||
|
let processedCount = 0;
|
||||||
|
let parsedCount = 0;
|
||||||
|
let reviewCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
let producerStatus: NativeCaptureProducerStatus | null = null;
|
||||||
|
let terminalObservedAt = 0;
|
||||||
|
let integrityError = "";
|
||||||
|
let snapshotWriteQueue = Promise.resolve();
|
||||||
|
|
||||||
|
const progressTotal = () => {
|
||||||
|
if (producerStatus && !producerStatus.running) {
|
||||||
|
return Math.min(producerStatus.captured, streamLimit);
|
||||||
|
}
|
||||||
|
return configuredTotal
|
||||||
|
|| producerStatus?.target
|
||||||
|
|| Math.min(orderedSequences.length, streamLimit);
|
||||||
|
};
|
||||||
|
const publishProgress = (running: boolean, stored = 0) => safePublishProgress(deps, {
|
||||||
|
running,
|
||||||
|
runDir,
|
||||||
|
total: progressTotal(),
|
||||||
|
processed: processedCount,
|
||||||
|
parsed: parsedCount,
|
||||||
|
review: reviewCount,
|
||||||
|
stored,
|
||||||
|
errors: errorCount + (integrityError ? 1 : 0),
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
...(integrityError ? { error: integrityError } : {}),
|
||||||
|
});
|
||||||
|
const queueDurableSnapshot = () => {
|
||||||
|
const snapshot = [...publishedJobs];
|
||||||
|
const status = buildProcessStatus({
|
||||||
|
jobsPath,
|
||||||
|
ok: true,
|
||||||
|
options,
|
||||||
|
processedJobs: snapshot,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
stored: 0,
|
||||||
|
});
|
||||||
|
snapshotWriteQueue = snapshotWriteQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => writeProcessingOutputs({ reportPath, scanResultsPath, status, processedJobs: snapshot }));
|
||||||
|
};
|
||||||
|
const flushOrderedResults = () => {
|
||||||
|
let published = false;
|
||||||
|
while (nextPublishIndex < orderedSequences.length) {
|
||||||
|
const sequence = orderedSequences[nextPublishIndex];
|
||||||
|
const completed = completedBySequence.get(sequence);
|
||||||
|
if (!completed) break;
|
||||||
|
completedBySequence.delete(sequence);
|
||||||
|
nextPublishIndex += 1;
|
||||||
|
publishedJobs.push(completed);
|
||||||
|
appendCachedLiveResult(runDir, completed.scanResult);
|
||||||
|
safePublishResult(deps, completed.scanResult);
|
||||||
|
published = true;
|
||||||
|
}
|
||||||
|
if (published && (publishedJobs.length === 1 || publishedJobs.length % 8 === 0)) {
|
||||||
|
queueDurableSnapshot();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleAvailableJobs = () => {
|
||||||
|
while (activeJobs.size < POST_CAPTURE_QUEUE_CONCURRENCY && pendingJobs.length > 0) {
|
||||||
|
const job = pendingJobs.shift();
|
||||||
|
if (!job) break;
|
||||||
|
const task = processNativeCaptureJob({
|
||||||
|
deps,
|
||||||
|
ikCatalog,
|
||||||
|
job,
|
||||||
|
persist: Boolean(options.persist),
|
||||||
|
runDir,
|
||||||
|
runId,
|
||||||
|
}).then((processedJob) => {
|
||||||
|
processedCount += 1;
|
||||||
|
if (processedJob.result.parsed) parsedCount += 1;
|
||||||
|
if (processedJob.result.needsReview) reviewCount += 1;
|
||||||
|
if (processedJob.result.error) errorCount += 1;
|
||||||
|
completedBySequence.set(job.sequence, processedJob);
|
||||||
|
flushOrderedResults();
|
||||||
|
publishProgress(true);
|
||||||
|
}).finally(() => {
|
||||||
|
activeJobs.delete(job.sequence);
|
||||||
|
});
|
||||||
|
activeJobs.set(job.sequence, task);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
publishProgress(true);
|
||||||
|
while (true) {
|
||||||
|
const queueCapacity = Math.max(
|
||||||
|
0,
|
||||||
|
STREAM_QUEUE_HIGH_WATERMARK - pendingJobs.length - activeJobs.size,
|
||||||
|
);
|
||||||
|
const appendedJobs = queueCapacity > 0
|
||||||
|
? await readAppendedNativeCaptureJobs(jobsPath, tailState, queueCapacity)
|
||||||
|
: [];
|
||||||
|
for (const job of appendedJobs) {
|
||||||
|
const identity = nativeCaptureJobIdentity(job);
|
||||||
|
const existingIdentity = seenJobsBySequence.get(job.sequence);
|
||||||
|
if (existingIdentity) {
|
||||||
|
if (existingIdentity !== identity && !integrityError) {
|
||||||
|
integrityError = `Conflicting capture jobs use sequence ${job.sequence}.`;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (orderedSequences.length >= streamLimit) continue;
|
||||||
|
seenJobsBySequence.set(job.sequence, identity);
|
||||||
|
orderedSequences.push(job.sequence);
|
||||||
|
pendingJobs.push(job);
|
||||||
|
}
|
||||||
|
scheduleAvailableJobs();
|
||||||
|
|
||||||
|
const nextProducerStatus = await readNativeCaptureProducerStatus(runDir);
|
||||||
|
if (nextProducerStatus) producerStatus = nextProducerStatus;
|
||||||
|
if (producerStatus && !producerStatus.running && terminalObservedAt === 0) {
|
||||||
|
terminalObservedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCapturedJobs = producerStatus && !producerStatus.running
|
||||||
|
? Math.min(producerStatus.captured, streamLimit)
|
||||||
|
: null;
|
||||||
|
const producerDrained = expectedCapturedJobs !== null
|
||||||
|
&& orderedSequences.length >= expectedCapturedJobs;
|
||||||
|
if (producerDrained && pendingJobs.length === 0 && activeJobs.size === 0) break;
|
||||||
|
if (
|
||||||
|
expectedCapturedJobs !== null
|
||||||
|
&& terminalObservedAt > 0
|
||||||
|
&& Date.now() - terminalObservedAt >= STREAM_TERMINAL_DRAIN_GRACE_MS
|
||||||
|
&& pendingJobs.length === 0
|
||||||
|
&& activeJobs.size === 0
|
||||||
|
) {
|
||||||
|
integrityError = `Capture job stream ended with ${orderedSequences.length}/${expectedCapturedJobs} jobs.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeJobs.size > 0) {
|
||||||
|
await Promise.race([
|
||||||
|
Promise.race(activeJobs.values()),
|
||||||
|
delay(STREAM_POLL_INTERVAL_MS),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await delay(STREAM_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeJobs.size > 0) await Promise.all(activeJobs.values());
|
||||||
|
flushOrderedResults();
|
||||||
|
await snapshotWriteQueue.catch(() => undefined);
|
||||||
|
const status = await finalizeProcessedRun({
|
||||||
|
deps,
|
||||||
|
jobsPath,
|
||||||
|
ok: !integrityError,
|
||||||
|
options,
|
||||||
|
processedJobs: publishedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
error: integrityError || undefined,
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeProcessedRun({
|
||||||
|
deps,
|
||||||
|
error,
|
||||||
|
jobsPath,
|
||||||
|
ok,
|
||||||
|
options,
|
||||||
|
processedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
}: {
|
||||||
|
deps: NativeScannerProcessingServiceDependencies;
|
||||||
|
error?: string;
|
||||||
|
jobsPath: string;
|
||||||
|
ok: boolean;
|
||||||
|
options: ProcessRunOptions;
|
||||||
|
processedJobs: ProcessedNativeJob[];
|
||||||
|
runDir: string;
|
||||||
|
started: number;
|
||||||
|
}) {
|
||||||
|
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 processingTiming = await updateRunTiming(deps, runDir, {
|
||||||
|
processingCompletedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
let status = buildProcessStatus({
|
||||||
|
error,
|
||||||
|
jobsPath,
|
||||||
|
ok,
|
||||||
|
options,
|
||||||
|
processedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
stored,
|
||||||
|
timing: processingTiming,
|
||||||
|
});
|
||||||
|
// The complete results file is the durable hand-off boundary. Keep its
|
||||||
|
// timestamp distinct from a later renderer/service reconciliation read.
|
||||||
|
await writeJsonAtomic(status.scanResultsPath, processedJobs.map((entry) => entry.scanResult));
|
||||||
|
const durableTiming = await updateRunTiming(deps, runDir, {
|
||||||
|
resultsDurableAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
status = buildProcessStatus({
|
||||||
|
error,
|
||||||
|
jobsPath,
|
||||||
|
ok,
|
||||||
|
options,
|
||||||
|
processedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
stored,
|
||||||
|
timing: durableTiming ?? processingTiming,
|
||||||
|
});
|
||||||
|
await writeJsonAtomic(status.reportPath, status);
|
||||||
|
replaceCachedLiveResults(runDir, processedJobs.map((entry) => entry.scanResult));
|
||||||
|
safePublishProgress(deps, {
|
||||||
|
running: false,
|
||||||
|
runDir,
|
||||||
|
total: processedJobs.length,
|
||||||
|
processed: status.processed,
|
||||||
|
parsed: status.parsed,
|
||||||
|
review: status.review,
|
||||||
|
stored: status.stored,
|
||||||
|
errors: status.errors,
|
||||||
|
elapsedMs: status.elapsedMs,
|
||||||
|
...(status.error ? { error: status.error } : {}),
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProcessStatus({
|
||||||
|
error,
|
||||||
|
jobsPath,
|
||||||
|
ok,
|
||||||
|
options,
|
||||||
|
processedJobs,
|
||||||
|
runDir,
|
||||||
|
started,
|
||||||
|
stored,
|
||||||
|
timing,
|
||||||
|
}: {
|
||||||
|
error?: string;
|
||||||
|
jobsPath: string;
|
||||||
|
ok: boolean;
|
||||||
|
options: ProcessRunOptions;
|
||||||
|
processedJobs: ProcessedNativeJob[];
|
||||||
|
runDir: string;
|
||||||
|
started: number;
|
||||||
|
stored: number;
|
||||||
|
timing?: NativeScannerRunTiming;
|
||||||
|
}): NativeScannerProcessStatus {
|
||||||
|
const results = processedJobs.map((entry) => entry.result);
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
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 + (error ? 1 : 0),
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, processedJobs.length),
|
||||||
|
persisted: Boolean(options.persist),
|
||||||
|
results,
|
||||||
|
...(timing ? { timing } : {}),
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProcessingOutputs({
|
||||||
|
processedJobs,
|
||||||
|
reportPath,
|
||||||
|
scanResultsPath,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
processedJobs: ProcessedNativeJob[];
|
||||||
|
reportPath: string;
|
||||||
|
scanResultsPath: string;
|
||||||
|
status: NativeScannerProcessStatus;
|
||||||
|
}) {
|
||||||
|
await writeJsonAtomic(scanResultsPath, processedJobs.map((entry) => entry.scanResult));
|
||||||
|
await writeJsonAtomic(reportPath, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(filePath: string, payload: unknown) {
|
||||||
|
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
||||||
|
await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
await fs.rename(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeCaptureJobTailState = {
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readAppendedNativeCaptureJobs(
|
||||||
|
jobsPath: string,
|
||||||
|
state: NativeCaptureJobTailState,
|
||||||
|
maxJobs: number,
|
||||||
|
): Promise<NativeCaptureJobPayload[]> {
|
||||||
|
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(jobsPath, "r");
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (stat.size < state.offset) {
|
||||||
|
state.offset = 0;
|
||||||
|
}
|
||||||
|
const length = Math.min(stat.size - state.offset, STREAM_READ_CHUNK_BYTES);
|
||||||
|
if (length <= 0) return [];
|
||||||
|
const chunk = Buffer.alloc(length);
|
||||||
|
const { bytesRead } = await handle.read(chunk, 0, length, state.offset);
|
||||||
|
const jobs: NativeCaptureJobPayload[] = [];
|
||||||
|
let lineStart = 0;
|
||||||
|
let consumedBytes = 0;
|
||||||
|
for (let index = 0; index < bytesRead; index += 1) {
|
||||||
|
if (chunk[index] !== 0x0a) continue;
|
||||||
|
const line = chunk.subarray(lineStart, index).toString("utf8").trim();
|
||||||
|
lineStart = index + 1;
|
||||||
|
consumedBytes = lineStart;
|
||||||
|
if (!line) continue;
|
||||||
|
const job = JSON.parse(line) as NativeCaptureJobPayload;
|
||||||
|
if (Number.isFinite(job.sequence)) jobs.push(job);
|
||||||
|
if (jobs.length >= maxJobs) break;
|
||||||
|
}
|
||||||
|
state.offset += consumedBytes;
|
||||||
|
return jobs;
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) return [];
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNativeCaptureProducerStatus(runDir: string): Promise<NativeCaptureProducerStatus | null> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await fs.readFile(path.join(runDir, "status.json"), "utf8"));
|
||||||
|
const scanner = raw?.scanner ?? raw;
|
||||||
|
if (!scanner || typeof scanner !== "object") return null;
|
||||||
|
return {
|
||||||
|
running: Boolean(scanner.running),
|
||||||
|
status: typeof scanner.status === "string" ? scanner.status : "unknown",
|
||||||
|
target: Math.max(0, Math.round(Number(scanner.target) || 0)),
|
||||||
|
captured: Math.max(0, Math.round(Number(scanner.captured) || 0)),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error) || error instanceof SyntaxError) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStoredScanResults(resultsPath: string) {
|
||||||
|
const raw = await fs.readFile(resultsPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter(isStoredScanResultEntry).map(evaluateStoredScanResult)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCachedResultsAfterMutation(
|
||||||
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
|
requestedRunDir?: string,
|
||||||
|
) {
|
||||||
|
const runDir = deps.resolveRunDir(requestedRunDir);
|
||||||
|
if (!runDir) return;
|
||||||
|
try {
|
||||||
|
const [results, deletedIds] = await Promise.all([
|
||||||
|
readStoredScanResults(path.join(runDir, "scan-results.json")),
|
||||||
|
loadNativeScannerDeletedResultIds(runDir),
|
||||||
|
]);
|
||||||
|
replaceCachedLiveResults(runDir, results.filter((entry) => !deletedIds.has(entry.id)));
|
||||||
|
} catch {
|
||||||
|
// Mutation status remains authoritative; a later disk load can retry.
|
||||||
|
liveResultsByRunDir.delete(runDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordResultReconciliationIfComplete(
|
||||||
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
|
runDir: string,
|
||||||
|
results: StoredScanResultEntry[],
|
||||||
|
) {
|
||||||
|
const currentTiming = await loadRunTiming(deps, runDir);
|
||||||
|
// Legacy runs intentionally stay untouched. A reconciliation timestamp is
|
||||||
|
// meaningful only for a run that already owns the new capture/processing
|
||||||
|
// timeline and whose complete result file was durably written by this path.
|
||||||
|
if (!currentTiming?.resultsDurableAt || currentTiming.resultsReconciledAt) return currentTiming ?? undefined;
|
||||||
|
|
||||||
|
const producer = await readNativeCaptureProducerStatus(runDir).catch(() => null);
|
||||||
|
const terminal = producer
|
||||||
|
&& !producer.running
|
||||||
|
&& (producer.status === "done" || producer.status === "completed")
|
||||||
|
&& producer.captured > 0
|
||||||
|
&& results.length === producer.captured;
|
||||||
|
if (!terminal) return currentTiming;
|
||||||
|
return updateRunTiming(deps, runDir, { resultsReconciledAt: new Date().toISOString() }) ?? currentTiming;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRunTiming(
|
||||||
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
|
runDir: string,
|
||||||
|
patch: NativeScannerRunTimingPatch,
|
||||||
|
) {
|
||||||
|
if (!deps.recordRunTiming || !runDir) return undefined;
|
||||||
|
try {
|
||||||
|
return await deps.recordRunTiming(runDir, patch);
|
||||||
|
} catch {
|
||||||
|
// Timing is evidence, never a dependency of scanner correctness. A later
|
||||||
|
// caller can still load the durable results and report the missing marker.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRunTiming(
|
||||||
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
|
runDir: string,
|
||||||
|
) {
|
||||||
|
if (!deps.loadRunTiming || !runDir) return null;
|
||||||
|
try {
|
||||||
|
return await deps.loadRunTiming(runDir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCachedLiveResults(runDir: string, results: StoredScanResultEntry[]) {
|
||||||
|
liveResultsByRunDir.delete(runDir);
|
||||||
|
liveResultsByRunDir.set(runDir, [...results]);
|
||||||
|
while (liveResultsByRunDir.size > MAX_CACHED_LIVE_RUNS) {
|
||||||
|
const oldest = liveResultsByRunDir.keys().next().value;
|
||||||
|
if (typeof oldest !== "string") break;
|
||||||
|
liveResultsByRunDir.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCachedLiveResult(runDir: string, result: StoredScanResultEntry) {
|
||||||
|
const current = liveResultsByRunDir.get(runDir) ?? [];
|
||||||
|
if (current.some((entry) => entry.id === result.id)) return;
|
||||||
|
current.push(result);
|
||||||
|
liveResultsByRunDir.set(runDir, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePublishProgress(
|
||||||
|
deps: NativeScannerProcessingServiceDependencies,
|
||||||
|
progress: NativeScannerProcessingProgressStatus,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
deps.onProgress?.(progress);
|
||||||
|
} catch {
|
||||||
|
// UI progress must never abort durable OCR/parse processing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePublishResult(deps: NativeScannerProcessingServiceDependencies, result: StoredScanResultEntry) {
|
||||||
|
try {
|
||||||
|
deps.onResult?.(result);
|
||||||
|
} catch {
|
||||||
|
// UI delivery is best effort; loadResults can reconcile from memory or disk.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeCaptureJobIdentity(job: NativeCaptureJobPayload) {
|
||||||
|
return JSON.stringify({
|
||||||
|
sequence: job.sequence,
|
||||||
|
category: job.category ?? "",
|
||||||
|
page: job.page ?? null,
|
||||||
|
row: job.row ?? null,
|
||||||
|
col: job.col ?? null,
|
||||||
|
relativePath: job.relativePath ?? "",
|
||||||
|
absolutePath: job.absolutePath ?? "",
|
||||||
|
starCount: job.starCount ?? null,
|
||||||
|
starConfidence: job.starConfidence ?? null,
|
||||||
|
starSource: job.starSource ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown) {
|
||||||
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record<string, number> {
|
export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record<string, number> {
|
||||||
return {
|
return {
|
||||||
processed: status.processed,
|
processed: status.processed,
|
||||||
@@ -252,13 +901,53 @@ async function processNativeCaptureJob({
|
|||||||
const capture = await deps.buildCaptureResult(imagePath, job);
|
const capture = await deps.buildCaptureResult(imagePath, job);
|
||||||
const parsed = parseArtifactCandidate(capture);
|
const parsed = parseArtifactCandidate(capture);
|
||||||
const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null;
|
const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null;
|
||||||
const notes = [...new Set([...(parsed?.notes ?? []), ...(ikMatch?.notes ?? [])])];
|
const rarityEvidence = nativeJobRarityEvidence(job);
|
||||||
const needsReview = parsedArtifactNeedsReview(parsed) || Boolean(ikMatch && !ikMatch.matched);
|
const notes = [...new Set([
|
||||||
|
...(parsed?.notes ?? []),
|
||||||
|
...(ikMatch?.notes ?? []),
|
||||||
|
...(rarityEvidence.uncertain ? ["Artifact star row could not be confirmed; keep this result in Review."] : []),
|
||||||
|
])];
|
||||||
|
const needsReview = shouldFlagArtifactForReview(parsed)
|
||||||
|
|| Boolean(ikMatch && !ikMatch.matched)
|
||||||
|
|| rarityEvidence.uncertain;
|
||||||
const canPersist = parsedArtifactCanPersist(parsed, needsReview);
|
const canPersist = parsedArtifactCanPersist(parsed, needsReview);
|
||||||
const shouldPersist = Boolean(persist && parsed && canPersist && !needsReview);
|
const baseScanResult = 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,
|
||||||
|
persistedArtifact: false,
|
||||||
|
notes,
|
||||||
|
locked: capture.locked,
|
||||||
|
ikMatch: ikMatch ?? undefined,
|
||||||
|
rarity: rarityEvidence.rarity,
|
||||||
|
rarityConfidence: rarityEvidence.confidence,
|
||||||
|
raritySource: rarityEvidence.rarity ? "native" : undefined,
|
||||||
|
});
|
||||||
|
const shouldPersist = Boolean(
|
||||||
|
persist
|
||||||
|
&& parsed
|
||||||
|
&& canPersist
|
||||||
|
&& !needsReview
|
||||||
|
&& baseScanResult.valueStatus === "evaluated"
|
||||||
|
&& baseScanResult.valueEvaluation?.status === "evaluated"
|
||||||
|
&& baseScanResult.valueEvaluation.score !== null
|
||||||
|
&& Number.isFinite(baseScanResult.valueEvaluation.score),
|
||||||
|
);
|
||||||
const storedRecord = parsed && shouldPersist
|
const storedRecord = parsed && shouldPersist
|
||||||
? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked)
|
? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked)
|
||||||
: null;
|
: null;
|
||||||
|
const scanResult = storedRecord
|
||||||
|
? { ...baseScanResult, artifactRecordId: storedRecord.id, persistedArtifact: true }
|
||||||
|
: baseScanResult;
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
sequence: job.sequence,
|
sequence: job.sequence,
|
||||||
@@ -273,30 +962,14 @@ async function processNativeCaptureJob({
|
|||||||
slot: parsed?.slot,
|
slot: parsed?.slot,
|
||||||
confidence: parsed?.confidence ?? 0,
|
confidence: parsed?.confidence ?? 0,
|
||||||
needsReview,
|
needsReview,
|
||||||
|
rarity: rarityEvidence.rarity,
|
||||||
|
rarityConfidence: rarityEvidence.confidence,
|
||||||
persisted: shouldPersist,
|
persisted: shouldPersist,
|
||||||
ikMatch: ikMatch ?? undefined,
|
ikMatch: ikMatch ?? undefined,
|
||||||
notes,
|
notes,
|
||||||
ocr: capture.ocr ?? [],
|
ocr: capture.ocr ?? [],
|
||||||
},
|
},
|
||||||
scanResult: createStoredScanResultEntry({
|
scanResult,
|
||||||
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,
|
storedRecord,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -305,6 +978,26 @@ async function processNativeCaptureJob({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nativeJobRarityEvidence(job: NativeCaptureJobPayload): {
|
||||||
|
rarity?: ArtifactRarity;
|
||||||
|
confidence?: number;
|
||||||
|
/** The newer helper attempted direct visual detection but could not prove a count. */
|
||||||
|
uncertain: boolean;
|
||||||
|
} {
|
||||||
|
const hasDetectionPayload = Object.prototype.hasOwnProperty.call(job, "starCount")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(job, "starConfidence")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(job, "starSource");
|
||||||
|
if (!hasDetectionPayload) return { uncertain: false };
|
||||||
|
|
||||||
|
const starCount = Number(job.starCount);
|
||||||
|
const confidence = Number(job.starConfidence);
|
||||||
|
const validRarity = Number.isInteger(starCount) && starCount >= 1 && starCount <= 5;
|
||||||
|
const confident = Number.isFinite(confidence) && confidence >= 0.8 && confidence <= 1;
|
||||||
|
const hasSource = typeof job.starSource === "string" && job.starSource.trim().length > 0;
|
||||||
|
if (!validRarity || !confident || !hasSource) return { uncertain: true };
|
||||||
|
return { rarity: starCount as ArtifactRarity, confidence, uncertain: false };
|
||||||
|
}
|
||||||
|
|
||||||
function reviewJobResult({
|
function reviewJobResult({
|
||||||
category,
|
category,
|
||||||
error,
|
error,
|
||||||
@@ -473,15 +1166,6 @@ function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry
|
|||||||
&& Array.isArray(entry.notes);
|
&& Array.isArray(entry.notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedArtifactNeedsReview(parsed: ReturnType<typeof parseArtifactCandidate>) {
|
|
||||||
if (!parsed) return true;
|
|
||||||
if (parsed.confidence < 78) return true;
|
|
||||||
const criticalFields: Array<"name" | "slot" | "mainStat" | "mainValue" | "setName"> = ["name", "slot", "mainStat", "mainValue", "setName"];
|
|
||||||
if (criticalFields.some((field) => (parsed.fields[field]?.confidence ?? 0) < 70)) return true;
|
|
||||||
if (parsed.substats.length === 0) return true;
|
|
||||||
return parsed.notes.some((note) => /not confidently parsed|substats look incomplete|likely OCR misread/i.test(note));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsedArtifactCanPersist(parsed: ReturnType<typeof parseArtifactCandidate>, needsReview: boolean) {
|
function parsedArtifactCanPersist(parsed: ReturnType<typeof parseArtifactCandidate>, needsReview: boolean) {
|
||||||
if (!parsed) return false;
|
if (!parsed) return false;
|
||||||
if (parsed.name === "Unknown artifact") return false;
|
if (parsed.name === "Unknown artifact") return false;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native runs remain replayable evidence. Instead of rewriting their raw result
|
||||||
|
* array, locally removed rows are recorded here and filtered at load time.
|
||||||
|
*/
|
||||||
|
export const NATIVE_SCANNER_RESULT_TOMBSTONES_FILE = "deleted-results.jsonl";
|
||||||
|
|
||||||
|
export interface NativeScannerResultTombstone {
|
||||||
|
version: 1;
|
||||||
|
resultId: string;
|
||||||
|
deletedAt: string;
|
||||||
|
imagePath?: string;
|
||||||
|
removeLinkedStoreRecord: boolean;
|
||||||
|
storeRecordId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeScannerResultTombstonePath(runDir: string) {
|
||||||
|
return path.join(runDir, NATIVE_SCANNER_RESULT_TOMBSTONES_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNativeScannerResultTombstones(runDir: string): Promise<NativeScannerResultTombstone[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(nativeScannerResultTombstonePath(runDir), "utf8");
|
||||||
|
return raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(parseTombstone)
|
||||||
|
.filter((entry): entry is NativeScannerResultTombstone => Boolean(entry));
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadNativeScannerDeletedResultIds(runDir: string) {
|
||||||
|
const tombstones = await loadNativeScannerResultTombstones(runDir);
|
||||||
|
return new Set(tombstones.map((entry) => entry.resultId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendNativeScannerResultTombstone(
|
||||||
|
runDir: string,
|
||||||
|
tombstone: NativeScannerResultTombstone,
|
||||||
|
) {
|
||||||
|
const filePath = nativeScannerResultTombstonePath(runDir);
|
||||||
|
await fs.appendFile(filePath, `${JSON.stringify(tombstone)}\n`, "utf8");
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTombstone(value: string): NativeScannerResultTombstone | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<NativeScannerResultTombstone>;
|
||||||
|
const resultId = typeof parsed.resultId === "string" ? parsed.resultId.trim() : "";
|
||||||
|
const deletedAt = typeof parsed.deletedAt === "string" ? parsed.deletedAt : "";
|
||||||
|
if (!resultId || !deletedAt) return null;
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
resultId,
|
||||||
|
deletedAt,
|
||||||
|
...(typeof parsed.imagePath === "string" ? { imagePath: parsed.imagePath } : {}),
|
||||||
|
removeLinkedStoreRecord: Boolean(parsed.removeLinkedStoreRecord),
|
||||||
|
...(typeof parsed.storeRecordId === "string" ? { storeRecordId: parsed.storeRecordId } : {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
|
||||||
|
}
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
import { reviewedMainValueError, type ParsedArtifactCandidate } from "../../src/lib/artifactOcrParser.js";
|
||||||
|
import { evaluateArtifactValue, evaluateStoredScanResult } from "../../src/lib/artifactEvaluation.js";
|
||||||
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js";
|
||||||
import { buildScanResultPromotionSummary, scanResultToStoredArtifact } from "../../src/lib/scanResultPromotion.js";
|
import { buildScanResultPromotionSummary, scanResultToStoredArtifact } from "../../src/lib/scanResultPromotion.js";
|
||||||
import { implausibleSubstats } from "../../src/lib/substatRolls.js";
|
import { implausibleSubstats } from "../../src/lib/substatRolls.js";
|
||||||
import type {
|
import type {
|
||||||
ArtifactStoreLoadResult,
|
ArtifactStoreLoadResult,
|
||||||
ArtifactStoreSaveResult,
|
ArtifactStoreSaveResult,
|
||||||
|
NativeScannerDeleteResultStatus,
|
||||||
NativeScannerPromotionStatus,
|
NativeScannerPromotionStatus,
|
||||||
NativeScannerReviewArtifactInput,
|
NativeScannerReviewArtifactInput,
|
||||||
NativeScannerReviewStatus,
|
NativeScannerReviewStatus,
|
||||||
ReviewSamplePayload,
|
ReviewSamplePayload,
|
||||||
} from "../../src/types/global.js";
|
} from "../../src/types/global.js";
|
||||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
import type { ArtifactRarity, ScanResultArtifactIdentity, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js";
|
||||||
|
import {
|
||||||
|
appendNativeScannerResultTombstone,
|
||||||
|
loadNativeScannerDeletedResultIds,
|
||||||
|
loadNativeScannerResultTombstones,
|
||||||
|
nativeScannerResultTombstonePath,
|
||||||
|
} from "./nativeScannerResultTombstones.js";
|
||||||
|
|
||||||
export interface NativeScannerResultWorkflowDependencies {
|
export interface NativeScannerResultWorkflowDependencies {
|
||||||
resolveRunDir(runDir?: string): string;
|
resolveRunDir(runDir?: string): string;
|
||||||
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
loadArtifacts?: () => Promise<ArtifactStoreLoadResult>;
|
||||||
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
saveArtifacts(records: StoredArtifactRecord[]): Promise<Pick<ArtifactStoreSaveResult, "added" | "updated"> & Partial<ArtifactStoreSaveResult>>;
|
||||||
|
removeArtifacts?: (ids: string[]) => Promise<{ ok: boolean; removed: number }>;
|
||||||
|
isProcessingRunActive?: (runDir: string) => boolean;
|
||||||
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>;
|
||||||
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
loadIkArtifactCatalog?: () => Promise<IkArtifactCatalog | null>;
|
||||||
}
|
}
|
||||||
@@ -31,6 +41,11 @@ export interface NativeScannerResultWorkflowService {
|
|||||||
artifact?: NativeScannerReviewArtifactInput;
|
artifact?: NativeScannerReviewArtifactInput;
|
||||||
note?: string;
|
note?: string;
|
||||||
}): Promise<NativeScannerReviewStatus>;
|
}): Promise<NativeScannerReviewStatus>;
|
||||||
|
deleteResult(options: {
|
||||||
|
runDir?: string;
|
||||||
|
resultId: string;
|
||||||
|
removeLinkedStoreRecord?: boolean;
|
||||||
|
}): Promise<NativeScannerDeleteResultStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNativeScannerResultWorkflowService(
|
export function createNativeScannerResultWorkflowService(
|
||||||
@@ -46,6 +61,10 @@ export function createNativeScannerResultWorkflowService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const deletedIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||||
|
if (requestedIds.some((id) => deletedIds.has(id))) {
|
||||||
|
return emptyPromotionStatus(runDir, logPath, requestedIds.length, "Deleted scan results cannot be promoted.");
|
||||||
|
}
|
||||||
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
||||||
const selectedIds = new Set(requestedIds);
|
const selectedIds = new Set(requestedIds);
|
||||||
const selectedResults = scanResults.filter((entry) => selectedIds.has(entry.id));
|
const selectedResults = scanResults.filter((entry) => selectedIds.has(entry.id));
|
||||||
@@ -102,6 +121,10 @@ export function createNativeScannerResultWorkflowService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const deletedIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||||
|
if (deletedIds.has(resultId)) {
|
||||||
|
return emptyReviewStatus(runDir, logPath, resultId, options.action, "Deleted scan results cannot be reviewed.");
|
||||||
|
}
|
||||||
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
const { path: resultsPath, results: scanResults } = await loadScanResults(runDir);
|
||||||
const index = scanResults.findIndex((entry) => entry.id === resultId);
|
const index = scanResults.findIndex((entry) => entry.id === resultId);
|
||||||
if (index < 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Selected scan result was not found.");
|
if (index < 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, "Selected scan result was not found.");
|
||||||
@@ -118,7 +141,7 @@ export function createNativeScannerResultWorkflowService(
|
|||||||
updated = rejectResult(current, reviewedAt, note);
|
updated = rejectResult(current, reviewedAt, note);
|
||||||
} else {
|
} else {
|
||||||
const artifact = normalizeReviewedArtifact(options.artifact);
|
const artifact = normalizeReviewedArtifact(options.artifact);
|
||||||
const errors = reviewedArtifactErrors(artifact);
|
const errors = reviewedArtifactErrors(artifact, current.artifact?.rarity);
|
||||||
if (errors.length > 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, errors.join(" "));
|
if (errors.length > 0) return emptyReviewStatus(runDir, logPath, resultId, options.action, errors.join(" "));
|
||||||
const parsed = reviewedArtifactToParsed(artifact);
|
const parsed = reviewedArtifactToParsed(artifact);
|
||||||
const catalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog() : null;
|
const catalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog() : null;
|
||||||
@@ -148,13 +171,102 @@ export function createNativeScannerResultWorkflowService(
|
|||||||
return emptyReviewStatus(runDir, logPath, resultId, options.action, errorMessage(error));
|
return emptyReviewStatus(runDir, logPath, resultId, options.action, errorMessage(error));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteResult(options) {
|
||||||
|
const runDir = deps.resolveRunDir(options.runDir);
|
||||||
|
const resultId = String(options.resultId ?? "").trim();
|
||||||
|
const tombstonePath = runDir ? nativeScannerResultTombstonePath(runDir) : "";
|
||||||
|
if (!runDir || !resultId) {
|
||||||
|
return emptyDeleteStatus(runDir, tombstonePath, resultId, "Deleting a local scan result requires a run directory and result ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await isNativeRunActive(runDir) || deps.isProcessingRunActive?.(runDir)) {
|
||||||
|
return emptyDeleteStatus(runDir, tombstonePath, resultId, "An active native scan cannot be changed. Stop and finish the scan first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTombstones = await loadNativeScannerResultTombstones(runDir);
|
||||||
|
if (existingTombstones.some((entry) => entry.resultId === resultId)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
runDir,
|
||||||
|
tombstonePath,
|
||||||
|
resultId,
|
||||||
|
deletedResult: false,
|
||||||
|
alreadyDeleted: true,
|
||||||
|
cropPath: "",
|
||||||
|
deletedCrop: false,
|
||||||
|
deletedStoreRecord: false,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await loadScanResults(runDir);
|
||||||
|
const current = results.find((entry) => entry.id === resultId);
|
||||||
|
if (!current) {
|
||||||
|
return emptyDeleteStatus(runDir, tombstonePath, resultId, "Selected scan result was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLinkedStoreRecord = Boolean(options.removeLinkedStoreRecord);
|
||||||
|
const storeRecordId = removeLinkedStoreRecord ? current.artifactRecordId?.trim() : undefined;
|
||||||
|
await appendNativeScannerResultTombstone(runDir, {
|
||||||
|
version: 1,
|
||||||
|
resultId,
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
...(current.imagePath ? { imagePath: current.imagePath } : {}),
|
||||||
|
removeLinkedStoreRecord,
|
||||||
|
...(storeRecordId ? { storeRecordId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const crop = await removeLocalCrop(runDir, current.imagePath);
|
||||||
|
if (crop.warning) warnings.push(crop.warning);
|
||||||
|
|
||||||
|
let deletedStoreRecord = false;
|
||||||
|
if (removeLinkedStoreRecord) {
|
||||||
|
if (!storeRecordId) {
|
||||||
|
warnings.push("This scan result has no explicitly linked local store record to remove.");
|
||||||
|
} else if (!deps.removeArtifacts) {
|
||||||
|
warnings.push("The linked local store record was retained because local-store deletion is unavailable.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const removal = await deps.removeArtifacts([storeRecordId]);
|
||||||
|
if (!removal.ok) {
|
||||||
|
warnings.push("The scan result was removed, but the linked local store record could not be removed.");
|
||||||
|
} else {
|
||||||
|
deletedStoreRecord = removal.removed > 0;
|
||||||
|
if (!deletedStoreRecord) warnings.push("The linked local store record was already absent.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push("The scan result was removed, but the linked local store record could not be removed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
runDir,
|
||||||
|
tombstonePath,
|
||||||
|
resultId,
|
||||||
|
deletedResult: true,
|
||||||
|
alreadyDeleted: false,
|
||||||
|
cropPath: crop.path,
|
||||||
|
deletedCrop: crop.deleted,
|
||||||
|
...(storeRecordId ? { storeRecordId } : {}),
|
||||||
|
deletedStoreRecord,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return emptyDeleteStatus(runDir, tombstonePath, resultId, errorMessage(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadScanResults(runDir: string) {
|
async function loadScanResults(runDir: string) {
|
||||||
const resultsPath = path.join(runDir, "scan-results.json");
|
const resultsPath = path.join(runDir, "scan-results.json");
|
||||||
const raw = JSON.parse(await fs.readFile(resultsPath, "utf8"));
|
const raw = JSON.parse(await fs.readFile(resultsPath, "utf8"));
|
||||||
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry) : [];
|
const results = Array.isArray(raw) ? raw.filter(isStoredScanResultEntry).map(evaluateStoredScanResult) : [];
|
||||||
return { path: resultsPath, results };
|
return { path: resultsPath, results };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,14 +279,14 @@ async function appendWorkflowLog(logPath: string, payload: object) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
|
function rejectResult(current: StoredScanResultEntry, reviewedAt: string, note: string): StoredScanResultEntry {
|
||||||
return {
|
return evaluateStoredScanResult({
|
||||||
...current,
|
...current,
|
||||||
extractionStatus: "review",
|
extractionStatus: "review",
|
||||||
needsReview: true,
|
needsReview: true,
|
||||||
valueStatus: "review",
|
valueStatus: "review",
|
||||||
notes: [...new Set([...current.notes, note || "Manual review rejected this result."])],
|
notes: [...new Set([...current.notes, note || "Manual review rejected this result."])],
|
||||||
review: { status: "rejected", reviewedAt, note: note || undefined, correctedFields: [] },
|
review: { status: "rejected", reviewedAt, note: note || undefined, correctedFields: [] },
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function approveResult(
|
function approveResult(
|
||||||
@@ -185,22 +297,34 @@ function approveResult(
|
|||||||
note: string,
|
note: string,
|
||||||
correctedFields: string[],
|
correctedFields: string[],
|
||||||
): StoredScanResultEntry {
|
): StoredScanResultEntry {
|
||||||
return {
|
const preservedRarity = current.artifact?.rarity;
|
||||||
|
const preservedRarityMetadata = preservedRarity === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
rarity: preservedRarity,
|
||||||
|
...(current.artifact?.rarityConfidence === undefined ? {} : { rarityConfidence: current.artifact.rarityConfidence }),
|
||||||
|
...(current.artifact?.raritySource === undefined ? {} : { raritySource: current.artifact.raritySource }),
|
||||||
|
};
|
||||||
|
const approvedArtifact: ScanResultArtifactIdentity = {
|
||||||
|
...artifact,
|
||||||
|
...preservedRarityMetadata,
|
||||||
|
};
|
||||||
|
return evaluateStoredScanResult({
|
||||||
...current,
|
...current,
|
||||||
extractionStatus: "parsed",
|
extractionStatus: "parsed",
|
||||||
extractionConfidence: 100,
|
extractionConfidence: 100,
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
valueStatus: "deferred",
|
valueStatus: "deferred",
|
||||||
valueScore: null,
|
valueScore: null,
|
||||||
artifact,
|
artifact: approvedArtifact,
|
||||||
ikMatch,
|
ikMatch,
|
||||||
fieldConfidences: reviewedFieldConfidences(artifact),
|
fieldConfidences: reviewedFieldConfidences(artifact, current.artifact),
|
||||||
artifactRecordId: undefined,
|
artifactRecordId: undefined,
|
||||||
persistedArtifact: false,
|
persistedArtifact: false,
|
||||||
notes: note ? [`Manual review approved: ${note}`] : ["Manual review approved."],
|
notes: note ? [`Manual review approved: ${note}`] : ["Manual review approved."],
|
||||||
error: undefined,
|
error: undefined,
|
||||||
review: { status: "approved", reviewedAt, note: note || undefined, correctedFields },
|
review: { status: "approved", reviewedAt, note: note || undefined, correctedFields },
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveApprovedEvalSample(
|
async function saveApprovedEvalSample(
|
||||||
@@ -258,6 +382,73 @@ function emptyReviewStatus(
|
|||||||
return { ok: false, runDir, logPath, resultId, action, evalSampleSaved: false, correctedFields: [], error };
|
return { ok: false, runDir, logPath, resultId, action, evalSampleSaved: false, correctedFields: [], error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyDeleteStatus(
|
||||||
|
runDir: string,
|
||||||
|
tombstonePath: string,
|
||||||
|
resultId: string,
|
||||||
|
error: string,
|
||||||
|
): NativeScannerDeleteResultStatus {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
runDir,
|
||||||
|
tombstonePath,
|
||||||
|
resultId,
|
||||||
|
deletedResult: false,
|
||||||
|
alreadyDeleted: false,
|
||||||
|
cropPath: "",
|
||||||
|
deletedCrop: false,
|
||||||
|
deletedStoreRecord: false,
|
||||||
|
warnings: [],
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isNativeRunActive(runDir: string) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await fs.readFile(path.join(runDir, "status.json"), "utf8"));
|
||||||
|
const scanner = raw?.scanner ?? raw;
|
||||||
|
return Boolean(scanner?.running);
|
||||||
|
} catch {
|
||||||
|
// Older offline runs may not have a status file. A missing status is not
|
||||||
|
// evidence of a running producer, while a present running state blocks.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLocalCrop(runDir: string, imagePath: string) {
|
||||||
|
const resolvedRunDir = path.resolve(runDir);
|
||||||
|
const candidate = imagePath
|
||||||
|
? path.isAbsolute(imagePath) ? path.resolve(imagePath) : path.resolve(resolvedRunDir, imagePath)
|
||||||
|
: "";
|
||||||
|
if (!candidate) {
|
||||||
|
return { path: "", deleted: false, warning: "No crop image was recorded for this local scan result." };
|
||||||
|
}
|
||||||
|
if (!isPathInside(resolvedRunDir, candidate)) {
|
||||||
|
return { path: candidate, deleted: false, warning: "The recorded crop path is outside the native scan run and was retained." };
|
||||||
|
}
|
||||||
|
if (!/\.png$/i.test(candidate)) {
|
||||||
|
return { path: candidate, deleted: false, warning: "The recorded crop is not a PNG file and was retained." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.unlink(candidate);
|
||||||
|
return { path: candidate, deleted: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) {
|
||||||
|
return { path: candidate, deleted: false, warning: "The crop image was already absent." };
|
||||||
|
}
|
||||||
|
return { path: candidate, deleted: false, warning: "The scan result was removed, but its crop image could not be removed." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(root: string, candidate: string) {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): NativeScannerReviewArtifactInput {
|
function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): NativeScannerReviewArtifactInput {
|
||||||
return {
|
return {
|
||||||
name: String(input?.name ?? "").trim(),
|
name: String(input?.name ?? "").trim(),
|
||||||
@@ -272,7 +463,7 @@ function normalizeReviewedArtifact(input?: NativeScannerReviewArtifactInput): Na
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
|
function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput, confirmedRarity?: ArtifactRarity) {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!artifact.name || artifact.name === "Unknown artifact") errors.push("Artifact name is required.");
|
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.slot || artifact.slot === "Unknown slot") errors.push("Artifact slot is required.");
|
||||||
@@ -283,8 +474,15 @@ function reviewedArtifactErrors(artifact: NativeScannerReviewArtifactInput) {
|
|||||||
if (artifact.substats.length === 0) errors.push("At least one substat is required.");
|
if (artifact.substats.length === 0) errors.push("At least one substat is required.");
|
||||||
const mainValueError = reviewedMainValueError(artifact.slot, artifact.mainStat, artifact.level, artifact.mainValue);
|
const mainValueError = reviewedMainValueError(artifact.slot, artifact.mainStat, artifact.level, artifact.mainValue);
|
||||||
if (mainValueError) errors.push(mainValueError);
|
if (mainValueError) errors.push(mainValueError);
|
||||||
const implausible = implausibleSubstats(artifact.substats, artifact.level > 16 ? 5 : undefined);
|
const implausible = implausibleSubstats(artifact.substats, confirmedRarity === 5 || artifact.level > 16 ? 5 : undefined);
|
||||||
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
|
if (implausible.length > 0) errors.push(`Implausible substats: ${implausible.join(", ")}.`);
|
||||||
|
if (errors.length === 0) {
|
||||||
|
const evaluation = evaluateArtifactValue({
|
||||||
|
...artifact,
|
||||||
|
...(confirmedRarity === undefined ? {} : { rarity: confirmedRarity }),
|
||||||
|
});
|
||||||
|
if (evaluation.status !== "evaluated" && evaluation.status !== "excluded") errors.push(evaluation.summary);
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +514,20 @@ function artifactChangedFields(
|
|||||||
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
|
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
|
function reviewedFieldConfidences(
|
||||||
return [
|
artifact: NativeScannerReviewArtifactInput,
|
||||||
|
previous?: ScanResultArtifactIdentity,
|
||||||
|
) {
|
||||||
|
const rarityField = previous?.rarity === undefined
|
||||||
|
? []
|
||||||
|
: [{
|
||||||
|
key: "rarity",
|
||||||
|
label: "Stars",
|
||||||
|
value: `${previous.rarity}★`,
|
||||||
|
confidence: Math.round((previous.rarityConfidence ?? 0) * 100),
|
||||||
|
source: "visual" as const,
|
||||||
|
}];
|
||||||
|
const manualFields = [
|
||||||
{ key: "name", label: "Name", value: artifact.name },
|
{ key: "name", label: "Name", value: artifact.name },
|
||||||
{ key: "slot", label: "Slot", value: artifact.slot },
|
{ key: "slot", label: "Slot", value: artifact.slot },
|
||||||
{ key: "level", label: "Level", value: String(artifact.level) },
|
{ key: "level", label: "Level", value: String(artifact.level) },
|
||||||
@@ -327,6 +537,7 @@ function reviewedFieldConfidences(artifact: NativeScannerReviewArtifactInput) {
|
|||||||
{ key: "equipped", label: "Equipped", value: artifact.equipped },
|
{ key: "equipped", label: "Equipped", value: artifact.equipped },
|
||||||
{ key: "substats", label: "Substats", value: artifact.substats.join(", ") },
|
{ key: "substats", label: "Substats", value: artifact.substats.join(", ") },
|
||||||
].map((field) => ({ ...field, confidence: 100, source: "database" as const }));
|
].map((field) => ({ ...field, confidence: 100, source: "database" as const }));
|
||||||
|
return [...manualFields, ...rarityField];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReviewOcr(runDir: string, sequence: number) {
|
async function loadReviewOcr(runDir: string, sequence: number) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
realpathSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const COMPLETE_RUN_FILES = [
|
||||||
|
"manifest.json",
|
||||||
|
"status.json",
|
||||||
|
"capture-jobs.jsonl",
|
||||||
|
"scan-results.json",
|
||||||
|
"processing-report.json",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface NativeScannerRunDirectoryOptions {
|
||||||
|
outputRoot: string;
|
||||||
|
requestedRunDir?: string;
|
||||||
|
activeRunDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNativeScannerRunDirectory({
|
||||||
|
outputRoot,
|
||||||
|
requestedRunDir,
|
||||||
|
activeRunDir,
|
||||||
|
}: NativeScannerRunDirectoryOptions) {
|
||||||
|
const root = path.resolve(outputRoot);
|
||||||
|
const requested = requestedRunDir?.trim();
|
||||||
|
if (requested) return resolveContainedDirectory(root, requested);
|
||||||
|
|
||||||
|
const active = activeRunDir?.trim();
|
||||||
|
if (active) return resolveContainedDirectory(root, active);
|
||||||
|
|
||||||
|
return newestCompleteNativeScannerRun(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newestCompleteNativeScannerRun(outputRoot: string) {
|
||||||
|
const root = path.resolve(outputRoot);
|
||||||
|
if (!isDirectory(root)) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidates = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => completeRunCandidate(root, path.join(root, entry.name)))
|
||||||
|
.filter((entry): entry is CompleteRunCandidate => Boolean(entry))
|
||||||
|
.sort((left, right) => right.createdAt - left.createdAt || right.name.localeCompare(left.name));
|
||||||
|
return candidates[0]?.runDir ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompleteRunCandidate {
|
||||||
|
runDir: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeRunCandidate(root: string, candidate: string): CompleteRunCandidate | null {
|
||||||
|
const runDir = resolveContainedDirectory(root, candidate);
|
||||||
|
if (!runDir || COMPLETE_RUN_FILES.some((file) => !existsSync(path.join(runDir, file)))) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusPayload = JSON.parse(readFileSync(path.join(runDir, "status.json"), "utf8"));
|
||||||
|
const status = statusPayload?.scanner ?? statusPayload;
|
||||||
|
const target = Number(status?.target ?? 0);
|
||||||
|
const captured = Number(status?.captured ?? 0);
|
||||||
|
if (status?.running !== false || status?.status !== "done" || target < 1 || captured < target) return null;
|
||||||
|
|
||||||
|
const results = JSON.parse(readFileSync(path.join(runDir, "scan-results.json"), "utf8"));
|
||||||
|
if (!Array.isArray(results) || results.length < captured) return null;
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(path.join(runDir, "manifest.json"), "utf8"));
|
||||||
|
const manifestCreatedAt = Date.parse(String(manifest?.createdAt ?? ""));
|
||||||
|
return {
|
||||||
|
runDir,
|
||||||
|
name: path.basename(runDir),
|
||||||
|
createdAt: Number.isFinite(manifestCreatedAt) ? manifestCreatedAt : statSync(runDir).mtimeMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContainedDirectory(root: string, candidate: string) {
|
||||||
|
try {
|
||||||
|
const resolvedRoot = realpathSync(root);
|
||||||
|
const resolvedCandidate = realpathSync(path.resolve(candidate));
|
||||||
|
if (!isPathInside(resolvedRoot, resolvedCandidate) || !isDirectory(resolvedCandidate)) return "";
|
||||||
|
return resolvedCandidate;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectory(candidate: string) {
|
||||||
|
try {
|
||||||
|
return statSync(candidate).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(root: string, candidate: string) {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { NativeScannerRunTiming, NativeScannerRunTimingPatch } from "../../src/types/global.js";
|
||||||
|
|
||||||
|
export const NATIVE_SCANNER_RUN_TIMING_FILE = "run-timing.json";
|
||||||
|
export const NATIVE_SCANNER_RUN_TIMING_VERSION = "native-scanner-run-timing-v1" as const;
|
||||||
|
|
||||||
|
const timingWriteQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
const timestampFields = [
|
||||||
|
"requestStartedAt",
|
||||||
|
"scannerStartedAt",
|
||||||
|
"captureStartedAt",
|
||||||
|
"captureCompletedAt",
|
||||||
|
"processingStartedAt",
|
||||||
|
"processingCompletedAt",
|
||||||
|
"resultsDurableAt",
|
||||||
|
"resultsReconciledAt",
|
||||||
|
] as const satisfies ReadonlyArray<keyof NativeScannerRunTimingPatch>;
|
||||||
|
|
||||||
|
type TimingTimestampField = typeof timestampFields[number];
|
||||||
|
|
||||||
|
export function nativeScannerRunTimingPath(runDir: string) {
|
||||||
|
return path.join(path.resolve(runDir), NATIVE_SCANNER_RUN_TIMING_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readNativeScannerRunTiming(runDir: string): Promise<NativeScannerRunTiming | null> {
|
||||||
|
const timingPath = nativeScannerRunTimingPath(runDir);
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await fs.readFile(timingPath, "utf8")) as unknown;
|
||||||
|
return normalizeTiming(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds first-observed lifecycle markers to one run's durable timing record.
|
||||||
|
* A marker is deliberately immutable after the first valid observation so
|
||||||
|
* polling or a later renderer retry cannot rewrite the measured interval.
|
||||||
|
*/
|
||||||
|
export async function recordNativeScannerRunTiming(
|
||||||
|
runDir: string,
|
||||||
|
patch: NativeScannerRunTimingPatch,
|
||||||
|
): Promise<NativeScannerRunTiming> {
|
||||||
|
const resolvedRunDir = path.resolve(runDir);
|
||||||
|
const previous = timingWriteQueues.get(resolvedRunDir) ?? Promise.resolve();
|
||||||
|
const task = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
const current = await readNativeScannerRunTiming(resolvedRunDir);
|
||||||
|
const next = mergeTiming(current, patch);
|
||||||
|
if (!sameTiming(current, next)) {
|
||||||
|
await writeJsonAtomic(nativeScannerRunTimingPath(resolvedRunDir), next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const settled = task.then(() => undefined, () => undefined);
|
||||||
|
timingWriteQueues.set(resolvedRunDir, settled);
|
||||||
|
void settled.finally(() => {
|
||||||
|
if (timingWriteQueues.get(resolvedRunDir) === settled) timingWriteQueues.delete(resolvedRunDir);
|
||||||
|
});
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNativeScannerRunTiming(timing: NativeScannerRunTiming): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (timing.version !== NATIVE_SCANNER_RUN_TIMING_VERSION) {
|
||||||
|
issues.push(`unsupported timing version ${String(timing.version)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of timestampFields) {
|
||||||
|
const value = timing[field];
|
||||||
|
if (value !== undefined && !isValidTimestamp(value)) {
|
||||||
|
issues.push(`${field} is not a valid ISO timestamp`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOrder(issues, timing, "requestStartedAt", "scannerStartedAt");
|
||||||
|
validateOrder(issues, timing, "requestStartedAt", "captureStartedAt");
|
||||||
|
validateOrder(issues, timing, "requestStartedAt", "processingStartedAt");
|
||||||
|
validateOrder(issues, timing, "captureStartedAt", "captureCompletedAt");
|
||||||
|
validateOrder(issues, timing, "captureCompletedAt", "processingCompletedAt");
|
||||||
|
validateOrder(issues, timing, "processingStartedAt", "processingCompletedAt");
|
||||||
|
validateOrder(issues, timing, "processingCompletedAt", "resultsDurableAt");
|
||||||
|
validateOrder(issues, timing, "resultsDurableAt", "resultsReconciledAt");
|
||||||
|
|
||||||
|
validateDuration(issues, timing, "requestToResultsDurableMs", "requestStartedAt", "resultsDurableAt");
|
||||||
|
validateDuration(issues, timing, "requestToResultsReconciledMs", "requestStartedAt", "resultsReconciledAt");
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCompleteNativeScannerRunTiming(timing: NativeScannerRunTiming | null | undefined) {
|
||||||
|
return Boolean(
|
||||||
|
timing?.requestStartedAt
|
||||||
|
&& timing.scannerStartedAt
|
||||||
|
&& timing.captureCompletedAt
|
||||||
|
&& timing.processingStartedAt
|
||||||
|
&& timing.processingCompletedAt
|
||||||
|
&& timing.resultsDurableAt
|
||||||
|
&& timing.resultsReconciledAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTiming(current: NativeScannerRunTiming | null, patch: NativeScannerRunTimingPatch): NativeScannerRunTiming {
|
||||||
|
const next: NativeScannerRunTiming = { version: NATIVE_SCANNER_RUN_TIMING_VERSION };
|
||||||
|
for (const field of timestampFields) {
|
||||||
|
const value = firstValidTimestamp(current?.[field], patch[field]);
|
||||||
|
if (value) next[field] = value;
|
||||||
|
}
|
||||||
|
const durableMs = durationMs(next.requestStartedAt, next.resultsDurableAt);
|
||||||
|
if (durableMs !== null) next.requestToResultsDurableMs = durableMs;
|
||||||
|
const reconciledMs = durationMs(next.requestStartedAt, next.resultsReconciledAt);
|
||||||
|
if (reconciledMs !== null) next.requestToResultsReconciledMs = reconciledMs;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTiming(value: unknown): NativeScannerRunTiming {
|
||||||
|
if (!value || typeof value !== "object") throw new Error("Native scanner timing file is not an object.");
|
||||||
|
const candidate = value as Partial<NativeScannerRunTiming>;
|
||||||
|
const raw: NativeScannerRunTiming = {
|
||||||
|
version: candidate.version as NativeScannerRunTiming["version"],
|
||||||
|
};
|
||||||
|
for (const field of timestampFields) {
|
||||||
|
if (candidate[field] !== undefined) raw[field] = candidate[field] as string;
|
||||||
|
}
|
||||||
|
if (candidate.requestToResultsDurableMs !== undefined) {
|
||||||
|
raw.requestToResultsDurableMs = candidate.requestToResultsDurableMs as number;
|
||||||
|
}
|
||||||
|
if (candidate.requestToResultsReconciledMs !== undefined) {
|
||||||
|
raw.requestToResultsReconciledMs = candidate.requestToResultsReconciledMs as number;
|
||||||
|
}
|
||||||
|
const issues = validateNativeScannerRunTiming(raw);
|
||||||
|
if (issues.length > 0) throw new Error(`Invalid native scanner timing file: ${issues.join("; ")}`);
|
||||||
|
return mergeTiming(null, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrder(
|
||||||
|
issues: string[],
|
||||||
|
timing: NativeScannerRunTiming,
|
||||||
|
earlier: TimingTimestampField,
|
||||||
|
later: TimingTimestampField,
|
||||||
|
) {
|
||||||
|
const earlierMs = timestampMs(timing[earlier]);
|
||||||
|
const laterMs = timestampMs(timing[later]);
|
||||||
|
if (earlierMs !== null && laterMs !== null && laterMs < earlierMs) {
|
||||||
|
issues.push(`${later} is before ${earlier}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDuration(
|
||||||
|
issues: string[],
|
||||||
|
timing: NativeScannerRunTiming,
|
||||||
|
field: "requestToResultsDurableMs" | "requestToResultsReconciledMs",
|
||||||
|
startedField: TimingTimestampField,
|
||||||
|
completedField: TimingTimestampField,
|
||||||
|
) {
|
||||||
|
const declared = timing[field];
|
||||||
|
const expected = durationMs(timing[startedField], timing[completedField]);
|
||||||
|
if (declared !== undefined && (!Number.isInteger(declared) || declared < 0)) {
|
||||||
|
issues.push(`${field} is not a non-negative integer`);
|
||||||
|
}
|
||||||
|
if (expected !== null && declared !== expected) {
|
||||||
|
issues.push(`${field} does not match ${startedField} to ${completedField}`);
|
||||||
|
}
|
||||||
|
if (expected === null && declared !== undefined) {
|
||||||
|
issues.push(`${field} exists without both endpoint timestamps`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValidTimestamp(...values: Array<string | null | undefined>) {
|
||||||
|
return values.find((value): value is string => isValidTimestamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTimestamp(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampMs(value: unknown) {
|
||||||
|
return isValidTimestamp(value) ? Date.parse(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMs(startedAt: string | undefined, completedAt: string | undefined) {
|
||||||
|
const started = timestampMs(startedAt);
|
||||||
|
const completed = timestampMs(completedAt);
|
||||||
|
if (started === null || completed === null || completed < started) return null;
|
||||||
|
return Math.round(completed - started);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTiming(left: NativeScannerRunTiming | null, right: NativeScannerRunTiming) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(filePath: string, payload: unknown) {
|
||||||
|
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
||||||
|
await fs.writeFile(temporaryPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
await fs.rename(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown) {
|
||||||
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>InputHelper.Tests</AssemblyName>
|
||||||
|
<RootNamespace>GenshinAssistant.InputHelper.Tests</RootNamespace>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\input-helper\InputHelper.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
using GenshinAssistant.InputHelper;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace GenshinAssistant.InputHelper.Tests;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static int failures;
|
||||||
|
|
||||||
|
private sealed record ArtifactStarReportEntry(
|
||||||
|
int? Sequence,
|
||||||
|
string ImagePath,
|
||||||
|
int? StarCount,
|
||||||
|
double Confidence,
|
||||||
|
string? Source);
|
||||||
|
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
if (TryRunArtifactStarReport(args, out var reportExitCode)) return reportExitCode;
|
||||||
|
|
||||||
|
VerifyReadyOnlyForExpectedForeground();
|
||||||
|
VerifyEveryStopSignalWins();
|
||||||
|
VerifyFocusLossBlocksWithoutRefocus();
|
||||||
|
VerifyKeyDispatchRequiresConfirmedFocus();
|
||||||
|
VerifyTopResetIsBoundedAndCoversTheSupportedRange();
|
||||||
|
VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep();
|
||||||
|
VerifyOnlyTheRealGameProcessCanBecomeTheTarget();
|
||||||
|
VerifyArtifactStarDetectorCountsThreeFourAndFiveStars();
|
||||||
|
VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise();
|
||||||
|
VerifyArtifactStarDetectorRealCropsFromEnvironment();
|
||||||
|
|
||||||
|
if (failures == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("InputHelper safety policy and ArtifactStarDetector: all tests passed.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"InputHelper safety policy: {failures} test(s) failed.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only batch report for already captured native Artifact cards. This
|
||||||
|
/// gives the review-corpus workflow an auditable rarity signal without
|
||||||
|
/// focusing Genshin or changing any saved scan result.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryRunArtifactStarReport(string[] args, out int exitCode)
|
||||||
|
{
|
||||||
|
exitCode = 0;
|
||||||
|
if (args.Length == 0 || !string.Equals(args[0], "--report-artifact-stars", StringComparison.Ordinal)) return false;
|
||||||
|
|
||||||
|
var directory = args.Skip(1)
|
||||||
|
.FirstOrDefault(argument => argument.StartsWith("--directory=", StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Split('=', 2)[1]
|
||||||
|
.Trim();
|
||||||
|
var output = args.Skip(1)
|
||||||
|
.FirstOrDefault(argument => argument.StartsWith("--output=", StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Split('=', 2)[1]
|
||||||
|
.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("--report-artifact-stars requires --directory=<existing native run directory>.");
|
||||||
|
exitCode = 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = Directory
|
||||||
|
.EnumerateFiles(directory, "artifact-*.png", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(ReportArtifactStar)
|
||||||
|
.ToArray();
|
||||||
|
var report = new
|
||||||
|
{
|
||||||
|
version = "native-artifact-star-report-v1",
|
||||||
|
createdAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
runDir = Path.GetFullPath(directory),
|
||||||
|
total = entries.Length,
|
||||||
|
counts = entries
|
||||||
|
.GroupBy(entry => entry.StarCount?.ToString() ?? "unknown")
|
||||||
|
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count()),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
});
|
||||||
|
if (string.IsNullOrWhiteSpace(output))
|
||||||
|
{
|
||||||
|
Console.WriteLine(json);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var fullOutputPath = Path.GetFullPath(output);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)!);
|
||||||
|
File.WriteAllText(fullOutputPath, json + Environment.NewLine);
|
||||||
|
Console.WriteLine($"Artifact star report: {fullOutputPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Artifact star report failed: {exception.Message}");
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArtifactStarReportEntry ReportArtifactStar(string path)
|
||||||
|
{
|
||||||
|
using var bitmap = new Bitmap(path);
|
||||||
|
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||||
|
return new ArtifactStarReportEntry(
|
||||||
|
SequenceFromArtifactPath(path),
|
||||||
|
Path.GetFullPath(path),
|
||||||
|
detection.StarCount,
|
||||||
|
detection.Confidence,
|
||||||
|
detection.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? SequenceFromArtifactPath(string path)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var prefix = "artifact-";
|
||||||
|
return fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& int.TryParse(fileName[prefix.Length..], out var sequence)
|
||||||
|
? sequence
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyReadyOnlyForExpectedForeground()
|
||||||
|
{
|
||||||
|
var decision = Evaluate(expectedHwnd: 42, foregroundHwnd: 42);
|
||||||
|
Check(decision.CanContinue, "expected Genshin foreground should allow input");
|
||||||
|
Check(decision.Outcome == NativeScannerInputGuardOutcome.Ready, "ready decision should use Ready outcome");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyEveryStopSignalWins()
|
||||||
|
{
|
||||||
|
var cases = new[]
|
||||||
|
{
|
||||||
|
(Name: "stop request", Decision: Evaluate(stopRequested: true, foregroundHwnd: 7)),
|
||||||
|
(Name: "ESC", Decision: Evaluate(escapePressed: true, foregroundHwnd: 7)),
|
||||||
|
(Name: "Enter", Decision: Evaluate(enterPressed: true, foregroundHwnd: 7)),
|
||||||
|
(Name: "F9", Decision: Evaluate(f9Pressed: true, foregroundHwnd: 7)),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in cases)
|
||||||
|
{
|
||||||
|
Check(!item.Decision.CanContinue, $"{item.Name} should reject input");
|
||||||
|
Check(item.Decision.Outcome == NativeScannerInputGuardOutcome.Stopped, $"{item.Name} should stop cleanly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyFocusLossBlocksWithoutRefocus()
|
||||||
|
{
|
||||||
|
var mismatch = Evaluate(expectedHwnd: 42, foregroundHwnd: 7, foregroundProcess: "explorer");
|
||||||
|
Check(!mismatch.CanContinue, "foreground mismatch should reject input");
|
||||||
|
Check(mismatch.Outcome == NativeScannerInputGuardOutcome.Blocked, "foreground mismatch should block the run");
|
||||||
|
Check(mismatch.Message.Contains("without refocusing", StringComparison.Ordinal), "focus-loss message should make no-refocus behavior explicit");
|
||||||
|
Check(mismatch.Message.Contains("explorer", StringComparison.Ordinal), "focus-loss message should name the actual foreground process");
|
||||||
|
|
||||||
|
var missingTarget = Evaluate(expectedHwnd: 0, foregroundHwnd: 0);
|
||||||
|
Check(missingTarget.Outcome == NativeScannerInputGuardOutcome.Blocked, "missing target window should block the run");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyKeyDispatchRequiresConfirmedFocus()
|
||||||
|
{
|
||||||
|
var sendCalls = 0;
|
||||||
|
var unfocused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(false, () =>
|
||||||
|
{
|
||||||
|
sendCalls++;
|
||||||
|
return 2;
|
||||||
|
});
|
||||||
|
Check(sendCalls == 0, "unfocused key dispatch must not call SendInput");
|
||||||
|
Check(unfocused.EventsSent == 0, "unfocused key dispatch should report zero events");
|
||||||
|
Check(unfocused.InputBlocked, "unfocused key dispatch should report blocked input");
|
||||||
|
|
||||||
|
var focused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () =>
|
||||||
|
{
|
||||||
|
sendCalls++;
|
||||||
|
return 2;
|
||||||
|
});
|
||||||
|
Check(sendCalls == 1, "focused key dispatch should call SendInput exactly once");
|
||||||
|
Check(focused.EventsSent == 2, "focused key dispatch should report both key events");
|
||||||
|
Check(!focused.InputBlocked, "focused complete key dispatch should not report blocked input");
|
||||||
|
|
||||||
|
var partial = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () => 1);
|
||||||
|
Check(partial.EventsSent == 1, "partial key dispatch should preserve the event count");
|
||||||
|
Check(partial.InputBlocked, "partial key dispatch should report blocked input");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyTopResetIsBoundedAndCoversTheSupportedRange()
|
||||||
|
{
|
||||||
|
var requiredPages = (int)Math.Ceiling(
|
||||||
|
NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity /
|
||||||
|
(double)NativeScannerInputSafetyPolicy.VisibleArtifactTargetsPerPage);
|
||||||
|
var minimumEvents = requiredPages * NativeScannerInputSafetyPolicy.PageScrollWheelEvents;
|
||||||
|
|
||||||
|
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents >= minimumEvents,
|
||||||
|
"top reset should cover every supported artifact page");
|
||||||
|
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents <= 3200,
|
||||||
|
"top reset should remain explicitly bounded");
|
||||||
|
Check(NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs >= 250,
|
||||||
|
"top reset should include a visible stabilization window");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep()
|
||||||
|
{
|
||||||
|
Check(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents == NativeScannerInputSafetyPolicy.PageScrollWheelEvents,
|
||||||
|
"wheel pacing should yield once per calibrated page-sized batch");
|
||||||
|
Check(NativeScannerInputSafetyPolicy.WheelPacingDelayMs > 0 && NativeScannerInputSafetyPolicy.WheelPacingDelayMs <= 5,
|
||||||
|
"wheel pacing delay should remain short and explicitly bounded");
|
||||||
|
Check(!NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(1),
|
||||||
|
"wheel pacing should not sleep after every single event");
|
||||||
|
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents),
|
||||||
|
"wheel pacing should yield at the batch boundary");
|
||||||
|
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents * 2),
|
||||||
|
"wheel pacing should yield at repeated batch boundaries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyOnlyTheRealGameProcessCanBecomeTheTarget()
|
||||||
|
{
|
||||||
|
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinImpact"),
|
||||||
|
"the global Genshin client process should be allowed");
|
||||||
|
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("yuanshen"),
|
||||||
|
"the Chinese Genshin client process should be allowed case-insensitively");
|
||||||
|
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin Artifact Assistant"),
|
||||||
|
"the assistant must never target itself");
|
||||||
|
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinLauncher"),
|
||||||
|
"a launcher or similarly named process must not become the game target");
|
||||||
|
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin"),
|
||||||
|
"partial process-name matches must not be accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyArtifactStarDetectorCountsThreeFourAndFiveStars()
|
||||||
|
{
|
||||||
|
foreach (var count in new[] { 3, 4, 5 })
|
||||||
|
{
|
||||||
|
using var bitmap = CreateSyntheticArtifactCard(count);
|
||||||
|
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||||
|
|
||||||
|
Check(detection.StarCount == count, $"synthetic {count}-star card should retain its exact count");
|
||||||
|
Check(detection.Confidence >= 0.80, $"synthetic {count}-star card should be high confidence");
|
||||||
|
Check(detection.Source == ArtifactStarDetector.SourceName, $"synthetic {count}-star card should name the native source");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise()
|
||||||
|
{
|
||||||
|
using var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||||
|
using var graphics = Graphics.FromImage(bitmap);
|
||||||
|
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||||
|
|
||||||
|
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||||
|
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||||
|
var rowY = roi.Y + roi.Height / 2;
|
||||||
|
// Thin gold marks and scattered pixels resemble a noisy capture but do
|
||||||
|
// not form full star glyphs. The detector must not convert this into a
|
||||||
|
// lower-rarity decision.
|
||||||
|
for (var index = 0; index < 5; index++)
|
||||||
|
{
|
||||||
|
graphics.FillRectangle(gold, roi.X + 22 + index * 34, rowY, 22, 4);
|
||||||
|
}
|
||||||
|
var random = new Random(20260711);
|
||||||
|
for (var index = 0; index < 120; index++)
|
||||||
|
{
|
||||||
|
graphics.FillRectangle(
|
||||||
|
gold,
|
||||||
|
roi.X + random.Next(roi.Width),
|
||||||
|
roi.Y + random.Next(roi.Height),
|
||||||
|
1,
|
||||||
|
1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||||
|
Check(detection.StarCount is null, "ambiguous gold noise must not produce a star count");
|
||||||
|
Check(detection.Confidence < 0.80, "ambiguous gold noise must remain low confidence");
|
||||||
|
Check(detection.Source is null, "ambiguous gold noise must not claim a native rarity source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional offline regression check for user-provided, already-captured
|
||||||
|
/// cards. The environment value is intentionally explicit rather than
|
||||||
|
/// probing a user's inventory: <c>3=C:\\sample3.png;4=C:\\sample4.png</c>.
|
||||||
|
/// This keeps the test read-only and makes the expected rarity auditable.
|
||||||
|
/// </summary>
|
||||||
|
private static void VerifyArtifactStarDetectorRealCropsFromEnvironment()
|
||||||
|
{
|
||||||
|
var samples = Environment.GetEnvironmentVariable("ARTIFACT_STAR_REAL_CROPS");
|
||||||
|
if (string.IsNullOrWhiteSpace(samples)) return;
|
||||||
|
|
||||||
|
foreach (var sample in samples.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var separator = sample.IndexOf('=');
|
||||||
|
var expectedText = separator > 0 ? sample[..separator] : string.Empty;
|
||||||
|
var path = separator > 0 ? sample[(separator + 1)..].Trim() : string.Empty;
|
||||||
|
if (!int.TryParse(expectedText, out var expected) || expected is < 1 or > 5 || !File.Exists(path))
|
||||||
|
{
|
||||||
|
Check(false, $"real-card sample must use '<1-5>=<existing png path>': {sample}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bitmap = new Bitmap(path);
|
||||||
|
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||||
|
Check(detection.StarCount == expected,
|
||||||
|
$"real-card sample '{Path.GetFileName(path)}' should detect {expected} stars, got {detection.StarCount?.ToString() ?? "unknown"}");
|
||||||
|
Check(detection.Confidence >= 0.80,
|
||||||
|
$"real-card sample '{Path.GetFileName(path)}' should meet the accepted confidence threshold");
|
||||||
|
Check(detection.Source == ArtifactStarDetector.SourceName,
|
||||||
|
$"real-card sample '{Path.GetFileName(path)}' should retain the native source");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bitmap CreateSyntheticArtifactCard(int starCount)
|
||||||
|
{
|
||||||
|
var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||||
|
using var graphics = Graphics.FromImage(bitmap);
|
||||||
|
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||||
|
|
||||||
|
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||||
|
var centerY = roi.Y + roi.Height / 2;
|
||||||
|
var firstCenterX = roi.X + 28;
|
||||||
|
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||||
|
for (var index = 0; index < starCount; index++)
|
||||||
|
{
|
||||||
|
DrawStar(graphics, gold, firstCenterX + index * 34, centerY, 13, 5.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawStar(Graphics graphics, Brush brush, int centerX, int centerY, float outerRadius, float innerRadius)
|
||||||
|
{
|
||||||
|
var points = new PointF[10];
|
||||||
|
for (var index = 0; index < points.Length; index++)
|
||||||
|
{
|
||||||
|
var radius = index % 2 == 0 ? outerRadius : innerRadius;
|
||||||
|
var angle = -Math.PI / 2 + index * Math.PI / 5;
|
||||||
|
points[index] = new PointF(
|
||||||
|
centerX + radius * (float)Math.Cos(angle),
|
||||||
|
centerY + radius * (float)Math.Sin(angle));
|
||||||
|
}
|
||||||
|
graphics.FillPolygon(brush, points);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeScannerInputGuardDecision Evaluate(
|
||||||
|
bool stopRequested = false,
|
||||||
|
bool escapePressed = false,
|
||||||
|
bool enterPressed = false,
|
||||||
|
bool f9Pressed = false,
|
||||||
|
long expectedHwnd = 42,
|
||||||
|
long foregroundHwnd = 42,
|
||||||
|
string foregroundProcess = "GenshinImpact")
|
||||||
|
=> NativeScannerInputSafetyPolicy.Evaluate(
|
||||||
|
stopRequested,
|
||||||
|
escapePressed,
|
||||||
|
enterPressed,
|
||||||
|
f9Pressed,
|
||||||
|
expectedHwnd,
|
||||||
|
foregroundHwnd,
|
||||||
|
foregroundProcess,
|
||||||
|
"test input");
|
||||||
|
|
||||||
|
private static void Check(bool condition, string message)
|
||||||
|
{
|
||||||
|
if (condition) return;
|
||||||
|
failures++;
|
||||||
|
Console.Error.WriteLine($"FAIL: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace GenshinAssistant.InputHelper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A visual-only rarity signal taken from the row of gold stars on an Artifact
|
||||||
|
/// detail-card crop. It is deliberately conservative: a malformed or
|
||||||
|
/// inconsistent row produces no count instead of guessing a rarity.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct ArtifactStarDetection(
|
||||||
|
int? StarCount,
|
||||||
|
double Confidence,
|
||||||
|
string? Source)
|
||||||
|
{
|
||||||
|
internal static ArtifactStarDetection Unknown() => new(null, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the bright gold Artifact-star glyphs inside the stable, normalized
|
||||||
|
/// star-row region of the native 492x838-style detail crop. This detector only
|
||||||
|
/// reads the captured bitmap; it has no screen, process, or input side effects.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ArtifactStarDetector
|
||||||
|
{
|
||||||
|
internal const string SourceName = "native-star-row-v1";
|
||||||
|
|
||||||
|
private const double MinimumAcceptedConfidence = 0.80;
|
||||||
|
private const double RoiLeft = 0.025;
|
||||||
|
private const double RoiRight = 0.56;
|
||||||
|
private const double RoiTop = 0.245;
|
||||||
|
private const double RoiBottom = 0.345;
|
||||||
|
|
||||||
|
internal static ArtifactStarDetection Detect(Bitmap? bitmap)
|
||||||
|
{
|
||||||
|
if (bitmap is null || bitmap.Width < 120 || bitmap.Height < 200)
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
var roi = StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||||
|
if (roi.Width < 24 || roi.Height < 16)
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytesPerRow = roi.Width * 4;
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(bytesPerRow * roi.Height);
|
||||||
|
BitmapData? data = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Native capture creates Format32bppArgb cards. Locking explicitly
|
||||||
|
// to that format keeps the detector allocation-bounded and avoids
|
||||||
|
// slow per-pixel GDI calls in the scan hot path.
|
||||||
|
data = bitmap.LockBits(roi, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||||
|
for (var y = 0; y < roi.Height; y++)
|
||||||
|
{
|
||||||
|
Marshal.Copy(
|
||||||
|
IntPtr.Add(data.Scan0, y * data.Stride),
|
||||||
|
buffer,
|
||||||
|
y * bytesPerRow,
|
||||||
|
bytesPerRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (data is not null)
|
||||||
|
{
|
||||||
|
bitmap.UnlockBits(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DetectFromBgra(buffer, roi.Width, roi.Height, bitmap.Width);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Rectangle StarRowRoiFor(int width, int height)
|
||||||
|
{
|
||||||
|
if (width <= 0 || height <= 0) return Rectangle.Empty;
|
||||||
|
var left = Math.Clamp((int)Math.Floor(width * RoiLeft), 0, Math.Max(0, width - 1));
|
||||||
|
var right = Math.Clamp((int)Math.Ceiling(width * RoiRight), left + 1, width);
|
||||||
|
var top = Math.Clamp((int)Math.Floor(height * RoiTop), 0, Math.Max(0, height - 1));
|
||||||
|
var bottom = Math.Clamp((int)Math.Ceiling(height * RoiBottom), top + 1, height);
|
||||||
|
return Rectangle.FromLTRB(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArtifactStarDetection DetectFromBgra(byte[] pixels, int width, int height, int cardWidth)
|
||||||
|
{
|
||||||
|
var yellowByColumn = new int[width];
|
||||||
|
var minYByColumn = Enumerable.Repeat(height, width).ToArray();
|
||||||
|
var maxYByColumn = Enumerable.Repeat(-1, width).ToArray();
|
||||||
|
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
var rowOffset = y * width * 4;
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
var offset = rowOffset + x * 4;
|
||||||
|
var blue = pixels[offset];
|
||||||
|
var green = pixels[offset + 1];
|
||||||
|
var red = pixels[offset + 2];
|
||||||
|
if (!IsStrongGold(red, green, blue)) continue;
|
||||||
|
|
||||||
|
yellowByColumn[x]++;
|
||||||
|
minYByColumn[x] = Math.Min(minYByColumn[x], y);
|
||||||
|
maxYByColumn[x] = Math.Max(maxYByColumn[x], y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var minimumYellowPixelsPerColumn = Math.Max(3, (int)Math.Ceiling(height * 0.045));
|
||||||
|
var rawSegments = FindColumnSegments(yellowByColumn, minYByColumn, maxYByColumn, minimumYellowPixelsPerColumn);
|
||||||
|
var stars = rawSegments
|
||||||
|
.Where(segment => IsPlausibleStarSegment(segment, cardWidth, height))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (stars.Count is < 1 or > 5)
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasPlausibleRowGeometry(stars, cardWidth, height))
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
var confidence = CalculateConfidence(stars, cardWidth, height);
|
||||||
|
if (confidence < MinimumAcceptedConfidence)
|
||||||
|
{
|
||||||
|
return ArtifactStarDetection.Unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArtifactStarDetection(stars.Count, confidence, SourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStrongGold(byte red, byte green, byte blue)
|
||||||
|
{
|
||||||
|
// The card background is warm brown/orange, while a rendered star has a
|
||||||
|
// noticeably brighter yellow core. Favor precision here because a false
|
||||||
|
// five-star classification would make a lower-rarity Artifact appear
|
||||||
|
// ineligible for downstream review instead of preserving uncertainty.
|
||||||
|
return red >= 215
|
||||||
|
&& green >= 165
|
||||||
|
&& blue <= 80
|
||||||
|
&& red >= green + 24
|
||||||
|
&& green >= blue + 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StarSegment> FindColumnSegments(
|
||||||
|
int[] yellowByColumn,
|
||||||
|
int[] minYByColumn,
|
||||||
|
int[] maxYByColumn,
|
||||||
|
int minimumYellowPixelsPerColumn)
|
||||||
|
{
|
||||||
|
const int maximumInternalGap = 2;
|
||||||
|
var segments = new List<StarSegment>();
|
||||||
|
var start = -1;
|
||||||
|
var lastActive = -1;
|
||||||
|
|
||||||
|
for (var x = 0; x < yellowByColumn.Length; x++)
|
||||||
|
{
|
||||||
|
var active = yellowByColumn[x] >= minimumYellowPixelsPerColumn;
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
if (start < 0) start = x;
|
||||||
|
lastActive = x;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start >= 0 && x - lastActive > maximumInternalGap)
|
||||||
|
{
|
||||||
|
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
|
||||||
|
start = -1;
|
||||||
|
lastActive = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start >= 0)
|
||||||
|
{
|
||||||
|
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StarSegment CreateSegment(
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
int[] yellowByColumn,
|
||||||
|
int[] minYByColumn,
|
||||||
|
int[] maxYByColumn)
|
||||||
|
{
|
||||||
|
var pixels = 0;
|
||||||
|
var peakColumnPixels = 0;
|
||||||
|
var minY = int.MaxValue;
|
||||||
|
var maxY = -1;
|
||||||
|
for (var x = start; x <= end; x++)
|
||||||
|
{
|
||||||
|
pixels += yellowByColumn[x];
|
||||||
|
peakColumnPixels = Math.Max(peakColumnPixels, yellowByColumn[x]);
|
||||||
|
minY = Math.Min(minY, minYByColumn[x]);
|
||||||
|
maxY = Math.Max(maxY, maxYByColumn[x]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StarSegment(start, end, pixels, peakColumnPixels, minY, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPlausibleStarSegment(StarSegment segment, int cardWidth, int roiHeight)
|
||||||
|
{
|
||||||
|
var minimumWidth = Math.Max(8, (int)Math.Round(cardWidth * 0.020));
|
||||||
|
var maximumWidth = Math.Max(minimumWidth + 1, (int)Math.Round(cardWidth * 0.090));
|
||||||
|
var minimumHeight = Math.Max(8, (int)Math.Round(roiHeight * 0.10));
|
||||||
|
var maximumHeight = Math.Max(minimumHeight + 1, (int)Math.Round(roiHeight * 0.75));
|
||||||
|
var minimumPixels = Math.Max(35, (int)Math.Round(cardWidth * 0.16));
|
||||||
|
var minimumPeakColumnPixels = Math.Max(8, (int)Math.Round(roiHeight * 0.12));
|
||||||
|
var density = segment.PixelCount / (double)Math.Max(1, segment.Width * segment.Height);
|
||||||
|
|
||||||
|
return segment.Width >= minimumWidth
|
||||||
|
&& segment.Width <= maximumWidth
|
||||||
|
&& segment.Height >= minimumHeight
|
||||||
|
&& segment.Height <= maximumHeight
|
||||||
|
&& segment.PixelCount >= minimumPixels
|
||||||
|
&& segment.PeakColumnPixels >= minimumPeakColumnPixels
|
||||||
|
&& density is >= 0.12 and <= 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPlausibleRowGeometry(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
|
||||||
|
{
|
||||||
|
var firstCenter = stars[0].CenterX;
|
||||||
|
var lastCenter = stars[^1].CenterX;
|
||||||
|
if (firstCenter < cardWidth * 0.035 || firstCenter > cardWidth * 0.16)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lastCenter > cardWidth * 0.47)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 1; index < stars.Count; index++)
|
||||||
|
{
|
||||||
|
var spacing = stars[index].CenterX - stars[index - 1].CenterX;
|
||||||
|
if (spacing < cardWidth * 0.045 || spacing > cardWidth * 0.12)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var meanCenterY = stars.Average(star => star.CenterY);
|
||||||
|
return stars.All(star => Math.Abs(star.CenterY - meanCenterY) <= roiHeight * 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateConfidence(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
|
||||||
|
{
|
||||||
|
var widths = stars.Select(star => (double)star.Width).ToArray();
|
||||||
|
var heights = stars.Select(star => (double)star.Height).ToArray();
|
||||||
|
var densities = stars.Select(star => star.PixelCount / (double)Math.Max(1, star.Width * star.Height)).ToArray();
|
||||||
|
var widthConsistency = Consistency(widths);
|
||||||
|
var heightConsistency = Consistency(heights);
|
||||||
|
var densityQuality = densities.Average(density => Clamp01(1 - Math.Abs(density - 0.45) / 0.45));
|
||||||
|
var evidence = Clamp01(stars.Sum(star => star.PixelCount) / (stars.Count * cardWidth * 0.35));
|
||||||
|
|
||||||
|
var spacingConsistency = 0.82;
|
||||||
|
if (stars.Count > 1)
|
||||||
|
{
|
||||||
|
var spacings = Enumerable.Range(1, stars.Count - 1)
|
||||||
|
.Select(index => stars[index].CenterX - stars[index - 1].CenterX)
|
||||||
|
.ToArray();
|
||||||
|
spacingConsistency = Consistency(spacings);
|
||||||
|
}
|
||||||
|
|
||||||
|
var meanCenterY = stars.Average(star => star.CenterY);
|
||||||
|
var alignment = Clamp01(1 - stars.Average(star => Math.Abs(star.CenterY - meanCenterY)) / Math.Max(1, roiHeight * 0.18));
|
||||||
|
var confidence = 0.20
|
||||||
|
+ 0.20 * widthConsistency
|
||||||
|
+ 0.15 * heightConsistency
|
||||||
|
+ 0.20 * spacingConsistency
|
||||||
|
+ 0.10 * densityQuality
|
||||||
|
+ 0.10 * evidence
|
||||||
|
+ 0.05 * alignment;
|
||||||
|
|
||||||
|
return Math.Round(Clamp01(confidence), 3, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Consistency(IReadOnlyList<double> values)
|
||||||
|
{
|
||||||
|
if (values.Count <= 1) return 0.88;
|
||||||
|
var mean = values.Average();
|
||||||
|
if (mean <= 0) return 0;
|
||||||
|
var variance = values.Average(value => Math.Pow(value - mean, 2));
|
||||||
|
return Clamp01(1 - Math.Sqrt(variance) / mean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clamp01(double value) => Math.Clamp(value, 0, 1);
|
||||||
|
|
||||||
|
private sealed record StarSegment(int StartX, int EndX, int PixelCount, int PeakColumnPixels, int MinY, int MaxY)
|
||||||
|
{
|
||||||
|
public int Width => EndX - StartX + 1;
|
||||||
|
public int Height => MaxY - MinY + 1;
|
||||||
|
public double CenterX => (StartX + EndX) / 2.0;
|
||||||
|
public double CenterY => (MinY + MaxY) / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("InputHelper.Tests")]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
namespace GenshinAssistant.InputHelper;
|
||||||
|
|
||||||
|
internal enum NativeScannerInputGuardOutcome
|
||||||
|
{
|
||||||
|
Ready,
|
||||||
|
Stopped,
|
||||||
|
Blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct NativeScannerInputGuardDecision(
|
||||||
|
NativeScannerInputGuardOutcome Outcome,
|
||||||
|
string Message)
|
||||||
|
{
|
||||||
|
public bool CanContinue => Outcome == NativeScannerInputGuardOutcome.Ready;
|
||||||
|
|
||||||
|
public static NativeScannerInputGuardDecision Ready()
|
||||||
|
=> new(NativeScannerInputGuardOutcome.Ready, "");
|
||||||
|
|
||||||
|
public static NativeScannerInputGuardDecision Blocked(string message)
|
||||||
|
=> new(NativeScannerInputGuardOutcome.Blocked, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class NativeScannerInputSafetyPolicy
|
||||||
|
{
|
||||||
|
// The live inventory capacity is 2,400 Artifacts. A full visible page
|
||||||
|
// contains 32 safe targets and the live-calibrated page transition uses 39
|
||||||
|
// wheel events. 3,200 upward events therefore cover the complete inventory
|
||||||
|
// with a deterministic safety margin while remaining explicitly bounded.
|
||||||
|
internal const int MaximumArtifactInventoryCapacity = 2400;
|
||||||
|
internal const int VisibleArtifactTargetsPerPage = 32;
|
||||||
|
internal const int PageScrollWheelEvents = 39;
|
||||||
|
internal const int InventoryTopResetWheelEvents = 3200;
|
||||||
|
internal const int InventoryTopResetStabilizationMs = 300;
|
||||||
|
internal const int PageScrollStabilizationMs = 120;
|
||||||
|
internal const int WheelPacingBatchEvents = PageScrollWheelEvents;
|
||||||
|
internal const int WheelPacingDelayMs = 1;
|
||||||
|
|
||||||
|
internal static bool ShouldPauseAfterWheelEvent(int completedEventCount)
|
||||||
|
=> completedEventCount > 0 && completedEventCount % WheelPacingBatchEvents == 0;
|
||||||
|
|
||||||
|
internal static NativeScannerInputGuardDecision Evaluate(
|
||||||
|
bool stopRequested,
|
||||||
|
bool escapePressed,
|
||||||
|
bool enterPressed,
|
||||||
|
bool f9Pressed,
|
||||||
|
long expectedGenshinHwnd,
|
||||||
|
long foregroundHwnd,
|
||||||
|
string foregroundProcess,
|
||||||
|
string action)
|
||||||
|
{
|
||||||
|
if (stopRequested)
|
||||||
|
{
|
||||||
|
return new NativeScannerInputGuardDecision(
|
||||||
|
NativeScannerInputGuardOutcome.Stopped,
|
||||||
|
$"stop requested before {action}");
|
||||||
|
}
|
||||||
|
if (escapePressed)
|
||||||
|
{
|
||||||
|
return new NativeScannerInputGuardDecision(
|
||||||
|
NativeScannerInputGuardOutcome.Stopped,
|
||||||
|
$"ESC held - native scan stopped before {action}");
|
||||||
|
}
|
||||||
|
if (enterPressed)
|
||||||
|
{
|
||||||
|
return new NativeScannerInputGuardDecision(
|
||||||
|
NativeScannerInputGuardOutcome.Stopped,
|
||||||
|
$"Enter held - native scan stopped before {action}");
|
||||||
|
}
|
||||||
|
if (f9Pressed)
|
||||||
|
{
|
||||||
|
return new NativeScannerInputGuardDecision(
|
||||||
|
NativeScannerInputGuardOutcome.Stopped,
|
||||||
|
$"F9 held - native scan stopped before {action}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedGenshinHwnd == 0)
|
||||||
|
{
|
||||||
|
return NativeScannerInputGuardDecision.Blocked(
|
||||||
|
$"Genshin target window is unavailable before {action}; native scan stopped without refocusing.");
|
||||||
|
}
|
||||||
|
if (foregroundHwnd != expectedGenshinHwnd)
|
||||||
|
{
|
||||||
|
var actual = string.IsNullOrWhiteSpace(foregroundProcess) ? "unknown" : foregroundProcess;
|
||||||
|
return NativeScannerInputGuardDecision.Blocked(
|
||||||
|
$"Genshin lost foreground before {action}; native scan stopped without refocusing (foreground: {actual}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NativeScannerInputGuardDecision.Ready();
|
||||||
|
}
|
||||||
|
}
|
||||||
+248
-29
@@ -74,6 +74,7 @@ internal static class Program
|
|||||||
response["escapePressed"] = state.Escape;
|
response["escapePressed"] = state.Escape;
|
||||||
response["enterPressed"] = state.Enter;
|
response["enterPressed"] = state.Enter;
|
||||||
response["f9Pressed"] = state.F9;
|
response["f9Pressed"] = state.F9;
|
||||||
|
response["isElevated"] = IsElevated();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,14 +176,17 @@ internal static class Program
|
|||||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||||
|
|
||||||
var key = GetString(root, "key");
|
var key = GetString(root, "key");
|
||||||
var sent = SendKeyPressBatch(ResolveVirtualKey(key));
|
var virtualKey = ResolveVirtualKey(key);
|
||||||
|
var dispatch = DispatchKeyPressWhenFocused(
|
||||||
|
info.Focused,
|
||||||
|
() => SendKeyPressBatch(virtualKey));
|
||||||
response["key"] = key;
|
response["key"] = key;
|
||||||
response["focused"] = info.Focused;
|
response["focused"] = info.Focused;
|
||||||
response["foregroundProcess"] = info.ForegroundProcess;
|
response["foregroundProcess"] = info.ForegroundProcess;
|
||||||
response["targetProcess"] = info.TargetProcess;
|
response["targetProcess"] = info.TargetProcess;
|
||||||
response["isElevated"] = IsElevated();
|
response["isElevated"] = IsElevated();
|
||||||
response["eventsSent"] = sent;
|
response["eventsSent"] = dispatch.EventsSent;
|
||||||
response["inputBlocked"] = sent < 2;
|
response["inputBlocked"] = dispatch.InputBlocked;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,17 +406,20 @@ internal static class Program
|
|||||||
|
|
||||||
private static IntPtr FindGenshinWindow()
|
private static IntPtr FindGenshinWindow()
|
||||||
{
|
{
|
||||||
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
|
if (_genshinHwnd != IntPtr.Zero
|
||||||
|
&& Native.IsWindow(_genshinHwnd)
|
||||||
|
&& IsSupportedGenshinProcessName(ProcessNameFromHwnd(_genshinHwnd)))
|
||||||
|
{
|
||||||
|
return _genshinHwnd;
|
||||||
|
}
|
||||||
|
_genshinHwnd = IntPtr.Zero;
|
||||||
|
|
||||||
foreach (var proc in Process.GetProcesses())
|
foreach (var proc in Process.GetProcesses())
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var name = proc.ProcessName;
|
var name = proc.ProcessName;
|
||||||
if ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|
if (IsSupportedGenshinProcessName(name) && proc.MainWindowHandle != IntPtr.Zero)
|
||||||
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
|
|
||||||
&& proc.MainWindowHandle != IntPtr.Zero)
|
|
||||||
{
|
{
|
||||||
_genshinHwnd = proc.MainWindowHandle;
|
_genshinHwnd = proc.MainWindowHandle;
|
||||||
return _genshinHwnd;
|
return _genshinHwnd;
|
||||||
@@ -428,6 +435,12 @@ internal static class Program
|
|||||||
return _genshinHwnd;
|
return _genshinHwnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool IsSupportedGenshinProcessName(string? processName)
|
||||||
|
{
|
||||||
|
return string.Equals(processName, "GenshinImpact", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(processName, "YuanShen", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static Rect? GetGenshinClientBounds()
|
private static Rect? GetGenshinClientBounds()
|
||||||
{
|
{
|
||||||
var hwnd = FindGenshinWindow();
|
var hwnd = FindGenshinWindow();
|
||||||
@@ -498,6 +511,15 @@ internal static class Program
|
|||||||
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
|
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static (uint EventsSent, bool InputBlocked) DispatchKeyPressWhenFocused(
|
||||||
|
bool focused,
|
||||||
|
Func<uint> sendKeyPress)
|
||||||
|
{
|
||||||
|
if (!focused) return (0, true);
|
||||||
|
var sent = sendKeyPress();
|
||||||
|
return (sent, sent < 2);
|
||||||
|
}
|
||||||
|
|
||||||
private static int ResolveVirtualKey(string key)
|
private static int ResolveVirtualKey(string key)
|
||||||
{
|
{
|
||||||
return key.Trim().ToUpperInvariant() switch
|
return key.Trim().ToUpperInvariant() switch
|
||||||
@@ -528,6 +550,7 @@ internal static class Program
|
|||||||
|
|
||||||
private sealed class NativeScannerService
|
private sealed class NativeScannerService
|
||||||
{
|
{
|
||||||
|
private const int GuardPollIntervalMs = 25;
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private ScannerRunStatus current = ScannerRunStatus.Idle();
|
private ScannerRunStatus current = ScannerRunStatus.Idle();
|
||||||
private bool stopRequested;
|
private bool stopRequested;
|
||||||
@@ -621,7 +644,10 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
if (current.Running) return current.ToPayload();
|
if (current.Running) return current.ToPayload();
|
||||||
stopRequested = false;
|
stopRequested = false;
|
||||||
var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800);
|
var safeLimit = Math.Clamp(
|
||||||
|
limit <= 0 ? 100 : limit,
|
||||||
|
1,
|
||||||
|
NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity);
|
||||||
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||||
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
|
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
|
||||||
var data = IkInventoryLists.Load(dataDir);
|
var data = IkInventoryLists.Load(dataDir);
|
||||||
@@ -732,27 +758,64 @@ internal static class Program
|
|||||||
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
|
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
|
||||||
var captured = 0;
|
var captured = 0;
|
||||||
var page = 1;
|
var page = 1;
|
||||||
|
var topResetStartedAt = DateTimeOffset.Now;
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
current.Message = "resetting artifact inventory to top";
|
||||||
|
}
|
||||||
|
WriteStatusFileSafe();
|
||||||
|
var topReset = ScrollArtifactInventoryToTop(grid, bounds.Value, focus.Hwnd);
|
||||||
|
RecordInitialTopResetTiming(topResetStartedAt, topReset.CanContinue);
|
||||||
|
if (!topReset.CanContinue)
|
||||||
|
{
|
||||||
|
FinishFromInputGuard(topReset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
current.Message = "artifact inventory reset to top";
|
||||||
|
}
|
||||||
|
WriteStatusFileSafe();
|
||||||
|
|
||||||
while (captured < limit)
|
while (captured < limit)
|
||||||
{
|
{
|
||||||
foreach (var target in grid.Targets)
|
foreach (var target in grid.Targets)
|
||||||
{
|
{
|
||||||
if (ShouldStop())
|
if (captured >= limit) break;
|
||||||
|
var beforeMove = GetInputGuardDecision(focus.Hwnd, $"artifact click {captured + 1}");
|
||||||
|
if (!beforeMove.CanContinue)
|
||||||
{
|
{
|
||||||
Finish("stopped", "stop requested");
|
FinishFromInputGuard(beforeMove);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (captured >= limit) break;
|
|
||||||
|
|
||||||
var screenX = bounds.Value.Left + target.X;
|
var screenX = bounds.Value.Left + target.X;
|
||||||
var screenY = bounds.Value.Top + target.Y;
|
var screenY = bounds.Value.Top + target.Y;
|
||||||
Native.SetCursorPos(screenX, screenY);
|
Native.SetCursorPos(screenX, screenY);
|
||||||
|
var beforeClick = GetInputGuardDecision(focus.Hwnd, $"artifact click {captured + 1}");
|
||||||
|
if (!beforeClick.CanContinue)
|
||||||
|
{
|
||||||
|
FinishFromInputGuard(beforeClick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var sent = SendMouseClickBatch();
|
var sent = SendMouseClickBatch();
|
||||||
Thread.Sleep(190);
|
Thread.Sleep(190);
|
||||||
|
|
||||||
|
var beforeCapture = GetInputGuardDecision(focus.Hwnd, $"artifact card capture {captured + 1}");
|
||||||
|
if (!beforeCapture.CanContinue)
|
||||||
|
{
|
||||||
|
FinishFromInputGuard(beforeCapture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
|
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
|
||||||
|
ArtifactStarDetection starDetection;
|
||||||
using (var card = CaptureArtifactCard(bounds.Value))
|
using (var card = CaptureArtifactCard(bounds.Value))
|
||||||
{
|
{
|
||||||
|
// This is a visual-only signal from the captured
|
||||||
|
// card. An unclear row remains null and never
|
||||||
|
// changes input behavior or scan control flow.
|
||||||
|
starDetection = ArtifactStarDetector.Detect(card);
|
||||||
card.Save(cardPath, ImageFormat.Png);
|
card.Save(cardPath, ImageFormat.Png);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,12 +834,16 @@ internal static class Program
|
|||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
detailRect.Width,
|
detailRect.Width,
|
||||||
detailRect.Height,
|
detailRect.Height,
|
||||||
category);
|
category,
|
||||||
|
starDetection.StarCount,
|
||||||
|
starDetection.Confidence,
|
||||||
|
starDetection.Source);
|
||||||
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
|
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
|
||||||
|
|
||||||
captured++;
|
captured++;
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
current.Captured = captured;
|
current.Captured = captured;
|
||||||
current.Queued = captured;
|
current.Queued = captured;
|
||||||
current.Clicked = captured;
|
current.Clicked = captured;
|
||||||
@@ -784,13 +851,19 @@ internal static class Program
|
|||||||
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
|
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
|
||||||
current.LastArtifactPath = cardPath;
|
current.LastArtifactPath = cardPath;
|
||||||
current.LastJob = job.ToPayload();
|
current.LastJob = job.ToPayload();
|
||||||
current.ActiveMs = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
|
||||||
|
current.TotalMs = ElapsedMs(current.StartedAt, now);
|
||||||
}
|
}
|
||||||
WriteStatusFileSafe();
|
WriteStatusFileSafe();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (captured >= limit) break;
|
if (captured >= limit) break;
|
||||||
ScrollOneArtifactPage(grid, bounds.Value);
|
var pageScroll = ScrollOneArtifactPage(grid, bounds.Value, focus.Hwnd, page + 1);
|
||||||
|
if (!pageScroll.CanContinue)
|
||||||
|
{
|
||||||
|
FinishFromInputGuard(pageScroll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
page++;
|
page++;
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
@@ -798,7 +871,6 @@ internal static class Program
|
|||||||
current.Message = $"scrolled to page {page}";
|
current.Message = $"scrolled to page {page}";
|
||||||
}
|
}
|
||||||
WriteStatusFileSafe();
|
WriteStatusFileSafe();
|
||||||
Thread.Sleep(120);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Finish("done", $"captured {captured} {category} card crops");
|
Finish("done", $"captured {captured} {category} card crops");
|
||||||
@@ -839,37 +911,144 @@ internal static class Program
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
|
private NativeScannerInputGuardDecision ScrollArtifactInventoryToTop(NativeGrid grid, Rect bounds, IntPtr genshinHwnd)
|
||||||
{
|
{
|
||||||
|
return ScrollAtGridAnchor(
|
||||||
|
grid,
|
||||||
|
bounds,
|
||||||
|
genshinHwnd,
|
||||||
|
NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents,
|
||||||
|
120,
|
||||||
|
NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs,
|
||||||
|
"initial artifact inventory top reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeScannerInputGuardDecision ScrollOneArtifactPage(NativeGrid grid, Rect bounds, IntPtr genshinHwnd, int page)
|
||||||
|
{
|
||||||
|
return ScrollAtGridAnchor(
|
||||||
|
grid,
|
||||||
|
bounds,
|
||||||
|
genshinHwnd,
|
||||||
|
NativeScannerInputSafetyPolicy.PageScrollWheelEvents,
|
||||||
|
-120,
|
||||||
|
NativeScannerInputSafetyPolicy.PageScrollStabilizationMs,
|
||||||
|
$"artifact page {page} scroll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeScannerInputGuardDecision ScrollAtGridAnchor(
|
||||||
|
NativeGrid grid,
|
||||||
|
Rect bounds,
|
||||||
|
IntPtr genshinHwnd,
|
||||||
|
int wheelEvents,
|
||||||
|
int wheelDelta,
|
||||||
|
int stabilizationMs,
|
||||||
|
string action)
|
||||||
|
{
|
||||||
|
var beforeMove = GetInputGuardDecision(genshinHwnd, action);
|
||||||
|
if (!beforeMove.CanContinue) return beforeMove;
|
||||||
|
|
||||||
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
|
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
|
||||||
Thread.Sleep(25);
|
Thread.Sleep(GuardPollIntervalMs);
|
||||||
for (var index = 0; index < 39; index++)
|
for (var index = 0; index < wheelEvents; index++)
|
||||||
{
|
{
|
||||||
SendMouseWheel(-120);
|
var beforeWheel = GetInputGuardDecision(genshinHwnd, action);
|
||||||
Thread.Sleep(1);
|
if (!beforeWheel.CanContinue) return beforeWheel;
|
||||||
|
if (SendMouseWheel(wheelDelta) < 1)
|
||||||
|
{
|
||||||
|
return NativeScannerInputGuardDecision.Blocked(
|
||||||
|
$"Windows blocked input during {action}; native scan stopped before continuing.");
|
||||||
|
}
|
||||||
|
// Windows can round Thread.Sleep(1) to roughly 15.6 ms. Sleeping
|
||||||
|
// after every event made the bounded 3,200-event reset take
|
||||||
|
// about 50 seconds live. Keep the per-event foreground/stop
|
||||||
|
// guard, but yield only once per calibrated page-sized batch.
|
||||||
|
if (NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(index + 1))
|
||||||
|
{
|
||||||
|
Thread.Sleep(NativeScannerInputSafetyPolicy.WheelPacingDelayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldStop()
|
return WaitForInputStabilization(genshinHwnd, stabilizationMs, $"{action} stabilization");
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeScannerInputGuardDecision WaitForInputStabilization(IntPtr genshinHwnd, int durationMs, string action)
|
||||||
{
|
{
|
||||||
|
var remaining = Math.Max(0, durationMs);
|
||||||
|
while (remaining > 0)
|
||||||
|
{
|
||||||
|
var wait = Math.Min(GuardPollIntervalMs, remaining);
|
||||||
|
Thread.Sleep(wait);
|
||||||
|
remaining -= wait;
|
||||||
|
var decision = GetInputGuardDecision(genshinHwnd, action);
|
||||||
|
if (!decision.CanContinue) return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NativeScannerInputGuardDecision.Ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeScannerInputGuardDecision GetInputGuardDecision(IntPtr genshinHwnd, string action)
|
||||||
|
{
|
||||||
|
bool requested;
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
if (stopRequested) return true;
|
requested = stopRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
var state = GetCursorState();
|
var state = GetCursorState();
|
||||||
return state.Escape || state.Enter || state.F9;
|
var foreground = Native.GetForegroundWindow();
|
||||||
|
var foregroundProcess = foreground == genshinHwnd ? "" : ProcessNameFromHwnd(foreground);
|
||||||
|
return NativeScannerInputSafetyPolicy.Evaluate(
|
||||||
|
requested,
|
||||||
|
state.Escape,
|
||||||
|
state.Enter,
|
||||||
|
state.F9,
|
||||||
|
genshinHwnd.ToInt64(),
|
||||||
|
foreground.ToInt64(),
|
||||||
|
foregroundProcess,
|
||||||
|
action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FinishFromInputGuard(NativeScannerInputGuardDecision decision)
|
||||||
|
{
|
||||||
|
var status = decision.Outcome == NativeScannerInputGuardOutcome.Stopped ? "stopped" : "blocked";
|
||||||
|
Finish(status, decision.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordInitialTopResetTiming(DateTimeOffset startedAt, bool completed)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
current.InitialTopResetMs = ElapsedMs(startedAt, now);
|
||||||
|
current.InitialTopResetCompleted = completed;
|
||||||
|
current.TotalMs = ElapsedMs(current.StartedAt, now);
|
||||||
|
if (completed)
|
||||||
|
{
|
||||||
|
current.CaptureStartedAt = now;
|
||||||
|
current.ActiveMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WriteStatusFileSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ElapsedMs(DateTimeOffset startedAt, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
return startedAt == DateTimeOffset.MinValue
|
||||||
|
? 0
|
||||||
|
: (int)Math.Max(0, (now - startedAt).TotalMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Finish(string status, string message)
|
private void Finish(string status, string message)
|
||||||
{
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
current.Running = false;
|
current.Running = false;
|
||||||
current.Status = status;
|
current.Status = status;
|
||||||
current.Message = message;
|
current.Message = message;
|
||||||
current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue
|
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
|
||||||
? 0
|
current.TotalMs = ElapsedMs(current.StartedAt, now);
|
||||||
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
current.CaptureCompletedAt = now;
|
||||||
stopRequested = false;
|
stopRequested = false;
|
||||||
}
|
}
|
||||||
WriteStatusFileSafe();
|
WriteStatusFileSafe();
|
||||||
@@ -929,6 +1108,29 @@ internal static class Program
|
|||||||
width = detailRect.Width,
|
width = detailRect.Width,
|
||||||
height = detailRect.Height,
|
height = detailRect.Height,
|
||||||
},
|
},
|
||||||
|
inputSafety = new
|
||||||
|
{
|
||||||
|
initialTopReset = new
|
||||||
|
{
|
||||||
|
enabled = true,
|
||||||
|
direction = "up",
|
||||||
|
anchorX = grid.AnchorX,
|
||||||
|
anchorY = grid.AnchorY,
|
||||||
|
inventoryCapacity = NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity,
|
||||||
|
wheelEvents = NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents,
|
||||||
|
foregroundGuardPerEvent = true,
|
||||||
|
pacingBatchEvents = NativeScannerInputSafetyPolicy.WheelPacingBatchEvents,
|
||||||
|
pacingDelayMs = NativeScannerInputSafetyPolicy.WheelPacingDelayMs,
|
||||||
|
stabilizationMs = NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs,
|
||||||
|
},
|
||||||
|
foregroundGuard = new
|
||||||
|
{
|
||||||
|
beforeEveryClick = true,
|
||||||
|
beforeEveryWheelEvent = true,
|
||||||
|
refocusAfterStart = false,
|
||||||
|
},
|
||||||
|
stopKeys = new[] { "ESC", "Enter", "F9" },
|
||||||
|
},
|
||||||
outputs = new
|
outputs = new
|
||||||
{
|
{
|
||||||
jobs = "capture-jobs.jsonl",
|
jobs = "capture-jobs.jsonl",
|
||||||
@@ -1074,6 +1276,9 @@ internal static class Program
|
|||||||
public int Clicked { get; set; }
|
public int Clicked { get; set; }
|
||||||
public int Pages { get; set; }
|
public int Pages { get; set; }
|
||||||
public int ActiveMs { get; set; }
|
public int ActiveMs { get; set; }
|
||||||
|
public int TotalMs { get; set; }
|
||||||
|
public int InitialTopResetMs { get; set; }
|
||||||
|
public bool InitialTopResetCompleted { get; set; }
|
||||||
public string Message { get; set; } = "";
|
public string Message { get; set; } = "";
|
||||||
public string OutputRoot { get; set; } = "";
|
public string OutputRoot { get; set; } = "";
|
||||||
public string RunDir { get; set; } = "";
|
public string RunDir { get; set; } = "";
|
||||||
@@ -1086,6 +1291,8 @@ internal static class Program
|
|||||||
public object? SupportedCategories { get; set; }
|
public object? SupportedCategories { get; set; }
|
||||||
public object? LastJob { get; set; }
|
public object? LastJob { get; set; }
|
||||||
public DateTimeOffset StartedAt { get; set; }
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
|
public DateTimeOffset CaptureStartedAt { get; set; }
|
||||||
|
public DateTimeOffset CaptureCompletedAt { get; set; }
|
||||||
|
|
||||||
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
|
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
|
||||||
|
|
||||||
@@ -1112,6 +1319,9 @@ internal static class Program
|
|||||||
clicked = Clicked,
|
clicked = Clicked,
|
||||||
pages = Pages,
|
pages = Pages,
|
||||||
activeMs = ActiveMs,
|
activeMs = ActiveMs,
|
||||||
|
totalMs = TotalMs,
|
||||||
|
initialTopResetMs = InitialTopResetMs,
|
||||||
|
initialTopResetCompleted = InitialTopResetCompleted,
|
||||||
message = Message,
|
message = Message,
|
||||||
outputRoot = OutputRoot,
|
outputRoot = OutputRoot,
|
||||||
runDir = RunDir,
|
runDir = RunDir,
|
||||||
@@ -1123,6 +1333,9 @@ internal static class Program
|
|||||||
lastArtifactPath = LastArtifactPath,
|
lastArtifactPath = LastArtifactPath,
|
||||||
supportedCategories = SupportedCategories,
|
supportedCategories = SupportedCategories,
|
||||||
lastJob = LastJob,
|
lastJob = LastJob,
|
||||||
|
startedAt = StartedAt,
|
||||||
|
captureStartedAt = CaptureStartedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureStartedAt,
|
||||||
|
captureCompletedAt = CaptureCompletedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureCompletedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,7 +1354,10 @@ internal static class Program
|
|||||||
DateTimeOffset CapturedAt,
|
DateTimeOffset CapturedAt,
|
||||||
int DetailWidth,
|
int DetailWidth,
|
||||||
int DetailHeight,
|
int DetailHeight,
|
||||||
string Category)
|
string Category,
|
||||||
|
int? StarCount,
|
||||||
|
double StarConfidence,
|
||||||
|
string? StarSource)
|
||||||
{
|
{
|
||||||
public object ToPayload() => new
|
public object ToPayload() => new
|
||||||
{
|
{
|
||||||
@@ -1158,6 +1374,9 @@ internal static class Program
|
|||||||
relativePath = RelativePath,
|
relativePath = RelativePath,
|
||||||
absolutePath = AbsolutePath,
|
absolutePath = AbsolutePath,
|
||||||
capturedAt = CapturedAt,
|
capturedAt = CapturedAt,
|
||||||
|
starCount = StarCount,
|
||||||
|
starConfidence = StarConfidence,
|
||||||
|
starSource = StarSource,
|
||||||
detail = new
|
detail = new
|
||||||
{
|
{
|
||||||
width = DetailWidth,
|
width = DetailWidth,
|
||||||
|
|||||||
Generated
+3045
-1235
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "genshin-artifact-assistant",
|
"name": "genshin-artifact-assistant",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"author": "Bao",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Local Windows assistant for scanning Genshin artifacts and suggesting no-brainer builds.",
|
"description": "Local Windows assistant for scanning Genshin artifacts and suggesting no-brainer builds.",
|
||||||
"main": "dist-electron/electron/main.js",
|
"main": "dist-electron/electron/main.js",
|
||||||
@@ -11,6 +12,20 @@
|
|||||||
"dev:web": "vite --host 127.0.0.1",
|
"dev:web": "vite --host 127.0.0.1",
|
||||||
"dev:admin": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\dev-admin.ps1 -ProjectRoot .",
|
"dev:admin": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\dev-admin.ps1 -ProjectRoot .",
|
||||||
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
|
"build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\electron\\\\preload.cjs",
|
||||||
|
"icon:build": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\generate-app-icon.ps1",
|
||||||
|
"package:dir": "npm run icon:build && npm run build && npm run helper:build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --dir --win",
|
||||||
|
"package:verify:static": "node scripts/verify-packaged-app.mjs --static",
|
||||||
|
"package:verify": "node scripts/verify-packaged-app.mjs --input=outputs/dist/win-unpacked",
|
||||||
|
"package:offline-check": "npm run package:dir && npm run package:verify",
|
||||||
|
"package:live:inspect": "node scripts/run-packaged-live-acceptance.mjs --mode=inspect --view=inventory",
|
||||||
|
"package:live:scan5": "node scripts/run-packaged-live-acceptance.mjs --mode=scan5 --view=inventory",
|
||||||
|
"package:live:ui-scan5": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-scan5 --view=scan",
|
||||||
|
"package:live:ui-row1": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-scan-row1 --view=scan",
|
||||||
|
"package:live:ui-stream20": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-stream20 --view=scan",
|
||||||
|
"package:live:ui-full-inventory": "node scripts/run-packaged-live-acceptance.mjs --mode=ui-full-inventory --view=scan",
|
||||||
|
"package:live:builds": "node scripts/run-packaged-builds-acceptance.mjs",
|
||||||
|
"acceptance:offline": "node scripts/run-offline-acceptance.mjs",
|
||||||
|
"dev-cleanup:test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 -SelfTest",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
@@ -33,12 +48,15 @@
|
|||||||
"scan:assessment:validate": "node scripts/validate-scan-assessment.cjs",
|
"scan:assessment:validate": "node scripts/validate-scan-assessment.cjs",
|
||||||
"scan:native:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1",
|
"scan:native:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1",
|
||||||
"scan:native:smoke:5": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1 -Limit 5",
|
"scan:native:smoke:5": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\native-live-smoke.ps1 -Limit 5",
|
||||||
|
"scan:native:replay": "vite-node scripts/replay-native-results.ts",
|
||||||
|
"scan:native:analyze-review": "vite-node scripts/analyze-native-review.ts",
|
||||||
|
"scan:native:validate:saved": "vite-node scripts/validate-saved-native-runs.ts",
|
||||||
|
"helper:test": "dotnet run --project native/input-helper-tests/InputHelper.Tests.csproj -c Release",
|
||||||
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
|
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
|
||||||
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "33.2.1",
|
|
||||||
"genshin-db": "^5.2.12",
|
"genshin-db": "^5.2.12",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -47,13 +65,17 @@
|
|||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electron/asar": "^4.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^18.3.17",
|
"@types/react": "^18.3.17",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^43.1.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^2.1.8",
|
"vite-node": "^3.2.4",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -84,6 +106,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
|
"icon": "build/icon.png",
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"requestedExecutionLevel": "requireAdministrator"
|
"requestedExecutionLevel": "requireAdministrator"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseArtifactCandidate } from "../src/lib/artifactOcrParser.js";
|
||||||
|
import { artifactReviewReasons } from "../src/lib/scannerLearning.js";
|
||||||
|
import type { CaptureResult } from "../src/types/global.js";
|
||||||
|
|
||||||
|
type ProcessingOcrEntry = NonNullable<CaptureResult["ocr"]>[number];
|
||||||
|
|
||||||
|
type ProcessingResult = {
|
||||||
|
sequence: number;
|
||||||
|
page?: number;
|
||||||
|
capturedAt?: string;
|
||||||
|
parsed?: boolean;
|
||||||
|
needsReview?: boolean;
|
||||||
|
artifactName?: string;
|
||||||
|
slot?: string;
|
||||||
|
setName?: string;
|
||||||
|
ikMatch?: { matched?: boolean; notes?: string[] };
|
||||||
|
notes?: string[];
|
||||||
|
ocr?: ProcessingOcrEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProcessingReport = {
|
||||||
|
results?: ProcessingResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const scanRoot = path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||||
|
"genshin-artifact-assistant",
|
||||||
|
"native-scans",
|
||||||
|
);
|
||||||
|
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||||
|
const sourcePath = path.join(runDir, "processing-report.json");
|
||||||
|
const source = JSON.parse(await fs.readFile(sourcePath, "utf8")) as ProcessingReport;
|
||||||
|
const sourceResults = Array.isArray(source.results) ? source.results : [];
|
||||||
|
if (sourceResults.length === 0) throw new Error(`No processing results found in ${sourcePath}`);
|
||||||
|
|
||||||
|
const analyzed = sourceResults.map((sourceResult) => {
|
||||||
|
const parsed = parseArtifactCandidate(captureFromProcessingResult(sourceResult));
|
||||||
|
const reasons = artifactReviewReasons(parsed);
|
||||||
|
if (sourceResult.ikMatch && !sourceResult.ikMatch.matched) reasons.push("ik_mismatch");
|
||||||
|
const uniqueReasons = [...new Set(reasons)];
|
||||||
|
const needsReview = uniqueReasons.length > 0;
|
||||||
|
const oldNeedsReview = Boolean(sourceResult.needsReview);
|
||||||
|
const repairedInitialMainValue = Boolean(
|
||||||
|
parsed
|
||||||
|
&& parsed.level === 0
|
||||||
|
&& parsed.substats.length === 4
|
||||||
|
&& parsed.fields.mainValue.source === "derived"
|
||||||
|
&& parsed.fields.mainValue.confidence >= 90
|
||||||
|
&& sourceResult.notes?.some((note) => /mainValue confidence is low/i.test(note)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sequence: sourceResult.sequence,
|
||||||
|
page: sourceResult.page ?? 0,
|
||||||
|
oldNeedsReview,
|
||||||
|
needsReview,
|
||||||
|
reasons: uniqueReasons,
|
||||||
|
level: parsed?.level ?? null,
|
||||||
|
artifactName: parsed?.name ?? "Unknown artifact",
|
||||||
|
slot: parsed?.slot ?? "Unknown slot",
|
||||||
|
mainStat: parsed?.mainStat ?? "Unknown main stat",
|
||||||
|
mainValue: parsed?.mainValue ?? "?",
|
||||||
|
substatCount: parsed?.substats.length ?? 0,
|
||||||
|
extractionConfidence: parsed?.confidence ?? 0,
|
||||||
|
repairedInitialMainValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const review = analyzed.filter((entry) => entry.needsReview);
|
||||||
|
const report = {
|
||||||
|
version: "native-review-analysis-v1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
runId: path.basename(runDir),
|
||||||
|
runDir,
|
||||||
|
sourcePath,
|
||||||
|
total: analyzed.length,
|
||||||
|
parsed: analyzed.filter((entry) => entry.artifactName !== "Unknown artifact").length,
|
||||||
|
oldReview: analyzed.filter((entry) => entry.oldNeedsReview).length,
|
||||||
|
review: review.length,
|
||||||
|
reviewRate: review.length / analyzed.length,
|
||||||
|
reclassifiedToClean: analyzed.filter((entry) => entry.oldNeedsReview && !entry.needsReview).length,
|
||||||
|
reclassifiedToReview: analyzed.filter((entry) => !entry.oldNeedsReview && entry.needsReview).length,
|
||||||
|
repairedInitialMainValues: analyzed.filter((entry) => entry.repairedInitialMainValue).length,
|
||||||
|
reasons: groupStrings(review.flatMap((entry) => entry.reasons)),
|
||||||
|
levels: groupStrings(review.map((entry) => String(entry.level ?? "missing"))),
|
||||||
|
pageBands: groupStrings(review.map((entry) => pageBand(entry.page))),
|
||||||
|
remainingSamples: review.slice(0, 25),
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputDir = options.outputDir
|
||||||
|
? path.resolve(options.outputDir)
|
||||||
|
: path.resolve("outputs", "native-review-analysis", report.runId);
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const reportPath = path.join(outputDir, "native-review-analysis.json");
|
||||||
|
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.log(`Native review analysis: ${report.runId}`);
|
||||||
|
console.log(`Results: ${report.total}; old review=${report.oldReview}; current review=${report.review} (${(report.reviewRate * 100).toFixed(2)}%)`);
|
||||||
|
console.log(`Reclassified: clean=${report.reclassifiedToClean}; review=${report.reclassifiedToReview}; initial-main repairs=${report.repairedInitialMainValues}`);
|
||||||
|
console.log(`Top reasons: ${report.reasons.slice(0, 8).map((entry) => `${entry.name}=${entry.count}`).join(", ") || "none"}`);
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
|
||||||
|
if (options.maxReviewRate !== null && report.reviewRate > options.maxReviewRate) {
|
||||||
|
throw new Error(`Review rate ${(report.reviewRate * 100).toFixed(2)}% exceeds ${(options.maxReviewRate * 100).toFixed(2)}%.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureFromProcessingResult(result: ProcessingResult): CaptureResult {
|
||||||
|
return {
|
||||||
|
id: `native-review-${result.sequence}`,
|
||||||
|
name: result.artifactName || `native-review-${result.sequence}`,
|
||||||
|
width: 492,
|
||||||
|
height: 838,
|
||||||
|
dataUrl: "",
|
||||||
|
capturedAt: result.capturedAt || new Date(0).toISOString(),
|
||||||
|
captureTarget: "genshin-client",
|
||||||
|
ocr: Array.isArray(result.ocr) ? result.ocr : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupStrings(values: string[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageBand(page: number) {
|
||||||
|
if (!Number.isFinite(page) || page < 1) return "missing";
|
||||||
|
const start = Math.floor((page - 1) / 5) * 5 + 1;
|
||||||
|
return `${String(start).padStart(2, "0")}-${String(start + 4).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]) {
|
||||||
|
const runDir = valueArg(args, "--run-dir=");
|
||||||
|
const outputDir = valueArg(args, "--output-dir=");
|
||||||
|
const rawMaximum = valueArg(args, "--max-review-rate=");
|
||||||
|
const parsedMaximum = rawMaximum ? Number(rawMaximum) : Number.NaN;
|
||||||
|
const maxReviewRate = Number.isFinite(parsedMaximum)
|
||||||
|
? Math.max(0, Math.min(1, parsedMaximum))
|
||||||
|
: null;
|
||||||
|
return { runDir, outputDir, maxReviewRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueArg(args: string[], prefix: string) {
|
||||||
|
return args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestRunDir(root: string) {
|
||||||
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const runDir = path.join(root, candidate);
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(runDir, "processing-report.json"));
|
||||||
|
return runDir;
|
||||||
|
} catch {
|
||||||
|
// Continue; this is a read-only saved-run analysis.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No processing-report.json found below ${root}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$ProcessId,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$OutputPath,
|
||||||
|
[int]$ClickX = -1,
|
||||||
|
[int]$ClickY = -1,
|
||||||
|
[int]$WaitAfterClickMs = 700,
|
||||||
|
[switch]$CloseAfterCapture
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
trap {
|
||||||
|
$errorPath = [IO.Path]::GetFullPath("$OutputPath.error.txt")
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $errorPath) | Out-Null
|
||||||
|
[string]$_.Exception.Message | Set-Content -LiteralPath $errorPath -Encoding UTF8
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class WindowCaptureNative {
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
|
||||||
|
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint flags);
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
|
||||||
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint message, UIntPtr wParam, IntPtr lParam);
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$process = Get-Process -Id $ProcessId -ErrorAction Stop
|
||||||
|
$handle = [IntPtr]$process.MainWindowHandle
|
||||||
|
if ($handle -eq [IntPtr]::Zero) { throw "Process $ProcessId has no main window." }
|
||||||
|
|
||||||
|
[WindowCaptureNative]::ShowWindowAsync($handle, 9) | Out-Null
|
||||||
|
[WindowCaptureNative]::SetForegroundWindow($handle) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
|
||||||
|
$rect = New-Object WindowCaptureNative+RECT
|
||||||
|
if (-not [WindowCaptureNative]::GetWindowRect($handle, [ref]$rect)) { throw "GetWindowRect failed." }
|
||||||
|
$width = $rect.Right - $rect.Left
|
||||||
|
$height = $rect.Bottom - $rect.Top
|
||||||
|
if ($width -lt 1 -or $height -lt 1) { throw "Window dimensions are invalid: ${width}x${height}." }
|
||||||
|
|
||||||
|
$clicked = $false
|
||||||
|
if ($ClickX -ge 0 -or $ClickY -ge 0) {
|
||||||
|
if ($ClickX -lt 0 -or $ClickY -lt 0 -or $ClickX -ge $width -or $ClickY -ge $height) {
|
||||||
|
throw "Click coordinates must stay inside the target window."
|
||||||
|
}
|
||||||
|
if ([WindowCaptureNative]::GetForegroundWindow() -ne $handle) {
|
||||||
|
throw "Target process is not foreground; no click was sent."
|
||||||
|
}
|
||||||
|
[WindowCaptureNative]::SetCursorPos($rect.Left + $ClickX, $rect.Top + $ClickY) | Out-Null
|
||||||
|
[WindowCaptureNative]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||||
|
[WindowCaptureNative]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||||
|
$clicked = $true
|
||||||
|
Start-Sleep -Milliseconds ([Math]::Max(0, $WaitAfterClickMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||||
|
$outputDirectory = Split-Path -Parent $resolvedOutput
|
||||||
|
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $width, $height
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$printed = $false
|
||||||
|
try {
|
||||||
|
$hdc = $graphics.GetHdc()
|
||||||
|
try {
|
||||||
|
$printed = [WindowCaptureNative]::PrintWindow($handle, $hdc, 2)
|
||||||
|
} finally {
|
||||||
|
$graphics.ReleaseHdc($hdc)
|
||||||
|
}
|
||||||
|
if (-not $printed) {
|
||||||
|
$graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size)
|
||||||
|
}
|
||||||
|
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
} finally {
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$closeRequested = $false
|
||||||
|
if ($CloseAfterCapture) {
|
||||||
|
$closeRequested = [WindowCaptureNative]::PostMessage($handle, 0x0010, [UIntPtr]::Zero, [IntPtr]::Zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
ok = $true
|
||||||
|
processId = $ProcessId
|
||||||
|
title = $process.MainWindowTitle
|
||||||
|
width = $width
|
||||||
|
height = $height
|
||||||
|
printWindow = $printed
|
||||||
|
clicked = $clicked
|
||||||
|
closeRequested = $closeRequested
|
||||||
|
outputPath = $resolvedOutput
|
||||||
|
} | ConvertTo-Json
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
param(
|
||||||
|
[string]$OutputPath = "build\icon.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $resolvedOutput) | Out-Null
|
||||||
|
|
||||||
|
$size = 512
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $size, $size
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||||
|
$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
|
||||||
|
$graphics.Clear([System.Drawing.Color]::Transparent)
|
||||||
|
|
||||||
|
function New-RoundedRectanglePath([System.Drawing.RectangleF]$rectangle, [float]$radius) {
|
||||||
|
$diameter = $radius * 2
|
||||||
|
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
|
$path.AddArc($rectangle.X, $rectangle.Y, $diameter, $diameter, 180, 90)
|
||||||
|
$path.AddArc($rectangle.Right - $diameter, $rectangle.Y, $diameter, $diameter, 270, 90)
|
||||||
|
$path.AddArc($rectangle.Right - $diameter, $rectangle.Bottom - $diameter, $diameter, $diameter, 0, 90)
|
||||||
|
$path.AddArc($rectangle.X, $rectangle.Bottom - $diameter, $diameter, $diameter, 90, 90)
|
||||||
|
$path.CloseFigure()
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
|
||||||
|
$bounds = New-Object System.Drawing.RectangleF 22, 22, 468, 468
|
||||||
|
$shape = New-RoundedRectanglePath $bounds 104
|
||||||
|
$background = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||||
|
$bounds,
|
||||||
|
[System.Drawing.Color]::FromArgb(255, 29, 17, 59),
|
||||||
|
[System.Drawing.Color]::FromArgb(255, 33, 91, 125),
|
||||||
|
135
|
||||||
|
)
|
||||||
|
$graphics.FillPath($background, $shape)
|
||||||
|
|
||||||
|
$glow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(42, 157, 112, 255))
|
||||||
|
$graphics.FillEllipse($glow, 66, 44, 330, 330)
|
||||||
|
$cyanGlow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(34, 82, 229, 242))
|
||||||
|
$graphics.FillEllipse($cyanGlow, 206, 196, 262, 262)
|
||||||
|
|
||||||
|
$borderPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(120, 221, 205, 255)), 6
|
||||||
|
$graphics.DrawPath($borderPen, $shape)
|
||||||
|
|
||||||
|
$orbitPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(235, 239, 232, 255)), 19
|
||||||
|
$orbitPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||||
|
$orbitPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||||
|
$state = $graphics.Save()
|
||||||
|
$graphics.TranslateTransform(256, 256)
|
||||||
|
$graphics.RotateTransform(-38)
|
||||||
|
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 18, 212)
|
||||||
|
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 248, 92)
|
||||||
|
$graphics.Restore($state)
|
||||||
|
|
||||||
|
$coreBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 151, 235, 242))
|
||||||
|
$coreBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 250, 247, 255)), 12
|
||||||
|
$graphics.FillEllipse($coreBrush, 202, 202, 108, 108)
|
||||||
|
$graphics.DrawEllipse($coreBorder, 202, 202, 108, 108)
|
||||||
|
|
||||||
|
$satelliteBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 241, 211, 142))
|
||||||
|
$satelliteBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 248, 228)), 8
|
||||||
|
$graphics.FillEllipse($satelliteBrush, 342, 88, 66, 66)
|
||||||
|
$graphics.DrawEllipse($satelliteBorder, 342, 88, 66, 66)
|
||||||
|
$graphics.FillEllipse($satelliteBrush, 104, 350, 54, 54)
|
||||||
|
$graphics.DrawEllipse($satelliteBorder, 104, 350, 54, 54)
|
||||||
|
|
||||||
|
$starBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(210, 255, 255, 255))
|
||||||
|
$graphics.FillEllipse($starBrush, 124, 112, 13, 13)
|
||||||
|
$graphics.FillEllipse($starBrush, 394, 282, 10, 10)
|
||||||
|
$graphics.FillEllipse($starBrush, 286, 398, 8, 8)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
} finally {
|
||||||
|
$starBrush.Dispose()
|
||||||
|
$satelliteBorder.Dispose()
|
||||||
|
$satelliteBrush.Dispose()
|
||||||
|
$coreBorder.Dispose()
|
||||||
|
$coreBrush.Dispose()
|
||||||
|
$orbitPen.Dispose()
|
||||||
|
$borderPen.Dispose()
|
||||||
|
$cyanGlow.Dispose()
|
||||||
|
$glow.Dispose()
|
||||||
|
$background.Dispose()
|
||||||
|
$shape.Dispose()
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
ok = $true
|
||||||
|
width = $size
|
||||||
|
height = $size
|
||||||
|
outputPath = $resolvedOutput
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
@@ -13,11 +13,89 @@
|
|||||||
# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos
|
# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos
|
||||||
# weg, egal wie sie beendet wurden.
|
# weg, egal wie sie beendet wurden.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$SelfTest
|
||||||
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||||
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
||||||
$devControlPort = 17317
|
$devControlPort = 17317
|
||||||
|
|
||||||
|
function ConvertTo-ComparablePath([string]$Value) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value)) { return "" }
|
||||||
|
try {
|
||||||
|
return [System.IO.Path]::GetFullPath($Value).TrimEnd("\").ToLowerInvariant()
|
||||||
|
} catch {
|
||||||
|
return $Value.Trim().TrimEnd("\").ToLowerInvariant()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-CommandLineContainsPath([string]$CommandLine, [string]$CandidatePath) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($CommandLine) -or [string]::IsNullOrWhiteSpace($CandidatePath)) { return $false }
|
||||||
|
$normalizedCommand = $CommandLine.Replace("/", "\").ToLowerInvariant()
|
||||||
|
$normalizedCandidate = (ConvertTo-ComparablePath $CandidatePath).Replace("/", "\")
|
||||||
|
return $normalizedCommand.Contains($normalizedCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-GaaStaleProcess([object]$Process, [string]$ProjectRoot, [string]$ElectronExecutable) {
|
||||||
|
$name = [string]$Process.Name
|
||||||
|
$executable = ConvertTo-ComparablePath ([string]$Process.ExecutablePath)
|
||||||
|
$commandLine = [string]$Process.CommandLine
|
||||||
|
|
||||||
|
if ($name -eq "electron.exe") {
|
||||||
|
return $executable -eq (ConvertTo-ComparablePath $ElectronExecutable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name -eq "InputHelper.exe") {
|
||||||
|
$helperPaths = @(
|
||||||
|
(Join-Path $ProjectRoot "native\input-helper\bin\publish\InputHelper.exe"),
|
||||||
|
(Join-Path $ProjectRoot "outputs\dist\win-unpacked\resources\input-helper\InputHelper.exe")
|
||||||
|
)
|
||||||
|
return @($helperPaths | Where-Object { $executable -eq (ConvertTo-ComparablePath $_) }).Count -gt 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name -eq "powershell.exe" -or $name -eq "pwsh.exe") {
|
||||||
|
$legacyHelper = Join-Path $env:APPDATA "genshin-artifact-assistant\input-helper.ps1"
|
||||||
|
return Test-CommandLineContainsPath $commandLine $legacyHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name -ne "node.exe") { return $false }
|
||||||
|
|
||||||
|
# Node itself is shared by Codex and many other tools. Only the known dev
|
||||||
|
# entrypoints from this repository are safe to classify as stale GAA work.
|
||||||
|
$knownDevEntrypoints = @(
|
||||||
|
(Join-Path $ProjectRoot "node_modules\vite\bin\vite.js"),
|
||||||
|
(Join-Path $ProjectRoot "node_modules\concurrently\dist\bin\concurrently.js"),
|
||||||
|
(Join-Path $ProjectRoot "node_modules\wait-on\bin\wait-on"),
|
||||||
|
(Join-Path $ProjectRoot "node_modules\cross-env\src\bin\cross-env.js")
|
||||||
|
)
|
||||||
|
return @($knownDevEntrypoints | Where-Object { Test-CommandLineContainsPath $commandLine $_ }).Count -gt 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($SelfTest) {
|
||||||
|
$cases = @(
|
||||||
|
@{ Name = "project electron"; Expected = $true; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = $electronPath; CommandLine = "`"$electronPath`" ." } },
|
||||||
|
@{ Name = "other electron"; Expected = $false; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = "C:\Other\electron.exe"; CommandLine = "" } },
|
||||||
|
@{ Name = "project vite"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\vite\bin\vite.js')`" --host 127.0.0.1" } },
|
||||||
|
@{ Name = "project concurrently"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\concurrently\dist\bin\concurrently.js')`" -k" } },
|
||||||
|
@{ Name = "Codex CUA working dir"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Users\vmbao\AppData\Local\OpenAI\Codex\runtimes\cua_node\bin\node.exe"; CommandLine = "node kernel.js --working-dir `"$project`"" } },
|
||||||
|
@{ Name = "unrelated node in repo"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node custom-task.js --cwd `"$project`"" } },
|
||||||
|
@{ Name = "legacy helper"; Expected = $true; Process = [pscustomobject]@{ Name = "powershell.exe"; ExecutablePath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; CommandLine = "powershell -File `"$(Join-Path $env:APPDATA 'genshin-artifact-assistant\input-helper.ps1')`"" } }
|
||||||
|
)
|
||||||
|
$failedCases = @()
|
||||||
|
foreach ($case in $cases) {
|
||||||
|
$actual = Test-GaaStaleProcess $case.Process $project $electronPath
|
||||||
|
if ($actual -ne $case.Expected) { $failedCases += "$($case.Name): expected=$($case.Expected), actual=$actual" }
|
||||||
|
}
|
||||||
|
if ($failedCases.Count -gt 0) {
|
||||||
|
$failedCases | ForEach-Object { Write-Error $_ }
|
||||||
|
throw "$($failedCases.Count) stale-process classifier self-test(s) failed."
|
||||||
|
}
|
||||||
|
Write-Host "Stale-process classifier: $($cases.Count)/$($cases.Count) tests passed."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$killed = 0
|
$killed = 0
|
||||||
$candidatePids = @{}
|
$candidatePids = @{}
|
||||||
|
|
||||||
@@ -37,28 +115,11 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Get-CimInstance Win32_Process |
|
Get-CimInstance Win32_Process |
|
||||||
Where-Object {
|
Where-Object { Test-GaaStaleProcess $_ $project $electronPath } |
|
||||||
($_.Name -eq "electron.exe" -and $_.ExecutablePath -eq $electronPath) -or
|
|
||||||
($_.Name -eq "node.exe" -and $_.CommandLine -like "*$project*") -or
|
|
||||||
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
|
|
||||||
} |
|
|
||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
$candidatePids[[int]$_.ProcessId] = $_.Name
|
$candidatePids[[int]$_.ProcessId] = $_.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
|
|
||||||
ForEach-Object {
|
|
||||||
if ($_.OwningProcess -gt 0) {
|
|
||||||
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
|
||||||
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
# Get-NetTCPConnection can be unavailable on some machines; process matching
|
|
||||||
# above still handles the normal non-elevated path.
|
|
||||||
}
|
|
||||||
|
|
||||||
$failed = 0
|
$failed = 0
|
||||||
foreach ($entry in $candidatePids.GetEnumerator()) {
|
foreach ($entry in $candidatePids.GetEnumerator()) {
|
||||||
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
|
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
|
||||||
|
|||||||
+251
-14
@@ -25,12 +25,33 @@ function Invoke-DevJson([string]$Path) {
|
|||||||
try {
|
try {
|
||||||
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
|
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
|
||||||
} catch {
|
} catch {
|
||||||
$response = $_.Exception.Response
|
$requestError = $_
|
||||||
|
$errorBody = [string]$requestError.ErrorDetails.Message
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||||
|
try {
|
||||||
|
return $errorBody | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
# PowerShell 7 can put a generic message here while the JSON body remains on Response.Content.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = ""
|
||||||
|
$response = $requestError.Exception.Response
|
||||||
if ($response) {
|
if ($response) {
|
||||||
|
if ($response.PSObject.Methods.Name -contains "GetResponseStream") {
|
||||||
$stream = $response.GetResponseStream()
|
$stream = $response.GetResponseStream()
|
||||||
if ($stream) {
|
if ($stream) {
|
||||||
$reader = New-Object System.IO.StreamReader($stream)
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
try {
|
||||||
$body = $reader.ReadToEnd()
|
$body = $reader.ReadToEnd()
|
||||||
|
} finally {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($response.Content) {
|
||||||
|
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||||
try {
|
try {
|
||||||
return $body | ConvertFrom-Json
|
return $body | ConvertFrom-Json
|
||||||
@@ -38,9 +59,10 @@ function Invoke-DevJson([string]$Path) {
|
|||||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||||
|
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $errorBody"
|
||||||
}
|
}
|
||||||
}
|
throw $requestError
|
||||||
throw
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +73,15 @@ function Save-Json([string]$Name, [object]$Payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Test-ProbeSucceeded([object]$ProbePayload) {
|
function Test-ProbeSucceeded([object]$ProbePayload) {
|
||||||
if ($ProbePayload.ok) { return $true }
|
return [bool](
|
||||||
if ($ProbePayload.changed) { return $true }
|
$ProbePayload.ok `
|
||||||
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
|
-and $ProbePayload.changed `
|
||||||
return $false
|
-and $ProbePayload.click `
|
||||||
|
-and $ProbePayload.click.ok `
|
||||||
|
-and $ProbePayload.click.clicked `
|
||||||
|
-and $ProbePayload.click.moved `
|
||||||
|
-and -not $ProbePayload.click.inputBlocked
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-NativeScanner([object]$StatusPayload) {
|
function Get-NativeScanner([object]$StatusPayload) {
|
||||||
@@ -63,6 +90,31 @@ function Get-NativeScanner([object]$StatusPayload) {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Stop-NativeScannerAfterError([int]$WaitSeconds = 10) {
|
||||||
|
$stop = Invoke-DevJson "/scanner/stop"
|
||||||
|
Save-Json "99-native-stop-after-error" $stop | Out-Null
|
||||||
|
$lastPayload = $stop
|
||||||
|
$lastScanner = Get-NativeScanner $stop
|
||||||
|
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||||
|
|
||||||
|
while (($null -eq $lastScanner -or [bool]$lastScanner.running) -and (Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
$lastPayload = Invoke-DevJson "/scanner/status"
|
||||||
|
$lastScanner = Get-NativeScanner $lastPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-Json "99-native-stop-final-status" $lastPayload | Out-Null
|
||||||
|
$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running
|
||||||
|
return [pscustomobject]@{
|
||||||
|
requested = $true
|
||||||
|
endpointOk = [bool]$stop.ok
|
||||||
|
stopped = [bool]$stopped
|
||||||
|
ok = [bool]($stop.ok -and $stopped)
|
||||||
|
status = if ($lastScanner) { [string]$lastScanner.status } else { "missing" }
|
||||||
|
runId = if ($lastScanner) { [string]$lastScanner.runId } else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-ExpectedAppSignature() {
|
function Get-ExpectedAppSignature() {
|
||||||
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
|
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
|
||||||
$mainSource = Get-Content -LiteralPath $mainPath -Raw
|
$mainSource = Get-Content -LiteralPath $mainPath -Raw
|
||||||
@@ -97,9 +149,16 @@ $summary = [ordered]@{
|
|||||||
finalStatus = $null
|
finalStatus = $null
|
||||||
process = $null
|
process = $null
|
||||||
results = $null
|
results = $null
|
||||||
|
timing = $null
|
||||||
|
contract = $null
|
||||||
|
cleanupStop = $null
|
||||||
errors = @()
|
errors = @()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scannerStartAttempted = $false
|
||||||
|
$scannerFinished = $false
|
||||||
|
$startedRunId = ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$expectedSignature = Get-ExpectedAppSignature
|
$expectedSignature = Get-ExpectedAppSignature
|
||||||
$summary.expectedSignature = $expectedSignature
|
$summary.expectedSignature = $expectedSignature
|
||||||
@@ -109,7 +168,7 @@ try {
|
|||||||
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
|
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
|
||||||
}
|
}
|
||||||
Save-Json "01-health" $health | Out-Null
|
Save-Json "01-health" $health | Out-Null
|
||||||
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
if (-not $health.ok -or -not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||||
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
|
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +180,7 @@ try {
|
|||||||
valid = [bool]$data.status.valid
|
valid = [bool]$data.status.valid
|
||||||
}
|
}
|
||||||
Save-Json "02-native-data" $data | Out-Null
|
Save-Json "02-native-data" $data | Out-Null
|
||||||
if (-not $data.ok) {
|
if (-not $data.ok -or -not $data.status -or -not [bool]$data.status.valid) {
|
||||||
throw "Native IK data check failed."
|
throw "Native IK data check failed."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +201,15 @@ try {
|
|||||||
blockReason = [string]$preflight.status.blockReason
|
blockReason = [string]$preflight.status.blockReason
|
||||||
}
|
}
|
||||||
Save-Json "03-native-preflight" $preflight | Out-Null
|
Save-Json "03-native-preflight" $preflight | Out-Null
|
||||||
if (-not $preflight.ok) {
|
if (
|
||||||
|
-not $preflight.ok `
|
||||||
|
-or -not $preflight.status `
|
||||||
|
-or -not [bool]$preflight.status.ready `
|
||||||
|
-or -not [bool]$preflight.status.categoryReady `
|
||||||
|
-or [string]$preflight.status.category -ne $Category `
|
||||||
|
-or -not [bool]$preflight.status.genshinFound `
|
||||||
|
-or -not [bool]$preflight.status.isSixteenNine
|
||||||
|
) {
|
||||||
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
|
$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"
|
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
|
||||||
}
|
}
|
||||||
@@ -160,12 +227,24 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scannerStartAttempted = $true
|
||||||
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
||||||
Save-Json "05-native-start" $start | Out-Null
|
Save-Json "05-native-start" $start | Out-Null
|
||||||
$startedScanner = Get-NativeScanner $start
|
$startedScanner = Get-NativeScanner $start
|
||||||
if ($null -eq $startedScanner) {
|
if (-not $start.ok -or $null -eq $startedScanner) {
|
||||||
throw "Native scanner start returned no scanner payload."
|
throw "Native scanner start returned no scanner payload."
|
||||||
}
|
}
|
||||||
|
$startedRunId = [string]$startedScanner.runId
|
||||||
|
if ([string]::IsNullOrWhiteSpace($startedRunId)) {
|
||||||
|
throw "Native scanner start returned no runId."
|
||||||
|
}
|
||||||
|
if (-not [bool]$startedScanner.running) {
|
||||||
|
$scannerFinished = $true
|
||||||
|
throw "Native scanner did not enter running state: status='$($startedScanner.status)', message='$($startedScanner.message)'."
|
||||||
|
}
|
||||||
|
if ([int]$startedScanner.target -ne $safeLimit -or [string]$startedScanner.category -ne $Category) {
|
||||||
|
throw "Native scanner start contract mismatch: target=$($startedScanner.target), category='$($startedScanner.category)', expected=$safeLimit/$Category."
|
||||||
|
}
|
||||||
|
|
||||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||||
$polls = @()
|
$polls = @()
|
||||||
@@ -195,27 +274,61 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$summary.finalStatus = @{
|
$summary.finalStatus = @{
|
||||||
|
runId = [string]$finalScanner.runId
|
||||||
|
category = [string]$finalScanner.category
|
||||||
|
target = [int]$finalScanner.target
|
||||||
status = [string]$finalScanner.status
|
status = [string]$finalScanner.status
|
||||||
running = [bool]$finalScanner.running
|
running = [bool]$finalScanner.running
|
||||||
runDir = [string]$finalScanner.runDir
|
runDir = [string]$finalScanner.runDir
|
||||||
captured = [int]$finalScanner.captured
|
captured = [int]$finalScanner.captured
|
||||||
|
queued = [int]$finalScanner.queued
|
||||||
clicked = [int]$finalScanner.clicked
|
clicked = [int]$finalScanner.clicked
|
||||||
pages = [int]$finalScanner.pages
|
pages = [int]$finalScanner.pages
|
||||||
|
initialTopResetMs = [int]$finalScanner.initialTopResetMs
|
||||||
|
initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted
|
||||||
activeMs = [int]$finalScanner.activeMs
|
activeMs = [int]$finalScanner.activeMs
|
||||||
|
totalMs = [int]$finalScanner.totalMs
|
||||||
message = [string]$finalScanner.message
|
message = [string]$finalScanner.message
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($finalScanner.running) {
|
if ($finalScanner.running) {
|
||||||
throw "Native scanner did not finish before timeout."
|
throw "Native scanner did not finish before timeout."
|
||||||
}
|
}
|
||||||
|
if ([string]$finalScanner.runId -ne $startedRunId) {
|
||||||
|
throw "Native scanner final status belongs to run '$($finalScanner.runId)', expected '$startedRunId'."
|
||||||
|
}
|
||||||
|
$scannerFinished = $true
|
||||||
if ([string]$finalScanner.status -ne "done") {
|
if ([string]$finalScanner.status -ne "done") {
|
||||||
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
||||||
}
|
}
|
||||||
if ([int]$finalScanner.captured -lt $safeLimit) {
|
if ([int]$finalScanner.target -ne $safeLimit -or [string]$finalScanner.category -ne $Category) {
|
||||||
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
|
throw "Native scanner final contract mismatch: target=$($finalScanner.target), category='$($finalScanner.category)', expected=$safeLimit/$Category."
|
||||||
|
}
|
||||||
|
if ([int]$finalScanner.captured -ne $safeLimit) {
|
||||||
|
throw "Native scanner captured $($finalScanner.captured), expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if ([int]$finalScanner.queued -ne $safeLimit) {
|
||||||
|
throw "Native scanner queued $($finalScanner.queued), expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if ([int]$finalScanner.clicked -ne $safeLimit) {
|
||||||
|
throw "Native scanner clicked $($finalScanner.clicked), expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if (-not [bool]$finalScanner.initialTopResetCompleted) {
|
||||||
|
throw "Native scanner did not confirm the bounded initial inventory top reset."
|
||||||
|
}
|
||||||
|
if ([int]$finalScanner.initialTopResetMs -le 0) {
|
||||||
|
throw "Native scanner reported invalid initialTopResetMs=$($finalScanner.initialTopResetMs)."
|
||||||
|
}
|
||||||
|
if ([int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs -or [int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs) {
|
||||||
|
throw "Native scanner timing contract is invalid: reset=$($finalScanner.initialTopResetMs), active=$($finalScanner.activeMs), total=$($finalScanner.totalMs)."
|
||||||
}
|
}
|
||||||
|
|
||||||
$runDirParam = UriEscape([string]$finalScanner.runDir)
|
if ([string]::IsNullOrWhiteSpace([string]$finalScanner.runDir) -or -not (Test-Path -LiteralPath ([string]$finalScanner.runDir) -PathType Container)) {
|
||||||
|
throw "Native scanner returned an invalid run directory '$($finalScanner.runDir)'."
|
||||||
|
}
|
||||||
|
$resolvedNativeRunDir = (Resolve-Path -LiteralPath ([string]$finalScanner.runDir)).Path
|
||||||
|
|
||||||
|
$runDirParam = UriEscape($resolvedNativeRunDir)
|
||||||
$persistParam = if ($Persist) { "1" } else { "0" }
|
$persistParam = if ($Persist) { "1" } else { "0" }
|
||||||
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
|
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
|
||||||
Save-Json "08-native-process" $process | Out-Null
|
Save-Json "08-native-process" $process | Out-Null
|
||||||
@@ -233,6 +346,36 @@ try {
|
|||||||
if (-not $process.ok) {
|
if (-not $process.ok) {
|
||||||
throw "Native post-capture processing failed."
|
throw "Native post-capture processing failed."
|
||||||
}
|
}
|
||||||
|
if ([int]$process.status.processed -ne $safeLimit) {
|
||||||
|
throw "Native processing handled $($process.status.processed), expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if ([int]$process.status.parsed -ne $safeLimit) {
|
||||||
|
throw "Native processing parsed $($process.status.parsed), expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if ([int]$process.status.errors -ne 0) {
|
||||||
|
throw "Native processing reported $($process.status.errors) errors."
|
||||||
|
}
|
||||||
|
$processResults = @($process.status.results)
|
||||||
|
$actualParsed = @($processResults | Where-Object { [bool]$_.parsed }).Count
|
||||||
|
$actualReview = @($processResults | Where-Object { [bool]$_.needsReview }).Count
|
||||||
|
$actualErrors = @($processResults | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.error) }).Count
|
||||||
|
if ($processResults.Count -ne $safeLimit) {
|
||||||
|
throw "Native processing returned $($processResults.Count) result rows, expected exactly $safeLimit."
|
||||||
|
}
|
||||||
|
if ($actualParsed -ne [int]$process.status.parsed -or $actualReview -ne [int]$process.status.review -or $actualErrors -ne [int]$process.status.errors) {
|
||||||
|
throw "Native processing summary does not match result rows (parsed=$($process.status.parsed)/$actualParsed, review=$($process.status.review)/$actualReview, errors=$($process.status.errors)/$actualErrors)."
|
||||||
|
}
|
||||||
|
if ([int]$process.status.review -lt 0 -or [int]$process.status.review -gt $safeLimit) {
|
||||||
|
throw "Native processing returned invalid review count $($process.status.review) for limit $safeLimit."
|
||||||
|
}
|
||||||
|
$reviewRate = if ($safeLimit -gt 0) { [double]$process.status.review / $safeLimit } else { 0 }
|
||||||
|
if ($reviewRate -gt 0.15) {
|
||||||
|
throw "Native processing review rate $([Math]::Round($reviewRate * 100, 1))% exceeds 15%."
|
||||||
|
}
|
||||||
|
$persistedProcessRows = @($processResults | Where-Object { [bool]$_.persisted })
|
||||||
|
if (-not $Persist -and ([bool]$process.status.persisted -or [int]$process.status.stored -ne 0 -or $persistedProcessRows.Count -ne 0)) {
|
||||||
|
throw "Dry native smoke wrote to the store (stored=$($process.status.stored), persisted=$($process.status.persisted), persistedRows=$($persistedProcessRows.Count))."
|
||||||
|
}
|
||||||
|
|
||||||
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
|
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
|
||||||
Save-Json "09-native-results" $results | Out-Null
|
Save-Json "09-native-results" $results | Out-Null
|
||||||
@@ -244,10 +387,104 @@ try {
|
|||||||
if (-not $results.ok) {
|
if (-not $results.ok) {
|
||||||
throw "Native scan results could not be loaded."
|
throw "Native scan results could not be loaded."
|
||||||
}
|
}
|
||||||
|
$loadedResults = @($results.status.results)
|
||||||
|
if ([int]$results.status.total -ne $safeLimit -or $loadedResults.Count -ne $safeLimit) {
|
||||||
|
throw "Native result contract mismatch: total=$($results.status.total), loaded=$($loadedResults.Count), expected=$safeLimit."
|
||||||
|
}
|
||||||
|
$timing = $results.status.timing
|
||||||
|
$summary.timing = @{
|
||||||
|
requestStartedAt = if ($timing) { [string]$timing.requestStartedAt } else { "" }
|
||||||
|
captureCompletedAt = if ($timing) { [string]$timing.captureCompletedAt } else { "" }
|
||||||
|
processingCompletedAt = if ($timing) { [string]$timing.processingCompletedAt } else { "" }
|
||||||
|
resultsDurableAt = if ($timing) { [string]$timing.resultsDurableAt } else { "" }
|
||||||
|
resultsReconciledAt = if ($timing) { [string]$timing.resultsReconciledAt } else { "" }
|
||||||
|
requestToResultsDurableMs = if ($timing) { [int64]$timing.requestToResultsDurableMs } else { -1 }
|
||||||
|
requestToResultsReconciledMs = if ($timing) { [int64]$timing.requestToResultsReconciledMs } else { -1 }
|
||||||
|
}
|
||||||
|
$requiredTimingFields = @(
|
||||||
|
"requestStartedAt",
|
||||||
|
"captureCompletedAt",
|
||||||
|
"processingCompletedAt",
|
||||||
|
"resultsDurableAt",
|
||||||
|
"resultsReconciledAt"
|
||||||
|
)
|
||||||
|
$missingTimingFields = @($requiredTimingFields | Where-Object { [string]::IsNullOrWhiteSpace([string]$timing.$_) })
|
||||||
|
if ($null -eq $timing -or $missingTimingFields.Count -ne 0) {
|
||||||
|
throw "Native run timing contract is incomplete: missing=$($missingTimingFields -join ', ')."
|
||||||
|
}
|
||||||
|
if ([int64]$timing.requestToResultsDurableMs -lt 0 -or [int64]$timing.requestToResultsReconciledMs -lt [int64]$timing.requestToResultsDurableMs) {
|
||||||
|
throw "Native run timing contract has invalid derived durations: durable=$($timing.requestToResultsDurableMs), reconciled=$($timing.requestToResultsReconciledMs)."
|
||||||
|
}
|
||||||
|
$persistedLoadedRows = @($loadedResults | Where-Object {
|
||||||
|
[bool]$_.persistedArtifact -or -not [string]::IsNullOrWhiteSpace([string]$_.artifactRecordId)
|
||||||
|
})
|
||||||
|
if (-not $Persist -and $persistedLoadedRows.Count -ne 0) {
|
||||||
|
throw "Dry native smoke loaded $($persistedLoadedRows.Count) result rows marked as persisted."
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobsPath = Join-Path $resolvedNativeRunDir "capture-jobs.jsonl"
|
||||||
|
$pngFiles = @(Get-ChildItem -LiteralPath $resolvedNativeRunDir -Filter "artifact-*.png" -File)
|
||||||
|
$jobs = @(
|
||||||
|
Get-Content -LiteralPath $jobsPath |
|
||||||
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||||
|
ForEach-Object { $_ | ConvertFrom-Json }
|
||||||
|
)
|
||||||
|
$expectedPngNames = @(1..$safeLimit | ForEach-Object { "artifact-{0:D4}.png" -f $_ })
|
||||||
|
$actualPngNames = @($pngFiles.Name | Sort-Object)
|
||||||
|
$pngNameMismatches = @(Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames)
|
||||||
|
$badSequenceJobs = @($jobs | Where-Object { [int]$_.sequence -lt 1 -or [int]$_.sequence -gt $safeLimit })
|
||||||
|
$duplicateSequenceJobs = @($jobs | Group-Object sequence | Where-Object { $_.Count -ne 1 })
|
||||||
|
$badClickJobs = @($jobs | Where-Object { [int]$_.clickEventsSent -ne 2 })
|
||||||
|
$badPathJobs = @($jobs | Where-Object {
|
||||||
|
$expectedName = "artifact-{0:D4}.png" -f [int]$_.sequence
|
||||||
|
$expectedAbsolutePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedNativeRunDir $expectedName))
|
||||||
|
[System.IO.Path]::GetFileName([string]$_.relativePath) -ne $expectedName `
|
||||||
|
-or [System.IO.Path]::GetFullPath([string]$_.absolutePath) -ne $expectedAbsolutePath
|
||||||
|
})
|
||||||
|
$emptyPngFiles = @($pngFiles | Where-Object { $_.Length -le 0 })
|
||||||
|
$summary.contract = @{
|
||||||
|
jobs = $jobs.Count
|
||||||
|
pngs = $pngFiles.Count
|
||||||
|
pngNameMismatches = $pngNameMismatches.Count
|
||||||
|
badSequenceJobs = $badSequenceJobs.Count
|
||||||
|
duplicateSequenceJobs = $duplicateSequenceJobs.Count
|
||||||
|
badClickJobs = $badClickJobs.Count
|
||||||
|
badPathJobs = $badPathJobs.Count
|
||||||
|
emptyPngs = $emptyPngFiles.Count
|
||||||
|
persistedProcessRows = $persistedProcessRows.Count
|
||||||
|
persistedLoadedRows = $persistedLoadedRows.Count
|
||||||
|
reviewRatePct = [Math]::Round($reviewRate * 100, 1)
|
||||||
|
}
|
||||||
|
if ($jobs.Count -ne $safeLimit -or $pngFiles.Count -ne $safeLimit) {
|
||||||
|
throw "Native file contract mismatch: jobs=$($jobs.Count), pngs=$($pngFiles.Count), expected=$safeLimit."
|
||||||
|
}
|
||||||
|
if ($pngNameMismatches.Count -ne 0 -or $emptyPngFiles.Count -ne 0) {
|
||||||
|
throw "Native PNG contract mismatch: names=$($pngNameMismatches.Count), empty=$($emptyPngFiles.Count)."
|
||||||
|
}
|
||||||
|
if ($badSequenceJobs.Count -ne 0 -or $duplicateSequenceJobs.Count -ne 0 -or $badPathJobs.Count -ne 0) {
|
||||||
|
throw "Native job contract mismatch: invalidSequences=$($badSequenceJobs.Count), duplicateSequences=$($duplicateSequenceJobs.Count), invalidPaths=$($badPathJobs.Count)."
|
||||||
|
}
|
||||||
|
if ($badClickJobs.Count -ne 0) {
|
||||||
|
throw "Native run contains $($badClickJobs.Count) jobs without exactly two click events."
|
||||||
|
}
|
||||||
|
|
||||||
$summary.ok = $true
|
$summary.ok = $true
|
||||||
} catch {
|
} catch {
|
||||||
$summary.errors += [string]$_.Exception.Message
|
$summary.errors += [string]$_.Exception.Message
|
||||||
|
if ($scannerStartAttempted -and -not $scannerFinished) {
|
||||||
|
try {
|
||||||
|
$summary.cleanupStop = Stop-NativeScannerAfterError
|
||||||
|
if (-not $summary.cleanupStop.ok) {
|
||||||
|
$summary.errors += "Native scanner cleanup stop did not reach a confirmed stopped state."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$summary.cleanupStop = @{
|
||||||
|
requested = $true
|
||||||
|
ok = $false
|
||||||
|
error = [string]$_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
throw
|
throw
|
||||||
} finally {
|
} finally {
|
||||||
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
|
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { loadNativeScannerDeletedResultIds } from "../electron/services/nativeScannerResultTombstones.js";
|
||||||
|
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||||
|
import type { StoredScanResultEntry } from "../src/types/storage.js";
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const scanRoot = path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "genshin-artifact-assistant", "native-scans");
|
||||||
|
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||||
|
const sourcePath = path.join(runDir, "scan-results.json");
|
||||||
|
const source = await fs.readFile(sourcePath, "utf8");
|
||||||
|
const parsed = JSON.parse(source);
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error(`No scan results found in ${sourcePath}`);
|
||||||
|
const rawEntries = parsed.filter(isStoredScanResultEntry);
|
||||||
|
if (rawEntries.length !== parsed.length) throw new Error(`Invalid scan-result entries: ${parsed.length - rawEntries.length}`);
|
||||||
|
const deletedResultIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||||
|
const entries = rawEntries.filter((entry) => !deletedResultIds.has(entry.id));
|
||||||
|
if (entries.length === 0) throw new Error(`No visible scan results found in ${sourcePath}`);
|
||||||
|
|
||||||
|
const report = replayNativeScanResults({
|
||||||
|
entries,
|
||||||
|
repeats: options.repeats,
|
||||||
|
runId: path.basename(runDir),
|
||||||
|
sourcePath,
|
||||||
|
sourceSha256: createHash("sha256").update(source).digest("hex"),
|
||||||
|
});
|
||||||
|
const outputDir = path.resolve("outputs", "native-replay", report.runId);
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const reportPath = path.join(outputDir, "native-replay-report.json");
|
||||||
|
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.log(`Native offline replay: ${report.runId}`);
|
||||||
|
if (deletedResultIds.size > 0) console.log(`Locally removed results excluded: ${deletedResultIds.size}`);
|
||||||
|
console.log(
|
||||||
|
`Results: ${report.total}; evaluated=${report.values.evaluated}; excluded=${report.values.excluded}; `
|
||||||
|
+ `review=${report.values.review}; unknown=${report.values.unknown}`,
|
||||||
|
);
|
||||||
|
console.log(`Projection: available=${report.projections.available}; complete=${report.projections.complete}; unavailable=${report.projections.unavailable}`);
|
||||||
|
console.log(`Score: min=${report.score.min}; avg=${report.score.average}; max=${report.score.max}`);
|
||||||
|
console.log(`Deterministic: ${report.deterministic} across ${report.repeats} repeats`);
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
|
||||||
|
if (!report.deterministic) throw new Error("Offline replay produced different evaluation hashes.");
|
||||||
|
if (report.cleanUnevaluated.length > 0) {
|
||||||
|
console.error(`Clean but unevaluated results: ${report.cleanUnevaluated.map((entry) => `#${entry.sequence} ${entry.summary}`).join(" | ")}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]) {
|
||||||
|
const runDir = args.find((argument) => argument.startsWith("--run-dir="))?.slice("--run-dir=".length) ?? "";
|
||||||
|
const repeatValue = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 3);
|
||||||
|
return { runDir, repeats: Number.isFinite(repeatValue) ? repeatValue : 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestRunDir(root: string) {
|
||||||
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const runDir = path.join(root, candidate);
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(runDir, "scan-results.json"));
|
||||||
|
return runDir;
|
||||||
|
} catch {
|
||||||
|
// Continue to the next saved run; no live capture is started.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No saved native scan-results.json found below ${root}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as Partial<StoredScanResultEntry>;
|
||||||
|
return typeof entry.id === "string"
|
||||||
|
&& typeof entry.runId === "string"
|
||||||
|
&& Number.isFinite(entry.sequence)
|
||||||
|
&& typeof entry.extractionStatus === "string"
|
||||||
|
&& typeof entry.valueStatus === "string"
|
||||||
|
&& Array.isArray(entry.notes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
const steps = [
|
||||||
|
{ id: "dev-cleanup-safety", command: npmCommand, args: ["run", "dev-cleanup:test"] },
|
||||||
|
{ id: "native-helper-safety", command: npmCommand, args: ["run", "helper:test"] },
|
||||||
|
{ id: "lint", command: npmCommand, args: ["run", "lint"] },
|
||||||
|
{ id: "tests", command: npmCommand, args: ["test"] },
|
||||||
|
{ id: "ocr-eval", command: npmCommand, args: ["run", "eval"] },
|
||||||
|
{ id: "assessment-self-test", command: npmCommand, args: ["run", "scan:assessment:test"] },
|
||||||
|
{ id: "saved-native-validation", command: npmCommand, args: ["run", "scan:native:validate:saved"] },
|
||||||
|
{ id: "package-offline-check", command: npmCommand, args: ["run", "package:offline-check"] },
|
||||||
|
{ id: "dependency-audit", command: npmCommand, args: ["audit"] },
|
||||||
|
{ id: "production-dependency-audit", command: npmCommand, args: ["audit", "--omit=dev"] },
|
||||||
|
{ id: "git-diff-check", command: "git", args: ["diff", "--check"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const startedAt = new Date();
|
||||||
|
const results = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
const stepStartedAt = Date.now();
|
||||||
|
console.log(`\n=== Offline acceptance: ${step.id} ===`);
|
||||||
|
const invocation = commandInvocation(step);
|
||||||
|
const result = spawnSync(invocation.command, invocation.args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
id: step.id,
|
||||||
|
ok: result.status === 0 && !result.error,
|
||||||
|
exitCode: result.status,
|
||||||
|
signal: result.signal,
|
||||||
|
error: result.error?.message ?? null,
|
||||||
|
elapsedMs: Date.now() - stepStartedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = path.join(projectRoot, "outputs", "offline-acceptance");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const reportPath = path.join(outputDir, "offline-acceptance-report.json");
|
||||||
|
const report = {
|
||||||
|
version: "offline-acceptance-v2",
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
ok: results.every((result) => result.ok),
|
||||||
|
projectRoot,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.log(`\nOffline acceptance: ${report.ok ? "PASS" : "FAIL"}`);
|
||||||
|
for (const result of results) {
|
||||||
|
console.log(`${result.ok ? "PASS" : "FAIL"} ${result.id} (${result.elapsedMs} ms)`);
|
||||||
|
}
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
if (!report.ok) process.exitCode = 1;
|
||||||
|
|
||||||
|
function commandInvocation(step) {
|
||||||
|
if (process.platform !== "win32" || !step.command.endsWith(".cmd")) return step;
|
||||||
|
const tokens = [step.command, ...step.args];
|
||||||
|
if (tokens.some((token) => !/^[A-Za-z0-9:._=@/-]+$/.test(token))) {
|
||||||
|
throw new Error(`Unsafe Windows command token in offline acceptance step ${step.id}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: process.env.ComSpec || "cmd.exe",
|
||||||
|
args: ["/d", "/s", "/c", tokens.join(" ")],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const args = new Map(process.argv.slice(2).map((entry) => {
|
||||||
|
const [key, ...value] = entry.replace(/^--/, "").split("=");
|
||||||
|
return [key, value.join("=") || "true"];
|
||||||
|
}));
|
||||||
|
|
||||||
|
const port = Number(args.get("port") ?? 9223);
|
||||||
|
const outputPath = path.resolve(args.get("output") ?? "outputs/packaged-live/builds-functional-acceptance.json");
|
||||||
|
const screenshotPath = path.resolve(args.get("screenshot") ?? "outputs/packaged-live/builds-functional-acceptance.png");
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error("--port must be an integer between 1 and 65535.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json());
|
||||||
|
const target = targets.find((candidate) => (
|
||||||
|
candidate.type === "page"
|
||||||
|
&& String(candidate.title).includes("Genshin Artifact Assistant")
|
||||||
|
&& !String(candidate.url).includes("overlay=1")
|
||||||
|
));
|
||||||
|
if (!target?.webSocketDebuggerUrl || !String(target.url).startsWith("file:")) {
|
||||||
|
throw new Error("No packaged Genshin Artifact Assistant renderer with CDP was found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("Timed out connecting to packaged Electron CDP.")), 10_000);
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Packaged Electron CDP connection failed."));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const diagnostics = { exceptions: [], consoleErrors: [], logErrors: [] };
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.method === "Runtime.exceptionThrown") {
|
||||||
|
diagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
|
||||||
|
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
|
||||||
|
diagnostics.consoleErrors.push((message.params.args ?? []).map((entry) => entry.value ?? entry.description ?? entry.type).join(" "));
|
||||||
|
} else if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") {
|
||||||
|
diagnostics.logErrors.push(message.params.entry.text ?? "Unknown renderer log error");
|
||||||
|
}
|
||||||
|
if (!message.id || !pending.has(message.id)) return;
|
||||||
|
const request = pending.get(message.id);
|
||||||
|
pending.delete(message.id);
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
||||||
|
else request.resolve(message.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(method, params = {}, timeoutMs = 30_000) {
|
||||||
|
const id = ++nextId;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`CDP ${method} timed out after ${timeoutMs} ms.`));
|
||||||
|
}, timeoutMs);
|
||||||
|
pending.set(id, { resolve, reject, timer });
|
||||||
|
socket.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(expression, timeoutMs = 30_000) {
|
||||||
|
const response = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, timeoutMs);
|
||||||
|
if (response.exceptionDetails) {
|
||||||
|
throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Renderer evaluation failed.");
|
||||||
|
}
|
||||||
|
return response.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await call("Page.bringToFront", {}, 10_000);
|
||||||
|
await call("Runtime.enable", {}, 10_000);
|
||||||
|
await call("Log.enable", {}, 10_000);
|
||||||
|
await call("Emulation.setDeviceMetricsOverride", {
|
||||||
|
width: 1304,
|
||||||
|
height: 821,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false,
|
||||||
|
}, 10_000);
|
||||||
|
await call("Emulation.setEmulatedMedia", {
|
||||||
|
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
|
||||||
|
}, 10_000);
|
||||||
|
await evaluate("localStorage.removeItem('gaa-ui-locale'); 'locale-cleared'", 10_000);
|
||||||
|
await call("Page.reload", { ignoreCache: true }, 10_000);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
|
const acceptance = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const waitFor = async (selector, timeout = 10_000) => {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (element) return element;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error('Timed out waiting for ' + selector);
|
||||||
|
};
|
||||||
|
const api = window.assistantApi;
|
||||||
|
if (!api) throw new Error('Packaged preload bridge is unavailable.');
|
||||||
|
const initialLocale = document.documentElement.lang;
|
||||||
|
if (initialLocale !== 'en' || document.documentElement.dataset.appLocale !== 'en') {
|
||||||
|
throw new Error('Fresh packaged UI did not default to English.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsTrigger = await waitFor('[data-app-settings-trigger]');
|
||||||
|
settingsTrigger.click();
|
||||||
|
const languageSelect = await waitFor('[data-app-language-select]');
|
||||||
|
languageSelect.value = 'de';
|
||||||
|
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
const germanLocaleApplied = document.documentElement.lang === 'de'
|
||||||
|
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artefakte';
|
||||||
|
languageSelect.value = 'en';
|
||||||
|
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
const englishLocaleRestored = document.documentElement.lang === 'en'
|
||||||
|
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artifacts';
|
||||||
|
document.querySelector('[data-app-settings-backdrop]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
|
||||||
|
const buildsNavigation = await waitFor('[data-navigation-id="builds"]');
|
||||||
|
buildsNavigation.focus();
|
||||||
|
const navigationFocusBeforeOpen = document.activeElement === buildsNavigation;
|
||||||
|
buildsNavigation.click();
|
||||||
|
const view = await waitFor('.build-preview-page');
|
||||||
|
await sleep(250);
|
||||||
|
const headingFocusAfterOpen = document.activeElement?.id === 'app-view-heading';
|
||||||
|
const results = await api.nativeScannerLoadResults({ limit: 2400 });
|
||||||
|
const contextInput = document.querySelector('.build-context-number input');
|
||||||
|
const contextCheckbox = document.querySelector('.build-context-confirm input[type="checkbox"]');
|
||||||
|
let contextInteraction = { available: false, accepted: false };
|
||||||
|
if (contextInput instanceof HTMLInputElement && contextCheckbox instanceof HTMLInputElement) {
|
||||||
|
// React tracks controlled input values on the instance. Assigning the
|
||||||
|
// property directly makes the browser value change, but React can
|
||||||
|
// restore the old draft on the next checkbox render. Use the native
|
||||||
|
// prototype setter, then dispatch a real input event so this CDP probe
|
||||||
|
// exercises the same state transition as a typed value.
|
||||||
|
const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||||
|
nativeValueSetter?.call(contextInput, '106.6');
|
||||||
|
contextInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(0);
|
||||||
|
contextCheckbox.click();
|
||||||
|
await sleep(100);
|
||||||
|
contextInteraction = { available: true, accepted: contextInput.value === '106.6' && contextCheckbox.checked };
|
||||||
|
}
|
||||||
|
const viewStyle = getComputedStyle(view);
|
||||||
|
const reducedMotionRespected = viewStyle.animationDuration === '0s'
|
||||||
|
|| viewStyle.animationDuration === '0ms'
|
||||||
|
|| matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const noDocumentOverflow = document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||||
|
&& document.documentElement.scrollHeight <= document.documentElement.clientHeight;
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
results.ok
|
||||||
|
&& germanLocaleApplied
|
||||||
|
&& englishLocaleRestored
|
||||||
|
&& navigationFocusBeforeOpen
|
||||||
|
&& headingFocusAfterOpen
|
||||||
|
&& contextInteraction.accepted
|
||||||
|
&& reducedMotionRespected
|
||||||
|
&& noDocumentOverflow
|
||||||
|
),
|
||||||
|
locale: { initial: initialLocale, germanLocaleApplied, englishLocaleRestored },
|
||||||
|
focus: { navigationFocusBeforeOpen, headingFocusAfterOpen },
|
||||||
|
contextInteraction,
|
||||||
|
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length, error: results.error ?? '' },
|
||||||
|
rendered: {
|
||||||
|
bodyText: view.textContent?.slice(0, 6_000) ?? '',
|
||||||
|
hasSuggestions: Boolean(document.querySelector('.build-suggestion-card')),
|
||||||
|
deferredProfiles: document.querySelectorAll('.build-deferred-list li').length,
|
||||||
|
overflow: { width: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, height: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight },
|
||||||
|
reducedMotionRespected,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
|
||||||
|
const screenshot = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }, 30_000);
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
||||||
|
const report = {
|
||||||
|
version: "packaged-builds-functional-acceptance-v1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
target: { title: target.title, url: target.url },
|
||||||
|
acceptance,
|
||||||
|
diagnostics,
|
||||||
|
screenshotPath,
|
||||||
|
ok: Boolean(acceptance?.ok) && diagnostics.exceptions.length === 0 && diagnostics.consoleErrors.length === 0 && diagnostics.logErrors.length === 0,
|
||||||
|
};
|
||||||
|
await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
console.log(`Packaged Builds acceptance: ${report.ok ? "PASS" : "FAIL"}`);
|
||||||
|
console.log(`Report: ${outputPath}`);
|
||||||
|
console.log(`Screenshot: ${screenshotPath}`);
|
||||||
|
if (!report.ok) process.exitCode = 1;
|
||||||
@@ -0,0 +1,941 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const args = new Map(process.argv.slice(2).map((entry) => {
|
||||||
|
const [key, ...value] = entry.replace(/^--/, "").split("=");
|
||||||
|
return [key, value.join("=") || "true"];
|
||||||
|
}));
|
||||||
|
const port = Number(args.get("port") ?? 9223);
|
||||||
|
const mode = args.get("mode") ?? "inspect";
|
||||||
|
const view = args.get("view") ?? "scan";
|
||||||
|
const state = args.get("state") ?? "default";
|
||||||
|
const closeAfter = args.get("close") === "true";
|
||||||
|
const consoleCheck = args.get("console-check") === "true";
|
||||||
|
const viewportWidth = args.has("viewport-width") ? Number(args.get("viewport-width")) : null;
|
||||||
|
const viewportHeight = args.has("viewport-height") ? Number(args.get("viewport-height")) : null;
|
||||||
|
const reprocessRunDir = args.has("run-dir") ? path.resolve(args.get("run-dir")) : "";
|
||||||
|
const reprocessTarget = args.has("expected-target") ? Number(args.get("expected-target")) : null;
|
||||||
|
const reprocessMaxReviewRate = Number(args.get("max-review-rate") ?? 0.15);
|
||||||
|
const viewIds = new Set(["scan", "inventory", "triage", "builds", "overlay", "diagnose"]);
|
||||||
|
const output = path.resolve(args.get("output") ?? `outputs/packaged-live/packaged-${mode}-${view}.json`);
|
||||||
|
const screenshotPath = path.resolve(args.get("screenshot") ?? `outputs/packaged-live/packaged-${mode}-${view}.png`);
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid CDP port: ${port}`);
|
||||||
|
if ((viewportWidth === null) !== (viewportHeight === null)) throw new Error("Both --viewport-width and --viewport-height are required together.");
|
||||||
|
if (viewportWidth !== null && (!Number.isInteger(viewportWidth) || !Number.isInteger(viewportHeight) || viewportWidth < 480 || viewportHeight < 320)) {
|
||||||
|
throw new Error("Viewport dimensions must be integer CSS pixels of at least 480x320.");
|
||||||
|
}
|
||||||
|
if (!new Set(["inspect", "scan5", "ui-scan5", "ui-scan-row1", "ui-stream20", "ui-full-inventory", "reprocess-run"]).has(mode)) throw new Error(`Unsupported mode: ${mode}`);
|
||||||
|
if (!viewIds.has(view)) throw new Error(`Unsupported view: ${view}`);
|
||||||
|
if (mode === "reprocess-run" && (!reprocessRunDir || !Number.isInteger(reprocessTarget) || reprocessTarget < 1 || reprocessTarget > 2400)) {
|
||||||
|
throw new Error("reprocess-run requires --run-dir and --expected-target in 1..2400.");
|
||||||
|
}
|
||||||
|
if (mode === "reprocess-run" && (!Number.isFinite(reprocessMaxReviewRate) || reprocessMaxReviewRate < 0 || reprocessMaxReviewRate > 1)) {
|
||||||
|
throw new Error("--max-review-rate must be in 0..1.");
|
||||||
|
}
|
||||||
|
if (!new Set(["default", "scan-settings", "artifact-open", "artifact-probe", "post-scan-results", "review-deeplink", "keyboard-focus", "overlay-open", "inventory-confirm", "inventory-delete-confirm", "inventory-store-delete-confirm", "inventory-roving", "inventory-technical"]).has(state)) throw new Error(`Unsupported state: ${state}`);
|
||||||
|
if (state === "scan-settings" && view !== "scan") throw new Error("scan-settings state requires --view=scan.");
|
||||||
|
if (state === "artifact-open" && view !== "scan") throw new Error("artifact-open state requires --view=scan.");
|
||||||
|
if (state === "artifact-probe" && view !== "scan") throw new Error("artifact-probe state requires --view=scan.");
|
||||||
|
if (state === "post-scan-results" && view !== "scan") throw new Error("post-scan-results state requires --view=scan.");
|
||||||
|
if (state === "review-deeplink" && view !== "triage") throw new Error("review-deeplink state requires --view=triage.");
|
||||||
|
if (state === "overlay-open" && view !== "overlay") throw new Error("overlay-open state requires --view=overlay.");
|
||||||
|
if (state === "inventory-confirm" && view !== "inventory") throw new Error("inventory-confirm state requires --view=inventory.");
|
||||||
|
if (state === "inventory-delete-confirm" && view !== "inventory") throw new Error("inventory-delete-confirm state requires --view=inventory.");
|
||||||
|
if (state === "inventory-store-delete-confirm" && view !== "inventory") throw new Error("inventory-store-delete-confirm state requires --view=inventory.");
|
||||||
|
if (state === "inventory-roving" && view !== "inventory") throw new Error("inventory-roving state requires --view=inventory.");
|
||||||
|
if (state === "inventory-technical" && view !== "inventory") throw new Error("inventory-technical state requires --view=inventory.");
|
||||||
|
|
||||||
|
const uiScanConfig = mode === "ui-full-inventory"
|
||||||
|
? {
|
||||||
|
scopeMode: "all",
|
||||||
|
expectedTarget: null,
|
||||||
|
deadlineMs: 900000,
|
||||||
|
}
|
||||||
|
: mode === "ui-scan-row1"
|
||||||
|
? {
|
||||||
|
scopeMode: "limit",
|
||||||
|
limit: 1,
|
||||||
|
expectedTarget: 8,
|
||||||
|
unit: "rows",
|
||||||
|
deadlineMs: 240000,
|
||||||
|
}
|
||||||
|
: mode === "ui-stream20"
|
||||||
|
? {
|
||||||
|
scopeMode: "limit",
|
||||||
|
limit: 20,
|
||||||
|
expectedTarget: 20,
|
||||||
|
unit: "artifacts",
|
||||||
|
deadlineMs: 240000,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scopeMode: "limit",
|
||||||
|
limit: 5,
|
||||||
|
expectedTarget: 5,
|
||||||
|
unit: "artifacts",
|
||||||
|
deadlineMs: 240000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json());
|
||||||
|
const target = targets.find((candidate) => (
|
||||||
|
candidate.type === "page"
|
||||||
|
&& String(candidate.title).includes("Genshin Artifact Assistant")
|
||||||
|
&& !String(candidate.url).includes("overlay=1")
|
||||||
|
));
|
||||||
|
if (!target?.webSocketDebuggerUrl || !String(target.url).startsWith("file:")) {
|
||||||
|
throw new Error("No packaged Genshin Artifact Assistant file:// renderer target found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("Timed out connecting to packaged Electron CDP.")), 10_000);
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Packaged Electron CDP connection failed."));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const rendererDiagnostics = {
|
||||||
|
exceptions: [],
|
||||||
|
consoleErrors: [],
|
||||||
|
logErrors: [],
|
||||||
|
};
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.method === "Runtime.exceptionThrown") {
|
||||||
|
rendererDiagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
|
||||||
|
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
|
||||||
|
rendererDiagnostics.consoleErrors.push((message.params.args ?? []).map((entry) => entry.value ?? entry.description ?? entry.type).join(" "));
|
||||||
|
} else if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") {
|
||||||
|
rendererDiagnostics.logErrors.push(message.params.entry.text ?? "Unknown renderer log error");
|
||||||
|
}
|
||||||
|
if (!message.id || !pending.has(message.id)) return;
|
||||||
|
const request = pending.get(message.id);
|
||||||
|
pending.delete(message.id);
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
||||||
|
else request.resolve(message.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(method, params = {}, timeoutMs = 30_000) {
|
||||||
|
const id = ++nextId;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`CDP ${method} timed out after ${timeoutMs} ms.`));
|
||||||
|
}, timeoutMs);
|
||||||
|
pending.set(id, { resolve, reject, timer });
|
||||||
|
socket.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(expression, timeoutMs = 30_000) {
|
||||||
|
const response = await call("Runtime.evaluate", {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
}, timeoutMs);
|
||||||
|
if (response.exceptionDetails) {
|
||||||
|
throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Renderer evaluation failed.");
|
||||||
|
}
|
||||||
|
return response.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await call("Page.bringToFront", {}, 10_000);
|
||||||
|
if (consoleCheck) {
|
||||||
|
await call("Runtime.enable", {}, 10_000);
|
||||||
|
await call("Log.enable", {}, 10_000);
|
||||||
|
}
|
||||||
|
if (viewportWidth !== null && viewportHeight !== null) {
|
||||||
|
await call("Emulation.setDeviceMetricsOverride", {
|
||||||
|
width: viewportWidth,
|
||||||
|
height: viewportHeight,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false,
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||||
|
|
||||||
|
const inspectExpression = `
|
||||||
|
(async () => {
|
||||||
|
const api = window.assistantApi;
|
||||||
|
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||||
|
const runtime = await api.getRuntimeInfo();
|
||||||
|
const guard = await api.getAutomationGuard();
|
||||||
|
const data = await api.nativeScannerDataStatus();
|
||||||
|
const results = await api.nativeScannerLoadResults({ limit: 3 });
|
||||||
|
const artifacts = await api.loadArtifacts();
|
||||||
|
const sources = await api.listCaptureSources();
|
||||||
|
const genshinSource = sources.find((source) => source.isGenshinCandidate) ?? null;
|
||||||
|
const readinessCapture = genshinSource
|
||||||
|
? await api.captureSource(genshinSource.id, 0, false, {
|
||||||
|
skipOcr: true,
|
||||||
|
omitFullFrame: true,
|
||||||
|
omitDetailPreview: true,
|
||||||
|
omitInventoryPreview: true,
|
||||||
|
omitCrops: true,
|
||||||
|
omitLockState: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
title: document.title,
|
||||||
|
url: location.href,
|
||||||
|
bridgeKeys: Object.keys(api).sort(),
|
||||||
|
runtime,
|
||||||
|
guard,
|
||||||
|
data,
|
||||||
|
latestResults: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||||
|
artifactStore: { ok: artifacts.ok, total: artifacts.total },
|
||||||
|
captureReadiness: readinessCapture ? {
|
||||||
|
source: { id: genshinSource.id, name: genshinSource.name },
|
||||||
|
captureTarget: readinessCapture.captureTarget,
|
||||||
|
layout: readinessCapture.layout,
|
||||||
|
artifactDetail: readinessCapture.artifactDetail,
|
||||||
|
inventoryGrid: readinessCapture.inventoryGrid ? {
|
||||||
|
rows: readinessCapture.inventoryGrid.rows,
|
||||||
|
cols: readinessCapture.inventoryGrid.cols,
|
||||||
|
confidence: readinessCapture.inventoryGrid.confidence,
|
||||||
|
source: readinessCapture.inventoryGrid.source,
|
||||||
|
} : null,
|
||||||
|
} : { source: null },
|
||||||
|
navigation: [...document.querySelectorAll("button,a")].map((element) => element.textContent.trim()).filter(Boolean).slice(0, 100),
|
||||||
|
bodyText: document.body.innerText.slice(0, 16000),
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
const scanExpression = `
|
||||||
|
(async () => {
|
||||||
|
const api = window.assistantApi;
|
||||||
|
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||||
|
const preflight = await api.nativeScannerPreflight({ category: "artifacts" });
|
||||||
|
if (!preflight.ready) return { ok: false, phase: "preflight", preflight };
|
||||||
|
let status = await api.nativeScannerStart({ limit: 5, category: "artifacts" });
|
||||||
|
const deadline = Date.now() + 120000;
|
||||||
|
while (status.running && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
status = await api.nativeScannerStatus();
|
||||||
|
}
|
||||||
|
if (status.running) {
|
||||||
|
status = await api.nativeScannerStop();
|
||||||
|
return { ok: false, phase: "timeout", status };
|
||||||
|
}
|
||||||
|
if (status.status !== "done" || status.captured !== 5 || !status.initialTopResetCompleted) {
|
||||||
|
return { ok: false, phase: "capture", status };
|
||||||
|
}
|
||||||
|
const processing = await api.nativeScannerProcessRun({ runDir: status.runDir, limit: 5, persist: false });
|
||||||
|
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: 5 });
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
processing.ok && processing.processed === 5 && processing.parsed === 5
|
||||||
|
&& processing.errors === 0 && processing.stored === 0 && !processing.persisted
|
||||||
|
&& results.ok && results.total === 5 && results.results.length === 5
|
||||||
|
&& results.results.every((result) => !result.persistedArtifact && !result.artifactRecordId)
|
||||||
|
),
|
||||||
|
phase: "complete",
|
||||||
|
preflight,
|
||||||
|
status,
|
||||||
|
processing: {
|
||||||
|
ok: processing.ok,
|
||||||
|
processed: processing.processed,
|
||||||
|
parsed: processing.parsed,
|
||||||
|
review: processing.review,
|
||||||
|
errors: processing.errors,
|
||||||
|
stored: processing.stored,
|
||||||
|
persisted: processing.persisted,
|
||||||
|
elapsedMs: processing.elapsedMs,
|
||||||
|
queueConcurrency: processing.queueConcurrency,
|
||||||
|
},
|
||||||
|
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
const uiScanExpression = `
|
||||||
|
(async () => {
|
||||||
|
const config = ${JSON.stringify(uiScanConfig)};
|
||||||
|
const api = window.assistantApi;
|
||||||
|
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||||
|
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||||
|
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||||
|
priorSummaryClose?.click();
|
||||||
|
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
const existingStatus = await api.nativeScannerStatus();
|
||||||
|
if (existingStatus.running || document.querySelector('.scan-progress-surface.is-busy')) {
|
||||||
|
return { ok: false, phase: 'precondition', error: 'A scanner run is already active.', existingStatus };
|
||||||
|
}
|
||||||
|
let dialog = document.querySelector('.scanner-settings-modal');
|
||||||
|
if (!dialog) {
|
||||||
|
const settingsButton = document.querySelector('[data-scan-action="open-settings"]');
|
||||||
|
if (!(settingsButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Scan settings action was not found.' };
|
||||||
|
settingsButton.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
dialog = document.querySelector('.scanner-settings-modal');
|
||||||
|
}
|
||||||
|
let configuredLimit = null;
|
||||||
|
let configuredScopeValid = false;
|
||||||
|
if (config.scopeMode === 'all') {
|
||||||
|
const fullScope = dialog?.querySelector('input[data-scan-scope="all"]');
|
||||||
|
if (!(fullScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Full inventory scope option was not found.' };
|
||||||
|
if (!fullScope.checked) fullScope.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
configuredScopeValid = fullScope.checked;
|
||||||
|
} else {
|
||||||
|
const limitedScope = dialog?.querySelector('input[data-scan-scope="limit"]');
|
||||||
|
if (!(limitedScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limited scope option was not found.' };
|
||||||
|
if (!limitedScope.checked) limitedScope.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
const unitButton = dialog?.querySelector('[data-scan-limit-unit="' + config.unit + '"]');
|
||||||
|
if (!(unitButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Limit unit action was not found.', unit: config.unit };
|
||||||
|
unitButton.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
const inputSelector = 'input[data-scan-limit-input]';
|
||||||
|
const limitInput = document.querySelector(inputSelector);
|
||||||
|
if (!(limitInput instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limit input was not found.' };
|
||||||
|
for (let attempt = 0; attempt < 30 && Number(document.querySelector(inputSelector)?.value) !== config.limit; attempt += 1) {
|
||||||
|
const currentInput = document.querySelector(inputSelector);
|
||||||
|
const current = Number(currentInput?.value);
|
||||||
|
const action = current > config.limit ? 'decrease-limit' : 'increase-limit';
|
||||||
|
const stepper = dialog?.querySelector('[data-scan-action="' + action + '"]');
|
||||||
|
if (!(stepper instanceof HTMLButtonElement)) break;
|
||||||
|
stepper.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
configuredLimit = Number(document.querySelector(inputSelector)?.value);
|
||||||
|
if (configuredLimit !== config.limit) return { ok: false, phase: 'settings', error: 'Scan limit could not be configured.', configuredLimit, config };
|
||||||
|
configuredScopeValid = limitedScope.checked
|
||||||
|
&& unitButton.getAttribute('aria-pressed') === 'true';
|
||||||
|
}
|
||||||
|
const configuredText = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||||
|
if (!configuredScopeValid) return { ok: false, phase: 'settings', error: 'Scan scope was not configured.', configuredLimit, configuredText, config };
|
||||||
|
document.querySelector('[data-scan-action="close-settings"]')?.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
const startButton = document.querySelector('[data-scan-action="start-auto"]');
|
||||||
|
if (!(startButton instanceof HTMLButtonElement) || startButton.disabled) return { ok: false, phase: 'start', error: 'Auto-Scan action is unavailable.', configuredText };
|
||||||
|
startButton.click();
|
||||||
|
|
||||||
|
const deadline = Date.now() + config.deadlineMs;
|
||||||
|
let sawBusy = false;
|
||||||
|
let sawProcessing = false;
|
||||||
|
let sawCaptureRunning = false;
|
||||||
|
let sawLiveResultDuringCapture = false;
|
||||||
|
let sawEvaluationAdvanceDuringCapture = false;
|
||||||
|
let sawResultSlideIn = false;
|
||||||
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
let maxResultRowsDuringCapture = 0;
|
||||||
|
let maxEvaluatedDuringCapture = 0;
|
||||||
|
const resultObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue;
|
||||||
|
const resultNode = node.matches('.result-rail-row') ? node : node.querySelector('.result-rail-row');
|
||||||
|
if (!(resultNode instanceof HTMLElement)) continue;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (getComputedStyle(resultNode).animationName.includes('scanner-result-slide-in')) {
|
||||||
|
sawResultSlideIn = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resultObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
let lastProgressText = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const progress = document.querySelector('.scan-progress-surface');
|
||||||
|
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||||
|
const progressText = [progress?.textContent?.trim(), evaluation?.textContent?.trim()].filter(Boolean).join(' ');
|
||||||
|
if (progress?.classList.contains('is-busy')) sawBusy = true;
|
||||||
|
const liveCaptureStatus = await api.nativeScannerStatus();
|
||||||
|
const liveProcessingStatus = await api.nativeScannerProcessingStatus();
|
||||||
|
if (evaluation && (liveProcessingStatus.running || liveProcessingStatus.processed > 0)) sawProcessing = true;
|
||||||
|
if (progressText) lastProgressText = progressText;
|
||||||
|
const liveResultRows = document.querySelectorAll('.result-rail-row').length;
|
||||||
|
if (liveCaptureStatus.running) {
|
||||||
|
sawCaptureRunning = true;
|
||||||
|
maxResultRowsDuringCapture = Math.max(maxResultRowsDuringCapture, liveResultRows);
|
||||||
|
maxEvaluatedDuringCapture = Math.max(maxEvaluatedDuringCapture, liveProcessingStatus.processed);
|
||||||
|
if (liveResultRows > 0) sawLiveResultDuringCapture = true;
|
||||||
|
if (liveProcessingStatus.processed > 0) sawEvaluationAdvanceDuringCapture = true;
|
||||||
|
}
|
||||||
|
const summary = document.querySelector('.scan-summary-modal');
|
||||||
|
if (sawBusy && summary && !progress?.classList.contains('is-busy')) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
resultObserver.disconnect();
|
||||||
|
|
||||||
|
const status = await api.nativeScannerStatus();
|
||||||
|
const processing = await api.nativeScannerProcessingStatus();
|
||||||
|
const resolvedTarget = config.expectedTarget ?? status.target;
|
||||||
|
const targetValid = Number.isInteger(resolvedTarget) && resolvedTarget > 0 && resolvedTarget <= 2400;
|
||||||
|
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: targetValid ? resolvedTarget : 2400 });
|
||||||
|
const loadedEntries = Array.isArray(results.results) ? results.results : [];
|
||||||
|
const summaryText = document.querySelector('.scan-summary-modal')?.textContent?.trim() ?? '';
|
||||||
|
const resultRows = document.querySelectorAll('.result-rail-row').length;
|
||||||
|
const expectedPages = targetValid ? Math.ceil(resolvedTarget / 32) : 0;
|
||||||
|
const reviewRate = targetValid ? processing.review / resolvedTarget : 1;
|
||||||
|
const persistenceDisabled = processing.stored === 0
|
||||||
|
&& loadedEntries.every((result) => !result.persistedArtifact && !result.artifactRecordId);
|
||||||
|
const documentFits = document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||||
|
&& document.documentElement.scrollHeight === document.documentElement.clientHeight;
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
sawBusy && sawProcessing && sawCaptureRunning
|
||||||
|
&& sawLiveResultDuringCapture && sawEvaluationAdvanceDuringCapture && (sawResultSlideIn || reducedMotion)
|
||||||
|
&& configuredScopeValid
|
||||||
|
&& targetValid && status.status === 'done' && status.target === resolvedTarget && status.captured === resolvedTarget
|
||||||
|
&& status.pages === expectedPages && status.initialTopResetCompleted
|
||||||
|
&& !processing.running && processing.total === resolvedTarget && processing.processed === resolvedTarget
|
||||||
|
&& processing.parsed === resolvedTarget && processing.errors === 0 && reviewRate <= 0.15
|
||||||
|
&& results.ok && results.total === resolvedTarget && loadedEntries.length === resolvedTarget
|
||||||
|
&& resultRows === resolvedTarget && persistenceDisabled && documentFits
|
||||||
|
),
|
||||||
|
phase: 'complete',
|
||||||
|
configuredText,
|
||||||
|
configuredLimit,
|
||||||
|
configuredScopeValid,
|
||||||
|
config,
|
||||||
|
resolvedTarget,
|
||||||
|
expectedPages,
|
||||||
|
reviewRate,
|
||||||
|
persistenceDisabled,
|
||||||
|
documentFits,
|
||||||
|
sawBusy,
|
||||||
|
sawProcessing,
|
||||||
|
sawCaptureRunning,
|
||||||
|
sawLiveResultDuringCapture,
|
||||||
|
sawEvaluationAdvanceDuringCapture,
|
||||||
|
sawResultSlideIn,
|
||||||
|
reducedMotion,
|
||||||
|
maxResultRowsDuringCapture,
|
||||||
|
maxEvaluatedDuringCapture,
|
||||||
|
lastProgressText,
|
||||||
|
summaryText,
|
||||||
|
resultRows,
|
||||||
|
status,
|
||||||
|
processing,
|
||||||
|
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: loadedEntries.length },
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
version: "packaged-live-acceptance-v1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
mode,
|
||||||
|
view,
|
||||||
|
state,
|
||||||
|
target: { title: target.title, url: target.url },
|
||||||
|
inspection: await evaluate(inspectExpression, 60_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "scan5") {
|
||||||
|
report.scan = await evaluate(scanExpression, 180_000);
|
||||||
|
if (!report.scan?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.navigation = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('[data-navigation-id=${JSON.stringify(view)}]');
|
||||||
|
if (!(target instanceof HTMLElement)) return { ok: false, error: ${JSON.stringify(`${view} navigation was not found.`)} };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||||
|
return {
|
||||||
|
ok: activeNavigation?.getAttribute('data-navigation-id') === ${JSON.stringify(view)},
|
||||||
|
clickedNavigation: target.textContent.trim(),
|
||||||
|
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||||
|
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||||
|
bodyText: document.body.innerText.slice(0, 16000),
|
||||||
|
viewport: { width: innerWidth, height: innerHeight },
|
||||||
|
document: {
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
clientHeight: document.documentElement.clientHeight,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
scrollHeight: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 30_000);
|
||||||
|
if (!report.navigation?.ok) process.exitCode = 1;
|
||||||
|
|
||||||
|
if (mode === "ui-scan5" || mode === "ui-scan-row1" || mode === "ui-stream20" || mode === "ui-full-inventory") {
|
||||||
|
report.scan = await evaluate(uiScanExpression, mode === "ui-full-inventory" ? 1_020_000 : 300_000);
|
||||||
|
if (!report.scan?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "reprocess-run") {
|
||||||
|
report.reprocess = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const runDir = ${JSON.stringify(reprocessRunDir)};
|
||||||
|
const expectedTarget = ${JSON.stringify(reprocessTarget)};
|
||||||
|
const maxReviewRate = ${JSON.stringify(reprocessMaxReviewRate)};
|
||||||
|
const processing = await window.assistantApi.nativeScannerProcessRun({
|
||||||
|
runDir,
|
||||||
|
persist: false,
|
||||||
|
limit: expectedTarget,
|
||||||
|
stream: false,
|
||||||
|
expectedTotal: expectedTarget,
|
||||||
|
});
|
||||||
|
const resultCount = Array.isArray(processing?.results) ? processing.results.length : 0;
|
||||||
|
const reviewRate = processing?.processed > 0 ? processing.review / processing.processed : 1;
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
processing?.ok
|
||||||
|
&& processing.processed === expectedTarget
|
||||||
|
&& processing.parsed === expectedTarget
|
||||||
|
&& resultCount === expectedTarget
|
||||||
|
&& processing.errors === 0
|
||||||
|
&& processing.stored === 0
|
||||||
|
&& processing.persisted === false
|
||||||
|
&& reviewRate <= maxReviewRate
|
||||||
|
),
|
||||||
|
runDir: processing?.runDir ?? runDir,
|
||||||
|
processed: processing?.processed ?? 0,
|
||||||
|
parsed: processing?.parsed ?? 0,
|
||||||
|
review: processing?.review ?? 0,
|
||||||
|
reviewRate,
|
||||||
|
stored: processing?.stored ?? 0,
|
||||||
|
errors: processing?.errors ?? 0,
|
||||||
|
elapsedMs: processing?.elapsedMs ?? 0,
|
||||||
|
resultCount,
|
||||||
|
persistenceDisabled: processing?.persisted === false,
|
||||||
|
maxReviewRate,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 1_020_000);
|
||||||
|
if (!report.reprocess?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "scan-settings") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('[data-scan-action="open-settings"]');
|
||||||
|
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Scan settings action was not found." };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const dialog = document.querySelector('[role="dialog"], .modal');
|
||||||
|
const visibleFocusable = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')]
|
||||||
|
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||||
|
const firstFocusable = visibleFocusable[0];
|
||||||
|
const lastFocusable = visibleFocusable.at(-1);
|
||||||
|
lastFocusable?.focus();
|
||||||
|
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
return {
|
||||||
|
ok: Boolean(dialog && firstFocusable && document.activeElement === firstFocusable),
|
||||||
|
bodyText: document.body.innerText.slice(0, 16000),
|
||||||
|
dialogText: dialog?.textContent?.trim() ?? null,
|
||||||
|
focusLooped: Boolean(dialog?.contains(document.activeElement) && document.activeElement === firstFocusable),
|
||||||
|
focusedLabel: document.activeElement?.getAttribute?.('aria-label') ?? document.activeElement?.textContent?.trim() ?? null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 30_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "artifact-open") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const api = window.assistantApi;
|
||||||
|
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||||
|
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||||
|
priorSummaryClose?.click();
|
||||||
|
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
const sources = await api.listCaptureSources();
|
||||||
|
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||||
|
if (!source) return { ok: false, inputSent: false, error: "Genshin source was not found." };
|
||||||
|
const options = {
|
||||||
|
skipOcr: true,
|
||||||
|
omitFullFrame: true,
|
||||||
|
omitInventoryPreview: true,
|
||||||
|
omitCrops: true,
|
||||||
|
omitLockState: true,
|
||||||
|
};
|
||||||
|
const before = await api.captureSource(source.id, 0, false, options);
|
||||||
|
const grid = before?.inventoryGrid;
|
||||||
|
const gridReady = before?.captureTarget === "genshin-client"
|
||||||
|
&& before?.width === 1920 && before?.height === 1080
|
||||||
|
&& grid?.source === "detected" && grid.confidence >= 60
|
||||||
|
&& grid.rows === 4 && grid.cols === 8 && grid.centers?.length >= 32;
|
||||||
|
if (!gridReady) {
|
||||||
|
return { ok: false, inputSent: false, error: "Accepted 1920x1080 4x8 Artifact grid was not detected.", before };
|
||||||
|
}
|
||||||
|
if (before.artifactDetail?.present && before.artifactDetail.confidence >= 45) {
|
||||||
|
return { ok: true, inputSent: false, alreadyOpen: true, artifactDetail: before.artifactDetail };
|
||||||
|
}
|
||||||
|
const target = grid.centers[0];
|
||||||
|
const focus = await api.focusGenshinForScanStart();
|
||||||
|
if (!focus.focused) return { ok: false, inputSent: false, error: "Genshin focus failed.", focus };
|
||||||
|
const click = await api.clickScreen(target.x, target.y);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||||
|
const after = await api.captureSource(source.id, 0, false, options);
|
||||||
|
await api.focusMainWindow();
|
||||||
|
return {
|
||||||
|
ok: Boolean(click.clicked && !click.inputBlocked && after?.artifactDetail?.present && after.artifactDetail.confidence >= 45),
|
||||||
|
inputSent: Boolean(click.clicked),
|
||||||
|
target,
|
||||||
|
click,
|
||||||
|
artifactDetail: after?.artifactDetail ?? null,
|
||||||
|
grid: after?.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, confidence: after.inventoryGrid.confidence } : null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 30_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "artifact-probe") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const api = window.assistantApi;
|
||||||
|
const preflight = await api.nativeScannerPreflight({ category: 'artifacts' });
|
||||||
|
if (!preflight.ready || preflight.bounds?.width !== 1920 || preflight.bounds?.height !== 1080) {
|
||||||
|
return { ok: false, error: 'Artifact probe requires the accepted 1920x1080 preflight.', preflight };
|
||||||
|
}
|
||||||
|
const focus = await api.focusGenshinForScanStart();
|
||||||
|
if (!focus.focused) return { ok: false, error: 'Genshin focus failed.', focus, preflight };
|
||||||
|
const click = await api.clickScreen(179, 254);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
|
const sources = await api.listCaptureSources();
|
||||||
|
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||||
|
const capture = source
|
||||||
|
? await api.captureSource(source.id, 0, false, {
|
||||||
|
skipOcr: true,
|
||||||
|
omitFullFrame: true,
|
||||||
|
omitDetailPreview: true,
|
||||||
|
omitInventoryPreview: true,
|
||||||
|
omitCrops: true,
|
||||||
|
omitLockState: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
await api.focusMainWindow();
|
||||||
|
return {
|
||||||
|
ok: Boolean(click.clicked && !click.inputBlocked && capture?.artifactDetail?.present),
|
||||||
|
focus,
|
||||||
|
click,
|
||||||
|
artifactDetail: capture?.artifactDetail ?? null,
|
||||||
|
inventoryGrid: capture?.inventoryGrid ?? null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 60_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "post-scan-results") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const summary = document.querySelector('.scan-summary-modal');
|
||||||
|
const close = summary?.querySelector('[data-scan-action="summary-close"]');
|
||||||
|
close?.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
const panel = document.querySelector('.scanner-result-panel');
|
||||||
|
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||||
|
const rail = document.querySelector('.result-rail-list');
|
||||||
|
const rows = [...document.querySelectorAll('.result-rail-row')];
|
||||||
|
const panelRect = panel?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
!document.querySelector('.scan-summary-modal')
|
||||||
|
&& panel && evaluation && rail && rows.length > 0
|
||||||
|
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||||
|
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||||
|
),
|
||||||
|
resultRows: rows.length,
|
||||||
|
firstResult: rows[0]?.textContent?.trim() ?? null,
|
||||||
|
lastResult: rows.at(-1)?.textContent?.trim() ?? null,
|
||||||
|
evaluationText: evaluation?.textContent?.trim() ?? null,
|
||||||
|
panelRect: panelRect ? { x: panelRect.x, y: panelRect.y, width: panelRect.width, height: panelRect.height } : null,
|
||||||
|
rail: rail ? { clientHeight: rail.clientHeight, scrollHeight: rail.scrollHeight, scrollTop: rail.scrollTop } : null,
|
||||||
|
document: {
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
clientHeight: document.documentElement.clientHeight,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
scrollHeight: document.documentElement.scrollHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "review-deeplink") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('.triage-primary-action');
|
||||||
|
if (!target) return { ok: false, error: "Review deep-link action was not found." };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||||
|
const activeFilter = document.querySelector('.inventory-filter-control button.active')?.textContent?.trim() ?? null;
|
||||||
|
return {
|
||||||
|
ok: activeNavigation?.getAttribute('data-navigation-id') === 'inventory' && Boolean(activeFilter),
|
||||||
|
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||||
|
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||||
|
activeFilter,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 30_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "keyboard-focus") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(() => {
|
||||||
|
const target = document.querySelector('.nav-item:not([aria-current="page"]):not(:disabled)');
|
||||||
|
if (!(target instanceof HTMLElement)) return { ok: false, error: "Focusable navigation item was not found." };
|
||||||
|
target.focus();
|
||||||
|
const styles = getComputedStyle(target);
|
||||||
|
return {
|
||||||
|
ok: document.activeElement === target && styles.outlineStyle !== "none" && Number.parseFloat(styles.outlineWidth) >= 2,
|
||||||
|
focusedText: target.textContent?.trim() ?? null,
|
||||||
|
outline: styles.outline,
|
||||||
|
boxShadow: styles.boxShadow,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 10_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "overlay-open") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('.overlay-open-button');
|
||||||
|
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Overlay action was not found." };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||||
|
const toast = document.querySelector('.app-toast');
|
||||||
|
return {
|
||||||
|
ok: Boolean(toast),
|
||||||
|
toastText: toast?.textContent?.trim() ?? null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "inventory-confirm") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
let target = document.querySelector('[data-inventory-action="promote"]:not(:disabled), [data-inventory-action="review"]:not(:disabled)');
|
||||||
|
if (!target) {
|
||||||
|
const reviewFilter = document.querySelector('[data-inventory-filter="review"]');
|
||||||
|
reviewFilter?.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
const rows = [...document.querySelectorAll('.inventory-row')];
|
||||||
|
for (const row of rows) {
|
||||||
|
row.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
target = document.querySelector('[data-inventory-action="review"]:not(:disabled)');
|
||||||
|
if (target) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) return { ok: false, skipped: true, error: "No safe confirmation/editor action is available for the current corpus." };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const confirmation = document.querySelector('.inventory-promotion-confirm, .inventory-review-editor');
|
||||||
|
return {
|
||||||
|
ok: Boolean(confirmation),
|
||||||
|
action: target.textContent.trim(),
|
||||||
|
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||||
|
focusedText: document.activeElement?.textContent?.trim() ?? null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok && !report.interaction?.skipped) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "inventory-delete-confirm") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||||
|
if (!(target instanceof HTMLButtonElement)) {
|
||||||
|
return { ok: false, error: "No local-only artifact deletion action is available for the selected row." };
|
||||||
|
}
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||||
|
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||||
|
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||||
|
const prompt = confirmation?.querySelector('strong');
|
||||||
|
const description = confirmation?.querySelector('small');
|
||||||
|
const visibleInViewport = (element) => {
|
||||||
|
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
confirmation
|
||||||
|
&& visibleInViewport(prompt)
|
||||||
|
&& visibleInViewport(description)
|
||||||
|
&& confirmButton instanceof HTMLButtonElement
|
||||||
|
&& cancelButton instanceof HTMLButtonElement
|
||||||
|
&& document.activeElement === confirmButton
|
||||||
|
),
|
||||||
|
action: target.textContent?.trim() ?? null,
|
||||||
|
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||||
|
promptVisible: visibleInViewport(prompt),
|
||||||
|
descriptionVisible: visibleInViewport(description),
|
||||||
|
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||||
|
confirmDisabled: confirmButton instanceof HTMLButtonElement ? confirmButton.disabled : null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "inventory-store-delete-confirm") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const storeFilter = document.querySelector('[data-inventory-filter="stored"]');
|
||||||
|
if (!(storeFilter instanceof HTMLButtonElement)) {
|
||||||
|
return { ok: false, error: "The local Store filter is unavailable." };
|
||||||
|
}
|
||||||
|
storeFilter.click();
|
||||||
|
await sleep(160);
|
||||||
|
const storeRow = document.querySelector('.inventory-list [role="option"]');
|
||||||
|
if (!(storeRow instanceof HTMLElement)) {
|
||||||
|
return { ok: false, error: "No local Store row is available for a non-mutating confirmation probe." };
|
||||||
|
}
|
||||||
|
storeRow.click();
|
||||||
|
await sleep(120);
|
||||||
|
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||||
|
if (!(target instanceof HTMLButtonElement)) {
|
||||||
|
return { ok: false, error: "The selected local Store row did not expose its delete action." };
|
||||||
|
}
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||||
|
const prompt = confirmation?.querySelector('strong');
|
||||||
|
const description = confirmation?.querySelector('small');
|
||||||
|
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||||
|
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||||
|
const linkedStoreToggle = document.querySelector('[data-inventory-action="toggle-linked-store"]');
|
||||||
|
const visibleInViewport = (element) => {
|
||||||
|
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
confirmation
|
||||||
|
&& visibleInViewport(prompt)
|
||||||
|
&& visibleInViewport(description)
|
||||||
|
&& confirmButton instanceof HTMLButtonElement
|
||||||
|
&& cancelButton instanceof HTMLButtonElement
|
||||||
|
&& document.activeElement === confirmButton
|
||||||
|
&& !linkedStoreToggle
|
||||||
|
),
|
||||||
|
action: target.textContent?.trim() ?? null,
|
||||||
|
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||||
|
promptVisible: visibleInViewport(prompt),
|
||||||
|
descriptionVisible: visibleInViewport(description),
|
||||||
|
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||||
|
hasLinkedStoreToggle: Boolean(linkedStoreToggle),
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "inventory-roving") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const listbox = document.querySelector('.inventory-list[role="listbox"]');
|
||||||
|
const before = listbox?.querySelector('[role="option"][aria-selected="true"]');
|
||||||
|
if (!(before instanceof HTMLElement)) return { ok: false, error: "Selected inventory row was not found." };
|
||||||
|
before.focus();
|
||||||
|
before.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const after = listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||||
|
return {
|
||||||
|
ok: Boolean(after && after !== before && document.activeElement === after && after.getAttribute('tabindex') === '0'),
|
||||||
|
before: before.textContent.trim(),
|
||||||
|
after: after?.textContent?.trim() ?? null,
|
||||||
|
activeMatchesSelection: document.activeElement === after,
|
||||||
|
tabbableRows: listbox.querySelectorAll('[role="option"][tabindex="0"]').length,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "inventory-technical") {
|
||||||
|
report.interaction = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const target = document.querySelector('.inventory-technical-button');
|
||||||
|
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Technical details action was not found." };
|
||||||
|
target.click();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const dialog = document.querySelector('.inventory-technical-modal[role="dialog"]');
|
||||||
|
const focusables = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,[tabindex]:not([tabindex="-1"])')]
|
||||||
|
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||||
|
const firstFocusable = focusables[0];
|
||||||
|
const lastFocusable = focusables.at(-1);
|
||||||
|
lastFocusable?.focus();
|
||||||
|
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const rect = dialog?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
ok: Boolean(
|
||||||
|
dialog && firstFocusable && document.activeElement === firstFocusable
|
||||||
|
&& rect && rect.top >= 0 && rect.bottom <= innerHeight
|
||||||
|
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||||
|
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||||
|
),
|
||||||
|
dialogText: dialog?.textContent?.trim() ?? null,
|
||||||
|
focusLooped: document.activeElement === firstFocusable,
|
||||||
|
dialogRect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`, 15_000);
|
||||||
|
if (!report.interaction?.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await call("Page.bringToFront", {}, 10_000);
|
||||||
|
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||||
|
const screenshot = await call("Page.captureScreenshot", {
|
||||||
|
format: "png",
|
||||||
|
fromSurface: true,
|
||||||
|
captureBeyondViewport: false,
|
||||||
|
}, 30_000);
|
||||||
|
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
||||||
|
fs.writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
||||||
|
report.screenshotPath = screenshotPath;
|
||||||
|
report.rendererDiagnostics = { checked: consoleCheck, ...rendererDiagnostics };
|
||||||
|
if (consoleCheck && (rendererDiagnostics.exceptions.length > 0 || rendererDiagnostics.consoleErrors.length > 0 || rendererDiagnostics.logErrors.length > 0)) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||||
|
fs.writeFileSync(output, JSON.stringify(report, null, 2), "utf8");
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
console.log(`Report: ${output}`);
|
||||||
|
console.log(`Screenshot: ${screenshotPath}`);
|
||||||
|
if (state === "scan-settings") await evaluate(`document.querySelector('[data-scan-action="close-settings"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||||
|
if (state === "inventory-delete-confirm") await evaluate(`document.querySelector('[data-inventory-action="cancel-delete"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||||
|
if (state === "inventory-store-delete-confirm") {
|
||||||
|
await evaluate(`
|
||||||
|
document.querySelector('[data-inventory-action="cancel-delete"]')?.click();
|
||||||
|
document.querySelector('[data-inventory-filter="all"]')?.click();
|
||||||
|
true
|
||||||
|
`, 5_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (state === "inventory-technical") await evaluate(`document.querySelector('.inventory-technical-modal .icon-button')?.click(); true`, 5_000).catch(() => undefined);
|
||||||
|
if (state === "overlay-open") await evaluate("window.assistantApi.hideOverlay(); true", 5_000).catch(() => undefined);
|
||||||
|
if (closeAfter) {
|
||||||
|
await evaluate("window.close(); true", 5_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (viewportWidth !== null && viewportHeight !== null) {
|
||||||
|
await call("Emulation.clearDeviceMetricsOverride", {}, 5_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
socket.close();
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { loadNativeScannerResultTombstones } from "../electron/services/nativeScannerResultTombstones.js";
|
||||||
|
import { evaluateStoredScanResult, projectArtifactUpgrade } from "../src/lib/artifactEvaluation.js";
|
||||||
|
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||||
|
import type { ArtifactUpgradeProjection, StoredScanResultEntry } from "../src/types/storage.js";
|
||||||
|
|
||||||
|
type NativeRunManifest = {
|
||||||
|
runId?: string;
|
||||||
|
category?: string;
|
||||||
|
target?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeRunStatus = {
|
||||||
|
scanner?: {
|
||||||
|
status?: string;
|
||||||
|
runId?: string;
|
||||||
|
target?: number;
|
||||||
|
captured?: number;
|
||||||
|
queued?: number;
|
||||||
|
clicked?: number;
|
||||||
|
pages?: number;
|
||||||
|
activeMs?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeProcessingReport = {
|
||||||
|
ok?: boolean;
|
||||||
|
processed?: number;
|
||||||
|
review?: number;
|
||||||
|
stored?: number;
|
||||||
|
errors?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
queueConcurrency?: number;
|
||||||
|
persisted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeCaptureJob = {
|
||||||
|
sequence?: number;
|
||||||
|
page?: number;
|
||||||
|
category?: string;
|
||||||
|
absolutePath?: string;
|
||||||
|
relativePath?: string;
|
||||||
|
downstream?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativeReviewLogEntry = {
|
||||||
|
ok?: boolean;
|
||||||
|
action?: string;
|
||||||
|
resultId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunValidationSummary = {
|
||||||
|
runId: string;
|
||||||
|
runDir: string;
|
||||||
|
target: number;
|
||||||
|
issues: string[];
|
||||||
|
evidence: {
|
||||||
|
jobs: number;
|
||||||
|
pngs: number;
|
||||||
|
results: number;
|
||||||
|
captured: number;
|
||||||
|
pages: number;
|
||||||
|
processingErrors: number;
|
||||||
|
persisted: boolean;
|
||||||
|
originalReview: number;
|
||||||
|
currentReview: number;
|
||||||
|
approvedReviewLogs: number;
|
||||||
|
duplicatePngHashGroups: number;
|
||||||
|
repeatedPagePairs: string[];
|
||||||
|
identicalBoundaryPairs: string[];
|
||||||
|
};
|
||||||
|
performance: {
|
||||||
|
captureMs: number;
|
||||||
|
capturePerSecond: number | null;
|
||||||
|
processingMs: number;
|
||||||
|
processingPerSecond: number | null;
|
||||||
|
};
|
||||||
|
replay: ReturnType<typeof replayNativeScanResults>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const scanRoot = options.scanRoot || path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||||
|
"genshin-artifact-assistant",
|
||||||
|
"native-scans",
|
||||||
|
);
|
||||||
|
const selectedRuns = await selectLatestRuns(scanRoot, options.targets);
|
||||||
|
const runs: RunValidationSummary[] = [];
|
||||||
|
|
||||||
|
for (const target of options.targets) {
|
||||||
|
const runDir = selectedRuns.get(target);
|
||||||
|
if (!runDir) throw new Error(`No saved native run with target ${target} found below ${scanRoot}`);
|
||||||
|
runs.push(await validateRun(runDir, target, options.repeats));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
version: "saved-native-run-validation-v1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
scanRoot,
|
||||||
|
targets: options.targets,
|
||||||
|
repeats: options.repeats,
|
||||||
|
ok: runs.every((run) => run.issues.length === 0),
|
||||||
|
runs,
|
||||||
|
};
|
||||||
|
const outputDir = path.resolve("outputs", "native-replay");
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const reportPath = path.join(outputDir, "saved-native-validation-report.json");
|
||||||
|
await fs.writeFile(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.log(`Saved native validation: ${result.ok ? "PASS" : "FAIL"}`);
|
||||||
|
for (const run of runs) {
|
||||||
|
const replay = run.replay;
|
||||||
|
console.log(
|
||||||
|
`${run.target}: ${run.runId}; capture=${formatRate(run.performance.capturePerSecond)}/s; `
|
||||||
|
+ `processing=${formatRate(run.performance.processingPerSecond)}/s; `
|
||||||
|
+ `evaluated=${replay.values.evaluated}; excluded=${replay.values.excluded}; `
|
||||||
|
+ `review=${replay.values.review}; unknown=${replay.values.unknown}; `
|
||||||
|
+ `projection=${replay.projections.available}/${replay.projections.complete}; issues=${run.issues.length}`,
|
||||||
|
);
|
||||||
|
for (const issue of run.issues) console.error(` - ${issue}`);
|
||||||
|
}
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
if (!result.ok) process.exitCode = 1;
|
||||||
|
|
||||||
|
async function validateRun(runDir: string, target: number, repeats: number): Promise<RunValidationSummary> {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const manifestPath = path.join(runDir, "manifest.json");
|
||||||
|
const statusPath = path.join(runDir, "status.json");
|
||||||
|
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
|
||||||
|
const resultsPath = path.join(runDir, "scan-results.json");
|
||||||
|
const processingPath = path.join(runDir, "processing-report.json");
|
||||||
|
const [manifest, statusPayload, jobs, rawResults, processing, reviewLogs] = await Promise.all([
|
||||||
|
readJson<NativeRunManifest>(manifestPath),
|
||||||
|
readJson<NativeRunStatus>(statusPath),
|
||||||
|
readJsonLines<NativeCaptureJob>(jobsPath),
|
||||||
|
readJson<unknown[]>(resultsPath),
|
||||||
|
readJson<NativeProcessingReport>(processingPath),
|
||||||
|
readOptionalJsonLines<NativeReviewLogEntry>(path.join(runDir, "review-log.jsonl")),
|
||||||
|
]);
|
||||||
|
const status = statusPayload.scanner ?? {};
|
||||||
|
const results = Array.isArray(rawResults) ? rawResults.filter(isStoredScanResultEntry) : [];
|
||||||
|
const runId = manifest.runId || path.basename(runDir);
|
||||||
|
|
||||||
|
expectEqual(issues, "manifest target", manifest.target, target);
|
||||||
|
expectEqual(issues, "manifest category", manifest.category, "artifacts");
|
||||||
|
expectEqual(issues, "status run id", status.runId, runId);
|
||||||
|
if (!new Set(["done", "completed"]).has(status.status ?? "")) issues.push(`scanner status is ${status.status || "missing"}`);
|
||||||
|
expectEqual(issues, "status target", status.target, target);
|
||||||
|
expectEqual(issues, "captured count", status.captured, target);
|
||||||
|
expectEqual(issues, "queued count", status.queued, target);
|
||||||
|
expectEqual(issues, "job count", jobs.length, target);
|
||||||
|
expectEqual(issues, "raw result count", rawResults.length, target);
|
||||||
|
expectEqual(issues, "valid result count", results.length, target);
|
||||||
|
expectEqual(issues, "processed count", processing.processed, target);
|
||||||
|
expectEqual(issues, "processing errors", processing.errors, 0);
|
||||||
|
expectEqual(issues, "processing persisted", processing.persisted, false);
|
||||||
|
if (processing.ok !== true) issues.push("processing report is not ok");
|
||||||
|
if (!Number.isFinite(processing.queueConcurrency) || (processing.queueConcurrency ?? 0) < 1) {
|
||||||
|
issues.push("processing queue concurrency is missing or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSequences(issues, "capture job", jobs.map((job) => job.sequence), target);
|
||||||
|
validateSequences(issues, "scan result", results.map((entry) => entry.sequence), target);
|
||||||
|
const resolvedRunDir = path.resolve(runDir);
|
||||||
|
let pngs = 0;
|
||||||
|
const pngHashes: Array<{ sequence: number; page: number; hash: string }> = [];
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (job.category !== "artifacts") issues.push(`job #${job.sequence ?? "?"} has category ${job.category ?? "missing"}`);
|
||||||
|
if (job.downstream !== "ocr-parse-store") issues.push(`job #${job.sequence ?? "?"} has unexpected downstream contract`);
|
||||||
|
const absolutePath = typeof job.absolutePath === "string" ? path.resolve(job.absolutePath) : "";
|
||||||
|
if (!absolutePath || !isPathInside(resolvedRunDir, absolutePath)) {
|
||||||
|
issues.push(`job #${job.sequence ?? "?"} image escapes or misses the run directory`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (path.extname(absolutePath).toLowerCase() !== ".png") {
|
||||||
|
issues.push(`job #${job.sequence ?? "?"} image is not PNG`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(absolutePath);
|
||||||
|
if (!stat.isFile() || stat.size === 0) issues.push(`job #${job.sequence ?? "?"} PNG is empty`);
|
||||||
|
else {
|
||||||
|
pngs += 1;
|
||||||
|
const bytes = await fs.readFile(absolutePath);
|
||||||
|
pngHashes.push({
|
||||||
|
sequence: finiteNumber(job.sequence),
|
||||||
|
page: finiteNumber(job.page),
|
||||||
|
hash: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
issues.push(`job #${job.sequence ?? "?"} PNG is missing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectEqual(issues, "PNG count", pngs, target);
|
||||||
|
const duplicatePngHashGroups = duplicateHashGroupCount(pngHashes);
|
||||||
|
const repeatedPagePairs = identicalPagePairs(pngHashes);
|
||||||
|
const identicalBoundaryPairs = identicalScrollBoundaryPairs(pngHashes);
|
||||||
|
if (repeatedPagePairs.length > 0) {
|
||||||
|
issues.push(`entire captured pages repeat exactly: ${repeatedPagePairs.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of results) {
|
||||||
|
expectEqual(issues, `result #${entry.sequence} run id`, entry.runId, runId);
|
||||||
|
expectEqual(issues, `result #${entry.sequence} category`, entry.category, "artifact");
|
||||||
|
const evaluated = evaluateStoredScanResult(entry);
|
||||||
|
if (evaluated.valueScore !== null && (evaluated.valueScore < 0 || evaluated.valueScore > 100)) {
|
||||||
|
issues.push(`result #${entry.sequence} has score outside 0-100`);
|
||||||
|
}
|
||||||
|
validateProjection(issues, entry.sequence, evaluated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay = replayNativeScanResults({ entries: results, repeats, runId, sourcePath: resultsPath });
|
||||||
|
if (!replay.deterministic) issues.push("replay payload is not deterministic");
|
||||||
|
if (replay.cleanUnevaluated.length > 0) issues.push(`${replay.cleanUnevaluated.length} clean results remain unevaluated`);
|
||||||
|
const currentReview = results.filter((entry) => entry.extractionStatus === "review" || entry.needsReview).length;
|
||||||
|
const originalReview = Number(processing.review ?? 0);
|
||||||
|
const approvedReviewLogs = reviewLogs.filter((entry) => entry.ok && entry.action === "approve" && entry.resultId).length;
|
||||||
|
const resolvedReview = Math.max(0, originalReview - currentReview);
|
||||||
|
if (resolvedReview > approvedReviewLogs) {
|
||||||
|
issues.push(`${resolvedReview} processing reviews disappeared but only ${approvedReviewLogs} approvals are logged`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureMs = finiteNumber(status.activeMs);
|
||||||
|
const processingMs = finiteNumber(processing.elapsedMs);
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
runDir,
|
||||||
|
target,
|
||||||
|
issues: [...new Set(issues)],
|
||||||
|
evidence: {
|
||||||
|
jobs: jobs.length,
|
||||||
|
pngs,
|
||||||
|
results: results.length,
|
||||||
|
captured: finiteNumber(status.captured),
|
||||||
|
pages: finiteNumber(status.pages),
|
||||||
|
processingErrors: finiteNumber(processing.errors),
|
||||||
|
persisted: processing.persisted === true,
|
||||||
|
originalReview,
|
||||||
|
currentReview,
|
||||||
|
approvedReviewLogs,
|
||||||
|
duplicatePngHashGroups,
|
||||||
|
repeatedPagePairs,
|
||||||
|
identicalBoundaryPairs,
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
captureMs,
|
||||||
|
capturePerSecond: rate(target, captureMs),
|
||||||
|
processingMs,
|
||||||
|
processingPerSecond: rate(target, processingMs),
|
||||||
|
},
|
||||||
|
replay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProjection(issues: string[], sequence: number, entry: StoredScanResultEntry) {
|
||||||
|
const evaluation = entry.valueEvaluation;
|
||||||
|
if (!evaluation) {
|
||||||
|
issues.push(`result #${sequence} has no derived evaluation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projection = projectArtifactUpgrade(entry.artifact, evaluation);
|
||||||
|
if (projection.status === "available") {
|
||||||
|
const scores = [projection.worstScore, projection.middleScore, projection.bestScore];
|
||||||
|
if (scores.some((score) => typeof score !== "number")) issues.push(`result #${sequence} projection misses a score`);
|
||||||
|
else if (!(scores[0]! <= scores[1]! && scores[1]! <= scores[2]!)) issues.push(`result #${sequence} projection order is invalid`);
|
||||||
|
if (evaluation.rarity !== 5 || (entry.artifact?.level ?? 20) >= 20 || (entry.artifact?.substats.length ?? 0) < 4) {
|
||||||
|
issues.push(`result #${sequence} projection violates its 5-star under-level boundary`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateProjectionScores(issues, sequence, projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProjectionScores(issues: string[], sequence: number, projection: ArtifactUpgradeProjection) {
|
||||||
|
for (const score of [projection.worstScore, projection.middleScore, projection.bestScore]) {
|
||||||
|
if (score !== null && (score < 0 || score > 100)) issues.push(`result #${sequence} projection score is outside 0-100`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLatestRuns(scanRoot: string, targets: number[]) {
|
||||||
|
const selected = new Map<number, string>();
|
||||||
|
const directories = (await fs.readdir(scanRoot, { withFileTypes: true }))
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
for (const directory of directories) {
|
||||||
|
const runDir = path.join(scanRoot, directory);
|
||||||
|
try {
|
||||||
|
const manifest = await readJson<NativeRunManifest>(path.join(runDir, "manifest.json"));
|
||||||
|
const target = Number(manifest.target);
|
||||||
|
// A user may intentionally remove a bad local row and its crop. Such a
|
||||||
|
// run is still valid for the app UI but no longer immutable acceptance
|
||||||
|
// evidence, so select an untouched run for validation instead.
|
||||||
|
if ((await loadNativeScannerResultTombstones(runDir)).length > 0) continue;
|
||||||
|
if (targets.includes(target) && !selected.has(target)) selected.set(target, runDir);
|
||||||
|
} catch {
|
||||||
|
// Ignore unrelated or incomplete directories; selected evidence is validated strictly below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSequences(issues: string[], label: string, values: Array<number | undefined>, target: number) {
|
||||||
|
const sequences = values.filter((value): value is number => Number.isInteger(value)).sort((left, right) => left - right);
|
||||||
|
const expected = Array.from({ length: target }, (_, index) => index + 1);
|
||||||
|
if (sequences.length !== expected.length || sequences.some((value, index) => value !== expected[index])) {
|
||||||
|
issues.push(`${label} sequence is not exactly 1-${target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateHashGroupCount(entries: Array<{ hash: string }>) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const entry of entries) counts.set(entry.hash, (counts.get(entry.hash) ?? 0) + 1);
|
||||||
|
return [...counts.values()].filter((count) => count > 1).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identicalPagePairs(entries: Array<{ page: number; sequence: number; hash: string }>) {
|
||||||
|
const pages = new Map<number, Array<{ sequence: number; hash: string }>>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const page = pages.get(entry.page) ?? [];
|
||||||
|
page.push({ sequence: entry.sequence, hash: entry.hash });
|
||||||
|
pages.set(entry.page, page);
|
||||||
|
}
|
||||||
|
const ordered = [...pages.entries()]
|
||||||
|
.map(([page, values]) => ({ page, hashes: values.sort((left, right) => left.sequence - right.sequence).map((value) => value.hash) }))
|
||||||
|
.sort((left, right) => left.page - right.page);
|
||||||
|
const repeated: string[] = [];
|
||||||
|
for (let leftIndex = 0; leftIndex < ordered.length; leftIndex += 1) {
|
||||||
|
for (let rightIndex = leftIndex + 1; rightIndex < ordered.length; rightIndex += 1) {
|
||||||
|
const left = ordered[leftIndex];
|
||||||
|
const right = ordered[rightIndex];
|
||||||
|
if (left.hashes.length > 0 && left.hashes.length === right.hashes.length && left.hashes.every((hash, index) => hash === right.hashes[index])) {
|
||||||
|
repeated.push(`${left.page}/${right.page}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repeated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identicalScrollBoundaryPairs(entries: Array<{ sequence: number; hash: string }>) {
|
||||||
|
const hashes = new Map(entries.map((entry) => [entry.sequence, entry.hash]));
|
||||||
|
return [[32, 33], [64, 65], [96, 97]]
|
||||||
|
.filter(([left, right]) => hashes.has(left) && hashes.get(left) === hashes.get(right))
|
||||||
|
.map(([left, right]) => `${left}/${right}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as Partial<StoredScanResultEntry>;
|
||||||
|
return typeof entry.id === "string"
|
||||||
|
&& typeof entry.runId === "string"
|
||||||
|
&& Number.isFinite(entry.sequence)
|
||||||
|
&& typeof entry.extractionStatus === "string"
|
||||||
|
&& typeof entry.valueStatus === "string"
|
||||||
|
&& Array.isArray(entry.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(root: string, candidate: string) {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectEqual(issues: string[], label: string, actual: unknown, expected: unknown) {
|
||||||
|
if (actual !== expected) issues.push(`${label}: expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rate(count: number, milliseconds: number) {
|
||||||
|
return milliseconds > 0 ? Math.round((count * 100_000 / milliseconds)) / 100 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRate(value: number | null) {
|
||||||
|
return value === null ? "n/a" : value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(filePath: string): Promise<T> {
|
||||||
|
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonLines<T>(filePath: string): Promise<T[]> {
|
||||||
|
return (await fs.readFile(filePath, "utf8"))
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => JSON.parse(line.replace(/^\uFEFF/, "")) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptionalJsonLines<T>(filePath: string): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
return await readJsonLines<T>(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]) {
|
||||||
|
const targetArg = args.find((argument) => argument.startsWith("--targets="))?.slice("--targets=".length) ?? "20,50,100";
|
||||||
|
const targets = [...new Set(targetArg.split(",").map(Number).filter((value) => Number.isInteger(value) && value > 0))];
|
||||||
|
if (targets.length === 0) throw new Error("--targets must contain at least one positive integer.");
|
||||||
|
const repeatArg = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 5);
|
||||||
|
const repeats = Number.isFinite(repeatArg) ? Math.max(2, Math.min(10, Math.round(repeatArg))) : 5;
|
||||||
|
const scanRoot = args.find((argument) => argument.startsWith("--scan-root="))?.slice("--scan-root=".length) ?? "";
|
||||||
|
return { targets, repeats, scanRoot: scanRoot ? path.resolve(scanRoot) : "" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { extractFile, listPackage } from "@electron/asar";
|
||||||
|
|
||||||
|
export function rendererAssetReferences(html) {
|
||||||
|
return [...String(html).matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)]
|
||||||
|
.map((match) => match[1].trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteRendererAssetReferences(html) {
|
||||||
|
return rendererAssetReferences(html).filter((reference) => reference.startsWith("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeSignature(source) {
|
||||||
|
return String(source).match(/APP_RUNTIME_SIGNATURE\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyProjectPackaging(projectRoot = process.cwd()) {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||||
|
const checks = [];
|
||||||
|
const viteConfigPath = path.join(projectRoot, "vite.config.ts");
|
||||||
|
const viteConfig = readText(viteConfigPath);
|
||||||
|
const rendererIndexPath = path.join(projectRoot, "dist", "index.html");
|
||||||
|
const rendererIndex = readText(rendererIndexPath);
|
||||||
|
const mainSource = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||||
|
const sourceRuntimeSignature = runtimeSignature(mainSource);
|
||||||
|
check(checks, packageJson.main === "dist-electron/electron/main.js", "main-entry", packageJson.main);
|
||||||
|
check(checks, packageJson.build?.win?.requestedExecutionLevel === "requireAdministrator", "windows-elevation", packageJson.build?.win?.requestedExecutionLevel);
|
||||||
|
check(checks, /\bbase\s*:\s*["']\.\/["']/.test(viteConfig), "vite-relative-base", "Vite base must be ./ for Electron loadFile");
|
||||||
|
check(checks, resourceMapping(packageJson, "native/input-helper/bin/publish", "input-helper"), "helper-resource-mapping", "native helper -> resources/input-helper");
|
||||||
|
check(checks, resourceMapping(packageJson, "data/ik-inventorylists", "ik-inventorylists"), "ik-resource-mapping", "IK lists -> resources/ik-inventorylists");
|
||||||
|
checkFile(checks, path.join(projectRoot, "native", "input-helper", "bin", "publish", "InputHelper.exe"), "compiled-helper", 1_000_000);
|
||||||
|
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "artifacts.json"), "ik-artifacts", 1_000);
|
||||||
|
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "ik-version", 1);
|
||||||
|
checkFile(checks, rendererIndexPath, "compiled-renderer-index", 100);
|
||||||
|
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "preload.cjs"), "compiled-preload", 100);
|
||||||
|
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "runtimePaths.js"), "compiled-runtime-path-policy", 100);
|
||||||
|
if (rendererIndex) checkRendererAssetPolicy(checks, rendererIndex, "project-renderer");
|
||||||
|
check(checks, sourceRuntimeSignature.startsWith("2026-07-10-"), "current-runtime-signature", sourceRuntimeSignature || "missing");
|
||||||
|
return packagingReport("project", projectRoot, checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPackagedDirectory(packageDir, projectRoot = process.cwd()) {
|
||||||
|
const resolved = path.resolve(packageDir);
|
||||||
|
const resources = path.join(resolved, "resources");
|
||||||
|
const asarPath = path.join(resources, "app.asar");
|
||||||
|
const checks = [];
|
||||||
|
checkFile(checks, path.join(resolved, "Genshin Artifact Assistant.exe"), "packaged-executable", 1_000_000);
|
||||||
|
checkFile(checks, asarPath, "app-asar", 1_000);
|
||||||
|
checkFile(checks, path.join(resources, "input-helper", "InputHelper.exe"), "packaged-helper", 1_000_000);
|
||||||
|
checkFile(checks, path.join(resources, "ik-inventorylists", "artifacts.json"), "packaged-ik-artifacts", 1_000);
|
||||||
|
checkFile(checks, path.join(resources, "ik-inventorylists", "version.txt"), "packaged-ik-version", 1);
|
||||||
|
if (fs.existsSync(asarPath)) {
|
||||||
|
const files = new Set(listPackage(asarPath).map((entry) => entry.replaceAll("\\", "/").replace(/^\//, "")));
|
||||||
|
for (const expected of ["dist/index.html", "dist-electron/electron/main.js", "dist-electron/electron/preload.cjs", "dist-electron/electron/runtimePaths.js", "package.json"]) {
|
||||||
|
check(checks, files.has(expected), `asar:${expected}`, expected);
|
||||||
|
}
|
||||||
|
if (files.has("dist/index.html")) {
|
||||||
|
const rendererIndex = extractFile(asarPath, asarExtractPath("dist/index.html")).toString("utf8");
|
||||||
|
checkRendererAssetPolicy(checks, rendererIndex, "packaged-renderer");
|
||||||
|
const missingAssets = rendererAssetReferences(rendererIndex)
|
||||||
|
.filter(isLocalRendererReference)
|
||||||
|
.map((reference) => rendererReferenceAsarPath(reference))
|
||||||
|
.filter((reference) => !files.has(reference));
|
||||||
|
check(checks, missingAssets.length === 0, "packaged-renderer-assets-present", missingAssets.join(", ") || "all referenced assets present");
|
||||||
|
}
|
||||||
|
if (files.has("dist-electron/electron/main.js")) {
|
||||||
|
const packagedMain = extractFile(asarPath, asarExtractPath("dist-electron/electron/main.js")).toString("utf8");
|
||||||
|
const sourceMain = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||||
|
const sourceSignature = runtimeSignature(sourceMain);
|
||||||
|
const packagedSignature = runtimeSignature(packagedMain);
|
||||||
|
check(
|
||||||
|
checks,
|
||||||
|
Boolean(sourceSignature) && packagedSignature === sourceSignature,
|
||||||
|
"runtime-signature-match",
|
||||||
|
`${sourceSignature || "missing source"} == ${packagedSignature || "missing package"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sourceVersion = fs.readFileSync(path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "utf8").trim();
|
||||||
|
const packagedVersionPath = path.join(resources, "ik-inventorylists", "version.txt");
|
||||||
|
const packagedVersion = fs.existsSync(packagedVersionPath) ? fs.readFileSync(packagedVersionPath, "utf8").trim() : "";
|
||||||
|
check(checks, sourceVersion === packagedVersion, "ik-version-match", `${sourceVersion} == ${packagedVersion || "missing"}`);
|
||||||
|
return packagingReport("packaged", resolved, checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceMapping(packageJson, from, to) {
|
||||||
|
return (packageJson.build?.extraResources ?? []).some((entry) => entry.from === from && entry.to === to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(filePath) {
|
||||||
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRendererAssetPolicy(checks, html, idPrefix) {
|
||||||
|
const absoluteReferences = absoluteRendererAssetReferences(html);
|
||||||
|
check(
|
||||||
|
checks,
|
||||||
|
absoluteReferences.length === 0,
|
||||||
|
`${idPrefix}-assets-relative`,
|
||||||
|
absoluteReferences.join(", ") || "all src/href references are relative",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRendererReference(reference) {
|
||||||
|
return !reference.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendererReferenceAsarPath(reference) {
|
||||||
|
const withoutQuery = reference.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||||
|
return path.posix.normalize(path.posix.join("dist", withoutQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
function asarExtractPath(reference) {
|
||||||
|
return reference.replaceAll("/", path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFile(checks, filePath, id, minimumBytes) {
|
||||||
|
const exists = fs.existsSync(filePath);
|
||||||
|
const size = exists ? fs.statSync(filePath).size : 0;
|
||||||
|
check(checks, exists && size >= minimumBytes, id, `${filePath} (${size} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(checks, ok, id, detail) {
|
||||||
|
checks.push({ id, ok: Boolean(ok), detail: String(detail ?? "") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagingReport(mode, input, checks) {
|
||||||
|
return {
|
||||||
|
version: "packaging-offline-v2",
|
||||||
|
mode,
|
||||||
|
input,
|
||||||
|
ok: checks.every((entry) => entry.ok),
|
||||||
|
passed: checks.filter((entry) => entry.ok).length,
|
||||||
|
failed: checks.filter((entry) => !entry.ok).length,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const staticOnly = process.argv.includes("--static");
|
||||||
|
const inputArg = process.argv.find((argument) => argument.startsWith("--input="));
|
||||||
|
const reports = [verifyProjectPackaging()];
|
||||||
|
if (!staticOnly) {
|
||||||
|
const input = inputArg?.slice("--input=".length) || path.resolve("outputs", "dist", "win-unpacked");
|
||||||
|
reports.push(verifyPackagedDirectory(input));
|
||||||
|
}
|
||||||
|
const result = { version: "packaging-offline-suite-v2", ok: reports.every((report) => report.ok), reports };
|
||||||
|
const outputDir = path.resolve("outputs", "packaging");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const reportPath = path.join(outputDir, "offline-package-report.json");
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||||
|
for (const report of reports) {
|
||||||
|
console.log(`${report.mode}: ${report.passed}/${report.checks.length} checks passed`);
|
||||||
|
for (const failed of report.checks.filter((entry) => !entry.ok)) console.error(`FAIL ${failed.id}: ${failed.detail}`);
|
||||||
|
}
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
if (!result.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
BUILD_FIT_PROFILE_CORPUS_VERSION,
|
||||||
|
curatedBuildFitProfiles,
|
||||||
|
findCuratedBuildFitProfile,
|
||||||
|
} from "./buildFitProfiles";
|
||||||
|
import {
|
||||||
|
BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||||
|
BUILD_FIT_INPUT_VERSION,
|
||||||
|
type BuildFitArtifactInputV1,
|
||||||
|
} from "../types/buildFit";
|
||||||
|
import {
|
||||||
|
BUILD_FIT_REQUIRED_FIELD_KEYS,
|
||||||
|
assessBuildFitCombination,
|
||||||
|
validateBuildFitContract,
|
||||||
|
} from "../lib/buildFitContract";
|
||||||
|
import type { ArtifactSlot } from "../types/domain";
|
||||||
|
import type { ScanResultFieldConfidence } from "../types/storage";
|
||||||
|
|
||||||
|
describe("curated Build-Fit profile corpus", () => {
|
||||||
|
it("contains unique, valid, explicitly sourced contracts", () => {
|
||||||
|
expect(BUILD_FIT_PROFILE_CORPUS_VERSION).toBe("build-fit-profile-corpus-v2");
|
||||||
|
expect(curatedBuildFitProfiles.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(new Set(curatedBuildFitProfiles.map((profile) => profile.id)).size).toBe(curatedBuildFitProfiles.length);
|
||||||
|
|
||||||
|
for (const profile of curatedBuildFitProfiles) {
|
||||||
|
expect(validateBuildFitContract(profile)).toEqual({ valid: true, issues: [] });
|
||||||
|
expect(Date.parse(profile.provenance.reviewedAt)).toBeLessThanOrEqual(Date.now());
|
||||||
|
expect(Date.parse(profile.provenance.validUntil)).toBeGreaterThan(Date.now());
|
||||||
|
expect(profile.target.assumptions.length).toBeGreaterThan(0);
|
||||||
|
expect(profile.provenance.sources.some((source) => source.kind === "curated" && source.reference.startsWith("https://"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only character and Set keys present in the vendored IK 6.7.0 catalogs", () => {
|
||||||
|
const artifactCatalog = readJson<Record<string, unknown>>("data/ik-inventorylists/artifacts.json");
|
||||||
|
const characterCatalog = readJson<Record<string, unknown>>("data/ik-inventorylists/characters.json");
|
||||||
|
|
||||||
|
for (const profile of curatedBuildFitProfiles) {
|
||||||
|
expect(characterCatalog).toHaveProperty(profile.character.id);
|
||||||
|
for (const plan of profile.setPlans) {
|
||||||
|
for (const requirement of plan.requirements) {
|
||||||
|
expect(artifactCatalog).toHaveProperty(requirement.setKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies the Furina Solo-Hydro golden shape and keeps ranking deferred", () => {
|
||||||
|
const profile = findCuratedBuildFitProfile("furina-off-field-solo-hydro-c0-c1");
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
const assessment = assessBuildFitCombination(profile, {
|
||||||
|
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||||
|
id: "furina-golden-fixture",
|
||||||
|
artifacts: furinaArtifacts(),
|
||||||
|
aggregateContext: [{
|
||||||
|
id: "furina-golden-er-context",
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
unit: "percent",
|
||||||
|
nonArtifactValue: 106.6,
|
||||||
|
sourceIds: ["local-build-context-v1"],
|
||||||
|
reason: "Explicit fixture total from character and weapon context.",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.status).toBe("eligible");
|
||||||
|
expect(assessment.score).toBeNull();
|
||||||
|
expect(assessment.recommendationStatus).toBe("deferred");
|
||||||
|
expect(assessment.facts.matchedSetPlanIds).toEqual(["golden-troupe-4pc"]);
|
||||||
|
expect(assessment.facts.aggregateTargets).toEqual([{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
unit: "percent",
|
||||||
|
priority: "required",
|
||||||
|
minimum: 200,
|
||||||
|
artifactContribution: 93.4,
|
||||||
|
nonArtifactValue: 106.6,
|
||||||
|
totalValue: 200,
|
||||||
|
status: "met",
|
||||||
|
sourceIds: ["local-build-context-v1"],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn the Furina ER requirement into a guessed total without build context", () => {
|
||||||
|
const profile = findCuratedBuildFitProfile("furina-off-field-solo-hydro-c0-c1");
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
const assessment = assessBuildFitCombination(profile, {
|
||||||
|
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||||
|
id: "furina-pending-context-fixture",
|
||||||
|
artifacts: furinaArtifacts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.status).toBe("eligible");
|
||||||
|
expect(assessment.facts.aggregateTargets[0]).toMatchObject({
|
||||||
|
artifactContribution: 93.4,
|
||||||
|
nonArtifactValue: null,
|
||||||
|
totalValue: null,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["neuvillette-on-field-charged-attack", "marechaussee-hunter-4pc"],
|
||||||
|
["raiden-off-field-hyperbloom-trigger", "flower-of-paradise-lost-4pc"],
|
||||||
|
["nahida-off-field-deepwood-support", "deepwood-memories-4pc"],
|
||||||
|
] as const)("validates the sourced %s golden combination", (profileId, planId) => {
|
||||||
|
const profile = findCuratedBuildFitProfile(profileId);
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
const assessment = assessBuildFitCombination(profile, {
|
||||||
|
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||||
|
id: `${profileId}-golden-fixture`,
|
||||||
|
artifacts: profileArtifacts(profileId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.status).toBe("eligible");
|
||||||
|
expect(assessment.score).toBeNull();
|
||||||
|
expect(assessment.recommendationStatus).toBe("deferred");
|
||||||
|
expect(assessment.facts.matchedSetPlanIds).toContain(planId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"neuvillette-on-field-charged-attack",
|
||||||
|
"raiden-off-field-hyperbloom-trigger",
|
||||||
|
"nahida-off-field-deepwood-support",
|
||||||
|
] as const)("blocks a Review artifact for %s instead of treating its profile evidence as safe", (profileId) => {
|
||||||
|
const profile = findCuratedBuildFitProfile(profileId);
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
if (!profile) return;
|
||||||
|
const artifacts = profileArtifacts(profileId);
|
||||||
|
artifacts[0] = { ...artifacts[0], extractionStatus: "review", needsReview: true };
|
||||||
|
|
||||||
|
const assessment = assessBuildFitCombination(profile, {
|
||||||
|
version: BUILD_FIT_COMBINATION_INPUT_VERSION,
|
||||||
|
id: `${profileId}-review-fixture`,
|
||||||
|
artifacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assessment.status).toBe("blocked");
|
||||||
|
expect(assessment.blockers.map((blocker) => blocker.code)).toContain(`artifact_ineligible:${artifacts[0].artifactId}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function furinaArtifacts(): BuildFitArtifactInputV1[] {
|
||||||
|
return [
|
||||||
|
artifact("furina-flower", "flower", "goldentroupe", "HP", "4,780", [
|
||||||
|
"Energy Recharge+10.4%",
|
||||||
|
"CRIT Rate+7.0%",
|
||||||
|
"CRIT DMG+14.0%",
|
||||||
|
"HP%+9.9%",
|
||||||
|
]),
|
||||||
|
artifact("furina-plume", "plume", "goldentroupe", "ATK", "311", [
|
||||||
|
"Energy Recharge+10.4%",
|
||||||
|
"CRIT Rate+7.0%",
|
||||||
|
"CRIT DMG+14.0%",
|
||||||
|
"HP%+9.9%",
|
||||||
|
]),
|
||||||
|
artifact("furina-sands", "sands", "goldentroupe", "Energy Recharge", "51.8%", [
|
||||||
|
"CRIT Rate+10.5%",
|
||||||
|
"CRIT DMG+14.0%",
|
||||||
|
"HP%+9.9%",
|
||||||
|
"HP+239",
|
||||||
|
]),
|
||||||
|
artifact("furina-goblet", "goblet", "goldentroupe", "HP%", "46.6%", [
|
||||||
|
"Energy Recharge+10.4%",
|
||||||
|
"CRIT Rate+7.0%",
|
||||||
|
"CRIT DMG+14.0%",
|
||||||
|
"HP+239",
|
||||||
|
]),
|
||||||
|
artifact("furina-circlet", "circlet", "tenacityofthemillelith", "CRIT Rate", "31.1%", [
|
||||||
|
"Energy Recharge+10.4%",
|
||||||
|
"CRIT DMG+21.0%",
|
||||||
|
"HP%+9.9%",
|
||||||
|
"HP+239",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileArtifacts(profileId: string): BuildFitArtifactInputV1[] {
|
||||||
|
if (profileId === "neuvillette-on-field-charged-attack") {
|
||||||
|
return completeSet("neuvillette", "marechausseehunter", [
|
||||||
|
["sands", "HP%", "46.6%"],
|
||||||
|
["goblet", "Hydro DMG Bonus", "46.6%"],
|
||||||
|
["circlet", "CRIT Rate", "31.1%"],
|
||||||
|
], ["Energy Recharge+10.4%", "CRIT Rate+7.0%", "CRIT DMG+14.0%", "HP%+9.9%"]);
|
||||||
|
}
|
||||||
|
if (profileId === "raiden-off-field-hyperbloom-trigger") {
|
||||||
|
return completeSet("raiden", "flowerofparadiselost", [
|
||||||
|
["sands", "Elemental Mastery", "187"],
|
||||||
|
["goblet", "Elemental Mastery", "187"],
|
||||||
|
["circlet", "Elemental Mastery", "187"],
|
||||||
|
], ["Elemental Mastery+40", "HP%+9.9%", "ATK+19", "DEF+21"]);
|
||||||
|
}
|
||||||
|
if (profileId === "nahida-off-field-deepwood-support") {
|
||||||
|
return completeSet("nahida", "deepwoodmemories", [
|
||||||
|
["sands", "Elemental Mastery", "187"],
|
||||||
|
["goblet", "Dendro DMG Bonus", "46.6%"],
|
||||||
|
["circlet", "CRIT Rate", "31.1%"],
|
||||||
|
], ["Energy Recharge+10.4%", "Elemental Mastery+40", "CRIT Rate+7.0%", "CRIT DMG+14.0%"]);
|
||||||
|
}
|
||||||
|
throw new Error(`Missing fixture for ${profileId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSet(
|
||||||
|
prefix: string,
|
||||||
|
setKey: string,
|
||||||
|
variableMainStats: Array<[ArtifactSlot, string, string]>,
|
||||||
|
substats: string[],
|
||||||
|
): BuildFitArtifactInputV1[] {
|
||||||
|
const mainStats = new Map(variableMainStats.map(([slot, stat, value]) => [slot, { stat, value }] as const));
|
||||||
|
return [
|
||||||
|
artifact(`${prefix}-flower`, "flower", setKey, "HP", "4,780", substats),
|
||||||
|
artifact(`${prefix}-plume`, "plume", setKey, "ATK", "311", substats),
|
||||||
|
artifact(`${prefix}-sands`, "sands", setKey, mainStats.get("sands")?.stat ?? "ATK%", mainStats.get("sands")?.value ?? "46.6%", substats),
|
||||||
|
artifact(`${prefix}-goblet`, "goblet", setKey, mainStats.get("goblet")?.stat ?? "ATK%", mainStats.get("goblet")?.value ?? "46.6%", substats),
|
||||||
|
artifact(`${prefix}-circlet`, "circlet", "offset", mainStats.get("circlet")?.stat ?? "CRIT Rate", mainStats.get("circlet")?.value ?? "31.1%", substats),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifact(
|
||||||
|
artifactId: string,
|
||||||
|
slot: ArtifactSlot,
|
||||||
|
setKey: string,
|
||||||
|
mainStat: string,
|
||||||
|
mainValue: string,
|
||||||
|
substats: string[],
|
||||||
|
): BuildFitArtifactInputV1 {
|
||||||
|
return {
|
||||||
|
version: BUILD_FIT_INPUT_VERSION,
|
||||||
|
artifactId,
|
||||||
|
canonicalSetKey: setKey,
|
||||||
|
artifact: {
|
||||||
|
name: artifactId,
|
||||||
|
slot,
|
||||||
|
level: 20,
|
||||||
|
setName: setKey,
|
||||||
|
mainStat,
|
||||||
|
mainValue,
|
||||||
|
substats,
|
||||||
|
equipped: "Not detected",
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
fieldConfidences: fieldConfidences(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldConfidences(): ScanResultFieldConfidence[] {
|
||||||
|
return BUILD_FIT_REQUIRED_FIELD_KEYS.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
value: "fixture",
|
||||||
|
confidence: 95,
|
||||||
|
source: "ocr",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(relativePath: string): T {
|
||||||
|
return JSON.parse(readFileSync(resolve(process.cwd(), relativePath), "utf8")) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import {
|
||||||
|
BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
BUILD_FIT_REQUIRED_FIELD_KEYS,
|
||||||
|
type BuildFitContractV1,
|
||||||
|
} from "../types/buildFit";
|
||||||
|
|
||||||
|
export const BUILD_FIT_PROFILE_CORPUS_VERSION = "build-fit-profile-corpus-v2" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated production candidates. Each profile has a bounded source-review
|
||||||
|
* lifetime, Golden/adversarial coverage, and no authority outside its explicit
|
||||||
|
* assumptions. The separate evidence ranker remains read-only and never uses
|
||||||
|
* Roll-Efficiency as a Build-Fit input.
|
||||||
|
*/
|
||||||
|
export const curatedBuildFitProfiles: readonly BuildFitContractV1[] = [
|
||||||
|
{
|
||||||
|
version: BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
id: "furina-off-field-solo-hydro-c0-c1",
|
||||||
|
revision: 1,
|
||||||
|
character: {
|
||||||
|
id: "furina",
|
||||||
|
name: "Furina",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: "off-field-solo-hydro-c0-c1",
|
||||||
|
label: "Off-field DPS + Support · Solo Hydro · C0-C1",
|
||||||
|
role: "sub_dps",
|
||||||
|
description: "Ousia off-field personal damage and Burst support in a team where Furina is the only Hydro character.",
|
||||||
|
assumptions: [
|
||||||
|
"Furina is constellation 0 or 1; constellation 2 changes her HP and damage balance, while constellation 4 materially lowers her Energy Recharge requirement.",
|
||||||
|
"Furina is the only Hydro character and uses her Burst once per rotation.",
|
||||||
|
"Weapon, Favonius procs, rotation, and team Energy contributions must be supplied as explicit aggregate context.",
|
||||||
|
"The profile targets Ousia off-field damage and support, not constellation 2+ behavior, on-field attacks, or a no-Burst team.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
gameVersion: "Luna II",
|
||||||
|
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||||
|
validUntil: "2026-10-08T20:00:00.000Z",
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "kqm-furina-quick-guide-luna-ii",
|
||||||
|
label: "KQM Furina Quick Guide",
|
||||||
|
kind: "curated",
|
||||||
|
version: "Luna II",
|
||||||
|
reference: "https://keqingmains.com/q/furina-quickguide/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-build-context-v1",
|
||||||
|
label: "Explicit local build context",
|
||||||
|
kind: "user",
|
||||||
|
version: "1",
|
||||||
|
reference: "local://build-fit/aggregate-context",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
setPlans: [
|
||||||
|
{
|
||||||
|
id: "golden-troupe-4pc",
|
||||||
|
label: "Golden Troupe · 4pc",
|
||||||
|
tier: "preferred",
|
||||||
|
requirements: [{ setKey: "goldentroupe", pieces: 4 }],
|
||||||
|
reason: "The sourced guide identifies 4pc Golden Troupe as the personal-damage set for Furina's off-field Ousia Skill.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mainStats: {
|
||||||
|
sands: [
|
||||||
|
{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Use when the explicit team and weapon context cannot otherwise reach the required Burst uptime.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Use when the sourced Energy Recharge target is already met from the complete build context.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goblet: [
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Preferred for this pre-C2 Golden Troupe target according to the sourced guide.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "Hydro DMG Bonus",
|
||||||
|
priority: 2,
|
||||||
|
reason: "A sourced alternative that remains dependent on the surrounding build and team buffs.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
circlet: [
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Choose the CRIT main stat needed to balance the complete build.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Choose the CRIT main stat needed to balance the complete build.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
substats: [
|
||||||
|
{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
weight: 1,
|
||||||
|
reason: "Ordinal source priority: first until the explicit total-build requirement is met; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Ordinal source priority shared with CRIT DMG after Energy Recharge; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Ordinal source priority shared with CRIT Rate after Energy Recharge; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
weight: 0.6,
|
||||||
|
reason: "Ordinal source priority after Energy Recharge and CRIT; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregateTargets: [
|
||||||
|
{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
unit: "percent",
|
||||||
|
minimum: 200,
|
||||||
|
priority: "required",
|
||||||
|
reason: "The sourced guide lists 200%+ for Solo Hydro; exact weapon, Favonius, rotation, and team context remains explicit.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
evidenceRules: {
|
||||||
|
minimumExtractionConfidence: 88,
|
||||||
|
minimumFieldConfidence: 78,
|
||||||
|
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||||
|
},
|
||||||
|
conflictPolicy: {
|
||||||
|
equippedByOtherCharacter: "warn",
|
||||||
|
assignedToOtherBuild: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
id: "neuvillette-on-field-charged-attack",
|
||||||
|
revision: 1,
|
||||||
|
character: {
|
||||||
|
id: "neuvillette",
|
||||||
|
name: "Neuvillette",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: "on-field-charged-attack",
|
||||||
|
label: "On-field Charged Attack DPS",
|
||||||
|
role: "main_dps",
|
||||||
|
description: "On-field Hydro damage focused on enhanced Charged Attacks with a complete Marechaussee Hunter set.",
|
||||||
|
assumptions: [
|
||||||
|
"Neuvillette is played as an on-field Charged Attack damage dealer rather than only as a driver for teammates.",
|
||||||
|
"His Charged Attack HP drain is available to maintain the Marechaussee Hunter four-piece effect.",
|
||||||
|
"Weapon, team, Favonius procs, Energy Recharge requirement, constellation, and rotation are external context and are not inferred from Artifacts.",
|
||||||
|
"This profile does not select a team or claim that a profile candidate proves character ownership.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
gameVersion: "Luna IV",
|
||||||
|
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||||
|
validUntil: "2026-10-08T20:00:00.000Z",
|
||||||
|
sources: [{
|
||||||
|
id: "kqm-neuvillette-quick-guide-luna-iv",
|
||||||
|
label: "KQM Neuvillette Quick Guide",
|
||||||
|
kind: "curated",
|
||||||
|
version: "Luna IV",
|
||||||
|
reference: "https://keqingmains.com/q/neuvillette-quickguide/",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
setPlans: [{
|
||||||
|
id: "marechaussee-hunter-4pc",
|
||||||
|
label: "Marechaussee Hunter · 4pc",
|
||||||
|
tier: "preferred",
|
||||||
|
requirements: [{ setKey: "marechausseehunter", pieces: 4 }],
|
||||||
|
reason: "The sourced guide identifies four-piece Marechaussee Hunter as Neuvillette's strongest general Charged Attack set, with stacks enabled by his HP drain.",
|
||||||
|
}],
|
||||||
|
mainStats: {
|
||||||
|
sands: [{
|
||||||
|
stat: "HP%",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced Charged Attack profile scales from HP.",
|
||||||
|
}],
|
||||||
|
goblet: [
|
||||||
|
{
|
||||||
|
stat: "Hydro DMG Bonus",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced default damage-facing Goblet choice for this profile.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
priority: 2,
|
||||||
|
reason: "A sourced viable alternative whose exact value depends on weapon and team buffs.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
circlet: [
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Use the CRIT main stat that balances the complete build.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
priority: 1,
|
||||||
|
reason: "Use the CRIT main stat that balances the complete build.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
priority: 2,
|
||||||
|
reason: "A sourced viable alternative with appropriately strong supporting substats.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
substats: [
|
||||||
|
{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
weight: 1,
|
||||||
|
reason: "Ordinal source priority until the explicit team and weapon requirement is met; this contract does not invent a universal threshold.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Ordinal source priority shared with CRIT DMG; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Ordinal source priority shared with CRIT Rate; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP%",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Sourced HP scaling priority; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "HP",
|
||||||
|
weight: 0.6,
|
||||||
|
reason: "Flat HP is a lower sourced priority than HP% for this profile.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregateTargets: [],
|
||||||
|
evidenceRules: {
|
||||||
|
minimumExtractionConfidence: 88,
|
||||||
|
minimumFieldConfidence: 78,
|
||||||
|
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||||
|
},
|
||||||
|
conflictPolicy: {
|
||||||
|
equippedByOtherCharacter: "warn",
|
||||||
|
assignedToOtherBuild: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
id: "raiden-off-field-hyperbloom-trigger",
|
||||||
|
revision: 1,
|
||||||
|
character: {
|
||||||
|
id: "raidenshogun",
|
||||||
|
name: "Raiden Shogun",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: "off-field-hyperbloom-trigger",
|
||||||
|
label: "Off-field Hyperbloom Trigger",
|
||||||
|
role: "reaction",
|
||||||
|
description: "Off-field Electro application focused only on triggering Hyperbloom with Elemental Mastery Artifact rules.",
|
||||||
|
assumptions: [
|
||||||
|
"Raiden remains off-field after activating her Skill and is the intended Hyperbloom trigger.",
|
||||||
|
"This excludes Burst DPS, Aggravate, Quickbloom, Overloaded, and personal-damage comparisons.",
|
||||||
|
"Another team member is the intended four-piece Deepwood Memories holder; Raiden-held Deepwood is a separate fallback, not this target.",
|
||||||
|
"Character level, team composition, weapon, and reaction ownership remain external context and are not inferred from Artifacts.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
gameVersion: "5.7",
|
||||||
|
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||||
|
validUntil: "2026-10-08T20:00:00.000Z",
|
||||||
|
sources: [{
|
||||||
|
id: "kqm-raiden-quick-guide-5-7",
|
||||||
|
label: "KQM Raiden Shogun Quick Guide",
|
||||||
|
kind: "curated",
|
||||||
|
version: "5.7",
|
||||||
|
reference: "https://keqingmains.com/q/raiden-quickguide/",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
setPlans: [
|
||||||
|
{
|
||||||
|
id: "flower-of-paradise-lost-4pc",
|
||||||
|
label: "Flower of Paradise Lost · 4pc",
|
||||||
|
tier: "preferred",
|
||||||
|
requirements: [{ setKey: "flowerofparadiselost", pieces: 4 }],
|
||||||
|
reason: "The sourced Hyperbloom section lists four-piece Flower of Paradise Lost as the highest-damage set for this narrow trigger target.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gilded-dreams-4pc",
|
||||||
|
label: "Gilded Dreams · 4pc",
|
||||||
|
tier: "alternative",
|
||||||
|
requirements: [{ setKey: "gildeddreams", pieces: 4 }],
|
||||||
|
reason: "The sourced guide lists four-piece Gilded Dreams as an EM-focused and resin-efficient alternative.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mainStats: {
|
||||||
|
sands: [{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||||
|
}],
|
||||||
|
goblet: [{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||||
|
}],
|
||||||
|
circlet: [{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced Hyperbloom trigger table specifies Elemental Mastery.",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
substats: [{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
weight: 1,
|
||||||
|
reason: "The sourced narrow trigger target treats Elemental Mastery as the only relevant Artifact substat; this is not a general Raiden profile.",
|
||||||
|
}],
|
||||||
|
aggregateTargets: [],
|
||||||
|
evidenceRules: {
|
||||||
|
minimumExtractionConfidence: 88,
|
||||||
|
minimumFieldConfidence: 78,
|
||||||
|
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||||
|
},
|
||||||
|
conflictPolicy: {
|
||||||
|
equippedByOtherCharacter: "warn",
|
||||||
|
assignedToOtherBuild: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
id: "nahida-off-field-deepwood-support",
|
||||||
|
revision: 1,
|
||||||
|
character: {
|
||||||
|
id: "nahida",
|
||||||
|
name: "Nahida",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: "off-field-deepwood-support",
|
||||||
|
label: "Off-field Deepwood Support",
|
||||||
|
role: "support",
|
||||||
|
description: "Off-field Skill Dendro application and team support while Nahida is the intended Deepwood Memories holder.",
|
||||||
|
assumptions: [
|
||||||
|
"Nahida is used primarily off-field for Skill-based Dendro application and support.",
|
||||||
|
"Nahida is the intended team holder of four-piece Deepwood Memories; only one team member should supply that effect.",
|
||||||
|
"Weapon, team, rotation, Favonius, exact Energy Recharge requirement, and which teammate owns the highest Elemental Mastery remain external context.",
|
||||||
|
"The profile does not turn the conditional 900-1000 Elemental Mastery discussion into a hard universal target.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
gameVersion: "5.6",
|
||||||
|
reviewedAt: "2026-07-10T20:00:00.000Z",
|
||||||
|
validUntil: "2026-10-08T20:00:00.000Z",
|
||||||
|
sources: [{
|
||||||
|
id: "kqm-nahida-quick-guide-5-6",
|
||||||
|
label: "KQM Nahida Quick Guide",
|
||||||
|
kind: "curated",
|
||||||
|
version: "5.6",
|
||||||
|
reference: "https://keqingmains.com/q/nahida-quickguide/",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
setPlans: [{
|
||||||
|
id: "deepwood-memories-4pc",
|
||||||
|
label: "Deepwood Memories · 4pc",
|
||||||
|
tier: "preferred",
|
||||||
|
requirements: [{ setKey: "deepwoodmemories", pieces: 4 }],
|
||||||
|
reason: "The sourced guide describes four-piece Deepwood Memories as a useful generalist team set that Nahida can maintain reliably.",
|
||||||
|
}],
|
||||||
|
mainStats: {
|
||||||
|
sands: [{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts Elemental Mastery Sands.",
|
||||||
|
}],
|
||||||
|
goblet: [
|
||||||
|
{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts Elemental Mastery Goblet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "Dendro DMG Bonus",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts Dendro DMG Bonus Goblet.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
circlet: [
|
||||||
|
{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts Elemental Mastery Circlet.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts CRIT Circlet according to complete build context.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
priority: 1,
|
||||||
|
reason: "The sourced off-field table accepts CRIT Circlet according to complete build context.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
substats: [
|
||||||
|
{
|
||||||
|
stat: "Energy Recharge",
|
||||||
|
weight: 1,
|
||||||
|
reason: "Ordinal source priority until the explicit team requirement is met; this contract does not invent a universal threshold.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "Elemental Mastery",
|
||||||
|
weight: 0.8,
|
||||||
|
reason: "Sourced off-field priority; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT Rate",
|
||||||
|
weight: 0.6,
|
||||||
|
reason: "Sourced lower ordinal priority after Energy Recharge and Elemental Mastery; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: "CRIT DMG",
|
||||||
|
weight: 0.6,
|
||||||
|
reason: "Sourced lower ordinal priority after Energy Recharge and Elemental Mastery; not a damage multiplier.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregateTargets: [],
|
||||||
|
evidenceRules: {
|
||||||
|
minimumExtractionConfidence: 88,
|
||||||
|
minimumFieldConfidence: 78,
|
||||||
|
requiredFieldKeys: [...BUILD_FIT_REQUIRED_FIELD_KEYS],
|
||||||
|
},
|
||||||
|
conflictPolicy: {
|
||||||
|
equippedByOtherCharacter: "warn",
|
||||||
|
assignedToOtherBuild: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findCuratedBuildFitProfile(id: string) {
|
||||||
|
return curatedBuildFitProfiles.find((profile) => profile.id === id) ?? null;
|
||||||
|
}
|
||||||
@@ -5276,7 +5276,6 @@
|
|||||||
"Determmation oT": "Viridescent Venerer's Determination",
|
"Determmation oT": "Viridescent Venerer's Determination",
|
||||||
"From Grand Dreams. Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
"From Grand Dreams. Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
||||||
"From Grand Dreams Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
"From Grand Dreams Tn aking": "Moment That Ceased Upon Waking From Grand Dreams",
|
||||||
"Postintty That Ceased Upon": "Moment That Ceased Upon Waking From Grand Dreams",
|
|
||||||
"Viridescent Vencrers Vessel": "Viridescent Venerer's Vessel",
|
"Viridescent Vencrers Vessel": "Viridescent Venerer's Vessel",
|
||||||
"Holy Crown of the Believer ": "Holy Crown of the Believer"
|
"Holy Crown of the Believer ": "Holy Crown of the Believer"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { JsonArtifactStoreRepository } from "../../electron/repositories/artifactStoreRepository";
|
||||||
|
import type { StoredArtifactRecord } from "../types/storage";
|
||||||
|
|
||||||
|
const tempDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifact store repository local removal", () => {
|
||||||
|
it("removes only the explicitly requested local record", async () => {
|
||||||
|
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
|
||||||
|
tempDirectories.push(userDataPath);
|
||||||
|
const repository = new JsonArtifactStoreRepository(userDataPath);
|
||||||
|
await repository.saveMany([record("remove-me"), record("keep-me", "Gladiator's Destiny")]);
|
||||||
|
|
||||||
|
const removal = await repository.removeByIds(["remove-me", "missing-record"]);
|
||||||
|
const loaded = await repository.loadAll();
|
||||||
|
|
||||||
|
expect(removal).toMatchObject({ ok: true, removed: 1, total: 1 });
|
||||||
|
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remove a record for blank local ids", async () => {
|
||||||
|
const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-artifact-store-"));
|
||||||
|
tempDirectories.push(userDataPath);
|
||||||
|
const repository = new JsonArtifactStoreRepository(userDataPath);
|
||||||
|
await repository.saveMany([record("keep-me")]);
|
||||||
|
|
||||||
|
const removal = await repository.removeByIds(["", " "]);
|
||||||
|
const loaded = await repository.loadAll();
|
||||||
|
|
||||||
|
expect(removal).toMatchObject({ ok: true, removed: 0, total: 1 });
|
||||||
|
expect(loaded.artifacts.map((entry) => entry.id)).toEqual(["keep-me"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function record(id: string, name = "Gladiator's Nostalgia"): StoredArtifactRecord {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slot: "Flower of Life",
|
||||||
|
level: 20,
|
||||||
|
setName: "Gladiator's Finale",
|
||||||
|
mainStat: "HP",
|
||||||
|
mainValue: "4780",
|
||||||
|
substats: ["CRIT Rate+7.8%", "CRIT DMG+14.0%", "ATK%+5.8%", "Energy Recharge+11.7%"],
|
||||||
|
equipped: "Not detected",
|
||||||
|
confidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
source: "native-ik-scan",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isLikelyGenshinSourceName } from "../../electron/services/captureSourceClassifier";
|
||||||
|
|
||||||
|
describe("capture source classifier", () => {
|
||||||
|
it.each([
|
||||||
|
"Genshin Impact",
|
||||||
|
"GenshinImpact",
|
||||||
|
"Genshin",
|
||||||
|
"YuanShen",
|
||||||
|
"\u539f\u795e",
|
||||||
|
])("accepts the real game title %s", (title) => {
|
||||||
|
expect(isLikelyGenshinSourceName(title)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Genshin Artifact Assistant",
|
||||||
|
"Genshin Artifact Assistant Codex | Working",
|
||||||
|
"docs - Genshin scanner",
|
||||||
|
"Screen 1",
|
||||||
|
"",
|
||||||
|
])("rejects non-game windows containing Genshin text: %s", (title) => {
|
||||||
|
expect(isLikelyGenshinSourceName(title)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -99,4 +99,201 @@ export const confirmedReviewCorpus: ConfirmedReviewEvalCase[] = [
|
|||||||
note: "native-review-approved | run=20260709-223441 | sequence=76",
|
note: "native-review-approved | run=20260709-223441 | sequence=76",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-minnesang-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Minnesang of Love and Lament",
|
||||||
|
"artifact-slot": "Circlet of Logos",
|
||||||
|
"artifact-main-stat-label": "ATK",
|
||||||
|
"artifact-main-stat-value": "7.0%",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "CRIT Rate+2.7%\n- HP+4..1%\n- ATK+18\n- DEF+5.1%",
|
||||||
|
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n© 4-Piece Set: After a Normal\nAttack, Charged Attack,\nElemental Skill, or Elemental",
|
||||||
|
"artifact-footer": "",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Minnesang of Love and Lament",
|
||||||
|
slot: "Circlet of Logos",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "ATK%",
|
||||||
|
mainValue: "7.0%",
|
||||||
|
setName: "A Day Carved From Rising Winds",
|
||||||
|
substats: ["CRIT Rate+2.7%", "HP%+4.1%", "ATK+18", "DEF%+5.1%"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=309 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-holy-crown-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Holy Crown of the Believer",
|
||||||
|
"artifact-slot": "Circlet of Logos",
|
||||||
|
"artifact-main-stat-label": "Elemental Mastery",
|
||||||
|
"artifact-main-stat-value": "28",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "ATK+4.1%\n+ Energy Recharge+4.5%\n+ ATK+19\n+ HP+4..1%",
|
||||||
|
"artifact-set-effects": "¢2-Piece Set: Energy Recharge\n+20%.\n2 4-Piece Set: When dealing\nElemental DMG, gain the",
|
||||||
|
"artifact-footer": "Equipped: Illuga",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Holy Crown of the Believer",
|
||||||
|
slot: "Circlet of Logos",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "Elemental Mastery",
|
||||||
|
mainValue: "28",
|
||||||
|
setName: "Silken Moon's Serenade",
|
||||||
|
equipped: "Illuga",
|
||||||
|
substats: ["ATK%+4.1%", "Energy Recharge+4.5%", "ATK+19", "HP%+4.1%"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=870 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-conductor-top-hat-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Conductor's Top Hat",
|
||||||
|
"artifact-slot": "Circlet of Logos",
|
||||||
|
"artifact-main-stat-label": "DEF",
|
||||||
|
"artifact-main-stat-value": "8.1%",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "+ HP+209\n- HP+4..1%\n- CRIT DMG+5.4%\n- ATK+16 unactivated",
|
||||||
|
"artifact-set-effects": "«:2-Piece Set: Increases Elementa\nMastery by 80.\n2 4-Piece Set: Increases Charged\nAttack DMG by 35% if the",
|
||||||
|
"artifact-footer": "",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Conductor's Top Hat",
|
||||||
|
slot: "Circlet of Logos",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "DEF%",
|
||||||
|
mainValue: "8.7%",
|
||||||
|
setName: "Wanderer's Troupe",
|
||||||
|
substats: ["HP+209", "HP%+4.1%", "CRIT DMG+5.4%", "ATK+16"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1691 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-viridescent-arrow-feather-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Viridescent Arrow Feather",
|
||||||
|
"artifact-slot": "Plume of Death",
|
||||||
|
"artifact-main-stat-label": "ATK",
|
||||||
|
"artifact-main-stat-value": "",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "+ CRIT DMG+5.4%\n- DEF+19\n+ CRIT Rate+2.7%\n+ HP+4..1%",
|
||||||
|
"artifact-set-effects": "r2-Piece Set: Anemo DMG Bonus\n+15%\n2 4-Piece Set: Increases Swirl\nDMG by 60%. Decreases",
|
||||||
|
"artifact-footer": "",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Viridescent Arrow Feather",
|
||||||
|
slot: "Plume of Death",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "ATK",
|
||||||
|
mainValue: "47",
|
||||||
|
setName: "Viridescent Venerer",
|
||||||
|
substats: ["CRIT DMG+5.4%", "DEF+19", "CRIT Rate+2.7%", "HP%+4.1%"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1723 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-gladiator-destiny-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Gladiator's Destiny",
|
||||||
|
"artifact-slot": "Plume of Death",
|
||||||
|
"artifact-main-stat-label": "ATK",
|
||||||
|
"artifact-main-stat-value": "",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "- DEF+6.6%\n- HP+4..1%\n- Elemental Mastery+19\n- Energy Recharge+5.8%",
|
||||||
|
"artifact-set-effects": "Gladiator's Finale:\n© 2-Piece Set: ATK +18%.\n@4-Piece Set: If the wielder of\nthis artifact set uses a Sword,",
|
||||||
|
"artifact-footer": "",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Gladiator's Destiny",
|
||||||
|
slot: "Plume of Death",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "ATK",
|
||||||
|
mainValue: "47",
|
||||||
|
setName: "Gladiator's Finale",
|
||||||
|
substats: ["DEF%+6.6%", "HP%+4.1%", "Elemental Mastery+19", "Energy Recharge+5.8%"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=1844 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-maidens-distant-love-duplicated-decimal",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-name": "Maiden's Distant Love",
|
||||||
|
"artifact-slot": "Flower of Life",
|
||||||
|
"artifact-main-stat-label": "HP",
|
||||||
|
"artifact-main-stat-value": "",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-substats": "- DEF+23\n+ CRIT Rate+2.7%\n+ ATK+5.3%\n+ HP+4..1%",
|
||||||
|
"artifact-set-effects": "¢2-Piece Set: Character Healing\nEffectiveness +15%\n4-Piece Set: Using an Elemental\nSkill or Burst increases healing",
|
||||||
|
"artifact-footer": "",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Maiden's Distant Love",
|
||||||
|
slot: "Flower of Life",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "HP",
|
||||||
|
mainValue: "717",
|
||||||
|
setName: "Maiden Beloved",
|
||||||
|
substats: ["DEF+23", "CRIT Rate+2.7%", "ATK%+5.3%", "HP%+4.1%"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=2005 | duplicated decimal in HP percent substat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "native-review-ovations-slot-safe-name",
|
||||||
|
confirmed: true,
|
||||||
|
ocr: {
|
||||||
|
"artifact-slot": "Goblet of Eonothem",
|
||||||
|
"artifact-footer": "",
|
||||||
|
"artifact-set-effects": "© 2-Piece Set: ATK +18%.\n2 4-Piece Set: Increases\nSuperconduct Reaction DMG by\n80% and Stellar-Conduct",
|
||||||
|
"artifact-main-stat-value": "8.1%",
|
||||||
|
"artifact-level": "+0",
|
||||||
|
"artifact-main-stat-label": "DEF",
|
||||||
|
"artifact-substats": "- DEF+21\n+ Energy Recharge+4.5%\n- CRIT Rate+3.1%\n- ATK+14 unactivated",
|
||||||
|
"artifact-name": "Postintty That Ceased Upon",
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
name: "Ovations That Ceased Upon Festivity",
|
||||||
|
slot: "Goblet of Eonothem",
|
||||||
|
level: 0,
|
||||||
|
mainStat: "DEF%",
|
||||||
|
mainValue: "8.7%",
|
||||||
|
setName: "Disenchantment in Deep Shadow",
|
||||||
|
substats: ["DEF+21", "Energy Recharge+4.5%", "CRIT Rate+3.1%", "ATK+14"],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
source: "review-sample",
|
||||||
|
resolution: "492x838",
|
||||||
|
note: "visually confirmed preserved crop | run=20260710-223233 | sequence=141 | direct goblet-slot evidence disambiguates the fuzzy piece name",
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { automationGuardFromHelperResponse } from "../../electron/services/inputHelper";
|
||||||
|
|
||||||
|
const fallbackScript = readFileSync(
|
||||||
|
path.join(process.cwd(), "electron", "services", "inputHelperPowerShellFallback.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("input helper guard contract", () => {
|
||||||
|
it("preserves the helper elevation signal together with stop keys", () => {
|
||||||
|
expect(automationGuardFromHelperResponse({
|
||||||
|
ok: true,
|
||||||
|
isElevated: true,
|
||||||
|
cursorX: 691,
|
||||||
|
cursorY: 540,
|
||||||
|
escapePressed: false,
|
||||||
|
enterPressed: true,
|
||||||
|
f9Pressed: false,
|
||||||
|
})).toEqual({
|
||||||
|
ok: true,
|
||||||
|
isElevated: true,
|
||||||
|
cursorX: 691,
|
||||||
|
cursorY: 540,
|
||||||
|
escapePressed: false,
|
||||||
|
enterPressed: true,
|
||||||
|
f9Pressed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves elevation unknown instead of inventing a value for legacy responses", () => {
|
||||||
|
expect(automationGuardFromHelperResponse({ ok: true }).isElevated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the PowerShell fallback on the same exact game-process whitelist", () => {
|
||||||
|
expect(fallbackScript).toContain("$cachedName -in @('GenshinImpact', 'YuanShen')");
|
||||||
|
expect(fallbackScript).toContain("$_.ProcessName -in @('GenshinImpact', 'YuanShen')");
|
||||||
|
expect(fallbackScript).not.toContain("-match 'GenshinImpact|YuanShen|Genshin'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const scriptPath = path.join(process.cwd(), "scripts", "native-live-smoke.ps1");
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
|
|
||||||
|
describe("native live smoke script contract", () => {
|
||||||
|
it("accepts the click probe only when input delivery and the detail change are both proven", () => {
|
||||||
|
expect(script).toContain("$ProbePayload.ok");
|
||||||
|
expect(script).toContain("$ProbePayload.changed");
|
||||||
|
expect(script).toContain("$ProbePayload.click.ok");
|
||||||
|
expect(script).toContain("$ProbePayload.click.clicked");
|
||||||
|
expect(script).toContain("$ProbePayload.click.moved");
|
||||||
|
expect(script).toContain("-and -not $ProbePayload.click.inputBlocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires exact capture, queue, click, process, and result counts", () => {
|
||||||
|
expect(script).toContain("[int]$finalScanner.target -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$finalScanner.captured -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$finalScanner.queued -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$finalScanner.clicked -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$process.status.processed -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$process.status.parsed -ne $safeLimit");
|
||||||
|
expect(script).toContain("$processResults.Count -ne $safeLimit");
|
||||||
|
expect(script).toContain("[int]$results.status.total -ne $safeLimit");
|
||||||
|
expect(script).toContain("$loadedResults.Count -ne $safeLimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a completed bounded inventory-top reset and coherent timings", () => {
|
||||||
|
expect(script).toContain("initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted");
|
||||||
|
expect(script).toContain("-not [bool]$finalScanner.initialTopResetCompleted");
|
||||||
|
expect(script).toContain("[int]$finalScanner.initialTopResetMs -le 0");
|
||||||
|
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs");
|
||||||
|
expect(script).toContain("[int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs");
|
||||||
|
expect(script).toContain("$results.status.timing");
|
||||||
|
expect(script).toContain("$requiredTimingFields");
|
||||||
|
expect(script).toContain("requestToResultsReconciledMs");
|
||||||
|
expect(script).toContain("Native run timing contract is incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles review and error counts and enforces the fifteen-percent review gate", () => {
|
||||||
|
expect(script).toContain("$actualReview -ne [int]$process.status.review");
|
||||||
|
expect(script).toContain("$actualErrors -ne [int]$process.status.errors");
|
||||||
|
expect(script).toContain("[int]$process.status.review -lt 0");
|
||||||
|
expect(script).toContain("[int]$process.status.review -gt $safeLimit");
|
||||||
|
expect(script).toContain("if ($reviewRate -gt 0.15)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dry-run persistence in both processing and loaded result rows", () => {
|
||||||
|
expect(script).toContain("$persistedProcessRows");
|
||||||
|
expect(script).toContain("[bool]$process.status.persisted");
|
||||||
|
expect(script).toContain("[int]$process.status.stored -ne 0");
|
||||||
|
expect(script).toContain("[bool]$_.persistedArtifact");
|
||||||
|
expect(script).toContain("[string]$_.artifactRecordId");
|
||||||
|
expect(script).toContain("-not $Persist -and $persistedLoadedRows.Count -ne 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds each job to one expected non-empty PNG and exactly two click events", () => {
|
||||||
|
expect(script).toContain('"artifact-{0:D4}.png" -f $_');
|
||||||
|
expect(script).toContain("Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames");
|
||||||
|
expect(script).toContain("$duplicateSequenceJobs");
|
||||||
|
expect(script).toContain("[int]$_.clickEventsSent -ne 2");
|
||||||
|
expect(script).toContain("$badPathJobs");
|
||||||
|
expect(script).toContain("$emptyPngFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests cleanup after every start attempt and waits for a confirmed stopped state", () => {
|
||||||
|
const attemptIndex = script.indexOf("$scannerStartAttempted = $true");
|
||||||
|
const startIndex = script.indexOf('$start = Invoke-DevJson "/scanner/start');
|
||||||
|
expect(attemptIndex).toBeGreaterThan(-1);
|
||||||
|
expect(startIndex).toBeGreaterThan(attemptIndex);
|
||||||
|
expect(script).toContain("function Stop-NativeScannerAfterError");
|
||||||
|
expect(script).toContain('Invoke-DevJson "/scanner/stop"');
|
||||||
|
expect(script).toContain("while (($null -eq $lastScanner -or [bool]$lastScanner.running)");
|
||||||
|
expect(script).toContain("$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running");
|
||||||
|
expect(script).toContain("if ($scannerStartAttempted -and -not $scannerFinished)");
|
||||||
|
expect(script).toContain("Native scanner cleanup stop did not reach a confirmed stopped state.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { replayNativeScanResults } from "./nativeScanReplay";
|
||||||
|
import type { StoredScanResultEntry } from "../types/storage";
|
||||||
|
|
||||||
|
describe("native scan offline replay", () => {
|
||||||
|
it("replays evaluation and projection deterministically", () => {
|
||||||
|
const report = replayNativeScanResults({
|
||||||
|
entries: [entry()],
|
||||||
|
repeats: 4,
|
||||||
|
runId: "run-1",
|
||||||
|
sourcePath: "scan-results.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
deterministic: true,
|
||||||
|
repeats: 4,
|
||||||
|
total: 1,
|
||||||
|
values: { evaluated: 1, review: 0, unknown: 0, deferred: 0, excluded: 0 },
|
||||||
|
projections: { available: 1, complete: 0, review: 0, unavailable: 0 },
|
||||||
|
cleanUnevaluated: [],
|
||||||
|
});
|
||||||
|
expect(new Set(report.replayHashes).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports clean data that cannot be valued instead of hiding it", () => {
|
||||||
|
const report = replayNativeScanResults({
|
||||||
|
entries: [entry({ artifact: { ...entry().artifact!, substats: ["CRIT DMG+8.1%"] } })],
|
||||||
|
runId: "run-2",
|
||||||
|
sourcePath: "scan-results.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.values.unknown).toBe(1);
|
||||||
|
expect(report.cleanUnevaluated).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps confirmed non-5-star results visible without treating them as missing evaluations", () => {
|
||||||
|
const report = replayNativeScanResults({
|
||||||
|
entries: [entry({ artifact: { ...entry().artifact!, rarity: 4 } })],
|
||||||
|
runId: "run-3",
|
||||||
|
sourcePath: "scan-results.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.values).toEqual({ deferred: 0, review: 0, unknown: 0, excluded: 1, evaluated: 0 });
|
||||||
|
expect(report.projections).toEqual({ available: 0, complete: 0, review: 0, unavailable: 1 });
|
||||||
|
expect(report.cleanUnevaluated).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function entry(overrides: Partial<StoredScanResultEntry> = {}): StoredScanResultEntry {
|
||||||
|
return {
|
||||||
|
id: "entry-1",
|
||||||
|
runId: "run-1",
|
||||||
|
sequence: 1,
|
||||||
|
category: "artifact",
|
||||||
|
source: "native-ik-scan",
|
||||||
|
imagePath: "cards/artifact-0001.png",
|
||||||
|
capturedAt: "2026-07-09T00:00:00.000Z",
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "deferred",
|
||||||
|
valueScore: null,
|
||||||
|
artifact: {
|
||||||
|
name: "Gladiator's Intoxication",
|
||||||
|
slot: "Goblet of Eonothem",
|
||||||
|
level: 16,
|
||||||
|
rarity: 5,
|
||||||
|
rarityConfidence: 0.99,
|
||||||
|
raritySource: "native",
|
||||||
|
setName: "Gladiator's Finale",
|
||||||
|
mainStat: "Elemental Mastery",
|
||||||
|
mainValue: "155",
|
||||||
|
substats: ["CRIT DMG+14.0%", "DEF+39", "ATK+37", "Energy Recharge+6.5%"],
|
||||||
|
equipped: "Not detected",
|
||||||
|
},
|
||||||
|
persistedArtifact: false,
|
||||||
|
notes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { evaluateStoredScanResult, projectArtifactUpgrade } from "../lib/artifactEvaluation.js";
|
||||||
|
import type { ArtifactUpgradeProjection, ArtifactValueStatus, StoredScanResultEntry } from "../types/storage.js";
|
||||||
|
|
||||||
|
export interface NativeScanReplayReport {
|
||||||
|
version: "native-scan-replay-v1";
|
||||||
|
runId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sourceSha256: string;
|
||||||
|
repeats: number;
|
||||||
|
deterministic: boolean;
|
||||||
|
replayHashes: string[];
|
||||||
|
total: number;
|
||||||
|
extraction: Record<string, number>;
|
||||||
|
values: Record<ArtifactValueStatus, number>;
|
||||||
|
projections: Record<ArtifactUpgradeProjection["status"], number>;
|
||||||
|
score: { min: number | null; average: number | null; max: number | null };
|
||||||
|
cleanUnevaluated: Array<{ id: string; sequence: number; status: ArtifactValueStatus; summary: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayNativeScanResults(input: {
|
||||||
|
entries: readonly StoredScanResultEntry[];
|
||||||
|
repeats?: number;
|
||||||
|
runId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sourceSha256?: string;
|
||||||
|
}): NativeScanReplayReport {
|
||||||
|
const repeats = Math.max(2, Math.min(10, Math.round(input.repeats ?? 3)));
|
||||||
|
const replayPayloads = Array.from({ length: repeats }, () => input.entries.map((entry) => {
|
||||||
|
const evaluated = evaluateStoredScanResult(entry);
|
||||||
|
return {
|
||||||
|
id: evaluated.id,
|
||||||
|
sequence: evaluated.sequence,
|
||||||
|
extractionStatus: evaluated.extractionStatus,
|
||||||
|
needsReview: evaluated.needsReview,
|
||||||
|
valueStatus: evaluated.valueStatus,
|
||||||
|
valueScore: evaluated.valueScore,
|
||||||
|
valueEvaluation: evaluated.valueEvaluation,
|
||||||
|
projection: projectArtifactUpgrade(evaluated.artifact, evaluated.valueEvaluation ?? {
|
||||||
|
version: "roll-efficiency-v1",
|
||||||
|
status: evaluated.valueStatus,
|
||||||
|
score: evaluated.valueScore,
|
||||||
|
minScore: evaluated.valueScore,
|
||||||
|
maxScore: evaluated.valueScore,
|
||||||
|
label: "Legacy",
|
||||||
|
summary: "Legacy result without evaluation payload.",
|
||||||
|
rarity: null,
|
||||||
|
rollCount: null,
|
||||||
|
buildFitStatus: "deferred",
|
||||||
|
reasons: [],
|
||||||
|
substats: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
const replayHashes = replayPayloads.map(stableHash);
|
||||||
|
const deterministic = replayHashes.every((hash) => hash === replayHashes[0]);
|
||||||
|
const first = replayPayloads[0] ?? [];
|
||||||
|
const values = valueStatusCounts(first);
|
||||||
|
const projections = projectionStatusCounts(first);
|
||||||
|
const scores = first.map((entry) => entry.valueScore).filter((score): score is number => typeof score === "number");
|
||||||
|
const cleanUnevaluated = first
|
||||||
|
// A confirmed non-5-star result is intentionally terminal: it is retained
|
||||||
|
// for the read-only inventory view, but must not make an otherwise clean
|
||||||
|
// native run look like it silently lost an evaluation.
|
||||||
|
.filter((entry) => entry.extractionStatus === "parsed" && !entry.needsReview
|
||||||
|
&& entry.valueStatus !== "evaluated" && entry.valueStatus !== "excluded")
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
sequence: entry.sequence,
|
||||||
|
status: entry.valueStatus,
|
||||||
|
summary: entry.valueEvaluation?.summary ?? "No evaluation payload.",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "native-scan-replay-v1",
|
||||||
|
runId: input.runId,
|
||||||
|
sourcePath: input.sourcePath,
|
||||||
|
sourceSha256: input.sourceSha256 ?? stableHash(input.entries),
|
||||||
|
repeats,
|
||||||
|
deterministic,
|
||||||
|
replayHashes,
|
||||||
|
total: first.length,
|
||||||
|
extraction: countBy(first.map((entry) => entry.extractionStatus)),
|
||||||
|
values,
|
||||||
|
projections,
|
||||||
|
score: scores.length > 0 ? {
|
||||||
|
min: Math.min(...scores),
|
||||||
|
average: Math.round((scores.reduce((sum, score) => sum + score, 0) / scores.length) * 10) / 10,
|
||||||
|
max: Math.max(...scores),
|
||||||
|
} : { min: null, average: null, max: null },
|
||||||
|
cleanUnevaluated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueStatusCounts(entries: Array<{ valueStatus: ArtifactValueStatus }>): Record<ArtifactValueStatus, number> {
|
||||||
|
return {
|
||||||
|
deferred: entries.filter((entry) => entry.valueStatus === "deferred").length,
|
||||||
|
review: entries.filter((entry) => entry.valueStatus === "review").length,
|
||||||
|
unknown: entries.filter((entry) => entry.valueStatus === "unknown").length,
|
||||||
|
excluded: entries.filter((entry) => entry.valueStatus === "excluded").length,
|
||||||
|
evaluated: entries.filter((entry) => entry.valueStatus === "evaluated").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectionStatusCounts(
|
||||||
|
entries: Array<{ projection: ArtifactUpgradeProjection }>,
|
||||||
|
): Record<ArtifactUpgradeProjection["status"], number> {
|
||||||
|
return {
|
||||||
|
available: entries.filter((entry) => entry.projection.status === "available").length,
|
||||||
|
complete: entries.filter((entry) => entry.projection.status === "complete").length,
|
||||||
|
review: entries.filter((entry) => entry.projection.status === "review").length,
|
||||||
|
unavailable: entries.filter((entry) => entry.projection.status === "unavailable").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy(values: string[]) {
|
||||||
|
return values.reduce<Record<string, number>>((counts, value) => {
|
||||||
|
counts[value] = (counts[value] ?? 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableHash(value: unknown) {
|
||||||
|
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||||
|
}
|
||||||
@@ -4,8 +4,14 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { createNativeScannerProcessingService } from "../../electron/services/nativeScannerProcessingService";
|
import { createNativeScannerProcessingService } from "../../electron/services/nativeScannerProcessingService";
|
||||||
import type { NativeCaptureJobPayload } from "../../electron/services/nativeScannerProcessingService";
|
import type { NativeCaptureJobPayload } from "../../electron/services/nativeScannerProcessingService";
|
||||||
|
import {
|
||||||
|
readNativeScannerRunTiming,
|
||||||
|
recordNativeScannerRunTiming,
|
||||||
|
validateNativeScannerRunTiming,
|
||||||
|
} from "../../electron/services/nativeScannerRunTiming";
|
||||||
import type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
|
import type { IkArtifactCatalog } from "../lib/ikArtifactMatcher";
|
||||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
|
import type { StoredArtifactRecord, StoredScanResultEntry } from "../types/storage";
|
||||||
|
import type { NativeScannerProcessingProgressStatus } from "../types/global";
|
||||||
import { captureFromOcr } from "./ocrEvalHarness";
|
import { captureFromOcr } from "./ocrEvalHarness";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@@ -21,6 +27,27 @@ async function writeJobs(runDir: string, jobs: NativeCaptureJobPayload[]) {
|
|||||||
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
|
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), `${lines}\n`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function appendJob(runDir: string, job: NativeCaptureJobPayload) {
|
||||||
|
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), `${JSON.stringify(job)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProducerStatus(
|
||||||
|
runDir: string,
|
||||||
|
status: { running: boolean; status: string; target: number; captured: number },
|
||||||
|
) {
|
||||||
|
await fs.writeFile(path.join(runDir, "status.json"), JSON.stringify({ scanner: status }), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(read: () => Promise<T | null>, timeoutMs = 2_000): Promise<T> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const value = await read();
|
||||||
|
if (value !== null) return value;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out after ${timeoutMs} ms.`);
|
||||||
|
}
|
||||||
|
|
||||||
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
|
async function writeCrop(runDir: string, relativePath = "cards/artifact-0001.png") {
|
||||||
const absolutePath = path.join(runDir, relativePath);
|
const absolutePath = path.join(runDir, relativePath);
|
||||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||||
@@ -38,13 +65,25 @@ async function writePreviewPng(runDir: string, relativePath = "cards/preview.png
|
|||||||
return { relativePath, absolutePath };
|
return { relativePath, absolutePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
function safePlumeCapture() {
|
function safePlumeCapture(level = 20) {
|
||||||
|
return captureFromOcr({
|
||||||
|
"artifact-name": "Pristine Plume of the Blessed",
|
||||||
|
"artifact-slot": "Plume of Death",
|
||||||
|
"artifact-main-stat-label": "ATK",
|
||||||
|
"artifact-level": `+${level}`,
|
||||||
|
"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 illegalTotalPlumeCapture() {
|
||||||
return captureFromOcr({
|
return captureFromOcr({
|
||||||
"artifact-name": "Pristine Plume of the Blessed",
|
"artifact-name": "Pristine Plume of the Blessed",
|
||||||
"artifact-slot": "Plume of Death",
|
"artifact-slot": "Plume of Death",
|
||||||
"artifact-main-stat-label": "ATK",
|
"artifact-main-stat-label": "ATK",
|
||||||
"artifact-level": "+20",
|
"artifact-level": "+20",
|
||||||
"artifact-substats": "+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\n+ ATK+5.8%",
|
"artifact-substats": "+ CRIT Rate+3.1%\n+ CRIT DMG+7.0%\n+ ATK+5.8%\n+ HP+239",
|
||||||
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
|
"artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.",
|
||||||
"artifact-footer": "Equipped: Aino",
|
"artifact-footer": "Equipped: Aino",
|
||||||
});
|
});
|
||||||
@@ -92,6 +131,7 @@ describe("native scanner processing service", () => {
|
|||||||
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
|
await writeJobs(runDir, [{ sequence: 7, page: 2, row: 1, col: 3, relativePath }]);
|
||||||
|
|
||||||
const savedRecords: StoredArtifactRecord[] = [];
|
const savedRecords: StoredArtifactRecord[] = [];
|
||||||
|
const progress: NativeScannerProcessingProgressStatus[] = [];
|
||||||
const service = createNativeScannerProcessingService({
|
const service = createNativeScannerProcessingService({
|
||||||
resolveRunDir: () => runDir,
|
resolveRunDir: () => runDir,
|
||||||
buildCaptureResult: async (imagePath, job) => {
|
buildCaptureResult: async (imagePath, job) => {
|
||||||
@@ -103,6 +143,7 @@ describe("native scanner processing service", () => {
|
|||||||
savedRecords.push(...records);
|
savedRecords.push(...records);
|
||||||
return { added: records.length, updated: 0 };
|
return { added: records.length, updated: 0 };
|
||||||
},
|
},
|
||||||
|
onProgress: (entry) => progress.push(entry),
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = await service.processRun();
|
const status = await service.processRun();
|
||||||
@@ -116,6 +157,8 @@ describe("native scanner processing service", () => {
|
|||||||
expect(status.parsed).toBe(1);
|
expect(status.parsed).toBe(1);
|
||||||
expect(status.stored).toBe(0);
|
expect(status.stored).toBe(0);
|
||||||
expect(status.persisted).toBe(false);
|
expect(status.persisted).toBe(false);
|
||||||
|
expect(progress[0]).toMatchObject({ running: true, total: 1, processed: 0 });
|
||||||
|
expect(progress.at(-1)).toMatchObject({ running: false, total: 1, processed: 1, parsed: 1, stored: 0 });
|
||||||
expect(status.results[0]).toMatchObject({
|
expect(status.results[0]).toMatchObject({
|
||||||
sequence: 7,
|
sequence: 7,
|
||||||
page: 2,
|
page: 2,
|
||||||
@@ -138,7 +181,7 @@ describe("native scanner processing service", () => {
|
|||||||
expect(loadedResults.results[0]).toMatchObject({
|
expect(loadedResults.results[0]).toMatchObject({
|
||||||
sequence: 7,
|
sequence: 7,
|
||||||
extractionStatus: "parsed",
|
extractionStatus: "parsed",
|
||||||
valueStatus: "deferred",
|
valueStatus: "evaluated",
|
||||||
});
|
});
|
||||||
expect(scanResults).toHaveLength(1);
|
expect(scanResults).toHaveLength(1);
|
||||||
expect(scanResults[0]).toMatchObject({
|
expect(scanResults[0]).toMatchObject({
|
||||||
@@ -147,8 +190,8 @@ describe("native scanner processing service", () => {
|
|||||||
category: "artifact",
|
category: "artifact",
|
||||||
source: "native-ik-scan",
|
source: "native-ik-scan",
|
||||||
extractionStatus: "parsed",
|
extractionStatus: "parsed",
|
||||||
valueStatus: "deferred",
|
valueStatus: "evaluated",
|
||||||
valueScore: null,
|
valueScore: expect.any(Number),
|
||||||
persistedArtifact: false,
|
persistedArtifact: false,
|
||||||
artifact: {
|
artifact: {
|
||||||
name: "Pristine Plume of the Blessed",
|
name: "Pristine Plume of the Blessed",
|
||||||
@@ -159,6 +202,47 @@ describe("native scanner processing service", () => {
|
|||||||
expect(savedRecords).toHaveLength(0);
|
expect(savedRecords).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists one shared timing timeline through durable results and terminal reconciliation", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { relativePath } = await writeCrop(runDir);
|
||||||
|
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
|
||||||
|
const origin = new Date(Date.now() - 1_000);
|
||||||
|
await recordNativeScannerRunTiming(runDir, {
|
||||||
|
requestStartedAt: origin.toISOString(),
|
||||||
|
scannerStartedAt: new Date(origin.getTime() + 10).toISOString(),
|
||||||
|
captureStartedAt: new Date(origin.getTime() + 20).toISOString(),
|
||||||
|
captureCompletedAt: new Date(origin.getTime() + 100).toISOString(),
|
||||||
|
});
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
recordRunTiming: recordNativeScannerRunTiming,
|
||||||
|
loadRunTiming: readNativeScannerRunTiming,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processed = await service.processRun();
|
||||||
|
const loaded = await service.loadResults();
|
||||||
|
const timing = await readNativeScannerRunTiming(runDir);
|
||||||
|
const report = JSON.parse(await fs.readFile(path.join(runDir, "processing-report.json"), "utf8"));
|
||||||
|
|
||||||
|
expect(processed.timing).toMatchObject({
|
||||||
|
processingStartedAt: expect.any(String),
|
||||||
|
processingCompletedAt: expect.any(String),
|
||||||
|
resultsDurableAt: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(loaded.timing).toMatchObject({
|
||||||
|
resultsReconciledAt: expect.any(String),
|
||||||
|
requestToResultsDurableMs: expect.any(Number),
|
||||||
|
requestToResultsReconciledMs: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(report.timing).toMatchObject({ resultsDurableAt: expect.any(String) });
|
||||||
|
expect(timing).not.toBeNull();
|
||||||
|
expect(validateNativeScannerRunTiming(timing!)).toEqual([]);
|
||||||
|
expect(timing?.requestToResultsReconciledMs).toBeGreaterThanOrEqual(timing?.requestToResultsDurableMs ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a safe empty status when native scan results are not available", async () => {
|
it("returns a safe empty status when native scan results are not available", async () => {
|
||||||
const runDir = await makeRunDir();
|
const runDir = await makeRunDir();
|
||||||
const service = createNativeScannerProcessingService({
|
const service = createNativeScannerProcessingService({
|
||||||
@@ -316,12 +400,125 @@ describe("native scanner processing service", () => {
|
|||||||
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
|
const scanResults = JSON.parse(await fs.readFile(persistedRun.scanResultsPath, "utf8"));
|
||||||
expect(scanResults[0]).toMatchObject({
|
expect(scanResults[0]).toMatchObject({
|
||||||
extractionStatus: "parsed",
|
extractionStatus: "parsed",
|
||||||
valueStatus: "deferred",
|
valueStatus: "evaluated",
|
||||||
artifactRecordId: savedRecords[0].id,
|
artifactRecordId: savedRecords[0].id,
|
||||||
persistedArtifact: true,
|
persistedArtifact: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not persist a clean parse whose legal total-roll evaluation fails", 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 () => illegalTotalPlumeCapture(),
|
||||||
|
saveArtifacts: async (records) => {
|
||||||
|
savedRecords.push(...records);
|
||||||
|
return { added: records.length, updated: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.processRun({ persist: true });
|
||||||
|
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, parsed: 1, review: 0, stored: 0, persisted: true });
|
||||||
|
expect(savedRecords).toHaveLength(0);
|
||||||
|
expect(scanResults[0]).toMatchObject({
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "unknown",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps confirmed non-5-star results visible but excluded from persistence", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { relativePath } = await writeCrop(runDir);
|
||||||
|
await writeJobs(runDir, [{
|
||||||
|
sequence: 1,
|
||||||
|
relativePath,
|
||||||
|
starCount: 4,
|
||||||
|
starConfidence: 0.91,
|
||||||
|
starSource: "native-star-row-v1",
|
||||||
|
}]);
|
||||||
|
const savedRecords: StoredArtifactRecord[] = [];
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(16),
|
||||||
|
saveArtifacts: async (records) => {
|
||||||
|
savedRecords.push(...records);
|
||||||
|
return { added: records.length, updated: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.processRun({ persist: true });
|
||||||
|
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, parsed: 1, review: 0, stored: 0, persisted: true });
|
||||||
|
expect(status.results[0]).toMatchObject({
|
||||||
|
parsed: true,
|
||||||
|
needsReview: false,
|
||||||
|
rarity: 4,
|
||||||
|
rarityConfidence: 0.91,
|
||||||
|
persisted: false,
|
||||||
|
});
|
||||||
|
expect(scanResults[0]).toMatchObject({
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "excluded",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
artifact: {
|
||||||
|
rarity: 4,
|
||||||
|
rarityConfidence: 0.91,
|
||||||
|
raritySource: "native",
|
||||||
|
},
|
||||||
|
fieldConfidences: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ key: "rarity", label: "Stars", value: "4★", confidence: 91, source: "visual" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(savedRecords).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed into Review when native star evidence is not confirmed", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { relativePath } = await writeCrop(runDir);
|
||||||
|
await writeJobs(runDir, [{
|
||||||
|
sequence: 1,
|
||||||
|
relativePath,
|
||||||
|
starCount: null,
|
||||||
|
starConfidence: 0,
|
||||||
|
starSource: null,
|
||||||
|
}]);
|
||||||
|
const savedRecords: StoredArtifactRecord[] = [];
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async (records) => {
|
||||||
|
savedRecords.push(...records);
|
||||||
|
return { added: records.length, updated: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.processRun({ persist: true });
|
||||||
|
const scanResults = JSON.parse(await fs.readFile(status.scanResultsPath, "utf8"));
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, parsed: 1, review: 1, stored: 0, persisted: true });
|
||||||
|
expect(status.results[0]).toMatchObject({ parsed: true, needsReview: true, persisted: false });
|
||||||
|
expect(scanResults[0]).toMatchObject({
|
||||||
|
extractionStatus: "review",
|
||||||
|
needsReview: true,
|
||||||
|
valueStatus: "review",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
});
|
||||||
|
expect(scanResults[0].notes.join(" ")).toContain("Artifact star row could not be confirmed");
|
||||||
|
expect(savedRecords).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the native capture timestamp in durable scan results", async () => {
|
it("preserves the native capture timestamp in durable scan results", async () => {
|
||||||
const runDir = await makeRunDir();
|
const runDir = await makeRunDir();
|
||||||
const { relativePath } = await writeCrop(runDir);
|
const { relativePath } = await writeCrop(runDir);
|
||||||
@@ -363,7 +560,7 @@ describe("native scanner processing service", () => {
|
|||||||
setName: "Silken Moon's Serenade",
|
setName: "Silken Moon's Serenade",
|
||||||
mainStat: "ATK",
|
mainStat: "ATK",
|
||||||
mainValue: "311",
|
mainValue: "311",
|
||||||
substats: ["CRIT DMG+7.0%", "DEF+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
|
substats: ["CRIT DMG+7.0%", "DEF%+30.6%", "Elemental Mastery+40", "ATK%+5.8%"],
|
||||||
equipped: "Aino",
|
equipped: "Aino",
|
||||||
},
|
},
|
||||||
ikMatch: {
|
ikMatch: {
|
||||||
@@ -399,6 +596,157 @@ describe("native scanner processing service", () => {
|
|||||||
expect(logLines).toHaveLength(1);
|
expect(logLines).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tombstones a locally removed result, deletes only its contained crop, and keeps raw run evidence intact", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { absolutePath } = await writeCrop(runDir, "artifact-0001.png");
|
||||||
|
const result: StoredScanResultEntry = {
|
||||||
|
id: "remove-local-result",
|
||||||
|
runId: "run-remove-local",
|
||||||
|
sequence: 1,
|
||||||
|
category: "artifact",
|
||||||
|
source: "native-ik-scan",
|
||||||
|
imagePath: absolutePath,
|
||||||
|
capturedAt: "2026-07-11T10:00:00.000Z",
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "deferred",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
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.deleteResult({ resultId: result.id });
|
||||||
|
const visible = await service.loadResults();
|
||||||
|
const rawResults = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||||
|
const tombstones = (await fs.readFile(path.join(runDir, "deleted-results.jsonl"), "utf8")).trim().split(/\r?\n/).map((line) => JSON.parse(line));
|
||||||
|
const repeat = await service.deleteResult({ resultId: result.id });
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: true, deletedStoreRecord: false });
|
||||||
|
await expect(fs.access(absolutePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
expect(visible).toMatchObject({ ok: true, total: 0, results: [] });
|
||||||
|
expect(rawResults).toHaveLength(1);
|
||||||
|
expect(tombstones).toHaveLength(1);
|
||||||
|
expect(tombstones[0]).toMatchObject({ resultId: result.id, removeLinkedStoreRecord: false });
|
||||||
|
expect(repeat).toMatchObject({ ok: true, alreadyDeleted: true, deletedResult: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("optionally removes only the exact linked local Store record after tombstoning the scan result", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { absolutePath } = await writeCrop(runDir, "artifact-0002.png");
|
||||||
|
const result: StoredScanResultEntry = {
|
||||||
|
id: "remove-linked-store-result",
|
||||||
|
runId: "run-remove-store",
|
||||||
|
sequence: 2,
|
||||||
|
category: "artifact",
|
||||||
|
source: "native-ik-scan",
|
||||||
|
imagePath: absolutePath,
|
||||||
|
capturedAt: "2026-07-11T10:01:00.000Z",
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "deferred",
|
||||||
|
valueScore: null,
|
||||||
|
artifactRecordId: "exact-local-store-record",
|
||||||
|
persistedArtifact: true,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
|
||||||
|
const removedIds: string[][] = [];
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
removeArtifacts: async (ids) => {
|
||||||
|
removedIds.push(ids);
|
||||||
|
return { ok: true, removed: 1 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.deleteResult({ resultId: result.id, removeLinkedStoreRecord: true });
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: true, deletedStoreRecord: true, storeRecordId: "exact-local-store-record" });
|
||||||
|
expect(removedIds).toEqual([["exact-local-store-record"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks local deletion while the native run is active", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { absolutePath } = await writeCrop(runDir, "artifact-0003.png");
|
||||||
|
const result: StoredScanResultEntry = {
|
||||||
|
id: "active-result",
|
||||||
|
runId: "run-active",
|
||||||
|
sequence: 3,
|
||||||
|
category: "artifact",
|
||||||
|
source: "native-ik-scan",
|
||||||
|
imagePath: absolutePath,
|
||||||
|
capturedAt: "2026-07-11T10:02:00.000Z",
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "deferred",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([result], null, 2), "utf8");
|
||||||
|
await writeProducerStatus(runDir, { running: true, status: "running", target: 3, captured: 3 });
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.deleteResult({ resultId: result.id });
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: false, deletedResult: false });
|
||||||
|
expect(status.error).toContain("active native scan");
|
||||||
|
await expect(fs.access(absolutePath)).resolves.toBeUndefined();
|
||||||
|
await expect(fs.access(path.join(runDir, "deleted-results.jsonl"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never removes a recorded crop path outside the native run directory", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-outside-"));
|
||||||
|
tempDirs.push(outsideDir);
|
||||||
|
const outsideCrop = path.join(outsideDir, "outside.png");
|
||||||
|
await fs.writeFile(outsideCrop, "outside crop", "utf8");
|
||||||
|
const result: StoredScanResultEntry = {
|
||||||
|
id: "outside-crop-result",
|
||||||
|
runId: "run-outside-crop",
|
||||||
|
sequence: 4,
|
||||||
|
category: "artifact",
|
||||||
|
source: "native-ik-scan",
|
||||||
|
imagePath: outsideCrop,
|
||||||
|
capturedAt: "2026-07-11T10:03:00.000Z",
|
||||||
|
extractionStatus: "parsed",
|
||||||
|
extractionConfidence: 96,
|
||||||
|
needsReview: false,
|
||||||
|
valueStatus: "deferred",
|
||||||
|
valueScore: null,
|
||||||
|
persistedArtifact: false,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
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.deleteResult({ resultId: result.id });
|
||||||
|
|
||||||
|
expect(status).toMatchObject({ ok: true, deletedResult: true, deletedCrop: false });
|
||||||
|
expect(status.warnings.join(" ")).toContain("outside the native scan run");
|
||||||
|
await expect(fs.access(outsideCrop)).resolves.toBeUndefined();
|
||||||
|
expect((await service.loadResults()).results).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("approves a corrected review result only after structural and IK validation", async () => {
|
it("approves a corrected review result only after structural and IK validation", async () => {
|
||||||
const runDir = await makeRunDir();
|
const runDir = await makeRunDir();
|
||||||
const reviewResult: StoredScanResultEntry = {
|
const reviewResult: StoredScanResultEntry = {
|
||||||
@@ -415,7 +763,7 @@ describe("native scanner processing service", () => {
|
|||||||
valueStatus: "review",
|
valueStatus: "review",
|
||||||
valueScore: null,
|
valueScore: null,
|
||||||
persistedArtifact: false,
|
persistedArtifact: false,
|
||||||
notes: ["Substat value has no valid roll combination: DEF+1."],
|
notes: ["Substat value has no valid roll combination: DEF%+1.7%."],
|
||||||
artifact: {
|
artifact: {
|
||||||
name: "Pristine Plume of the Blessed",
|
name: "Pristine Plume of the Blessed",
|
||||||
slot: "Plume of Death",
|
slot: "Plume of Death",
|
||||||
@@ -423,13 +771,13 @@ describe("native scanner processing service", () => {
|
|||||||
setName: "Silken Moon's Serenade",
|
setName: "Silken Moon's Serenade",
|
||||||
mainStat: "ATK",
|
mainStat: "ATK",
|
||||||
mainValue: "311",
|
mainValue: "311",
|
||||||
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+1"],
|
substats: ["HP+508", "DEF+44", "DEF%+1.7%", "CRIT DMG+13.2%"],
|
||||||
equipped: "Aino",
|
equipped: "Aino",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await fs.writeFile(path.join(runDir, "scan-results.json"), JSON.stringify([reviewResult], null, 2), "utf8");
|
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({
|
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 }] }],
|
results: [{ sequence: 6, ocr: [{ id: "artifact-substats", label: "Substats", text: "DEF%+1.7%", confidence: 80 }] }],
|
||||||
}), "utf8");
|
}), "utf8");
|
||||||
const evalSamples: unknown[] = [];
|
const evalSamples: unknown[] = [];
|
||||||
const service = createNativeScannerProcessingService({
|
const service = createNativeScannerProcessingService({
|
||||||
@@ -449,7 +797,7 @@ describe("native scanner processing service", () => {
|
|||||||
artifact: {
|
artifact: {
|
||||||
...reviewResult.artifact!,
|
...reviewResult.artifact!,
|
||||||
level: 20,
|
level: 20,
|
||||||
substats: ["CRIT Rate+2.3%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
|
substats: ["CRIT Rate+2.3%", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
|
expect(invalidRarity).toMatchObject({ ok: false, action: "approve" });
|
||||||
@@ -458,19 +806,21 @@ describe("native scanner processing service", () => {
|
|||||||
const status = await service.reviewResult({
|
const status = await service.reviewResult({
|
||||||
resultId: reviewResult.id,
|
resultId: reviewResult.id,
|
||||||
action: "approve",
|
action: "approve",
|
||||||
note: "Crop confirms DEF+23.",
|
note: "Crop confirms DEF%+11.7%.",
|
||||||
artifact: {
|
artifact: {
|
||||||
...reviewResult.artifact!,
|
...reviewResult.artifact!,
|
||||||
level: 20,
|
level: 20,
|
||||||
substats: ["CRIT DMG+7.0%", "Elemental Mastery+40", "ATK%+5.8%", "DEF+23"],
|
substats: ["HP+508", "DEF+44", "DEF%+11.7%", "CRIT DMG+13.2%"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
const updated = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||||
|
expect(status.ok, status.error).toBe(true);
|
||||||
|
expect(status).toMatchObject({ action: "approve", evalSampleSaved: true, correctedFields: ["substats"] });
|
||||||
const log = await fs.readFile(path.join(runDir, "review-log.jsonl"), "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: "evaluated" });
|
||||||
expect(updated[0]).toMatchObject({ extractionStatus: "parsed", needsReview: false, valueStatus: "deferred" });
|
expect(updated[0].valueScore).toBeGreaterThanOrEqual(70);
|
||||||
expect(updated[0].artifact.substats).toContain("DEF+23");
|
expect(updated[0].artifact.substats).toContain("DEF%+11.7%");
|
||||||
expect(updated[0].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
|
expect(updated[0].review).toMatchObject({ status: "approved", correctedFields: ["substats"] });
|
||||||
expect(evalSamples).toHaveLength(1);
|
expect(evalSamples).toHaveLength(1);
|
||||||
expect(log).toContain("review-result");
|
expect(log).toContain("review-result");
|
||||||
@@ -610,4 +960,216 @@ describe("native scanner processing service", () => {
|
|||||||
});
|
});
|
||||||
expect(savedRecords).toHaveLength(0);
|
expect(savedRecords).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tails capture jobs while the producer is still running and writes live durable results", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
|
||||||
|
await writeProducerStatus(runDir, { running: true, status: "running", target: 2, captured: 0 });
|
||||||
|
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
|
||||||
|
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const processing = service.processRun({ stream: true, expectedTotal: 2 })
|
||||||
|
.finally(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
await appendJob(runDir, { sequence: 1, relativePath: firstCrop.relativePath });
|
||||||
|
await writeProducerStatus(runDir, { running: true, status: "running", target: 2, captured: 1 });
|
||||||
|
|
||||||
|
const live = await waitFor(async () => {
|
||||||
|
const loaded = await service.loadResults({ afterSequence: 0 });
|
||||||
|
return loaded.results.length === 1 ? loaded : null;
|
||||||
|
});
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(live.total).toBe(1);
|
||||||
|
expect(live.results.map((entry) => entry.sequence)).toEqual([1]);
|
||||||
|
const durableLive = await waitFor(async () => {
|
||||||
|
try {
|
||||||
|
const results = JSON.parse(await fs.readFile(path.join(runDir, "scan-results.json"), "utf8"));
|
||||||
|
return Array.isArray(results) && results.length === 1 ? results : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(durableLive[0]).toMatchObject({ sequence: 1, valueStatus: "evaluated" });
|
||||||
|
|
||||||
|
await appendJob(runDir, { sequence: 2, relativePath: secondCrop.relativePath });
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 2, captured: 2 });
|
||||||
|
const final = await processing;
|
||||||
|
|
||||||
|
expect(final.ok).toBe(true);
|
||||||
|
expect(final.processed).toBe(2);
|
||||||
|
expect(final.results.map((entry) => entry.sequence)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes streamed results in capture order when workers finish out of order", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
|
||||||
|
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
|
||||||
|
await writeJobs(runDir, [
|
||||||
|
{ sequence: 1, relativePath: firstCrop.relativePath },
|
||||||
|
{ sequence: 2, relativePath: secondCrop.relativePath },
|
||||||
|
]);
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 2, captured: 2 });
|
||||||
|
const published: number[] = [];
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async (_imagePath, job) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, job.sequence === 1 ? 45 : 5));
|
||||||
|
return safePlumeCapture();
|
||||||
|
},
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
onResult: (result) => published.push(result.sequence),
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.processRun({ stream: true, expectedTotal: 2 });
|
||||||
|
|
||||||
|
expect(status.results.map((entry) => entry.sequence)).toEqual([1, 2]);
|
||||||
|
expect(published).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one in-flight processing promise per run directory", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const { relativePath } = await writeCrop(runDir);
|
||||||
|
await writeJobs(runDir, [{ sequence: 1, relativePath }]);
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
|
||||||
|
let buildCalls = 0;
|
||||||
|
let releaseBuild: () => void = () => undefined;
|
||||||
|
const buildGate = new Promise<void>((resolve) => {
|
||||||
|
releaseBuild = resolve;
|
||||||
|
});
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => {
|
||||||
|
buildCalls += 1;
|
||||||
|
await buildGate;
|
||||||
|
return safePlumeCapture();
|
||||||
|
},
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = service.processRun({ stream: true, expectedTotal: 1 });
|
||||||
|
const second = service.processRun({ stream: true, expectedTotal: 1 });
|
||||||
|
expect(second).toBe(first);
|
||||||
|
await waitFor(async () => buildCalls === 1 ? true : null);
|
||||||
|
releaseBuild();
|
||||||
|
const [firstStatus, secondStatus] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(buildCalls).toBe(1);
|
||||||
|
expect(secondStatus).toBe(firstStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drains jobs that become visible immediately after a stopped producer status", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
|
||||||
|
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
|
||||||
|
const secondCrop = await writeCrop(runDir, "cards/artifact-2.png");
|
||||||
|
await writeProducerStatus(runDir, { running: true, status: "running", target: 5, captured: 0 });
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
const processing = service.processRun({ stream: true, expectedTotal: 5 });
|
||||||
|
|
||||||
|
await appendJob(runDir, { sequence: 1, relativePath: firstCrop.relativePath });
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "stopped", target: 5, captured: 2 });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
await appendJob(runDir, { sequence: 2, relativePath: secondCrop.relativePath });
|
||||||
|
const status = await processing;
|
||||||
|
|
||||||
|
expect(status.ok).toBe(true);
|
||||||
|
expect(status.processed).toBe(2);
|
||||||
|
expect(status.results.map((entry) => entry.sequence)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves ordered result deltas from memory without requiring the final result file", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const jobs: NativeCaptureJobPayload[] = [];
|
||||||
|
for (let sequence = 1; sequence <= 3; sequence += 1) {
|
||||||
|
const { relativePath } = await writeCrop(runDir, `cards/artifact-${sequence}.png`);
|
||||||
|
jobs.push({ sequence, relativePath });
|
||||||
|
}
|
||||||
|
await writeJobs(runDir, jobs);
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => safePlumeCapture(),
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
await service.processRun();
|
||||||
|
await fs.rm(path.join(runDir, "scan-results.json"));
|
||||||
|
|
||||||
|
const firstDelta = await service.loadResults({ afterSequence: 1, limit: 1 });
|
||||||
|
const secondDelta = await service.loadResults({ afterSequence: 2 });
|
||||||
|
|
||||||
|
expect(firstDelta.total).toBe(3);
|
||||||
|
expect(firstDelta.results.map((entry) => entry.sequence)).toEqual([2]);
|
||||||
|
expect(secondDelta.total).toBe(3);
|
||||||
|
expect(secondDelta.results.map((entry) => entry.sequence)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a partial JSONL record to receive its newline before processing it", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
await fs.writeFile(path.join(runDir, "capture-jobs.jsonl"), "", "utf8");
|
||||||
|
await writeProducerStatus(runDir, { running: true, status: "running", target: 1, captured: 0 });
|
||||||
|
const { relativePath } = await writeCrop(runDir);
|
||||||
|
let buildCalls = 0;
|
||||||
|
const service = createNativeScannerProcessingService({
|
||||||
|
resolveRunDir: () => runDir,
|
||||||
|
buildCaptureResult: async () => {
|
||||||
|
buildCalls += 1;
|
||||||
|
return safePlumeCapture();
|
||||||
|
},
|
||||||
|
saveArtifacts: async () => ({ added: 0, updated: 0 }),
|
||||||
|
});
|
||||||
|
const processing = service.processRun({ stream: true, expectedTotal: 1 });
|
||||||
|
const line = JSON.stringify({ sequence: 1, relativePath } satisfies NativeCaptureJobPayload);
|
||||||
|
const splitAt = Math.floor(line.length / 2);
|
||||||
|
|
||||||
|
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), line.slice(0, splitAt), "utf8");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
expect(buildCalls).toBe(0);
|
||||||
|
expect((await service.loadResults({ afterSequence: 0 })).total).toBe(0);
|
||||||
|
|
||||||
|
await fs.appendFile(path.join(runDir, "capture-jobs.jsonl"), `${line.slice(splitAt)}\n`, "utf8");
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
|
||||||
|
const status = await processing;
|
||||||
|
|
||||||
|
expect(status.ok).toBe(true);
|
||||||
|
expect(status.processed).toBe(1);
|
||||||
|
expect(buildCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates identical sequence records and blocks conflicting payloads", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const firstCrop = await writeCrop(runDir, "cards/artifact-1.png");
|
||||||
|
const conflictingCrop = await writeCrop(runDir, "cards/conflicting-artifact-1.png");
|
||||||
|
await writeJobs(runDir, [
|
||||||
|
{ sequence: 1, relativePath: firstCrop.relativePath },
|
||||||
|
{ sequence: 1, relativePath: firstCrop.relativePath },
|
||||||
|
{ sequence: 1, relativePath: conflictingCrop.relativePath },
|
||||||
|
]);
|
||||||
|
await writeProducerStatus(runDir, { running: false, status: "done", target: 1, captured: 1 });
|
||||||
|
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({ stream: true, expectedTotal: 1 });
|
||||||
|
|
||||||
|
expect(status.ok).toBe(false);
|
||||||
|
expect(status.error).toContain("Conflicting capture jobs use sequence 1");
|
||||||
|
expect(status.processed).toBe(1);
|
||||||
|
expect(buildCalls).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { resolveNativeScannerRunDirectory } from "../../electron/services/nativeScannerRunDirectory";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native scanner run directory resolution", () => {
|
||||||
|
it("keeps an explicit contained run ahead of active and discovered runs", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const requested = makeRunDirectory(root, "requested");
|
||||||
|
const active = makeRunDirectory(root, "active");
|
||||||
|
writeCompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
|
||||||
|
|
||||||
|
expect(resolveNativeScannerRunDirectory({
|
||||||
|
outputRoot: root,
|
||||||
|
requestedRunDir: requested,
|
||||||
|
activeRunDir: active,
|
||||||
|
})).toBe(requested);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the in-memory active run ahead of disk discovery", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const active = makeRunDirectory(root, "active");
|
||||||
|
writeCompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
|
||||||
|
|
||||||
|
expect(resolveNativeScannerRunDirectory({ outputRoot: root, activeRunDir: active })).toBe(active);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the newest complete processed run after a fresh app start", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const older = writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
|
||||||
|
const newest = writeCompleteRun(root, "20260710-110000", "2026-07-10T11:00:00.000Z");
|
||||||
|
writeIncompleteRun(root, "20260710-120000", "2026-07-10T12:00:00.000Z");
|
||||||
|
|
||||||
|
const resolved = resolveNativeScannerRunDirectory({ outputRoot: root });
|
||||||
|
|
||||||
|
expect(resolved).toBe(newest);
|
||||||
|
expect(resolved).not.toBe(older);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips done runs whose durable result count is incomplete", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const complete = writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
|
||||||
|
writeCompleteRun(root, "20260710-110000", "2026-07-10T11:00:00.000Z", { captured: 2, resultCount: 1 });
|
||||||
|
|
||||||
|
expect(resolveNativeScannerRunDirectory({ outputRoot: root })).toBe(complete);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an explicit directory outside the native scan root without falling back", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
writeCompleteRun(root, "20260710-100000", "2026-07-10T10:00:00.000Z");
|
||||||
|
const outside = makeRoot();
|
||||||
|
|
||||||
|
expect(resolveNativeScannerRunDirectory({ outputRoot: root, requestedRunDir: outside })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeRoot() {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), "gaa-native-runs-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRunDirectory(root: string, name: string) {
|
||||||
|
const runDir = path.join(root, name);
|
||||||
|
mkdirSync(runDir, { recursive: true });
|
||||||
|
return runDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCompleteRun(
|
||||||
|
root: string,
|
||||||
|
name: string,
|
||||||
|
createdAt: string,
|
||||||
|
options: { captured?: number; resultCount?: number } = {},
|
||||||
|
) {
|
||||||
|
const runDir = makeRunDirectory(root, name);
|
||||||
|
const captured = options.captured ?? 1;
|
||||||
|
const resultCount = options.resultCount ?? captured;
|
||||||
|
writeFileSync(path.join(runDir, "manifest.json"), JSON.stringify({ createdAt }), "utf8");
|
||||||
|
writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
||||||
|
scanner: { running: false, status: "done", target: captured, captured },
|
||||||
|
}), "utf8");
|
||||||
|
writeFileSync(path.join(runDir, "capture-jobs.jsonl"), `${JSON.stringify({ sequence: 1 })}\n`, "utf8");
|
||||||
|
writeFileSync(path.join(runDir, "scan-results.json"), JSON.stringify(
|
||||||
|
Array.from({ length: resultCount }, (_, index) => ({ sequence: index + 1 })),
|
||||||
|
), "utf8");
|
||||||
|
writeFileSync(path.join(runDir, "processing-report.json"), JSON.stringify({ processed: resultCount }), "utf8");
|
||||||
|
return runDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIncompleteRun(root: string, name: string, createdAt: string) {
|
||||||
|
const runDir = writeCompleteRun(root, name, createdAt);
|
||||||
|
writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
||||||
|
scanner: { running: true, status: "running", target: 1, captured: 1 },
|
||||||
|
}), "utf8");
|
||||||
|
return runDir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
nativeScannerRunTimingPath,
|
||||||
|
readNativeScannerRunTiming,
|
||||||
|
recordNativeScannerRunTiming,
|
||||||
|
validateNativeScannerRunTiming,
|
||||||
|
} from "../../electron/services/nativeScannerRunTiming";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function makeRunDir() {
|
||||||
|
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), "gaa-native-timing-"));
|
||||||
|
tempDirs.push(runDir);
|
||||||
|
return runDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native scanner run timing", () => {
|
||||||
|
it("serializes concurrent lifecycle markers into an immutable shared end-to-end metric", async () => {
|
||||||
|
const runDir = await makeRunDir();
|
||||||
|
const origin = Date.parse("2026-07-11T10:00:00.000Z");
|
||||||
|
const at = (offsetMs: number) => new Date(origin + offsetMs).toISOString();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
recordNativeScannerRunTiming(runDir, { requestStartedAt: at(0) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { scannerStartedAt: at(5) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { captureStartedAt: at(20) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { processingStartedAt: at(25) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { captureCompletedAt: at(150) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { processingCompletedAt: at(225) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { resultsDurableAt: at(240) }),
|
||||||
|
recordNativeScannerRunTiming(runDir, { resultsReconciledAt: at(255) }),
|
||||||
|
]);
|
||||||
|
const first = await readNativeScannerRunTiming(runDir);
|
||||||
|
await recordNativeScannerRunTiming(runDir, {
|
||||||
|
requestStartedAt: at(99),
|
||||||
|
resultsReconciledAt: at(300),
|
||||||
|
});
|
||||||
|
const second = await readNativeScannerRunTiming(runDir);
|
||||||
|
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
requestStartedAt: at(0),
|
||||||
|
scannerStartedAt: at(5),
|
||||||
|
captureCompletedAt: at(150),
|
||||||
|
processingCompletedAt: at(225),
|
||||||
|
resultsDurableAt: at(240),
|
||||||
|
resultsReconciledAt: at(255),
|
||||||
|
requestToResultsDurableMs: 240,
|
||||||
|
requestToResultsReconciledMs: 255,
|
||||||
|
});
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(validateNativeScannerRunTiming(second!)).toEqual([]);
|
||||||
|
await expect(fs.access(nativeScannerRunTimingPath(runDir))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports impossible marker ordering or stale derived metrics", () => {
|
||||||
|
const issues = validateNativeScannerRunTiming({
|
||||||
|
version: "native-scanner-run-timing-v1",
|
||||||
|
requestStartedAt: "2026-07-11T10:00:01.000Z",
|
||||||
|
scannerStartedAt: "2026-07-11T10:00:00.000Z",
|
||||||
|
resultsReconciledAt: "2026-07-11T10:00:02.000Z",
|
||||||
|
requestToResultsReconciledMs: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issues).toEqual(expect.arrayContaining([
|
||||||
|
"scannerStartedAt is before requestStartedAt",
|
||||||
|
"requestToResultsReconciledMs does not match requestStartedAt to resultsReconciledAt",
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const scriptPath = path.join(process.cwd(), "scripts", "run-packaged-builds-acceptance.mjs");
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
|
|
||||||
|
describe("packaged Builds UI acceptance contract", () => {
|
||||||
|
it("proves the English default, German opt-in, and restoration to English", () => {
|
||||||
|
expect(script).toContain("localStorage.removeItem('gaa-ui-locale')");
|
||||||
|
expect(script).toContain("initialLocale !== 'en'");
|
||||||
|
expect(script).toContain("languageSelect.value = 'de'");
|
||||||
|
expect(script).toContain("languageSelect.value = 'en'");
|
||||||
|
expect(script).toContain("germanLocaleApplied");
|
||||||
|
expect(script).toContain("englishLocaleRestored");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the native input setter so a controlled React context field is actually exercised", () => {
|
||||||
|
expect(script).toContain("Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set");
|
||||||
|
expect(script).toContain("nativeValueSetter?.call(contextInput, '106.6')");
|
||||||
|
expect(script).toContain("contextInput.dispatchEvent(new Event('input', { bubbles: true }))");
|
||||||
|
expect(script).toContain("contextInteraction.accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the renderer probe read-only and checks focus, motion, and overflow", () => {
|
||||||
|
expect(script).toContain("nativeScannerLoadResults({ limit: 2400 })");
|
||||||
|
expect(script).not.toContain("nativeScannerStart");
|
||||||
|
expect(script).not.toContain("clickScreen(");
|
||||||
|
expect(script).toContain("headingFocusAfterOpen");
|
||||||
|
expect(script).toContain("reducedMotionRespected");
|
||||||
|
expect(script).toContain("noDocumentOverflow");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const scriptPath = path.join(process.cwd(), "scripts", "run-packaged-live-acceptance.mjs");
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
|
|
||||||
|
describe("packaged full-inventory UI acceptance contract", () => {
|
||||||
|
it("selects the explicit full-inventory scope without inventing a numeric target", () => {
|
||||||
|
expect(script).toContain('"ui-full-inventory"');
|
||||||
|
expect(script).toContain('scopeMode: "all"');
|
||||||
|
expect(script).toContain('expectedTarget: null');
|
||||||
|
expect(script).toContain("input[data-scan-scope=\"all\"]");
|
||||||
|
expect(script).toContain("const resolvedTarget = config.expectedTarget ?? status.target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stable UI hooks instead of the current locale's visible labels", () => {
|
||||||
|
expect(script).toContain('data-navigation-id');
|
||||||
|
expect(script).toContain('data-scan-action="open-settings"');
|
||||||
|
expect(script).toContain('data-scan-limit-input');
|
||||||
|
expect(script).toContain('data-inventory-filter="review"');
|
||||||
|
expect(script).not.toContain("Gesamtes Inventar");
|
||||||
|
expect(script).not.toContain("Auto-Scan starten");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only a bounded, complete, page-coherent run", () => {
|
||||||
|
expect(script).toContain("resolvedTarget <= 2400");
|
||||||
|
expect(script).toContain("const expectedPages = targetValid ? Math.ceil(resolvedTarget / 32) : 0");
|
||||||
|
expect(script).toContain("status.status === 'done'");
|
||||||
|
expect(script).toContain("status.captured === resolvedTarget");
|
||||||
|
expect(script).toContain("status.pages === expectedPages");
|
||||||
|
expect(script).toContain("processing.processed === resolvedTarget");
|
||||||
|
expect(script).toContain("loadedEntries.length === resolvedTarget");
|
||||||
|
expect(script).toContain("resultRows === resolvedTarget");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps review bounded, persistence disabled, streaming visible, and the document fitted", () => {
|
||||||
|
expect(script).toContain("reviewRate <= 0.15");
|
||||||
|
expect(script).toContain("processing.stored === 0");
|
||||||
|
expect(script).toContain("!result.persistedArtifact && !result.artifactRecordId");
|
||||||
|
expect(script).toContain("sawLiveResultDuringCapture && sawEvaluationAdvanceDuringCapture");
|
||||||
|
expect(script).toContain("resultRows === resolvedTarget && persistenceDisabled && documentFits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can reprocess a saved full run without capture or persistence", () => {
|
||||||
|
expect(script).toContain('"reprocess-run"');
|
||||||
|
expect(script).toContain("reprocess-run requires --run-dir and --expected-target in 1..2400");
|
||||||
|
expect(script).toContain("nativeScannerProcessRun({");
|
||||||
|
expect(script).toContain("persist: false");
|
||||||
|
expect(script).toContain("processing.processed === expectedTarget");
|
||||||
|
expect(script).toContain("reviewRate <= maxReviewRate");
|
||||||
|
expect(script).toContain("processing.persisted === false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can prove the local-only deletion confirmation without confirming a deletion", () => {
|
||||||
|
expect(script).toContain('"inventory-delete-confirm"');
|
||||||
|
expect(script).toContain('"inventory-store-delete-confirm"');
|
||||||
|
expect(script).toContain('data-inventory-action="delete"');
|
||||||
|
expect(script).toContain('data-inventory-action="confirm-delete"');
|
||||||
|
expect(script).toContain('data-inventory-action="cancel-delete"');
|
||||||
|
expect(script).toContain("inventory-delete-confirm state requires --view=inventory.");
|
||||||
|
expect(script).toContain("inventory-store-delete-confirm state requires --view=inventory.");
|
||||||
|
expect(script).toContain('data-inventory-filter="stored"');
|
||||||
|
expect(script).toContain("hasLinkedStoreToggle: Boolean(linkedStoreToggle)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRendererRepositories } from "../infrastructure/repositories/rendererBridgeRepositoryFactory";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderer bridge native scanner lifecycle", () => {
|
||||||
|
it("propagates start, status, and stop failures to emergency-recovery callers", async () => {
|
||||||
|
const failure = (operation: string) => vi.fn(async () => {
|
||||||
|
throw new Error(`${operation} failed`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
assistantApi: {
|
||||||
|
nativeScannerStart: failure("start"),
|
||||||
|
nativeScannerStatus: failure("status"),
|
||||||
|
nativeScannerStop: failure("stop"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const automation = createRendererRepositories()?.automation;
|
||||||
|
|
||||||
|
await expect(automation?.nativeScannerStart({ limit: 5, category: "artifacts" })).rejects.toThrow("start failed");
|
||||||
|
await expect(automation?.nativeScannerStatus()).rejects.toThrow("status failed");
|
||||||
|
await expect(automation?.nativeScannerStop()).rejects.toThrow("stop failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ikInventoryListsPathCandidates,
|
||||||
|
inputHelperPathCandidates,
|
||||||
|
} from "../../electron/runtimePaths";
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
resourcesPath: path.resolve("C:/packaged/resources"),
|
||||||
|
currentWorkingDirectory: path.resolve("C:/repo"),
|
||||||
|
appPath: path.resolve("C:/packaged/resources/app.asar"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("packaged runtime resource paths", () => {
|
||||||
|
it("uses only packaged helper resources when no explicit override is set", () => {
|
||||||
|
expect(inputHelperPathCandidates({ ...options, isPackaged: true })).toEqual([
|
||||||
|
path.join(options.resourcesPath, "input-helper", "InputHelper.exe"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only packaged IK resources when no explicit override is set", () => {
|
||||||
|
expect(ikInventoryListsPathCandidates({ ...options, isPackaged: true })).toEqual([
|
||||||
|
path.join(options.resourcesPath, "ik-inventorylists"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps repository fallbacks available in development", () => {
|
||||||
|
expect(inputHelperPathCandidates({ ...options, isPackaged: false })).toEqual([
|
||||||
|
path.join(options.currentWorkingDirectory, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||||
|
path.join(options.appPath, "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||||
|
path.join(options.resourcesPath, "input-helper", "InputHelper.exe"),
|
||||||
|
]);
|
||||||
|
expect(ikInventoryListsPathCandidates({ ...options, isPackaged: false })).toEqual([
|
||||||
|
path.join(options.currentWorkingDirectory, "data", "ik-inventorylists"),
|
||||||
|
path.join(options.appPath, "data", "ik-inventorylists"),
|
||||||
|
path.join(options.resourcesPath, "ik-inventorylists"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an explicit override without reintroducing implicit repo lookup", () => {
|
||||||
|
const overridePath = path.resolve("C:/explicit/ik-inventorylists");
|
||||||
|
expect(ikInventoryListsPathCandidates({
|
||||||
|
...options,
|
||||||
|
isPackaged: true,
|
||||||
|
overridePath,
|
||||||
|
})).toEqual([
|
||||||
|
overridePath,
|
||||||
|
path.join(options.resourcesPath, "ik-inventorylists"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
absoluteRendererAssetReferences,
|
||||||
|
rendererAssetReferences,
|
||||||
|
runtimeSignature,
|
||||||
|
} from "../../scripts/verify-packaged-app.mjs";
|
||||||
|
|
||||||
|
describe("packaged renderer verification", () => {
|
||||||
|
it("accepts renderer references that remain beside the loaded HTML file", () => {
|
||||||
|
const html = '<script src="./assets/app.js"></script><link href="./assets/app.css">';
|
||||||
|
expect(rendererAssetReferences(html)).toEqual(["./assets/app.js", "./assets/app.css"]);
|
||||||
|
expect(absoluteRendererAssetReferences(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects root-absolute renderer references that loadFile cannot resolve", () => {
|
||||||
|
const html = '<script src="/assets/app.js"></script><link href="/assets/app.css">';
|
||||||
|
expect(absoluteRendererAssetReferences(html)).toEqual(["/assets/app.js", "/assets/app.css"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the evidence signature used to reject stale packages", () => {
|
||||||
|
expect(runtimeSignature('const APP_RUNTIME_SIGNATURE = "2026-07-10-current";')).toBe("2026-07-10-current");
|
||||||
|
expect(runtimeSignature("const unrelated = true;")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||||
import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
|
import { captureSelectedSourceAction, exportCurrentGoodAction, hideOverlayAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
|
||||||
import type { AppControllerContext } from "../services/appControllerService";
|
import type { AppControllerContext } from "../services/appControllerService";
|
||||||
|
import { useToast } from "../../feedback";
|
||||||
|
|
||||||
interface AppControllerActionsInput {
|
interface AppControllerActionsInput {
|
||||||
appControllerContext: AppControllerContext;
|
appControllerContext: AppControllerContext;
|
||||||
@@ -14,11 +15,13 @@ export interface AppControllerActionsResult {
|
|||||||
runDemoScan: () => Promise<void>;
|
runDemoScan: () => Promise<void>;
|
||||||
exportCurrentGood: () => Promise<void>;
|
exportCurrentGood: () => Promise<void>;
|
||||||
showOverlay: () => Promise<void>;
|
showOverlay: () => Promise<void>;
|
||||||
|
hideOverlay: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppControllerActions({
|
export function useAppControllerActions({
|
||||||
appControllerContext,
|
appControllerContext,
|
||||||
}: AppControllerActionsInput): AppControllerActionsResult {
|
}: AppControllerActionsInput): AppControllerActionsResult {
|
||||||
|
const toast = useToast();
|
||||||
const loadStoredArtifactSnapshot = useCallback(async () => {
|
const loadStoredArtifactSnapshot = useCallback(async () => {
|
||||||
await loadStoredArtifactSnapshotAction(appControllerContext);
|
await loadStoredArtifactSnapshotAction(appControllerContext);
|
||||||
}, [appControllerContext]);
|
}, [appControllerContext]);
|
||||||
@@ -40,8 +43,22 @@ export function useAppControllerActions({
|
|||||||
}, [appControllerContext]);
|
}, [appControllerContext]);
|
||||||
|
|
||||||
const showOverlay = useCallback(async () => {
|
const showOverlay = useCallback(async () => {
|
||||||
await showOverlayAction(appControllerContext);
|
const result = await showOverlayAction(appControllerContext);
|
||||||
}, [appControllerContext]);
|
if (result.ok) {
|
||||||
|
toast.success("Overlay geoeffnet", { description: "Die schreibgeschuetzte Ergebnis-Karte ist jetzt sichtbar." });
|
||||||
|
} else {
|
||||||
|
toast.error("Overlay konnte nicht geoeffnet werden", { description: "Pruefe die Desktop-App-Verbindung und versuche es erneut." });
|
||||||
|
}
|
||||||
|
}, [appControllerContext, toast]);
|
||||||
|
|
||||||
|
const hideOverlay = useCallback(async () => {
|
||||||
|
const result = await hideOverlayAction(appControllerContext);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success("Overlay geschlossen");
|
||||||
|
} else {
|
||||||
|
toast.error("Overlay konnte nicht geschlossen werden", { description: "Es ist kein aktives Overlay vorhanden oder die App-Verbindung fehlt." });
|
||||||
|
}
|
||||||
|
}, [appControllerContext, toast]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadStoredArtifactSnapshot,
|
loadStoredArtifactSnapshot,
|
||||||
@@ -50,5 +67,6 @@ export function useAppControllerActions({
|
|||||||
runDemoScan,
|
runDemoScan,
|
||||||
exportCurrentGood,
|
exportCurrentGood,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
|
hideOverlay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createInitialSnapshot, createAppControllerContext } from "../services/a
|
|||||||
import { useAppControllerActions } from "./useAppControllerActions";
|
import { useAppControllerActions } from "./useAppControllerActions";
|
||||||
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
|
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
|
||||||
import { type NavigationId } from "../../layout/types";
|
import { type NavigationId } from "../../layout/types";
|
||||||
|
import { useRuntimeInfo } from "./useRuntimeInfo";
|
||||||
|
|
||||||
export function useAppControllerState(): AppControllerResult {
|
export function useAppControllerState(): AppControllerResult {
|
||||||
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
|
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
|
||||||
@@ -19,10 +20,11 @@ export function useAppControllerState(): AppControllerResult {
|
|||||||
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
|
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||||
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
|
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
|
||||||
|
const runtimeInfo = useRuntimeInfo(repositories?.runtime);
|
||||||
const [topbarStatus, setTopbarStatus] = useState(
|
const [topbarStatus, setTopbarStatus] = useState(
|
||||||
repositories
|
repositories
|
||||||
? "Electron bridge connected. Refresh sources after Genshin is open."
|
? "App-Verbindung aktiv. Starte Genshin; die Erkennung aktualisiert sich automatisch."
|
||||||
: "Electron bridge missing. Open the Electron app window, not the browser preview URL.",
|
: "App-Verbindung fehlt. Bitte die installierte Electron-App statt der Browser-Vorschau oeffnen.",
|
||||||
);
|
);
|
||||||
const appControllerContext = useMemo(
|
const appControllerContext = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -65,6 +67,7 @@ export function useAppControllerState(): AppControllerResult {
|
|||||||
runDemoScan,
|
runDemoScan,
|
||||||
exportCurrentGood,
|
exportCurrentGood,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
|
hideOverlay,
|
||||||
} = useAppControllerActions({ appControllerContext });
|
} = useAppControllerActions({ appControllerContext });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,6 +80,7 @@ export function useAppControllerState(): AppControllerResult {
|
|||||||
selectedSourceId,
|
selectedSourceId,
|
||||||
setSelectedSourceId,
|
setSelectedSourceId,
|
||||||
latestCapture,
|
latestCapture,
|
||||||
|
runtimeInfo,
|
||||||
topbarStatus,
|
topbarStatus,
|
||||||
bridgeReady,
|
bridgeReady,
|
||||||
canExportGood,
|
canExportGood,
|
||||||
@@ -88,5 +92,6 @@ export function useAppControllerState(): AppControllerResult {
|
|||||||
runDemoScan,
|
runDemoScan,
|
||||||
exportCurrentGood,
|
exportCurrentGood,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
|
hideOverlay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -1,23 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { RuntimeInfo } from "../../../types/global";
|
|
||||||
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||||
|
import type { RuntimeInfo } from "../../../types/global";
|
||||||
|
|
||||||
export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
|
const RUNTIME_REFRESH_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export function useRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort): RuntimeInfo | null {
|
||||||
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
|
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
runtimeRepo?.getRuntimeInfo().then((result) => {
|
|
||||||
|
const refreshRuntimeInfo = async () => {
|
||||||
|
const result = runtimeRepo
|
||||||
|
? await runtimeRepo.getRuntimeInfo().catch(() => null)
|
||||||
|
: null;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setRuntimeInfo(result);
|
setRuntimeInfo(result);
|
||||||
}
|
}
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
|
||||||
|
void refreshRuntimeInfo();
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
runtimeRepo?.getRuntimeInfo().then((result) => {
|
void refreshRuntimeInfo();
|
||||||
if (mounted) setRuntimeInfo(result);
|
}, RUNTIME_REFRESH_INTERVAL_MS);
|
||||||
}).catch(() => undefined);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
@@ -27,4 +32,3 @@ export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
|
|||||||
|
|
||||||
return runtimeInfo;
|
return runtimeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createInitialSnapshot,
|
||||||
|
hideOverlayAction,
|
||||||
|
isQuietScannerCapture,
|
||||||
|
showOverlayAction,
|
||||||
|
type AppControllerContext,
|
||||||
|
} from "./appControllerService";
|
||||||
|
|
||||||
|
function createOverlayContext(ok: boolean) {
|
||||||
|
const setTopbarStatus = vi.fn();
|
||||||
|
const context: AppControllerContext = {
|
||||||
|
overlayRepo: {
|
||||||
|
show: vi.fn().mockResolvedValue({ ok }),
|
||||||
|
hide: vi.fn().mockResolvedValue({ ok }),
|
||||||
|
},
|
||||||
|
isOverlay: false,
|
||||||
|
selectedSourceId: "",
|
||||||
|
snapshot: createInitialSnapshot(),
|
||||||
|
setCaptureSources: vi.fn(),
|
||||||
|
setSelectedSourceId: vi.fn(),
|
||||||
|
setTopbarStatus,
|
||||||
|
setSnapshot: vi.fn(),
|
||||||
|
};
|
||||||
|
return { context, setTopbarStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createInitialSnapshot", () => {
|
||||||
|
it("starts empty so demo recommendations are never presented as user data", () => {
|
||||||
|
expect(createInitialSnapshot()).toEqual({
|
||||||
|
artifacts: [],
|
||||||
|
characters: [],
|
||||||
|
recommendations: [],
|
||||||
|
builds: [],
|
||||||
|
scanEvents: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overlay actions", () => {
|
||||||
|
it("returns an explicit failure when opening the overlay is rejected", async () => {
|
||||||
|
const { context, setTopbarStatus } = createOverlayContext(false);
|
||||||
|
|
||||||
|
await expect(showOverlayAction(context)).resolves.toEqual({ ok: false });
|
||||||
|
expect(setTopbarStatus).toHaveBeenLastCalledWith("Overlay konnte nicht geoeffnet werden.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an explicit failure when no overlay can be closed", async () => {
|
||||||
|
const { context, setTopbarStatus } = createOverlayContext(false);
|
||||||
|
|
||||||
|
await expect(hideOverlayAction(context)).resolves.toEqual({ ok: false });
|
||||||
|
expect(setTopbarStatus).toHaveBeenLastCalledWith("Es ist kein Overlay geoeffnet.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scanner capture visibility", () => {
|
||||||
|
it("keeps count probes and fast scanner frames out of the visible capture state", () => {
|
||||||
|
expect(isQuietScannerCapture({ ocrMode: "inventory-count" })).toBe(true);
|
||||||
|
expect(isQuietScannerCapture({ ocrMode: "artifact", ocrProfile: "fast", omitFullFrame: true })).toBe(true);
|
||||||
|
expect(isQuietScannerCapture({ ocrMode: "artifact", ocrProfile: "full", omitFullFrame: true })).toBe(false);
|
||||||
|
expect(isQuietScannerCapture({ ocrMode: "full" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createDemoSnapshot, getPresets } from "../../../lib/demoData";
|
import { getPresets } from "../../../lib/demoData";
|
||||||
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
|
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
|
||||||
import { exportGood } from "../../../lib/goodFormat";
|
import { exportGood } from "../../../lib/goodFormat";
|
||||||
import { runAccountScan } from "../../../lib/scanner";
|
import { runAccountScan } from "../../../lib/scanner";
|
||||||
import type { AppSnapshot } from "../../../types/domain";
|
import type { AppSnapshot } from "../../../types/domain";
|
||||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
|
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
|
||||||
import type {
|
import type {
|
||||||
ArtifactRepositoryPort,
|
ArtifactRepositoryPort,
|
||||||
CaptureRepositoryPort,
|
CaptureRepositoryPort,
|
||||||
@@ -94,7 +94,7 @@ export async function initializeAppControllerStateAction(context: AppControllerC
|
|||||||
}
|
}
|
||||||
await refreshCaptureSourcesAction(context);
|
await refreshCaptureSourcesAction(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed.");
|
setTopbarStatus(error instanceof Error ? error.message : "Die App konnte nicht vollstaendig initialisiert werden.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +113,12 @@ export async function refreshCaptureSourcesAction(context: AppControllerContext)
|
|||||||
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
|
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
|
||||||
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
|
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
|
||||||
setTopbarStatus(
|
setTopbarStatus(
|
||||||
`${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`,
|
preferred?.isGenshinCandidate
|
||||||
|
? `Genshin erkannt und als Aufnahmequelle ausgewaehlt (${sources.length} Quellen gefunden).`
|
||||||
|
: `${sources.length} Aufnahmequellen gefunden. Starte Genshin und lasse das Fenster sichtbar.`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources.");
|
setTopbarStatus(error instanceof Error ? error.message : "Aufnahmequellen konnten nicht geladen werden.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,39 +130,44 @@ export async function captureSelectedSourceAction(
|
|||||||
): Promise<CaptureResult | null> {
|
): Promise<CaptureResult | null> {
|
||||||
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
|
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
|
||||||
if (!captureRepo?.captureSource) {
|
if (!captureRepo?.captureSource) {
|
||||||
setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app.");
|
setTopbarStatus("App-Verbindung fehlt. Aufnahmen funktionieren nur in der installierten Electron-App.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!selectedSourceId) {
|
if (!selectedSourceId) {
|
||||||
setTopbarStatus("No capture source selected. Press Sources after Genshin is open.");
|
setTopbarStatus("Keine Aufnahmequelle ausgewaehlt. Starte Genshin und pruefe die Erkennung erneut.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!setLatestCapture) {
|
if (!setLatestCapture) {
|
||||||
setTopbarStatus("Capture state cannot be updated.");
|
setTopbarStatus("Der Aufnahmezustand konnte nicht aktualisiert werden.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
setTopbarStatus(`Aufnahme in ${Math.round(delayMs / 1000)} Sekunden. Bringe Genshin in den Vordergrund.`);
|
||||||
}
|
}
|
||||||
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||||
const quietArtifactScanCapture = options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && options.omitFullFrame;
|
if (!isQuietScannerCapture(options)) {
|
||||||
if (!quietArtifactScanCapture) {
|
|
||||||
setLatestCapture(capture);
|
setLatestCapture(capture);
|
||||||
const ocrStatus = capture.ocrSkipped
|
const ocrStatus = capture.ocrSkipped
|
||||||
? "OCR skipped for fast scan."
|
? "Texterkennung wurde fuer den schnellen Scan uebersprungen."
|
||||||
: capture.ocrTimedOut
|
: capture.ocrTimedOut
|
||||||
? "OCR timed out; review sample needed."
|
? "Die Texterkennung hat zu lange gebraucht; der Fund muss geprueft werden."
|
||||||
: "OCR handoff is next.";
|
: "Aufnahme erfolgreich, die Auswertung folgt.";
|
||||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
setTopbarStatus(`${capture.name} mit ${capture.width}x${capture.height} aufgenommen. ${ocrStatus}`);
|
||||||
}
|
}
|
||||||
return capture;
|
return capture;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
setTopbarStatus(error instanceof Error ? error.message : "Die Aufnahme ist fehlgeschlagen.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isQuietScannerCapture(options?: CaptureOptions) {
|
||||||
|
return (
|
||||||
|
options?.ocrMode === "artifact" && options?.ocrProfile === "fast" && Boolean(options.omitFullFrame)
|
||||||
|
) || options?.ocrMode === "inventory-count";
|
||||||
|
}
|
||||||
|
|
||||||
export async function runDemoScanAction(
|
export async function runDemoScanAction(
|
||||||
context: AppControllerContext,
|
context: AppControllerContext,
|
||||||
captureSelectedSource: () => Promise<CaptureResult | null>,
|
captureSelectedSource: () => Promise<CaptureResult | null>,
|
||||||
@@ -184,7 +191,13 @@ export async function runDemoScanAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialSnapshot(): AppSnapshot {
|
export function createInitialSnapshot(): AppSnapshot {
|
||||||
return createDemoSnapshot();
|
return {
|
||||||
|
artifacts: [],
|
||||||
|
characters: [],
|
||||||
|
recommendations: [],
|
||||||
|
builds: [],
|
||||||
|
scanEvents: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
|
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
|
||||||
@@ -204,15 +217,34 @@ export function exportCurrentGoodAction(context: AppControllerContext): Promise<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showOverlayAction(context: AppControllerContext): Promise<void> {
|
export async function showOverlayAction(context: AppControllerContext): Promise<BooleanResult> {
|
||||||
const { overlayRepo, setTopbarStatus } = context;
|
const { overlayRepo, setTopbarStatus } = context;
|
||||||
if (!overlayRepo?.show) {
|
if (!overlayRepo?.show) {
|
||||||
setTopbarStatus("Overlay braucht die Electron-Bridge.");
|
setTopbarStatus("Overlay braucht die Electron-Bridge.");
|
||||||
return;
|
return { ok: false };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await overlayRepo.show();
|
const result = await overlayRepo.show();
|
||||||
|
setTopbarStatus(result.ok ? "Overlay wurde geoeffnet." : "Overlay konnte nicht geoeffnet werden.");
|
||||||
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
setTopbarStatus("Overlay konnte nicht gestartet werden.");
|
setTopbarStatus("Overlay konnte nicht gestartet werden.");
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hideOverlayAction(context: AppControllerContext): Promise<BooleanResult> {
|
||||||
|
const { overlayRepo, setTopbarStatus } = context;
|
||||||
|
if (!overlayRepo?.hide) {
|
||||||
|
setTopbarStatus("Overlay braucht die Electron-Bridge.");
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await overlayRepo.hide();
|
||||||
|
setTopbarStatus(result.ok ? "Overlay wurde geschlossen." : "Es ist kein Overlay geoeffnet.");
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
setTopbarStatus("Overlay konnte nicht geschlossen werden.");
|
||||||
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppSnapshot } from "../../types/domain";
|
import type { AppSnapshot } from "../../types/domain";
|
||||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global";
|
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo } from "../../types/global";
|
||||||
import { type AppMetricCard, type NavigationId } from "../layout/types";
|
import { type AppMetricCard, type NavigationId } from "../layout/types";
|
||||||
|
|
||||||
export interface AppControllerResult {
|
export interface AppControllerResult {
|
||||||
@@ -12,6 +12,7 @@ export interface AppControllerResult {
|
|||||||
selectedSourceId: string;
|
selectedSourceId: string;
|
||||||
setSelectedSourceId: (value: string) => void;
|
setSelectedSourceId: (value: string) => void;
|
||||||
latestCapture: CaptureResult | null;
|
latestCapture: CaptureResult | null;
|
||||||
|
runtimeInfo: RuntimeInfo | null;
|
||||||
topbarStatus: string;
|
topbarStatus: string;
|
||||||
bridgeReady: boolean;
|
bridgeReady: boolean;
|
||||||
canExportGood: boolean;
|
canExportGood: boolean;
|
||||||
@@ -23,4 +24,5 @@ export interface AppControllerResult {
|
|||||||
exportCurrentGood: () => Promise<void>;
|
exportCurrentGood: () => Promise<void>;
|
||||||
loadStoredArtifactSnapshot: () => Promise<void>;
|
loadStoredArtifactSnapshot: () => Promise<void>;
|
||||||
showOverlay: () => Promise<void>;
|
showOverlay: () => Promise<void>;
|
||||||
|
hideOverlay: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,231 @@
|
|||||||
import { AlertTriangle } from "lucide-react";
|
import {
|
||||||
import { useBuildCardModel } from "./hooks/useBuildCardModel";
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
Check,
|
||||||
|
Fingerprint,
|
||||||
|
LockKeyhole,
|
||||||
|
Orbit,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
||||||
import type { BuildCardProps, BuildsViewProps } from "./types";
|
import type { BuildsViewProps } from "./types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { getBuildCopy } from "./buildsCopy";
|
||||||
|
|
||||||
function BuildCard({ build, snapshot }: BuildCardProps) {
|
export function BuildsView({ snapshot, onOpenInventory }: BuildsViewProps) {
|
||||||
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
|
const { locale } = useI18n();
|
||||||
|
const copy = getBuildCopy(locale);
|
||||||
const {
|
const {
|
||||||
characterName,
|
artifactCount,
|
||||||
qualityLabel,
|
hasScanData,
|
||||||
roundedScore,
|
foundationLabel,
|
||||||
explanation,
|
foundationDetail,
|
||||||
artifactRows,
|
contractLabel,
|
||||||
warnings,
|
contractDetail,
|
||||||
hasWarnings,
|
profileLabel,
|
||||||
} = useBuildCardModel({
|
profileDetail,
|
||||||
build,
|
nativeResultCount,
|
||||||
characters: snapshot.characters,
|
isLoadingBuildFit,
|
||||||
artifactLookup,
|
buildFitLoadError,
|
||||||
});
|
ranking,
|
||||||
|
aggregateContextFields,
|
||||||
|
setAggregateContextDraft,
|
||||||
|
refreshBuildFit,
|
||||||
|
} = useBuildsViewModel({ snapshot, locale });
|
||||||
|
const suggestions = ranking.suggestions;
|
||||||
|
const hasSuggestions = suggestions.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel build-card">
|
<section className="build-preview-page" aria-labelledby="build-preview-title" aria-busy={isLoadingBuildFit}>
|
||||||
<div className="panel-heading">
|
<div className="build-preview-hero">
|
||||||
|
<div className="build-preview-copy">
|
||||||
|
<span className="build-preview-kicker"><Sparkles size={14} aria-hidden="true" /> {copy.heroKicker}</span>
|
||||||
|
<h2 id="build-preview-title">{hasSuggestions ? copy.heroReadyTitle : copy.heroEmptyTitle}</h2>
|
||||||
|
<p>
|
||||||
|
{hasSuggestions
|
||||||
|
? copy.heroReadyDescription
|
||||||
|
: copy.heroEmptyDescription}
|
||||||
|
</p>
|
||||||
|
<div className="build-preview-actions">
|
||||||
|
<button className="ghost-button build-inventory-action" type="button" onClick={onOpenInventory}>
|
||||||
|
<Archive size={16} aria-hidden="true" />
|
||||||
|
{artifactCount > 0 ? copy.openArtifacts(artifactCount) : copy.prepareArtifacts}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button build-refresh-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshBuildFit()}
|
||||||
|
disabled={isLoadingBuildFit}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" className={isLoadingBuildFit ? "spin" : ""} />
|
||||||
|
{isLoadingBuildFit ? copy.loadingEvidence : copy.refreshEvidence}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`build-preview-lock ${hasSuggestions ? "ready" : ""}`} aria-label={hasSuggestions ? copy.suggestionsAvailable : copy.suggestionsWaiting}>
|
||||||
|
<div className="build-orbit" aria-hidden="true"><Orbit size={64} /></div>
|
||||||
|
<div className="build-lock-core">{hasSuggestions ? <ShieldCheck size={27} aria-hidden="true" /> : <LockKeyhole size={27} aria-hidden="true" />}</div>
|
||||||
|
<strong>{hasSuggestions ? copy.suggestionCount(suggestions.length) : copy.noSuggestionTitle}</strong>
|
||||||
|
<span>{hasSuggestions ? copy.nativeChecked(nativeResultCount) : copy.blockedDetail}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="build-readiness-grid" aria-label={copy.readinessLabel}>
|
||||||
|
<article className={hasScanData ? "complete" : "pending"}>
|
||||||
|
<span className="build-stage-icon">{hasScanData ? <Check size={18} /> : <Archive size={18} />}</span>
|
||||||
|
<p>{copy.stageArtifacts}</p>
|
||||||
|
<strong>{foundationLabel}</strong>
|
||||||
|
<small>{foundationDetail}</small>
|
||||||
|
</article>
|
||||||
|
<article className="complete">
|
||||||
|
<span className="build-stage-icon"><Fingerprint size={18} /></span>
|
||||||
|
<p>{copy.stageContract}</p>
|
||||||
|
<strong>{contractLabel}</strong>
|
||||||
|
<small>{contractDetail}</small>
|
||||||
|
</article>
|
||||||
|
<article className={hasSuggestions ? "complete" : "active"}>
|
||||||
|
<span className="build-stage-icon"><Users size={18} /></span>
|
||||||
|
<p>{copy.stageProfiles}</p>
|
||||||
|
<strong>{profileLabel}</strong>
|
||||||
|
<small>{profileDetail}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingBuildFit ? (
|
||||||
|
<section className="build-empty-state" role="status" aria-live="polite">
|
||||||
|
<RefreshCw size={19} className="spin" aria-hidden="true" />
|
||||||
|
<div><strong>{copy.loadingTitle}</strong><p>{copy.loadingDescription}</p></div>
|
||||||
|
</section>
|
||||||
|
) : buildFitLoadError ? (
|
||||||
|
<section className="build-empty-state warning" role="status">
|
||||||
|
<AlertTriangle size={19} aria-hidden="true" />
|
||||||
|
<div><strong>{copy.lockedTitle}</strong><p>{buildFitLoadError}</p></div>
|
||||||
|
</section>
|
||||||
|
) : hasSuggestions ? (
|
||||||
|
<section className="build-suggestion-list" aria-label={copy.suggestionList}>
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<article key={suggestion.id} className="build-suggestion-card">
|
||||||
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{qualityLabel}</p>
|
<span>{suggestion.characterName}</span>
|
||||||
<h2>{characterName}</h2>
|
<h3>{suggestion.targetLabel}</h3>
|
||||||
</div>
|
</div>
|
||||||
<strong className="score">{roundedScore}</strong>
|
<strong title={copy.coverageTitle}>{suggestion.evidenceCoverage}% {copy.coverageLabel}</strong>
|
||||||
</div>
|
</header>
|
||||||
<p className="build-copy">{explanation}</p>
|
<p className="build-suggestion-description">{suggestion.targetDescription}</p>
|
||||||
<div className="build-pieces">
|
<div className="build-suggestion-meta">
|
||||||
{artifactRows.map((artifact) => (
|
<span><ShieldCheck size={14} aria-hidden="true" />{suggestion.matchedSetPlanLabel}</span>
|
||||||
<div key={artifact.id}>
|
<span><Fingerprint size={14} aria-hidden="true" />{copy.safeSlots(suggestion.artifacts.length)}</span>
|
||||||
<span>{artifact.slot}</span>
|
|
||||||
<strong>{artifact.setName}</strong>
|
|
||||||
<small>{artifact.details}</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul className="build-suggestion-artifacts" aria-label={copy.selectedArtifacts(suggestion.characterName)}>
|
||||||
|
{suggestion.artifacts.map((artifact) => (
|
||||||
|
<li key={artifact.artifactId}>
|
||||||
|
<span>{slotLabel(artifact.slot, copy)}</span>
|
||||||
|
<strong>{artifact.name}</strong>
|
||||||
|
<small>{artifact.setName} · {artifact.mainStat} {artifact.mainValue}</small>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
<ul className="build-suggestion-reasons">
|
||||||
|
{suggestion.reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
|
</ul>
|
||||||
|
{suggestion.warnings.length > 0 ? (
|
||||||
|
<div className="build-suggestion-warnings">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" />
|
||||||
|
<span>{suggestion.warnings.join(" ")}</span>
|
||||||
</div>
|
</div>
|
||||||
{hasWarnings && (
|
) : null}
|
||||||
<div className="warning-list">
|
<p className="build-suggestion-source">{copy.sourceNote} {suggestion.sourceLabels.join(" · ")}. {copy.sourceDisclaimer}</p>
|
||||||
{warnings.map((warning) => (
|
</article>
|
||||||
<span key={warning}><AlertTriangle size={14} />{warning}</span>
|
|
||||||
))}
|
))}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="build-empty-state" aria-label={copy.blockedProfiles}>
|
||||||
|
<LockKeyhole size={19} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{copy.noSuggestionTitle}</strong>
|
||||||
|
<p>{copy.noSuggestionDescription}</p>
|
||||||
|
<ul className="build-deferred-list">
|
||||||
|
{ranking.deferredProfiles.map((profile) => (
|
||||||
|
<li key={profile.profileId}><strong>{profile.characterName}</strong> · {profile.targetLabel}: {deferredLabel(profile.reason, copy)} {profile.detail}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{aggregateContextFields.length > 0 ? (
|
||||||
|
<section className="build-context-editor" aria-label={copy.aggregateContext}>
|
||||||
|
<div className="build-context-editor-head">
|
||||||
|
<Fingerprint size={17} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{copy.aggregateContext}</strong>
|
||||||
|
<p>{copy.aggregateContextDescription}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{aggregateContextFields.map((field) => (
|
||||||
|
<div key={`${field.profileId}:${field.targetKey}`} className="build-context-field">
|
||||||
|
<div>
|
||||||
|
<span>{field.characterName} · {field.targetLabel}</span>
|
||||||
|
<strong>{copy.outsideArtifacts(field.stat, targetRangeLabel(field, copy))}</strong>
|
||||||
|
<small>{field.reason}</small>
|
||||||
|
</div>
|
||||||
|
<label className="build-context-number">
|
||||||
|
<span>{copy.nonArtifactValue}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { value: event.target.value })}
|
||||||
|
aria-describedby={`${field.profileId}-${field.targetKey}-help`}
|
||||||
|
/>
|
||||||
|
<em>{field.unit === "percent" ? "%" : "flat"}</em>
|
||||||
|
</label>
|
||||||
|
<label className="build-context-confirm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.confirmed}
|
||||||
|
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { confirmed: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>{copy.contextConfirm}</span>
|
||||||
|
</label>
|
||||||
|
<small id={`${field.profileId}-${field.targetKey}-help`} className={field.error ? "error" : ""}>
|
||||||
|
{field.error || copy.contextPending}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="build-contract-note">
|
||||||
|
<LockKeyhole size={18} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{copy.contractBoundary}</strong>
|
||||||
|
<p>{copy.contractDescription}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BuildsView({ snapshot }: BuildsViewProps) {
|
function slotLabel(slot: string, copy: ReturnType<typeof getBuildCopy>) {
|
||||||
const { visibleBuilds } = useBuildsViewModel({ snapshot });
|
return copy.slots[slot] ?? slot;
|
||||||
|
}
|
||||||
return (
|
|
||||||
<div className="build-grid">
|
function deferredLabel(reason: string, copy: ReturnType<typeof getBuildCopy>) {
|
||||||
{visibleBuilds.map(({ build }) => (
|
return copy.deferredReasons[reason] ?? copy.deferredFallback;
|
||||||
<BuildCard key={build.id} build={build} snapshot={snapshot} />
|
}
|
||||||
))}
|
|
||||||
</div>
|
function targetRangeLabel(field: { minimum?: number; maximum?: number; unit: "flat" | "percent" }, copy: ReturnType<typeof getBuildCopy>) {
|
||||||
);
|
const unit = field.unit === "percent" ? "%" : "";
|
||||||
|
if (field.minimum !== undefined && field.maximum !== undefined) return `(${field.minimum}${unit}–${field.maximum}${unit})`;
|
||||||
|
if (field.minimum !== undefined) return copy.rangeAtLeast(field.minimum, unit);
|
||||||
|
if (field.maximum !== undefined) return copy.rangeAtMost(field.maximum, unit);
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { AppLocale } from "../../i18n";
|
||||||
|
|
||||||
|
type BuildCopy = {
|
||||||
|
heroKicker: string;
|
||||||
|
heroReadyTitle: string;
|
||||||
|
heroEmptyTitle: string;
|
||||||
|
heroReadyDescription: string;
|
||||||
|
heroEmptyDescription: string;
|
||||||
|
openArtifacts: (count: number) => string;
|
||||||
|
prepareArtifacts: string;
|
||||||
|
loadingEvidence: string;
|
||||||
|
refreshEvidence: string;
|
||||||
|
suggestionsAvailable: string;
|
||||||
|
suggestionsWaiting: string;
|
||||||
|
suggestionCount: (count: number) => string;
|
||||||
|
nativeChecked: (count: number) => string;
|
||||||
|
blockedDetail: string;
|
||||||
|
readinessLabel: string;
|
||||||
|
stageArtifacts: string;
|
||||||
|
stageContract: string;
|
||||||
|
stageProfiles: string;
|
||||||
|
loadingTitle: string;
|
||||||
|
loadingDescription: string;
|
||||||
|
lockedTitle: string;
|
||||||
|
suggestionList: string;
|
||||||
|
coverageTitle: string;
|
||||||
|
coverageLabel: string;
|
||||||
|
selectedArtifacts: (characterName: string) => string;
|
||||||
|
safeSlots: (count: number) => string;
|
||||||
|
sourceNote: string;
|
||||||
|
sourceDisclaimer: string;
|
||||||
|
blockedProfiles: string;
|
||||||
|
noSuggestionTitle: string;
|
||||||
|
noSuggestionDescription: string;
|
||||||
|
aggregateContext: string;
|
||||||
|
aggregateContextDescription: string;
|
||||||
|
outsideArtifacts: (stat: string, range: string) => string;
|
||||||
|
nonArtifactValue: string;
|
||||||
|
contextConfirm: string;
|
||||||
|
contextPending: string;
|
||||||
|
contractBoundary: string;
|
||||||
|
contractTitle: string;
|
||||||
|
contractDescription: string;
|
||||||
|
noBridge: string;
|
||||||
|
noCompleteRun: string;
|
||||||
|
loadFailed: string;
|
||||||
|
foundationReady: string;
|
||||||
|
foundationEmpty: string;
|
||||||
|
foundationReadyDetail: (count: number) => string;
|
||||||
|
foundationEmptyDetail: string;
|
||||||
|
contractLabel: string;
|
||||||
|
contractDetail: (contract: string, combination: string, ranking: string) => string;
|
||||||
|
profilesReady: (count: number) => string;
|
||||||
|
profilesChecked: (count: number) => string;
|
||||||
|
profilesReadyDetail: (corpus: string) => string;
|
||||||
|
profilesBlockedDetail: (corpus: string) => string;
|
||||||
|
slots: Record<string, string>;
|
||||||
|
deferredReasons: Record<string, string>;
|
||||||
|
deferredFallback: string;
|
||||||
|
rangeAtLeast: (value: number, unit: string) => string;
|
||||||
|
rangeAtMost: (value: number, unit: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const english: BuildCopy = {
|
||||||
|
heroKicker: "Build Fit · evidence suggestions",
|
||||||
|
heroReadyTitle: "Profiles from your scanned collection",
|
||||||
|
heroEmptyTitle: "Build Fit without false precision",
|
||||||
|
heroReadyDescription: "The selection uses only complete native scan evidence. Set shape, main stats, and documented substats are explained; roll efficiency, OCR confidence, and character ownership are neither guessed nor presented as a fit score.",
|
||||||
|
heroEmptyDescription: "Sourced profiles and the five-piece verifier are ready. A suggestion appears only when five safe native artifacts form a complete set shape and the required whole-build context is evidenced.",
|
||||||
|
openArtifacts: (count) => `View ${count} artifacts`,
|
||||||
|
prepareArtifacts: "Prepare artifacts",
|
||||||
|
loadingEvidence: "Loading evidence",
|
||||||
|
refreshEvidence: "Refresh evidence",
|
||||||
|
suggestionsAvailable: "Build Fit profile suggestions available",
|
||||||
|
suggestionsWaiting: "Build Fit is waiting for safe evidence",
|
||||||
|
suggestionCount: (count) => `${count} profile suggestion${count === 1 ? "" : "s"}`,
|
||||||
|
nativeChecked: (count) => `${count} native results checked · read-only`,
|
||||||
|
blockedDetail: "Review status, an incomplete set shape, or missing whole-build context deliberately blocks suggestions.",
|
||||||
|
readinessLabel: "Progress toward Build Fit",
|
||||||
|
stageArtifacts: "01 · Artifact data",
|
||||||
|
stageContract: "02 · Evaluation contract",
|
||||||
|
stageProfiles: "03 · Profiles & selection",
|
||||||
|
loadingTitle: "Loading complete native scan evidence",
|
||||||
|
loadingDescription: "Only results with IK set IDs and field confidence may enter Build Fit.",
|
||||||
|
lockedTitle: "Build Fit remains safely locked",
|
||||||
|
suggestionList: "Explainable Build Fit profile suggestions",
|
||||||
|
coverageTitle: "Rule coverage, not a DPS or optimizer score",
|
||||||
|
coverageLabel: "rule coverage",
|
||||||
|
selectedArtifacts: (characterName) => `Selected artifacts for ${characterName}`,
|
||||||
|
safeSlots: (count) => `${count}/5 safe slots`,
|
||||||
|
sourceNote: "Sources:",
|
||||||
|
sourceDisclaimer: "Character ownership, team, weapon, and rotation remain unconfirmed by this artifact scan.",
|
||||||
|
blockedProfiles: "Blocked Build Fit profiles",
|
||||||
|
noSuggestionTitle: "No suggestion is forced",
|
||||||
|
noSuggestionDescription: "The profiles below remain deliberately open until their data and context gates are met.",
|
||||||
|
aggregateContext: "Explicit whole-build context",
|
||||||
|
aggregateContextDescription: "These values apply only to the current view. They are neither inferred from the scan nor saved.",
|
||||||
|
outsideArtifacts: (stat, range) => `${stat} outside the five artifacts ${range}`,
|
||||||
|
nonArtifactValue: "Non-artifact value",
|
||||||
|
contextConfirm: "I confirm that this value includes all relevant non-artifact contributions for this profile.",
|
||||||
|
contextPending: "Without confirmation, the whole-build target remains open and this profile is not suggested.",
|
||||||
|
contractBoundary: "What this selection does not do",
|
||||||
|
contractTitle: "Safety boundary",
|
||||||
|
contractDescription: "It does not start a scan, write to the store, or take any action in Genshin. OCR confidence is a safety gate only; roll efficiency and profile rule coverage stay separate.",
|
||||||
|
noBridge: "The Electron connection to complete native scan results is unavailable.",
|
||||||
|
noCompleteRun: "No complete native artifact scan is available for Build Fit.",
|
||||||
|
loadFailed: "Build Fit data could not be loaded.",
|
||||||
|
foundationReady: "Native scan foundation available",
|
||||||
|
foundationEmpty: "Scan foundation is still empty",
|
||||||
|
foundationReadyDetail: (count) => `${count} artifacts were loaded as complete native evidence; Review and incomplete fields remain excluded.`,
|
||||||
|
foundationEmptyDetail: "Capture a complete native artifact scan first so Build Fit works only with safe evidence.",
|
||||||
|
contractLabel: "V1 gates and evidence ranking implemented",
|
||||||
|
contractDetail: (contract, combination, ranking) => `${contract} + ${combination}: safe inputs, slot/set shapes, and context gates. ${ranking} measures profile-rule coverage only, never roll efficiency or OCR confidence.`,
|
||||||
|
profilesReady: (count) => `${count} transparent profile suggestion${count === 1 ? "" : "s"} ready`,
|
||||||
|
profilesChecked: (count) => `${count} sourced profiles checked`,
|
||||||
|
profilesReadyDetail: (corpus) => `${corpus}: suggestions use only the newest complete native run and remain read-only.`,
|
||||||
|
profilesBlockedDetail: (corpus) => `${corpus}: safe profiles remain unsuggested when set shape, context, or native evidence is missing.`,
|
||||||
|
slots: { flower: "Flower", plume: "Plume", sands: "Sands", goblet: "Goblet", circlet: "Circlet" },
|
||||||
|
deferredReasons: {
|
||||||
|
profile_unavailable: "Profile source is no longer current.",
|
||||||
|
not_enough_safe_artifacts: "No complete safe set shape.",
|
||||||
|
aggregate_context_required: "Whole-build context is missing.",
|
||||||
|
aggregate_target_missed: "Whole-build target is not met.",
|
||||||
|
},
|
||||||
|
deferredFallback: "Profile remains open.",
|
||||||
|
rangeAtLeast: (value, unit) => `(at least ${value}${unit})`,
|
||||||
|
rangeAtMost: (value, unit) => `(at most ${value}${unit})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const german: BuildCopy = {
|
||||||
|
heroKicker: "Build-Fit · Evidenzvorschläge",
|
||||||
|
heroReadyTitle: "Profile aus deiner gescannten Sammlung",
|
||||||
|
heroEmptyTitle: "Build-Fit ohne Scheinpräzision",
|
||||||
|
heroReadyDescription: "Die Auswahl verwendet nur vollständige native Scan-Evidenz. Set-Form, Main-Stats und dokumentierte Substats werden erklärt; Roll-Effizienz, OCR-Vertrauen und Charakterbesitz werden nicht als Fit-Score ausgegeben oder geraten.",
|
||||||
|
heroEmptyDescription: "Gesourcte Profile und die Fünfer-Prüfung sind bereit. Ein Vorschlag erscheint nur, wenn fünf sichere native Artefakte eine vollständige Set-Form bilden und erforderlicher Gesamtbuild-Kontext belegt ist.",
|
||||||
|
openArtifacts: (count) => `${count} Artefakte ansehen`,
|
||||||
|
prepareArtifacts: "Artefakte vorbereiten",
|
||||||
|
loadingEvidence: "Evidenz wird geladen",
|
||||||
|
refreshEvidence: "Evidenz aktualisieren",
|
||||||
|
suggestionsAvailable: "Build-Fit-Profilvorschläge verfügbar",
|
||||||
|
suggestionsWaiting: "Build-Fit wartet auf sichere Evidenz",
|
||||||
|
suggestionCount: (count) => `${count} Profil-Vorschlag${count === 1 ? "" : "e"}`,
|
||||||
|
nativeChecked: (count) => `${count} native Ergebnisse geprüft · read-only`,
|
||||||
|
blockedDetail: "Review, fehlende Set-Form oder fehlender Gesamtbuild-Kontext blockieren bewusst.",
|
||||||
|
readinessLabel: "Fortschritt zur Build-Fit-Funktion",
|
||||||
|
stageArtifacts: "01 · Besitzdaten",
|
||||||
|
stageContract: "02 · Bewertungsvertrag",
|
||||||
|
stageProfiles: "03 · Profile & Auswahl",
|
||||||
|
loadingTitle: "Vollständige native Scan-Evidenz wird geladen",
|
||||||
|
loadingDescription: "Nur Ergebnisse mit IK-Set-ID und Feld-Confidence dürfen in Build-Fit eingehen.",
|
||||||
|
lockedTitle: "Build-Fit bleibt sicher gesperrt",
|
||||||
|
suggestionList: "Erklärbare Build-Fit-Profilvorschläge",
|
||||||
|
coverageTitle: "Regelabdeckung, kein DPS- oder Optimizer-Score",
|
||||||
|
coverageLabel: "Regelabdeckung",
|
||||||
|
selectedArtifacts: (characterName) => `Ausgewählte Artefakte für ${characterName}`,
|
||||||
|
safeSlots: (count) => `${count}/5 sichere Slots`,
|
||||||
|
sourceNote: "Quellen:",
|
||||||
|
sourceDisclaimer: "Charakterbesitz, Team, Waffe und Rotation bleiben durch diesen Artefakt-Scan unbestätigt.",
|
||||||
|
blockedProfiles: "Blockierte Build-Fit-Profile",
|
||||||
|
noSuggestionTitle: "Kein Vorschlag wird erzwungen",
|
||||||
|
noSuggestionDescription: "Die Profile unten bleiben bewusst offen, bis ihre Daten- oder Kontext-Gates erfüllt sind.",
|
||||||
|
aggregateContext: "Expliziter Gesamtbuild-Kontext",
|
||||||
|
aggregateContextDescription: "Diese Werte gelten nur für die aktuelle Ansicht und werden weder aus dem Scan abgeleitet noch gespeichert.",
|
||||||
|
outsideArtifacts: (stat, range) => `${stat} außerhalb der fünf Artefakte ${range}`,
|
||||||
|
nonArtifactValue: "Nicht-Artefakt-Wert",
|
||||||
|
contextConfirm: "Ich bestätige, dass dieser Wert alle relevanten Nicht-Artefakt-Beiträge für das Profil enthält.",
|
||||||
|
contextPending: "Ohne Bestätigung bleibt das Gesamtziel offen und dieses Profil wird nicht vorgeschlagen.",
|
||||||
|
contractBoundary: "Was diese Auswahl nicht tut",
|
||||||
|
contractTitle: "Sicherheitsgrenze",
|
||||||
|
contractDescription: "Sie startet keinen Scan, schreibt nichts in den Store und nimmt keine Aktion in Genshin vor. OCR-Confidence ist nur ein Sicherheits-Gate; Roll-Effizienz und Profil-Regelabdeckung bleiben getrennt.",
|
||||||
|
noBridge: "Die Electron-Verbindung zu vollständigen nativen Scan-Ergebnissen ist nicht verfügbar.",
|
||||||
|
noCompleteRun: "Es liegt kein vollständiger nativer Artefakt-Scan für Build-Fit vor.",
|
||||||
|
loadFailed: "Build-Fit-Daten konnten nicht geladen werden.",
|
||||||
|
foundationReady: "Native Scanbasis vorhanden",
|
||||||
|
foundationEmpty: "Scanbasis noch leer",
|
||||||
|
foundationReadyDetail: (count) => `${count} Artefakte wurden als vollständige native Evidenz geladen; Review und unvollständige Felder bleiben ausgeschlossen.`,
|
||||||
|
foundationEmptyDetail: "Erfasse zuerst einen vollständigen nativen Artefakt-Scan, damit Build-Fit nur auf sicherer Evidenz arbeitet.",
|
||||||
|
contractLabel: "V1-Gates und Evidenz-Ranking implementiert",
|
||||||
|
contractDetail: (contract, combination, ranking) => `${contract} + ${combination}: sichere Eingaben, Slot-/Set-Formen und Kontext-Gates. ${ranking} bewertet nur Profilregel-Abdeckung, nie Roll-Effizienz oder OCR-Vertrauen.`,
|
||||||
|
profilesReady: (count) => `${count} transparente Profil-Vorschläge bereit`,
|
||||||
|
profilesChecked: (count) => `${count} gesourcte Profile geprüft`,
|
||||||
|
profilesReadyDetail: (corpus) => `${corpus}: Vorschläge stammen ausschließlich aus dem neuesten vollständigen nativen Run und bleiben read-only.`,
|
||||||
|
profilesBlockedDetail: (corpus) => `${corpus}: sichere Profile bleiben ohne Vorschlag, wenn Set-Form, Kontext oder native Evidenz fehlen.`,
|
||||||
|
slots: { flower: "Blume", plume: "Feder", sands: "Sande", goblet: "Kelch", circlet: "Krone" },
|
||||||
|
deferredReasons: {
|
||||||
|
profile_unavailable: "Profilquelle nicht aktuell.",
|
||||||
|
not_enough_safe_artifacts: "Keine vollständige sichere Set-Form.",
|
||||||
|
aggregate_context_required: "Gesamtbuild-Kontext fehlt.",
|
||||||
|
aggregate_target_missed: "Gesamtbuild-Ziel ist nicht erfüllt.",
|
||||||
|
},
|
||||||
|
deferredFallback: "Profil ist offen.",
|
||||||
|
rangeAtLeast: (value, unit) => `(mindestens ${value}${unit})`,
|
||||||
|
rangeAtMost: (value, unit) => `(höchstens ${value}${unit})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBuildCopy(locale: AppLocale): BuildCopy {
|
||||||
|
return locale === "de" ? german : english;
|
||||||
|
}
|
||||||
@@ -1,18 +1,210 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { AppSnapshot } from "../../../types/domain";
|
import type { AppSnapshot } from "../../../types/domain";
|
||||||
import type { BuildSuggestion } from "../../../types/domain";
|
import type { StoredScanResultEntry } from "../../../types/storage";
|
||||||
|
import {
|
||||||
|
BUILD_FIT_PROFILE_CORPUS_VERSION,
|
||||||
|
curatedBuildFitProfiles,
|
||||||
|
} from "../../../data/buildFitProfiles";
|
||||||
|
import {
|
||||||
|
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
|
||||||
|
BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
||||||
|
type BuildFitEvidenceRankingResultV1,
|
||||||
|
} from "../../../types/buildFit";
|
||||||
|
import {
|
||||||
|
buildFitInputFromScanResult,
|
||||||
|
rankBuildFitEvidence,
|
||||||
|
} from "../../../lib/buildFitContract";
|
||||||
|
import {
|
||||||
|
aggregateContextKey,
|
||||||
|
buildExplicitAggregateContexts,
|
||||||
|
type BuildFitExplicitContextDraft,
|
||||||
|
} from "../../../lib/buildFitExplicitContext";
|
||||||
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
|
import type { AppLocale } from "../../../i18n";
|
||||||
|
import { getBuildCopy } from "../buildsCopy";
|
||||||
|
|
||||||
|
const MAX_NATIVE_BUILD_FIT_RESULTS = 2400;
|
||||||
|
|
||||||
interface UseBuildsViewModelInput {
|
interface UseBuildsViewModelInput {
|
||||||
snapshot: AppSnapshot;
|
snapshot: AppSnapshot;
|
||||||
|
locale: AppLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildsViewModel {
|
export interface BuildsViewModel {
|
||||||
visibleBuilds: Array<{
|
artifactCount: number;
|
||||||
build: BuildSuggestion;
|
hasScanData: boolean;
|
||||||
}>;
|
foundationLabel: string;
|
||||||
|
foundationDetail: string;
|
||||||
|
contractLabel: string;
|
||||||
|
contractDetail: string;
|
||||||
|
profileLabel: string;
|
||||||
|
profileDetail: string;
|
||||||
|
nativeRunDir: string;
|
||||||
|
nativeResultCount: number;
|
||||||
|
isLoadingBuildFit: boolean;
|
||||||
|
buildFitLoadError: string;
|
||||||
|
ranking: BuildFitEvidenceRankingResultV1;
|
||||||
|
aggregateContextFields: BuildFitAggregateContextField[];
|
||||||
|
setAggregateContextDraft: (
|
||||||
|
profileId: string,
|
||||||
|
targetKey: string,
|
||||||
|
update: Partial<BuildFitExplicitContextDraft>,
|
||||||
|
) => void;
|
||||||
|
refreshBuildFit: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel {
|
export interface BuildFitAggregateContextField {
|
||||||
|
profileId: string;
|
||||||
|
characterName: string;
|
||||||
|
targetLabel: string;
|
||||||
|
targetKey: string;
|
||||||
|
stat: string;
|
||||||
|
unit: "flat" | "percent";
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
reason: string;
|
||||||
|
value: string;
|
||||||
|
confirmed: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBuildsViewModel({ snapshot, locale }: UseBuildsViewModelInput): BuildsViewModel {
|
||||||
|
const copy = getBuildCopy(locale);
|
||||||
|
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||||
|
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
|
||||||
|
const [nativeRunDir, setNativeRunDir] = useState("");
|
||||||
|
const [nativeResultCount, setNativeResultCount] = useState(0);
|
||||||
|
const [isLoadingBuildFit, setIsLoadingBuildFit] = useState(Boolean(repositories));
|
||||||
|
const [buildFitLoadError, setBuildFitLoadError] = useState("");
|
||||||
|
const [aggregateContextDrafts, setAggregateContextDrafts] = useState<Record<string, Record<string, BuildFitExplicitContextDraft>>>({});
|
||||||
|
|
||||||
|
const refreshBuildFit = useCallback(async () => {
|
||||||
|
if (!repositories?.automation) {
|
||||||
|
setNativeResults([]);
|
||||||
|
setNativeRunDir("");
|
||||||
|
setNativeResultCount(0);
|
||||||
|
setBuildFitLoadError(copy.noBridge);
|
||||||
|
setIsLoadingBuildFit(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingBuildFit(true);
|
||||||
|
setBuildFitLoadError("");
|
||||||
|
try {
|
||||||
|
const result = await repositories.automation.nativeScannerLoadResults({
|
||||||
|
limit: MAX_NATIVE_BUILD_FIT_RESULTS,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setNativeResults([]);
|
||||||
|
setNativeRunDir("");
|
||||||
|
setNativeResultCount(0);
|
||||||
|
setBuildFitLoadError(result.error ?? copy.noCompleteRun);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNativeResults(result.results);
|
||||||
|
setNativeRunDir(result.runDir);
|
||||||
|
setNativeResultCount(result.total);
|
||||||
|
} catch (error) {
|
||||||
|
setNativeResults([]);
|
||||||
|
setNativeRunDir("");
|
||||||
|
setNativeResultCount(0);
|
||||||
|
setBuildFitLoadError(error instanceof Error ? error.message : copy.loadFailed);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBuildFit(false);
|
||||||
|
}
|
||||||
|
}, [copy.loadFailed, copy.noBridge, copy.noCompleteRun, repositories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshBuildFit();
|
||||||
|
}, [refreshBuildFit]);
|
||||||
|
|
||||||
|
const aggregateContextResults = useMemo(() => Object.fromEntries(
|
||||||
|
curatedBuildFitProfiles.map((profile) => [
|
||||||
|
profile.id,
|
||||||
|
buildExplicitAggregateContexts(profile, aggregateContextDrafts[profile.id] ?? {}),
|
||||||
|
]),
|
||||||
|
) as Record<string, ReturnType<typeof buildExplicitAggregateContexts>>, [aggregateContextDrafts]);
|
||||||
|
const aggregateContextByProfile = useMemo(() => Object.fromEntries(
|
||||||
|
Object.entries(aggregateContextResults)
|
||||||
|
.filter(([, result]) => result.contexts.length > 0)
|
||||||
|
.map(([profileId, result]) => [profileId, result.contexts]),
|
||||||
|
), [aggregateContextResults]);
|
||||||
|
const aggregateContextFields = useMemo(() => curatedBuildFitProfiles.flatMap((profile) => profile.aggregateTargets.map((target) => {
|
||||||
|
const targetKey = aggregateContextKey(target);
|
||||||
|
const draft = aggregateContextDrafts[profile.id]?.[targetKey] ?? { value: "", confirmed: false };
|
||||||
return {
|
return {
|
||||||
visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })),
|
profileId: profile.id,
|
||||||
|
characterName: profile.character.name,
|
||||||
|
targetLabel: profile.target.label,
|
||||||
|
targetKey,
|
||||||
|
stat: target.stat,
|
||||||
|
unit: target.unit,
|
||||||
|
...(target.minimum === undefined ? {} : { minimum: target.minimum }),
|
||||||
|
...(target.maximum === undefined ? {} : { maximum: target.maximum }),
|
||||||
|
reason: target.reason,
|
||||||
|
value: draft.value,
|
||||||
|
confirmed: draft.confirmed,
|
||||||
|
error: aggregateContextResults[profile.id]?.issues[targetKey] ?? "",
|
||||||
|
};
|
||||||
|
})), [aggregateContextDrafts, aggregateContextResults]);
|
||||||
|
const setAggregateContextDraft = useCallback((
|
||||||
|
profileId: string,
|
||||||
|
targetKey: string,
|
||||||
|
update: Partial<BuildFitExplicitContextDraft>,
|
||||||
|
) => {
|
||||||
|
setAggregateContextDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[profileId]: {
|
||||||
|
...current[profileId],
|
||||||
|
[targetKey]: {
|
||||||
|
value: current[profileId]?.[targetKey]?.value ?? "",
|
||||||
|
confirmed: current[profileId]?.[targetKey]?.confirmed ?? false,
|
||||||
|
...update,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ranking = useMemo(() => rankBuildFitEvidence({
|
||||||
|
version: BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
||||||
|
profiles: curatedBuildFitProfiles,
|
||||||
|
artifacts: nativeResults
|
||||||
|
.map((result) => buildFitInputFromScanResult(result))
|
||||||
|
.filter((result): result is NonNullable<typeof result> => result !== null),
|
||||||
|
aggregateContextByProfile,
|
||||||
|
}), [aggregateContextByProfile, nativeResults]);
|
||||||
|
const artifactCount = Math.max(snapshot.artifacts.length, nativeResultCount);
|
||||||
|
const hasScanData = artifactCount > 0;
|
||||||
|
const profileCount = curatedBuildFitProfiles.length;
|
||||||
|
const suggestionCount = ranking.suggestions.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artifactCount,
|
||||||
|
hasScanData,
|
||||||
|
foundationLabel: hasScanData ? copy.foundationReady : copy.foundationEmpty,
|
||||||
|
foundationDetail: hasScanData
|
||||||
|
? copy.foundationReadyDetail(nativeResultCount || artifactCount)
|
||||||
|
: copy.foundationEmptyDetail,
|
||||||
|
contractLabel: copy.contractLabel,
|
||||||
|
contractDetail: copy.contractDetail(
|
||||||
|
BUILD_FIT_CONTRACT_VERSION,
|
||||||
|
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
|
||||||
|
BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
||||||
|
),
|
||||||
|
profileLabel: suggestionCount > 0
|
||||||
|
? copy.profilesReady(suggestionCount)
|
||||||
|
: copy.profilesChecked(profileCount),
|
||||||
|
profileDetail: suggestionCount > 0
|
||||||
|
? copy.profilesReadyDetail(BUILD_FIT_PROFILE_CORPUS_VERSION)
|
||||||
|
: copy.profilesBlockedDetail(BUILD_FIT_PROFILE_CORPUS_VERSION),
|
||||||
|
nativeRunDir,
|
||||||
|
nativeResultCount,
|
||||||
|
isLoadingBuildFit,
|
||||||
|
buildFitLoadError,
|
||||||
|
ranking,
|
||||||
|
aggregateContextFields,
|
||||||
|
setAggregateContextDraft,
|
||||||
|
refreshBuildFit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
|
|||||||
|
|
||||||
export interface BuildsViewProps {
|
export interface BuildsViewProps {
|
||||||
snapshot: AppSnapshot;
|
snapshot: AppSnapshot;
|
||||||
|
onOpenInventory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildCardProps {
|
export interface BuildCardProps {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useId, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
"button:not([disabled])",
|
||||||
|
"a[href]",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"summary",
|
||||||
|
"[tabindex]:not([tabindex='-1'])",
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
function isVisibleFocusable(element: HTMLElement) {
|
||||||
|
if (element.matches(":disabled") || element.closest("[hidden], [aria-hidden='true']")) return false;
|
||||||
|
const closedDetails = element.closest("details:not([open])");
|
||||||
|
if (closedDetails) {
|
||||||
|
const summary = closedDetails.querySelector(":scope > summary");
|
||||||
|
if (!summary?.contains(element)) return false;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogFocusResult {
|
||||||
|
dialogRef: RefObject<HTMLDivElement>;
|
||||||
|
titleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogFocus(open: boolean, onClose: () => void): DialogFocusResult {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const focusable = () => [...dialog.querySelectorAll<HTMLElement>(focusableSelector)].filter(isVisibleFocusable);
|
||||||
|
const first = focusable()[0] ?? dialog;
|
||||||
|
const focusFrame = window.requestAnimationFrame(() => first.focus());
|
||||||
|
const inertedBranches: Array<{ element: HTMLElement; wasInert: boolean }> = [];
|
||||||
|
let activeBranch: HTMLElement = dialog;
|
||||||
|
|
||||||
|
while (activeBranch.parentElement) {
|
||||||
|
const parent = activeBranch.parentElement;
|
||||||
|
for (const sibling of parent.children) {
|
||||||
|
if (!(sibling instanceof HTMLElement) || sibling === activeBranch) continue;
|
||||||
|
inertedBranches.push({ element: sibling, wasInert: sibling.inert });
|
||||||
|
sibling.inert = true;
|
||||||
|
}
|
||||||
|
activeBranch = parent;
|
||||||
|
if (parent === document.body) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const items = focusable();
|
||||||
|
if (items.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstItem = items[0];
|
||||||
|
const lastItem = items[items.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === firstItem) {
|
||||||
|
event.preventDefault();
|
||||||
|
lastItem.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === lastItem) {
|
||||||
|
event.preventDefault();
|
||||||
|
firstItem.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFocusIn = (event: FocusEvent) => {
|
||||||
|
if (event.target instanceof Node && dialog.contains(event.target)) return;
|
||||||
|
window.requestAnimationFrame(() => (focusable()[0] ?? dialog).focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
document.addEventListener("focusin", onFocusIn);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(focusFrame);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("focusin", onFocusIn);
|
||||||
|
for (const { element, wasInert } of inertedBranches.reverse()) element.inert = wasInert;
|
||||||
|
window.requestAnimationFrame(() => previousFocus?.focus());
|
||||||
|
};
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
return { dialogRef, titleId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { CSSProperties, HTMLAttributes } from "react";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { getFeedbackCopy } from "./feedbackCopy";
|
||||||
|
|
||||||
|
export type InlineSpinnerProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
|
label?: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InlineSpinner({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
size = 16,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: InlineSpinnerProps) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const copy = getFeedbackCopy(locale);
|
||||||
|
const classes = ["ui-inline-spinner", className].filter(Boolean).join(" ");
|
||||||
|
const spinnerStyle = {
|
||||||
|
...style,
|
||||||
|
"--spinner-size": `${size}px`,
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
role="status"
|
||||||
|
aria-label={label ?? copy.loading}
|
||||||
|
style={spinnerStyle}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="ui-inline-spinner__graphic"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle className="ui-inline-spinner__track" cx="10" cy="10" r="7" />
|
||||||
|
<circle className="ui-inline-spinner__arc" cx="10" cy="10" r="7" />
|
||||||
|
</svg>
|
||||||
|
{label ? <span className="ui-inline-spinner__label">{label}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CSSProperties, HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export type SkeletonProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||||
|
width?: CSSProperties["width"];
|
||||||
|
height?: CSSProperties["height"];
|
||||||
|
radius?: CSSProperties["borderRadius"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
radius,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const classes = ["ui-skeleton", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius: radius,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CircleAlert,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from "react";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { getFeedbackCopy } from "./feedbackCopy";
|
||||||
|
|
||||||
|
export type ToastTone = "success" | "info" | "error";
|
||||||
|
|
||||||
|
export type ToastDetails = {
|
||||||
|
description?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastInput = ToastDetails & {
|
||||||
|
title: string;
|
||||||
|
tone?: ToastTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastItem = Required<Pick<ToastInput, "title" | "tone">> &
|
||||||
|
ToastDetails & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastApi = {
|
||||||
|
show: (input: ToastInput) => string;
|
||||||
|
success: (title: string, details?: ToastDetails) => string;
|
||||||
|
info: (title: string, details?: ToastDetails) => string;
|
||||||
|
error: (title: string, details?: ToastDetails) => string;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DURATION_MS = 4_000;
|
||||||
|
const ToastContext = createContext<ToastApi | null>(null);
|
||||||
|
|
||||||
|
const toneIcons: Record<ToastTone, LucideIcon> = {
|
||||||
|
success: Check,
|
||||||
|
info: Info,
|
||||||
|
error: CircleAlert,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: PropsWithChildren) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const copy = getFeedbackCopy(locale);
|
||||||
|
const [queue, setQueue] = useState<ToastItem[]>([]);
|
||||||
|
const nextId = useRef(0);
|
||||||
|
const activeToast = queue[0];
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setQueue((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const show = useCallback((input: ToastInput) => {
|
||||||
|
const id = `toast-${Date.now()}-${nextId.current++}`;
|
||||||
|
const toast: ToastItem = {
|
||||||
|
...input,
|
||||||
|
id,
|
||||||
|
tone: input.tone ?? "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
setQueue((current) => [...current, toast]);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const api = useMemo<ToastApi>(
|
||||||
|
() => ({
|
||||||
|
show,
|
||||||
|
success: (title, details) => show({ ...details, title, tone: "success" }),
|
||||||
|
info: (title, details) => show({ ...details, title, tone: "info" }),
|
||||||
|
error: (title, details) => show({ ...details, title, tone: "error" }),
|
||||||
|
dismiss,
|
||||||
|
}),
|
||||||
|
[dismiss, show],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-viewport">
|
||||||
|
{activeToast ? (
|
||||||
|
<Toast key={activeToast.id} toast={activeToast} onDismiss={dismiss} closeLabel={copy.closeNotification} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastProps = {
|
||||||
|
toast: ToastItem;
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
closeLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Toast({ toast, onDismiss, closeLabel }: ToastProps) {
|
||||||
|
const ToneIcon = toneIcons[toast.tone];
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [focusWithin, setFocusWithin] = useState(false);
|
||||||
|
const remainingMs = useRef(toast.durationMs ?? DEFAULT_DURATION_MS);
|
||||||
|
const paused = hovered || focusWithin;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.tone === "error" || paused) return;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const timer = window.setTimeout(() => onDismiss(toast.id), remainingMs.current);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
remainingMs.current = Math.max(0, remainingMs.current - (performance.now() - startedAt));
|
||||||
|
};
|
||||||
|
}, [onDismiss, paused, toast.id, toast.tone]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="app-toast"
|
||||||
|
data-tone={toast.tone}
|
||||||
|
role={toast.tone === "error" ? "alert" : "status"}
|
||||||
|
aria-live={toast.tone === "error" ? "assertive" : "polite"}
|
||||||
|
aria-atomic="true"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onFocusCapture={() => setFocusWithin(true)}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setFocusWithin(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="toast-icon" aria-hidden="true">
|
||||||
|
<ToneIcon size={14} strokeWidth={2.2} />
|
||||||
|
</span>
|
||||||
|
<div className="toast-copy">
|
||||||
|
<p className="toast-title">{toast.title}</p>
|
||||||
|
{toast.description ? (
|
||||||
|
<p className="toast-description">{toast.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="toast-close"
|
||||||
|
type="button"
|
||||||
|
aria-label={closeLabel}
|
||||||
|
onClick={() => onDismiss(toast.id)}
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useToast must be used within ToastProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getFeedbackCopy } from "./feedbackCopy";
|
||||||
|
|
||||||
|
describe("feedback copy", () => {
|
||||||
|
it("uses English feedback labels by default", () => {
|
||||||
|
expect(getFeedbackCopy("en")).toEqual({
|
||||||
|
loading: "Loading",
|
||||||
|
closeNotification: "Close notification",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides German feedback labels", () => {
|
||||||
|
expect(getFeedbackCopy("de").closeNotification).toBe("Hinweis schließen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AppLocale } from "../../i18n";
|
||||||
|
|
||||||
|
export type FeedbackCopy = {
|
||||||
|
loading: string;
|
||||||
|
closeNotification: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const english: FeedbackCopy = {
|
||||||
|
loading: "Loading",
|
||||||
|
closeNotification: "Close notification",
|
||||||
|
};
|
||||||
|
|
||||||
|
const german: FeedbackCopy = {
|
||||||
|
loading: "Wird geladen",
|
||||||
|
closeNotification: "Hinweis schließen",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFeedbackCopy(locale: AppLocale): FeedbackCopy {
|
||||||
|
return locale === "de" ? german : english;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { InlineSpinner, type InlineSpinnerProps } from "./InlineSpinner";
|
||||||
|
export { Skeleton, type SkeletonProps } from "./Skeleton";
|
||||||
|
export {
|
||||||
|
ToastProvider,
|
||||||
|
useToast,
|
||||||
|
type ToastApi,
|
||||||
|
type ToastDetails,
|
||||||
|
type ToastInput,
|
||||||
|
type ToastTone,
|
||||||
|
} from "./ToastProvider";
|
||||||
@@ -1,20 +1,57 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
Database,
|
||||||
|
Image as ImageIcon,
|
||||||
|
RefreshCw,
|
||||||
|
ScanLine,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import type { InventoryPipelineCard } from "../../lib/inventoryBrowser";
|
||||||
|
import type { ScanResultPromotionSummary } from "../../lib/scanResultPromotion";
|
||||||
|
import { InlineSpinner, Skeleton } from "../feedback";
|
||||||
|
import { useDialogFocus } from "../common/useDialogFocus";
|
||||||
|
import { getInventoryCopy } from "./inventoryCopy";
|
||||||
import { useInventoryViewModel } from "./hooks/useInventoryViewModel";
|
import { useInventoryViewModel } from "./hooks/useInventoryViewModel";
|
||||||
import type { InventoryViewProps } from "./types";
|
import type { InventoryViewProps } from "./types";
|
||||||
|
import "./inventory-galaxy.css";
|
||||||
|
|
||||||
export function InventoryView({ snapshot }: InventoryViewProps) {
|
const PRIMARY_FILTER_IDS = ["all", "non_five_star", "review", "evaluated", "promotable", "stored"];
|
||||||
|
|
||||||
|
export function InventoryView({ snapshot, initialFilter, initialSelectedId }: InventoryViewProps) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const copy = getInventoryCopy(locale);
|
||||||
const {
|
const {
|
||||||
rows,
|
rows,
|
||||||
selectedRow,
|
selectedRow,
|
||||||
selectedPreview,
|
selectedPreview,
|
||||||
previewStatus,
|
previewStatus,
|
||||||
|
previewLoading,
|
||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
|
searchQuery,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
statusText,
|
statusText,
|
||||||
promotionStatus,
|
promotionStatus,
|
||||||
promotionPending,
|
promotionPending,
|
||||||
promotionConfirming,
|
promotionConfirming,
|
||||||
|
deletionStatus,
|
||||||
|
deletionPending,
|
||||||
|
deletionConfirming,
|
||||||
|
deletionRemoveLinkedStoreRecord,
|
||||||
|
canDeleteSelected,
|
||||||
|
deletionTarget,
|
||||||
reviewEditing,
|
reviewEditing,
|
||||||
reviewPending,
|
reviewPending,
|
||||||
reviewStatus,
|
reviewStatus,
|
||||||
@@ -23,6 +60,7 @@ export function InventoryView({ snapshot }: InventoryViewProps) {
|
|||||||
nativeCount,
|
nativeCount,
|
||||||
storedCount,
|
storedCount,
|
||||||
reviewCount,
|
reviewCount,
|
||||||
|
evaluatedCount,
|
||||||
catalogSummary,
|
catalogSummary,
|
||||||
promotionSummary,
|
promotionSummary,
|
||||||
pipelineSummary,
|
pipelineSummary,
|
||||||
@@ -30,95 +68,184 @@ export function InventoryView({ snapshot }: InventoryViewProps) {
|
|||||||
sortOptions,
|
sortOptions,
|
||||||
setFilter,
|
setFilter,
|
||||||
setSort,
|
setSort,
|
||||||
|
setSearchQuery,
|
||||||
setSelectedId,
|
setSelectedId,
|
||||||
refresh,
|
refresh,
|
||||||
requestSelectedPromotion,
|
requestSelectedPromotion,
|
||||||
cancelSelectedPromotion,
|
cancelSelectedPromotion,
|
||||||
confirmSelectedPromotion,
|
confirmSelectedPromotion,
|
||||||
|
requestSelectedDeletion,
|
||||||
|
cancelSelectedDeletion,
|
||||||
|
setDeletionRemoveLinkedStoreRecord,
|
||||||
|
confirmSelectedDeletion,
|
||||||
startSelectedReview,
|
startSelectedReview,
|
||||||
cancelSelectedReview,
|
cancelSelectedReview,
|
||||||
setReviewDraft,
|
setReviewDraft,
|
||||||
submitSelectedReview,
|
submitSelectedReview,
|
||||||
} = useInventoryViewModel({ snapshot });
|
} = useInventoryViewModel({ snapshot, initialFilter, initialSelectedId, locale });
|
||||||
|
|
||||||
|
const initialLoading = loading && totalRows === 0;
|
||||||
|
const primaryFilterOptions = filterOptions.filter((option) => PRIMARY_FILTER_IDS.includes(option.id));
|
||||||
|
const isNonFiveStarView = filter === "non_five_star";
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const promotionTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const promotionConfirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const deletionTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const deletionConfirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const reviewTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const reviewFirstFieldRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wasPromotionConfirming = useRef(false);
|
||||||
|
const wasDeletionConfirming = useRef(false);
|
||||||
|
const wasReviewEditing = useRef(false);
|
||||||
|
const [technicalOverviewOpen, setTechnicalOverviewOpen] = useState(false);
|
||||||
|
const closeTechnicalOverview = useCallback(() => setTechnicalOverviewOpen(false), []);
|
||||||
|
const { dialogRef: technicalDialogRef, titleId: technicalDialogTitleId } = useDialogFocus(
|
||||||
|
technicalOverviewOpen,
|
||||||
|
closeTechnicalOverview,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = wasPromotionConfirming.current;
|
||||||
|
wasPromotionConfirming.current = promotionConfirming;
|
||||||
|
const focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
if (promotionConfirming) {
|
||||||
|
promotionConfirmRef.current?.focus();
|
||||||
|
} else if (wasOpen) {
|
||||||
|
(promotionTriggerRef.current ?? (selectedRow ? rowRefs.current.get(selectedRow.id) : null))?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(focusFrame);
|
||||||
|
}, [promotionConfirming, selectedRow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = wasDeletionConfirming.current;
|
||||||
|
wasDeletionConfirming.current = deletionConfirming;
|
||||||
|
const focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
if (deletionConfirming) {
|
||||||
|
deletionConfirmRef.current?.focus();
|
||||||
|
} else if (wasOpen) {
|
||||||
|
(deletionTriggerRef.current ?? (selectedRow ? rowRefs.current.get(selectedRow.id) : null))?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(focusFrame);
|
||||||
|
}, [deletionConfirming, selectedRow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = wasReviewEditing.current;
|
||||||
|
wasReviewEditing.current = reviewEditing;
|
||||||
|
const focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
if (reviewEditing) {
|
||||||
|
reviewFirstFieldRef.current?.focus();
|
||||||
|
} else if (wasOpen) {
|
||||||
|
(reviewTriggerRef.current ?? (selectedRow ? rowRefs.current.get(selectedRow.id) : null))?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(focusFrame);
|
||||||
|
}, [reviewEditing, selectedRow]);
|
||||||
|
|
||||||
|
const handleRowKeyDown = (event: KeyboardEvent<HTMLButtonElement>, rowIndex: number) => {
|
||||||
|
let nextIndex = rowIndex;
|
||||||
|
if (event.key === "ArrowDown") nextIndex = Math.min(rows.length - 1, rowIndex + 1);
|
||||||
|
else if (event.key === "ArrowUp") nextIndex = Math.max(0, rowIndex - 1);
|
||||||
|
else if (event.key === "Home") nextIndex = 0;
|
||||||
|
else if (event.key === "End") nextIndex = rows.length - 1;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const nextRow = rows[nextIndex];
|
||||||
|
if (!nextRow) return;
|
||||||
|
setSelectedId(nextRow.id);
|
||||||
|
window.requestAnimationFrame(() => rowRefs.current.get(nextRow.id)?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="inventory-view">
|
<section className="inventory-view inventory-galaxy" aria-busy={loading}>
|
||||||
<div className="inventory-toolbar">
|
<header className="inventory-toolbar">
|
||||||
|
<div className="inventory-title-block">
|
||||||
|
<p className="eyebrow">{copy.collectionKicker}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Native Artifact Inventory</p>
|
<h2>{copy.title}</h2>
|
||||||
<h2>Artifact Scan-Ergebnisse</h2>
|
<p>{copy.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="inventory-toolbar-actions">
|
<div className="inventory-toolbar-actions">
|
||||||
<div className="inventory-stats">
|
<div className="inventory-stats" aria-label={copy.artifactOverview}>
|
||||||
<span>{totalRows} gesamt</span>
|
<span>
|
||||||
<span>{nativeCount} native</span>
|
<Archive size={15} aria-hidden="true" />
|
||||||
<span>{storedCount} store</span>
|
<strong>{totalRows}</strong>
|
||||||
<span>{reviewCount} review</span>
|
<small>{copy.total}</small>
|
||||||
|
</span>
|
||||||
|
<span className={reviewCount > 0 ? "needs-attention" : ""}>
|
||||||
|
<ShieldCheck size={15} aria-hidden="true" />
|
||||||
|
<strong>{reviewCount}</strong>
|
||||||
|
<small>{copy.needsAttention}</small>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Sparkles size={15} aria-hidden="true" />
|
||||||
|
<strong>{evaluatedCount}</strong>
|
||||||
|
<small>{copy.evaluated}</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button icon-button" onClick={() => void refresh()} disabled={loading} title="Inventory aktualisieren">
|
<button
|
||||||
<RefreshCw size={16} />
|
className="ghost-button icon-button inventory-refresh-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
disabled={loading}
|
||||||
|
data-inventory-action="refresh"
|
||||||
|
aria-label={loading ? copy.refreshLoading : copy.refresh}
|
||||||
|
aria-describedby="inventory-results-status"
|
||||||
|
title={copy.refresh}
|
||||||
|
>
|
||||||
|
{loading ? <InlineSpinner size={17} /> : <RefreshCw size={17} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button inventory-technical-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTechnicalOverviewOpen(true)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
data-inventory-action="technical"
|
||||||
|
title={copy.technicalTooltip}
|
||||||
|
>
|
||||||
|
<Database size={16} aria-hidden="true" />
|
||||||
|
<span>{copy.technical}</span>
|
||||||
|
<em className={catalogSummary.ok ? "ok" : "warn"}>{catalogSummary.ok ? copy.technicalReady : copy.technicalCheck}</em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div className={`inventory-catalog-strip ${catalogSummary.className}`}>
|
|
||||||
<div>
|
|
||||||
<span>{catalogSummary.ok ? "Artifact IK bereit" : "IK Listen pruefen"}</span>
|
|
||||||
<strong>{catalogSummary.title}</strong>
|
|
||||||
<small>{catalogSummary.detail}</small>
|
|
||||||
</div>
|
|
||||||
{catalogSummary.counts.length > 0 && (
|
|
||||||
<div className="inventory-catalog-counts">
|
|
||||||
{catalogSummary.counts.map((entry) => (
|
|
||||||
<span key={entry.label}>
|
|
||||||
<strong>{entry.value}</strong>
|
|
||||||
<small>{entry.label}</small>
|
|
||||||
{entry.sample && <em>{entry.sample}</em>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inventory-pipeline-strip" aria-label="Native artifact pipeline status">
|
|
||||||
{pipelineSummary.cards.map((card) => (
|
|
||||||
<span key={card.id} className={card.className} title={card.detail}>
|
|
||||||
<strong>{card.title}</strong>
|
|
||||||
<small>{card.value}</small>
|
|
||||||
<em>{card.detail}</em>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="inventory-pipeline-caption">{pipelineSummary.caption}</p>
|
|
||||||
|
|
||||||
<div className={`inventory-promotion-strip ${promotionSummary.className}`}>
|
|
||||||
<div>
|
|
||||||
<span>{promotionSummary.title}</span>
|
|
||||||
<strong>{promotionSummary.detail}</strong>
|
|
||||||
<small>Analyse aus scan-results.json und lokalem Artifact-Store. Store-Schreibzugriff bleibt ein expliziter naechster Schritt.</small>
|
|
||||||
</div>
|
|
||||||
<div className="inventory-promotion-counts">
|
|
||||||
<span><strong>{promotionSummary.ready}</strong><small>Speicherbar</small></span>
|
|
||||||
<span><strong>{promotionSummary.alreadyStored + promotionSummary.persisted}</strong><small>Im Store</small></span>
|
|
||||||
<span><strong>{promotionSummary.review}</strong><small>Review</small></span>
|
|
||||||
<span><strong>{promotionSummary.blocked}</strong><small>Blockiert</small></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inventory-controls">
|
<div className="inventory-controls">
|
||||||
<div className="segmented-control">
|
<label className="inventory-search">
|
||||||
{filterOptions.map((option) => (
|
<Search size={16} aria-hidden="true" />
|
||||||
|
<span className="sr-only">{copy.searchArtifacts}</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder={copy.searchPlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="segmented-control inventory-filter-control" role="group" aria-label={copy.filterArtifacts}>
|
||||||
|
{primaryFilterOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
|
type="button"
|
||||||
className={filter === option.id ? "active" : ""}
|
className={filter === option.id ? "active" : ""}
|
||||||
onClick={() => setFilter(option.id)}
|
onClick={() => setFilter(option.id)}
|
||||||
|
aria-pressed={filter === option.id}
|
||||||
|
aria-label={copy.filterOption(option.label, option.count)}
|
||||||
|
data-inventory-filter={option.id}
|
||||||
>
|
>
|
||||||
{option.label} <span>{option.count}</span>
|
{option.label} <span>{option.count}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="inventory-sort">
|
<label className="inventory-sort">
|
||||||
<span>Sort</span>
|
<SlidersHorizontal size={15} aria-hidden="true" />
|
||||||
|
<span>{copy.sort}</span>
|
||||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
|
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
|
||||||
{sortOptions.map((option) => (
|
{sortOptions.map((option) => (
|
||||||
<option key={option.id} value={option.id}>{option.label}</option>
|
<option key={option.id} value={option.id}>{option.label}</option>
|
||||||
@@ -127,141 +254,485 @@ export function InventoryView({ snapshot }: InventoryViewProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{technicalOverviewOpen ? createPortal(
|
||||||
|
<div className="modal-backdrop inventory-galaxy inventory-modal-backdrop" role="presentation" onClick={closeTechnicalOverview}>
|
||||||
|
<div
|
||||||
|
ref={technicalDialogRef}
|
||||||
|
className="modal-panel inventory-technical-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={technicalDialogTitleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{copy.technicalKicker}</p>
|
||||||
|
<h2 id={technicalDialogTitleId}>{copy.technicalTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button icon-button" type="button" onClick={closeTechnicalOverview} aria-label={copy.closeDialog}>
|
||||||
|
<X size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="inventory-technical-content">
|
||||||
|
<section className={`inventory-catalog-strip ${catalogSummary.className}`}>
|
||||||
|
<div>
|
||||||
|
<span>{catalogSummary.ok ? copy.catalogReady : copy.catalogCheck}</span>
|
||||||
|
<strong>{catalogSummary.title}</strong>
|
||||||
|
<small>{copy.catalogDetail(catalogSummary)}</small>
|
||||||
|
</div>
|
||||||
|
{catalogSummary.counts.length > 0 ? (
|
||||||
|
<div className="inventory-catalog-counts">
|
||||||
|
{catalogSummary.counts.map((entry) => (
|
||||||
|
<span key={entry.label}>
|
||||||
|
<strong>{entry.value}</strong>
|
||||||
|
<small>{copy.catalogCountLabel(entry.label)}</small>
|
||||||
|
{entry.sample ? <em>{copy.catalogCountSample(entry.label, entry.sample)}</em> : null}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="inventory-pipeline-panel" aria-label={copy.pipelineStatus}>
|
||||||
|
<div className="inventory-pipeline-strip">
|
||||||
|
{pipelineSummary.cards.map((card) => (
|
||||||
|
<PipelineCard key={card.id} card={card} promotionSummary={promotionSummary} copy={copy} className={card.className} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="inventory-pipeline-caption">{copy.pipelineCaption(pipelineSummary.caption)}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`inventory-promotion-strip ${promotionSummary.className}`}>
|
||||||
|
<div>
|
||||||
|
<span>{copy.localStore}</span>
|
||||||
|
<strong>{copy.promotionSummary(promotionSummary)}</strong>
|
||||||
|
<small>{copy.localStoreWriteSafety}</small>
|
||||||
|
</div>
|
||||||
|
<div className="inventory-promotion-counts">
|
||||||
|
<span><strong>{promotionSummary.ready}</strong><small>{copy.promotable}</small></span>
|
||||||
|
<span><strong>{promotionSummary.alreadyStored + promotionSummary.persisted}</strong><small>{copy.stored}</small></span>
|
||||||
|
<span><strong>{promotionSummary.review}</strong><small>{copy.nativeReview}</small></span>
|
||||||
|
<span><strong>{promotionSummary.blocked}</strong><small>{copy.blocked}</small></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="inventory-technical-source-counts">
|
||||||
|
{nativeCount} {copy.scanResults} · {storedCount} {copy.storeEntries}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="inventory-browser">
|
<div className="inventory-browser">
|
||||||
<div className="inventory-list" aria-label="Artifact inventory results">
|
<section className="inventory-list-panel" aria-labelledby="inventory-list-title">
|
||||||
{rows.length > 0 ? rows.map((row) => (
|
<div className="inventory-list-heading">
|
||||||
|
<div>
|
||||||
|
<h3 id="inventory-list-title">{isNonFiveStarView ? copy.nonFiveStarArtifacts : copy.foundArtifacts}</h3>
|
||||||
|
<p id="inventory-results-status" role="status" aria-live="polite">
|
||||||
|
{isNonFiveStarView
|
||||||
|
? copy.nonFiveStarNotice(statusText)
|
||||||
|
: statusText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{loading && !initialLoading ? <InlineSpinner label={copy.updating} size={14} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="inventory-list"
|
||||||
|
role={!initialLoading && rows.length > 0 ? "listbox" : undefined}
|
||||||
|
aria-label={copy.artifactResults}
|
||||||
|
aria-orientation={!initialLoading && rows.length > 0 ? "vertical" : undefined}
|
||||||
|
>
|
||||||
|
{initialLoading ? (
|
||||||
|
<InventoryListSkeleton copy={copy} />
|
||||||
|
) : rows.length > 0 ? (
|
||||||
|
rows.map((row, rowIndex) => (
|
||||||
<button
|
<button
|
||||||
key={row.id}
|
key={row.id}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) rowRefs.current.set(row.id, element);
|
||||||
|
else rowRefs.current.delete(row.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedRow?.id === row.id}
|
||||||
|
tabIndex={selectedRow?.id === row.id ? 0 : -1}
|
||||||
className={`inventory-row ${selectedRow?.id === row.id ? "active" : ""}`}
|
className={`inventory-row ${selectedRow?.id === row.id ? "active" : ""}`}
|
||||||
onClick={() => setSelectedId(row.id)}
|
onClick={() => setSelectedId(row.id)}
|
||||||
|
onKeyDown={(event) => handleRowKeyDown(event, rowIndex)}
|
||||||
>
|
>
|
||||||
<span className="inventory-row-sequence">{row.sequenceLabel}</span>
|
<span className="inventory-row-sequence">{row.sequenceLabel}</span>
|
||||||
<span className="inventory-row-main">
|
<span className="inventory-row-main">
|
||||||
<strong>{row.title}</strong>
|
<strong>{row.title}</strong>
|
||||||
<span>{row.slot} - {row.mainStat} {row.mainValue} - {row.levelLabel}</span>
|
<span>{row.rarity !== null ? `${copy.rarityLabel(row.rarity)} · ` : ""}{copy.slotLabel(row.slot)} · {row.mainStat} {row.mainValue} · {row.levelLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inventory-row-source">{row.sourceLabel}</span>
|
<span className="inventory-row-source">{copy.sourceLabel(row.source)}</span>
|
||||||
<span className={`result-rail-status ${row.statusClassName}`}>{row.statusLabel}</span>
|
<span className={`inventory-row-value ${row.valueClassName}`}>{copy.valueLabel(row.valueStatus, row.valueScore)}</span>
|
||||||
|
<span className={`result-rail-status ${row.statusClassName}`}>{copy.statusLabel(row.statusLabel)}</span>
|
||||||
</button>
|
</button>
|
||||||
)) : (
|
))
|
||||||
<p className="inventory-empty">Keine Scan-Ergebnisse geladen.</p>
|
) : totalRows === 0 ? (
|
||||||
|
<div className="inventory-empty inventory-empty-first-scan">
|
||||||
|
<span className="inventory-empty-icon"><ScanLine size={24} aria-hidden="true" /></span>
|
||||||
|
<strong>{copy.noScans}</strong>
|
||||||
|
<p>{copy.noScansDescription}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inventory-empty inventory-empty-filtered">
|
||||||
|
<Search size={22} aria-hidden="true" />
|
||||||
|
<strong>{isNonFiveStarView ? copy.noNonFiveStar : copy.noMatching}</strong>
|
||||||
|
<p>{isNonFiveStarView ? copy.noNonFiveStarDescription : copy.noMatchingDescription}</p>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setFilter("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.resetFilters}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<aside className="inventory-detail">
|
<aside className="inventory-detail" aria-label={copy.artifactDetails}>
|
||||||
{selectedRow ? (
|
{initialLoading ? (
|
||||||
|
<InventoryDetailSkeleton copy={copy} />
|
||||||
|
) : selectedRow ? (
|
||||||
<>
|
<>
|
||||||
<div className="inventory-detail-heading">
|
<div className="inventory-detail-heading">
|
||||||
<p className="eyebrow">{selectedRow.sourceLabel} {selectedRow.sequenceLabel}</p>
|
|
||||||
<h3>{selectedRow.title}</h3>
|
|
||||||
<span className={`result-rail-status ${selectedRow.statusClassName}`}>{selectedRow.statusLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`inventory-action-card ${selectedRow.nextActionClassName}`}>
|
|
||||||
<span>Naechster Schritt</span>
|
|
||||||
<strong>{selectedRow.nextActionLabel}</strong>
|
|
||||||
<small>{selectedRow.nextActionDetail}</small>
|
|
||||||
{selectedRow.canPromote && !promotionConfirming && (
|
|
||||||
<button className="primary-button inventory-promote-button" onClick={requestSelectedPromotion} disabled={promotionPending}>
|
|
||||||
Ausgewaehltes Ergebnis promoten
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{selectedRow.canPromote && promotionConfirming && (
|
|
||||||
<div className="inventory-promotion-confirm">
|
|
||||||
<strong>Dieses gepruefte Ergebnis jetzt in den lokalen Store schreiben?</strong>
|
|
||||||
<div>
|
<div>
|
||||||
<button className="primary-button" onClick={() => void confirmSelectedPromotion()} disabled={promotionPending}>Bestaetigen</button>
|
<p className="eyebrow">{copy.sourceLabel(selectedRow.source)} {selectedRow.sequenceLabel}</p>
|
||||||
<button className="ghost-button" onClick={cancelSelectedPromotion} disabled={promotionPending}>Abbrechen</button>
|
<h3>{selectedRow.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`result-rail-status ${selectedRow.statusClassName}`}>{copy.statusLabel(selectedRow.statusLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{selectedRow.source === "native" && selectedRow.needsReview && !reviewEditing && (
|
{selectedRow.error ? (
|
||||||
<button className="primary-button inventory-promote-button" onClick={startSelectedReview} disabled={reviewPending}>
|
<p className="inventory-inline-error" role="alert">
|
||||||
Review bearbeiten
|
<AlertTriangle size={16} aria-hidden="true" />
|
||||||
|
<span>{selectedRow.error}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={`inventory-action-card ${selectedRow.nextActionClassName}`}>
|
||||||
|
<span>{copy.nextStep}</span>
|
||||||
|
<strong>{copy.nextActionLabel(selectedRow.nextActionLabel, selectedRow.valueStatus, selectedRow.canPromote, selectedRow.needsReview, selectedRow.rarity, selectedRow.source)}</strong>
|
||||||
|
<small>{copy.nextActionDetail(selectedRow.nextActionLabel, selectedRow.valueStatus, selectedRow.canPromote, selectedRow.needsReview, selectedRow.rarity, selectedRow.source)}</small>
|
||||||
|
{selectedRow.canPromote && !promotionConfirming && !deletionConfirming ? (
|
||||||
|
<button
|
||||||
|
ref={promotionTriggerRef}
|
||||||
|
className="primary-button inventory-promote-button"
|
||||||
|
type="button"
|
||||||
|
onClick={requestSelectedPromotion}
|
||||||
|
disabled={promotionPending}
|
||||||
|
data-inventory-action="promote"
|
||||||
|
>
|
||||||
|
{promotionPending ? <InlineSpinner label={copy.checkingPromotion} size={15} /> : copy.saveCheckedResult}
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
|
{selectedRow.canPromote && promotionConfirming && !deletionConfirming ? (
|
||||||
|
<div className="inventory-promotion-confirm" role="group" aria-label={copy.confirmSave}>
|
||||||
|
<strong>{copy.confirmSavePrompt}</strong>
|
||||||
|
<div>
|
||||||
|
<button ref={promotionConfirmRef} className="primary-button" type="button" onClick={() => void confirmSelectedPromotion()} disabled={promotionPending} data-inventory-action="confirm-promote">
|
||||||
|
{promotionPending ? <InlineSpinner label={copy.checkingPromotion} size={15} /> : copy.confirm}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={cancelSelectedPromotion} disabled={promotionPending} data-inventory-action="cancel-promote">{copy.cancel}</button>
|
||||||
</div>
|
</div>
|
||||||
{reviewEditing && reviewDraft && (
|
</div>
|
||||||
<div className="inventory-review-editor">
|
) : null}
|
||||||
|
{selectedRow.source === "native" && selectedRow.needsReview && !reviewEditing && !deletionConfirming ? (
|
||||||
|
<button ref={reviewTriggerRef} className="primary-button inventory-promote-button" type="button" onClick={startSelectedReview} disabled={reviewPending} data-inventory-action="review">
|
||||||
|
{copy.reviewData}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDeleteSelected && !reviewEditing && !deletionConfirming ? (
|
||||||
|
<button
|
||||||
|
ref={deletionTriggerRef}
|
||||||
|
className="ghost-button danger inventory-delete-button"
|
||||||
|
type="button"
|
||||||
|
onClick={requestSelectedDeletion}
|
||||||
|
disabled={deletionPending || promotionPending || reviewPending}
|
||||||
|
data-inventory-action="delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
{deletionTarget === "store" ? copy.removeLocalStoreRecord : copy.removeLocalScan}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDeleteSelected && deletionConfirming ? (
|
||||||
|
<div className="inventory-deletion-confirm" role="group" aria-label={deletionTarget === "store" ? copy.confirmLocalStoreRemoval : copy.confirmLocalRemoval}>
|
||||||
|
<strong>{deletionTarget === "store" ? copy.confirmLocalStoreRemovalPrompt : copy.confirmLocalRemovalPrompt}</strong>
|
||||||
|
<small>{deletionTarget === "store" ? copy.confirmLocalStoreRemovalDescription : copy.confirmLocalRemovalDescription}</small>
|
||||||
|
{selectedRow.linkedStoreRecordId ? (
|
||||||
|
<label className="inventory-deletion-store-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deletionRemoveLinkedStoreRecord}
|
||||||
|
onChange={(event) => setDeletionRemoveLinkedStoreRecord(event.target.checked)}
|
||||||
|
disabled={deletionPending}
|
||||||
|
data-inventory-action="toggle-linked-store"
|
||||||
|
/>
|
||||||
|
<span>{copy.alsoRemoveLinkedStoreRecord}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<button ref={deletionConfirmRef} className="ghost-button danger" type="button" onClick={() => void confirmSelectedDeletion()} disabled={deletionPending} data-inventory-action="confirm-delete">
|
||||||
|
{deletionPending ? <InlineSpinner label={copy.removing} size={15} /> : copy.removeLocally}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={cancelSelectedDeletion} disabled={deletionPending} data-inventory-action="cancel-delete">{copy.cancel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewEditing && reviewDraft ? (
|
||||||
|
<section className="inventory-review-editor" aria-labelledby="inventory-review-title">
|
||||||
<div className="inventory-review-heading">
|
<div className="inventory-review-heading">
|
||||||
<div>
|
<div>
|
||||||
<span>Manuelle Korrektur</span>
|
<span>{copy.manualCorrection}</span>
|
||||||
<strong>Crop pruefen, Felder korrigieren, dann freigeben oder ablehnen.</strong>
|
<strong id="inventory-review-title">{copy.reviewCrop}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button" onClick={cancelSelectedReview} disabled={reviewPending}>Schliessen</button>
|
<button className="ghost-button" type="button" onClick={cancelSelectedReview} disabled={reviewPending} data-inventory-action="cancel-review">{copy.close}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="inventory-review-guidance">{copy.reviewGuidance}</p>
|
||||||
<div className="inventory-review-grid">
|
<div className="inventory-review-grid">
|
||||||
<label><span>Name</span><input value={reviewDraft.name} onChange={(event) => setReviewDraft({ ...reviewDraft, name: event.target.value })} /></label>
|
<label><span>{copy.name}</span><input ref={reviewFirstFieldRef} value={reviewDraft.name} onChange={(event) => setReviewDraft({ ...reviewDraft, name: event.target.value })} /></label>
|
||||||
<label><span>Set</span><input value={reviewDraft.setName} onChange={(event) => setReviewDraft({ ...reviewDraft, setName: event.target.value })} /></label>
|
<label><span>{copy.set}</span><input value={reviewDraft.setName} onChange={(event) => setReviewDraft({ ...reviewDraft, setName: event.target.value })} /></label>
|
||||||
<label><span>Slot</span><select value={reviewDraft.slot} onChange={(event) => setReviewDraft({ ...reviewDraft, slot: event.target.value })}>
|
<label>
|
||||||
{["Flower of Life", "Plume of Death", "Sands of Eon", "Goblet of Eonothem", "Circlet of Logos"].map((slot) => <option key={slot}>{slot}</option>)}
|
<span>{copy.slot}</span>
|
||||||
</select></label>
|
<select value={reviewDraft.slot} onChange={(event) => setReviewDraft({ ...reviewDraft, slot: event.target.value })}>
|
||||||
<label><span>Level</span><input type="number" min="0" max="20" value={reviewDraft.level} onChange={(event) => setReviewDraft({ ...reviewDraft, level: event.target.value })} /></label>
|
{slotOptions.map((slot) => <option key={slot.value} value={slot.value}>{copy.slotLabel(slot.value)}</option>)}
|
||||||
<label><span>Main Stat</span><input value={reviewDraft.mainStat} onChange={(event) => setReviewDraft({ ...reviewDraft, mainStat: event.target.value })} /></label>
|
</select>
|
||||||
<label><span>Main Value</span><input value={reviewDraft.mainValue} onChange={(event) => setReviewDraft({ ...reviewDraft, mainValue: event.target.value })} /></label>
|
</label>
|
||||||
<label><span>Equipped</span><input value={reviewDraft.equipped} onChange={(event) => setReviewDraft({ ...reviewDraft, equipped: event.target.value })} /></label>
|
<label><span>{copy.level}</span><input type="number" min="0" max="20" value={reviewDraft.level} onChange={(event) => setReviewDraft({ ...reviewDraft, level: event.target.value })} /></label>
|
||||||
<label className="inventory-review-lock"><input type="checkbox" checked={reviewDraft.locked} onChange={(event) => setReviewDraft({ ...reviewDraft, locked: event.target.checked })} /><span>Locked</span></label>
|
<label><span>{copy.mainStat}</span><input value={reviewDraft.mainStat} onChange={(event) => setReviewDraft({ ...reviewDraft, mainStat: event.target.value })} /></label>
|
||||||
<label className="inventory-review-wide"><span>Substats, eine Zeile pro Wert</span><textarea rows={4} value={reviewDraft.substats} onChange={(event) => setReviewDraft({ ...reviewDraft, substats: event.target.value })} /></label>
|
<label><span>{copy.value}</span><input value={reviewDraft.mainValue} onChange={(event) => setReviewDraft({ ...reviewDraft, mainValue: event.target.value })} /></label>
|
||||||
<label className="inventory-review-wide"><span>Review-Notiz</span><input value={reviewDraft.note} onChange={(event) => setReviewDraft({ ...reviewDraft, note: event.target.value })} /></label>
|
<label><span>{copy.equippedBy}</span><input value={reviewDraft.equipped} onChange={(event) => setReviewDraft({ ...reviewDraft, equipped: event.target.value })} /></label>
|
||||||
|
<label className="inventory-review-lock"><input type="checkbox" checked={reviewDraft.locked} onChange={(event) => setReviewDraft({ ...reviewDraft, locked: event.target.checked })} /><span>{copy.lockedInGame}</span></label>
|
||||||
|
<label className="inventory-review-wide"><span>{copy.substatsOnePerLine}</span><textarea rows={4} value={reviewDraft.substats} onChange={(event) => setReviewDraft({ ...reviewDraft, substats: event.target.value })} /></label>
|
||||||
|
<label className="inventory-review-wide"><span>{copy.reviewNote}</span><input value={reviewDraft.note} onChange={(event) => setReviewDraft({ ...reviewDraft, note: event.target.value })} /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="inventory-review-actions">
|
<div className="inventory-review-actions">
|
||||||
<button className="primary-button" onClick={() => void submitSelectedReview("approve")} disabled={reviewPending}>Korrektur freigeben</button>
|
<button className="primary-button" type="button" onClick={() => void submitSelectedReview("approve")} disabled={reviewPending} data-inventory-action="approve-review">
|
||||||
<button className="ghost-button danger" onClick={() => void submitSelectedReview("reject")} disabled={reviewPending}>Ergebnis ablehnen</button>
|
{reviewPending ? <InlineSpinner label={copy.validating} size={15} /> : copy.approveCorrection}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button danger" type="button" onClick={() => void submitSelectedReview("reject")} disabled={reviewPending} data-inventory-action="reject-review">
|
||||||
|
{copy.rejectResult}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
<div className="inventory-detail-hero">
|
||||||
<div className={`inventory-preview ${selectedPreview ? "has-preview" : "is-empty"}`}>
|
<div className={`inventory-preview ${selectedPreview ? "has-preview" : "is-empty"}`}>
|
||||||
{selectedPreview ? (
|
{previewLoading ? (
|
||||||
<img src={selectedPreview.dataUrl} alt={`Preview ${selectedRow.title}`} />
|
<Skeleton className="inventory-preview-skeleton" width="100%" height="100%" radius="12px" />
|
||||||
|
) : selectedPreview ? (
|
||||||
|
<img src={selectedPreview.dataUrl} alt={copy.scannedArtifactCrop(selectedRow.title)} />
|
||||||
) : (
|
) : (
|
||||||
<span>{previewStatus || "Kein Crop-Preview geladen"}</span>
|
<span>
|
||||||
|
<ImageIcon size={22} aria-hidden="true" />
|
||||||
|
<span>{previewStatus || copy.noCrop}</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{previewStatus && selectedPreview && <p className="inventory-preview-status">{previewStatus}</p>}
|
<dl className="inventory-primary-facts">
|
||||||
<div className="inventory-detail-grid">
|
<div><dt>{copy.set}</dt><dd>{selectedRow.setName}</dd></div>
|
||||||
<span>Set</span><strong>{selectedRow.setName}</strong>
|
<div><dt>{copy.slot}</dt><dd>{copy.slotLabel(selectedRow.slot)}</dd></div>
|
||||||
<span>Slot</span><strong>{selectedRow.slot}</strong>
|
<div className="is-accent"><dt>{copy.mainStat}</dt><dd>{selectedRow.mainStat} {selectedRow.mainValue}</dd></div>
|
||||||
<span>Main</span><strong>{selectedRow.mainStat} {selectedRow.mainValue}</strong>
|
<div><dt>{copy.level}</dt><dd>{selectedRow.levelLabel}</dd></div>
|
||||||
<span>Level</span><strong>{selectedRow.levelLabel}</strong>
|
<div><dt>{copy.rarity}</dt><dd>{copy.rarityLabel(selectedRow.rarity)}</dd></div>
|
||||||
<span>Value</span><strong>{selectedRow.valueLabel}</strong>
|
<div><dt>{copy.equipped}</dt><dd>{selectedRow.equipped}</dd></div>
|
||||||
<span>IK</span><strong>{selectedRow.ikLabel}</strong>
|
</dl>
|
||||||
<span>GOOD</span><strong>{selectedRow.ikGoodLabel}</strong>
|
</div>
|
||||||
<span>Promotion</span><strong>{selectedRow.promotionLabel}</strong>
|
{previewStatus && selectedPreview ? <p className="inventory-preview-status">{copy.cropStatus(previewStatus)}</p> : null}
|
||||||
<span>Confidence</span><strong>{selectedRow.confidence}%</strong>
|
|
||||||
<span>Equipped</span><strong>{selectedRow.equipped}</strong>
|
<section className="inventory-substat-section">
|
||||||
|
<div className="inventory-section-heading">
|
||||||
|
<h4>{copy.substats}</h4>
|
||||||
|
{typeof selectedRow.locked === "boolean" ? <span>{selectedRow.locked ? copy.locked : copy.unlocked}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="inventory-substats">
|
<div className="inventory-substats">
|
||||||
{selectedRow.substats.length > 0 ? selectedRow.substats.map((substat) => (
|
{selectedRow.substats.length > 0 ? selectedRow.substats.map((substat) => (
|
||||||
<span key={`${selectedRow.id}-${substat}`}>{substat}</span>
|
<span key={`${selectedRow.id}-${substat}`}>{substat}</span>
|
||||||
)) : <span>Keine Substats gelesen</span>}
|
)) : <span>{copy.noSubstats}</span>}
|
||||||
</div>
|
</div>
|
||||||
{selectedRow.fieldConfidences.length > 0 && (
|
</section>
|
||||||
|
|
||||||
|
<div className={`inventory-value-card ${selectedRow.valueClassName}`}>
|
||||||
|
<div className="inventory-value-heading">
|
||||||
|
<div>
|
||||||
|
<span>{selectedRow.valueStatus === "excluded" ? copy.nonFiveStar : copy.rollEfficiency}</span>
|
||||||
|
<strong>{copy.valueLabel(selectedRow.valueStatus, selectedRow.valueScore)}</strong>
|
||||||
|
</div>
|
||||||
|
<small>{selectedRow.valueStatus === "excluded" ? copy.manualCheckNoAutomation : copy.noBuildFit}</small>
|
||||||
|
</div>
|
||||||
|
<div className="inventory-value-reasons">
|
||||||
|
{selectedRow.valueReasons.map((reason) => (
|
||||||
|
<span key={`${selectedRow.id}-value-${reason.code}`} className={reason.tone}>
|
||||||
|
<strong>{copy.reasonTitle(reason, {
|
||||||
|
score: selectedRow.valueScore,
|
||||||
|
rollCount: selectedRow.valueBreakdown.reduce((total, substat) => total + substat.rollCount, 0),
|
||||||
|
rarity: selectedRow.rarity,
|
||||||
|
})}</strong>
|
||||||
|
<small>{copy.reasonDetail(reason, {
|
||||||
|
mainStat: selectedRow.mainStat,
|
||||||
|
slot: copy.slotLabel(selectedRow.slot),
|
||||||
|
remainingRolls: selectedRow.upgradeProjection.remainingRolls,
|
||||||
|
rarity: selectedRow.rarity,
|
||||||
|
})}</small>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedRow.valueBreakdown.length > 0 ? (
|
||||||
|
<div className="inventory-roll-breakdown">
|
||||||
|
{selectedRow.valueBreakdown.map((substat) => (
|
||||||
|
<span key={`${selectedRow.id}-roll-${substat.entry}`}>
|
||||||
|
<strong>{substat.entry}</strong>
|
||||||
|
<small>{copy.rolls(substat.rollCount)} · {substat.efficiency}%</small>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRow.valueStatus !== "excluded" && selectedRow.upgradeProjection.currentLevel < 20 ? (
|
||||||
|
<div className={`inventory-projection-card ${selectedRow.upgradeProjection.status}`}>
|
||||||
|
<div>
|
||||||
|
<span>{copy.projectionTo20}</span>
|
||||||
|
<strong>{copy.projectionSummary(selectedRow.upgradeProjection)}</strong>
|
||||||
|
</div>
|
||||||
|
{selectedRow.upgradeProjection.status === "available" ? (
|
||||||
|
<div className="inventory-projection-scores">
|
||||||
|
<span><small>{copy.minimum}</small><strong>{selectedRow.upgradeProjection.worstScore}%</strong></span>
|
||||||
|
<span><small>{copy.middle}</small><strong>{selectedRow.upgradeProjection.middleScore}%</strong></span>
|
||||||
|
<span><small>{copy.maximum}</small><strong>{selectedRow.upgradeProjection.bestScore}%</strong></span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedRow.upgradeProjection.assumptions.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{copy.projectionAssumptions(selectedRow.upgradeProjection).map((assumption) => <li key={assumption}>{assumption}</li>)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<details className="inventory-selected-technical">
|
||||||
|
<summary>
|
||||||
|
<span>{copy.scanAndOcrDetails}</span>
|
||||||
|
<ChevronDown size={16} aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div className="inventory-selected-technical-content">
|
||||||
|
<dl className="inventory-detail-grid">
|
||||||
|
<dt>{copy.evaluation}</dt><dd>{copy.valueLabel(selectedRow.valueStatus, selectedRow.valueScore)}</dd>
|
||||||
|
<dt>{copy.ikMatch}</dt><dd>{copy.ikLabel(selectedRow.ikLabel)}</dd>
|
||||||
|
<dt>{copy.good}</dt><dd>{selectedRow.ikGoodLabel}</dd>
|
||||||
|
<dt>{copy.storeStatus}</dt><dd>{copy.promotionLabel(selectedRow.promotionLabel)}</dd>
|
||||||
|
<dt>{copy.ocrConfidence}</dt><dd>{selectedRow.confidence}%</dd>
|
||||||
|
<dt>{copy.equipped}</dt><dd>{selectedRow.equipped}</dd>
|
||||||
|
</dl>
|
||||||
|
{selectedRow.fieldConfidences.length > 0 ? (
|
||||||
<div className="inventory-field-confidence">
|
<div className="inventory-field-confidence">
|
||||||
{selectedRow.fieldConfidences.map((field) => (
|
{selectedRow.fieldConfidences.map((field) => (
|
||||||
<span key={`${selectedRow.id}-${field.key}`} className={field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"}>
|
<span key={`${selectedRow.id}-${field.key}`} className={field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"}>
|
||||||
<strong>{field.confidence}%</strong>
|
<strong>{field.confidence}%</strong>
|
||||||
<small>{field.label} · {field.source}</small>
|
<small>{field.label} · {copy.fieldSourceLabel(field.source)}</small>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{(selectedRow.notes.length > 0 || selectedRow.promotionReasons.length > 0 || selectedRow.error || selectedRow.imagePath || selectedRow.promotionRecordId) && (
|
{(selectedRow.notes.length > 0 || selectedRow.promotionReasons.length > 0 || selectedRow.imagePath || selectedRow.promotionRecordId) ? (
|
||||||
<div className="inventory-notes">
|
<div className="inventory-notes">
|
||||||
{selectedRow.imagePath && <span>{selectedRow.imagePath}</span>}
|
{selectedRow.imagePath ? <span>{selectedRow.imagePath}</span> : null}
|
||||||
{selectedRow.promotionRecordId && <span>{selectedRow.promotionRecordId}</span>}
|
{selectedRow.promotionRecordId ? <span>{selectedRow.promotionRecordId}</span> : null}
|
||||||
{selectedRow.error && <strong>{selectedRow.error}</strong>}
|
{selectedRow.promotionReasons.map((reason) => <span key={`${selectedRow.id}-promotion-${reason}`}>{copy.promotionReason(reason)}</span>)}
|
||||||
{selectedRow.promotionReasons.map((reason) => <span key={`${selectedRow.id}-promotion-${reason}`}>{reason}</span>)}
|
|
||||||
{selectedRow.notes.map((note) => <span key={`${selectedRow.id}-${note}`}>{note}</span>)}
|
{selectedRow.notes.map((note) => <span key={`${selectedRow.id}-${note}`}>{note}</span>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="inventory-empty">Waehle ein Ergebnis aus.</p>
|
<div className="inventory-empty inventory-empty-detail">
|
||||||
|
<CheckCircle2 size={22} aria-hidden="true" />
|
||||||
|
<strong>{copy.selectArtifact}</strong>
|
||||||
|
<p>{copy.selectArtifactDescription}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<p className="inventory-status">{statusText}</p>
|
|
||||||
{promotionStatus && <p className="inventory-status">{promotionStatus}</p>}
|
{(error || promotionStatus || deletionStatus || reviewStatus) ? (
|
||||||
{reviewStatus && <p className="inventory-status">{reviewStatus}</p>}
|
<div className="inventory-operation-status" aria-live="polite">
|
||||||
|
{error ? <p className="is-error" role="alert"><AlertTriangle size={14} aria-hidden="true" />{error}</p> : null}
|
||||||
|
{promotionStatus ? <p>{promotionStatus}</p> : null}
|
||||||
|
{deletionStatus ? <p>{deletionStatus}</p> : null}
|
||||||
|
{reviewStatus ? <p>{reviewStatus}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InventoryListSkeleton({ copy }: { copy: ReturnType<typeof getInventoryCopy> }) {
|
||||||
|
return (
|
||||||
|
<div className="inventory-list-skeleton" aria-label={copy.loadingArtifacts} role="status">
|
||||||
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
|
<div className="inventory-row-skeleton" key={index}>
|
||||||
|
<Skeleton width="30px" height="12px" />
|
||||||
|
<span><Skeleton width={`${68 - index * 4}%`} height="13px" /><Skeleton width="52%" height="10px" /></span>
|
||||||
|
<Skeleton width="56px" height="12px" />
|
||||||
|
<Skeleton width="68px" height="24px" radius="999px" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryDetailSkeleton({ copy }: { copy: ReturnType<typeof getInventoryCopy> }) {
|
||||||
|
return (
|
||||||
|
<div className="inventory-detail-skeleton" aria-label={copy.loadingArtifactDetails} role="status">
|
||||||
|
<span><Skeleton width="80px" height="10px" /><Skeleton width="58%" height="22px" /></span>
|
||||||
|
<Skeleton width="100%" height="82px" radius="12px" />
|
||||||
|
<div><Skeleton width="42%" height="170px" radius="12px" /><Skeleton width="54%" height="170px" radius="12px" /></div>
|
||||||
|
<Skeleton width="100%" height="126px" radius="12px" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineCard({
|
||||||
|
card,
|
||||||
|
promotionSummary,
|
||||||
|
copy,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
card: InventoryPipelineCard;
|
||||||
|
promotionSummary: ScanResultPromotionSummary;
|
||||||
|
copy: ReturnType<typeof getInventoryCopy>;
|
||||||
|
className: string;
|
||||||
|
}) {
|
||||||
|
const localized = copy.pipelineCard(card, promotionSummary);
|
||||||
|
return (
|
||||||
|
<span className={className} title={localized.detail}>
|
||||||
|
<strong>{localized.title}</strong>
|
||||||
|
<small>{localized.value}</small>
|
||||||
|
<em>{localized.detail}</em>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotOptions = [
|
||||||
|
{ value: "Flower of Life" },
|
||||||
|
{ value: "Plume of Death" },
|
||||||
|
{ value: "Sands of Eon" },
|
||||||
|
{ value: "Goblet of Eonothem" },
|
||||||
|
{ value: "Circlet of Logos" },
|
||||||
|
];
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { InventoryBrowserRow } from "../../../lib/inventoryBrowser";
|
||||||
|
import { resolveInventoryDeletionTarget } from "./useInventoryViewModel";
|
||||||
|
|
||||||
|
type DeletionRow = Pick<InventoryBrowserRow, "source" | "runDir" | "scanResultId" | "promotionRecordId">;
|
||||||
|
|
||||||
|
function row(overrides: Partial<DeletionRow> = {}): DeletionRow {
|
||||||
|
return {
|
||||||
|
source: "snapshot",
|
||||||
|
runDir: undefined,
|
||||||
|
scanResultId: undefined,
|
||||||
|
promotionRecordId: undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inventory local deletion target", () => {
|
||||||
|
it("allows only a native result with its run identity", () => {
|
||||||
|
expect(resolveInventoryDeletionTarget(row({ source: "native", runDir: "C:/run", scanResultId: "result-1" }), {
|
||||||
|
nativeResultRemoval: true,
|
||||||
|
storeRecordRemoval: true,
|
||||||
|
})).toBe("native");
|
||||||
|
expect(resolveInventoryDeletionTarget(row({ source: "native", scanResultId: "result-1" }), {
|
||||||
|
nativeResultRemoval: true,
|
||||||
|
storeRecordRemoval: true,
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows one exact Store record only when the bridge exposes the capability", () => {
|
||||||
|
expect(resolveInventoryDeletionTarget(row({ source: "store", promotionRecordId: "store-1" }), {
|
||||||
|
nativeResultRemoval: true,
|
||||||
|
storeRecordRemoval: true,
|
||||||
|
})).toBe("store");
|
||||||
|
expect(resolveInventoryDeletionTarget(row({ source: "store", promotionRecordId: "store-1" }), {
|
||||||
|
nativeResultRemoval: true,
|
||||||
|
storeRecordRemoval: false,
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never exposes a deletion target for snapshot rows", () => {
|
||||||
|
expect(resolveInventoryDeletionTarget(row({ source: "snapshot", promotionRecordId: "store-1" }), {
|
||||||
|
nativeResultRemoval: true,
|
||||||
|
storeRecordRemoval: true,
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { AppLocale } from "../../../i18n";
|
||||||
|
import { useToast } from "../../feedback";
|
||||||
import {
|
import {
|
||||||
buildArtifactPipelineSummary,
|
buildArtifactPipelineSummary,
|
||||||
buildInventoryCatalogSummary,
|
buildInventoryCatalogSummary,
|
||||||
@@ -15,6 +17,7 @@ import { buildScanResultPromotionSummary, type ScanResultPromotionSummary } from
|
|||||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||||
import type { NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerReviewArtifactInput } from "../../../types/global";
|
import type { NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerReviewArtifactInput } from "../../../types/global";
|
||||||
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
|
import type { StoredArtifactRecord, StoredScanResultEntry } from "../../../types/storage";
|
||||||
|
import { getInventoryCopy } from "../inventoryCopy";
|
||||||
import type { InventoryViewProps } from "../types";
|
import type { InventoryViewProps } from "../types";
|
||||||
|
|
||||||
export interface InventoryViewModel {
|
export interface InventoryViewModel {
|
||||||
@@ -22,13 +25,22 @@ export interface InventoryViewModel {
|
|||||||
selectedRow: InventoryBrowserRow | null;
|
selectedRow: InventoryBrowserRow | null;
|
||||||
selectedPreview: { dataUrl: string; width: number; height: number; path: string } | null;
|
selectedPreview: { dataUrl: string; width: number; height: number; path: string } | null;
|
||||||
previewStatus: string;
|
previewStatus: string;
|
||||||
|
previewLoading: boolean;
|
||||||
filter: InventoryBrowserFilter;
|
filter: InventoryBrowserFilter;
|
||||||
sort: InventoryBrowserSort;
|
sort: InventoryBrowserSort;
|
||||||
|
searchQuery: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
promotionStatus: string;
|
promotionStatus: string;
|
||||||
promotionPending: boolean;
|
promotionPending: boolean;
|
||||||
promotionConfirming: boolean;
|
promotionConfirming: boolean;
|
||||||
|
deletionStatus: string;
|
||||||
|
deletionPending: boolean;
|
||||||
|
deletionConfirming: boolean;
|
||||||
|
deletionRemoveLinkedStoreRecord: boolean;
|
||||||
|
canDeleteSelected: boolean;
|
||||||
|
deletionTarget: "native" | "store" | null;
|
||||||
reviewEditing: boolean;
|
reviewEditing: boolean;
|
||||||
reviewPending: boolean;
|
reviewPending: boolean;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
@@ -37,6 +49,7 @@ export interface InventoryViewModel {
|
|||||||
nativeCount: number;
|
nativeCount: number;
|
||||||
storedCount: number;
|
storedCount: number;
|
||||||
reviewCount: number;
|
reviewCount: number;
|
||||||
|
evaluatedCount: number;
|
||||||
catalogSummary: InventoryCatalogSummary;
|
catalogSummary: InventoryCatalogSummary;
|
||||||
promotionSummary: ScanResultPromotionSummary;
|
promotionSummary: ScanResultPromotionSummary;
|
||||||
pipelineSummary: InventoryPipelineSummary;
|
pipelineSummary: InventoryPipelineSummary;
|
||||||
@@ -44,11 +57,16 @@ export interface InventoryViewModel {
|
|||||||
sortOptions: Array<{ id: InventoryBrowserSort; label: string }>;
|
sortOptions: Array<{ id: InventoryBrowserSort; label: string }>;
|
||||||
setFilter: (filter: InventoryBrowserFilter) => void;
|
setFilter: (filter: InventoryBrowserFilter) => void;
|
||||||
setSort: (sort: InventoryBrowserSort) => void;
|
setSort: (sort: InventoryBrowserSort) => void;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
setSelectedId: (id: string) => void;
|
setSelectedId: (id: string) => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
requestSelectedPromotion: () => void;
|
requestSelectedPromotion: () => void;
|
||||||
cancelSelectedPromotion: () => void;
|
cancelSelectedPromotion: () => void;
|
||||||
confirmSelectedPromotion: () => Promise<void>;
|
confirmSelectedPromotion: () => Promise<void>;
|
||||||
|
requestSelectedDeletion: () => void;
|
||||||
|
cancelSelectedDeletion: () => void;
|
||||||
|
setDeletionRemoveLinkedStoreRecord: (remove: boolean) => void;
|
||||||
|
confirmSelectedDeletion: () => Promise<void>;
|
||||||
startSelectedReview: () => void;
|
startSelectedReview: () => void;
|
||||||
cancelSelectedReview: () => void;
|
cancelSelectedReview: () => void;
|
||||||
setReviewDraft: (draft: InventoryReviewDraft) => void;
|
setReviewDraft: (draft: InventoryReviewDraft) => void;
|
||||||
@@ -68,8 +86,34 @@ export interface InventoryReviewDraft {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInventoryViewModel({ snapshot }: InventoryViewProps): InventoryViewModel {
|
export type InventoryDeletionTarget = "native" | "store" | null;
|
||||||
|
|
||||||
|
export function resolveInventoryDeletionTarget(
|
||||||
|
row: Pick<InventoryBrowserRow, "source" | "runDir" | "scanResultId" | "promotionRecordId"> | null | undefined,
|
||||||
|
capabilities: { nativeResultRemoval: boolean; storeRecordRemoval: boolean },
|
||||||
|
): InventoryDeletionTarget {
|
||||||
|
if (row?.source === "native" && capabilities.nativeResultRemoval && row.runDir && row.scanResultId) {
|
||||||
|
return "native";
|
||||||
|
}
|
||||||
|
if (row?.source === "store" && capabilities.storeRecordRemoval && row.promotionRecordId) {
|
||||||
|
return "store";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseInventoryViewModelInput extends InventoryViewProps {
|
||||||
|
locale: AppLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryViewModel({
|
||||||
|
snapshot,
|
||||||
|
locale,
|
||||||
|
initialFilter = "all",
|
||||||
|
initialSelectedId = "",
|
||||||
|
}: UseInventoryViewModelInput): InventoryViewModel {
|
||||||
|
const copy = getInventoryCopy(locale);
|
||||||
const repositories = useMemo(() => createRendererRepositories(), []);
|
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||||
|
const toast = useToast();
|
||||||
const [storedArtifacts, setStoredArtifacts] = useState<StoredArtifactRecord[]>([]);
|
const [storedArtifacts, setStoredArtifacts] = useState<StoredArtifactRecord[]>([]);
|
||||||
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
|
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
|
||||||
const [nativeRunDir, setNativeRunDir] = useState("");
|
const [nativeRunDir, setNativeRunDir] = useState("");
|
||||||
@@ -77,14 +121,20 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
const [nativeCatalog, setNativeCatalog] = useState<NativeScannerCatalogStatus | null>(null);
|
const [nativeCatalog, setNativeCatalog] = useState<NativeScannerCatalogStatus | null>(null);
|
||||||
const [selectedPreview, setSelectedPreview] = useState<{ dataUrl: string; width: number; height: number; path: string } | null>(null);
|
const [selectedPreview, setSelectedPreview] = useState<{ dataUrl: string; width: number; height: number; path: string } | null>(null);
|
||||||
const [previewStatus, setPreviewStatus] = useState("");
|
const [previewStatus, setPreviewStatus] = useState("");
|
||||||
const [filter, setFilter] = useState<InventoryBrowserFilter>("all");
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<InventoryBrowserFilter>(initialFilter);
|
||||||
const [sort, setSort] = useState<InventoryBrowserSort>("newest");
|
const [sort, setSort] = useState<InventoryBrowserSort>("newest");
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [selectedId, setSelectedId] = useState(initialSelectedId);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [promotionStatus, setPromotionStatus] = useState("");
|
const [promotionStatus, setPromotionStatus] = useState("");
|
||||||
const [promotionPending, setPromotionPending] = useState(false);
|
const [promotionPending, setPromotionPending] = useState(false);
|
||||||
const [promotionConfirmingId, setPromotionConfirmingId] = useState("");
|
const [promotionConfirmingId, setPromotionConfirmingId] = useState("");
|
||||||
|
const [deletionStatus, setDeletionStatus] = useState("");
|
||||||
|
const [deletionPending, setDeletionPending] = useState(false);
|
||||||
|
const [deletionConfirmingId, setDeletionConfirmingId] = useState("");
|
||||||
|
const [deletionRemoveLinkedStoreRecord, setDeletionRemoveLinkedStoreRecord] = useState(false);
|
||||||
const [reviewEditingId, setReviewEditingId] = useState("");
|
const [reviewEditingId, setReviewEditingId] = useState("");
|
||||||
const [reviewDraft, setReviewDraft] = useState<InventoryReviewDraft | null>(null);
|
const [reviewDraft, setReviewDraft] = useState<InventoryReviewDraft | null>(null);
|
||||||
const [reviewPending, setReviewPending] = useState(false);
|
const [reviewPending, setReviewPending] = useState(false);
|
||||||
@@ -93,40 +143,76 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
if (!repositories) {
|
||||||
|
const message = copy.bridgeUnavailable;
|
||||||
|
setError(message);
|
||||||
|
toast.error(copy.inventoryCouldNotLoad, { description: message });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [artifactResult, nativeResult, dataResult, catalogResult] = await Promise.allSettled([
|
const [artifactResult, nativeResult, dataResult, catalogResult] = await Promise.allSettled([
|
||||||
repositories?.artifacts.loadAll(),
|
repositories.artifacts.loadAll(),
|
||||||
repositories?.automation.nativeScannerLoadResults({ limit: 500 }),
|
repositories.automation.nativeScannerLoadResults({ limit: 2400 }),
|
||||||
repositories?.automation.nativeScannerDataStatus(),
|
repositories.automation.nativeScannerDataStatus(),
|
||||||
repositories?.automation.nativeScannerCatalog(),
|
repositories.automation.nativeScannerCatalog(),
|
||||||
]);
|
]);
|
||||||
|
const refreshErrors: string[] = [];
|
||||||
|
|
||||||
if (artifactResult.status === "fulfilled" && artifactResult.value?.ok) {
|
if (artifactResult.status === "fulfilled" && artifactResult.value?.ok) {
|
||||||
setStoredArtifacts(artifactResult.value.artifacts);
|
setStoredArtifacts(artifactResult.value.artifacts);
|
||||||
|
} else if (artifactResult.status === "fulfilled") {
|
||||||
|
refreshErrors.push(copy.localArtifactStoreCouldNotLoad);
|
||||||
|
} else {
|
||||||
|
refreshErrors.push(copy.artifactStoreError(errorMessage(artifactResult.reason)));
|
||||||
}
|
}
|
||||||
if (nativeResult.status === "fulfilled" && nativeResult.value?.ok) {
|
if (nativeResult.status === "fulfilled" && nativeResult.value?.ok) {
|
||||||
setNativeResults(nativeResult.value.results);
|
setNativeResults(nativeResult.value.results);
|
||||||
setNativeRunDir(nativeResult.value.runDir);
|
setNativeRunDir(nativeResult.value.runDir);
|
||||||
|
} else if (nativeResult.status === "fulfilled") {
|
||||||
|
refreshErrors.push(nativeResult.value?.error ?? copy.scanResultsCouldNotLoad);
|
||||||
|
} else {
|
||||||
|
refreshErrors.push(copy.scanResultsError(errorMessage(nativeResult.reason)));
|
||||||
}
|
}
|
||||||
if (dataResult.status === "fulfilled" && dataResult.value?.valid !== undefined) {
|
if (dataResult.status === "fulfilled" && dataResult.value?.valid !== undefined) {
|
||||||
setNativeDataStatus(dataResult.value);
|
setNativeDataStatus(dataResult.value);
|
||||||
|
} else if (dataResult.status === "rejected") {
|
||||||
|
refreshErrors.push(copy.scannerDataError(errorMessage(dataResult.reason)));
|
||||||
}
|
}
|
||||||
if (catalogResult.status === "fulfilled" && catalogResult.value?.data?.valid !== undefined) {
|
if (catalogResult.status === "fulfilled" && catalogResult.value?.data?.valid !== undefined) {
|
||||||
setNativeCatalog(catalogResult.value);
|
setNativeCatalog(catalogResult.value);
|
||||||
setNativeDataStatus(catalogResult.value.data);
|
setNativeDataStatus(catalogResult.value.data);
|
||||||
|
} else if (catalogResult.status === "rejected") {
|
||||||
|
refreshErrors.push(copy.artifactCatalogError(errorMessage(catalogResult.reason)));
|
||||||
}
|
}
|
||||||
if (artifactResult.status === "rejected") {
|
|
||||||
setError(errorMessage(artifactResult.reason));
|
if (refreshErrors.length > 0) {
|
||||||
|
const message = refreshErrors.join(" ");
|
||||||
|
setError(message);
|
||||||
|
toast.error(copy.inventoryCouldNotFullyLoad, { description: message });
|
||||||
}
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
const message = errorMessage(refreshError);
|
||||||
|
setError(message);
|
||||||
|
toast.error(copy.inventoryCouldNotLoad, { description: message });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [repositories]);
|
}, [copy, repositories, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilter(initialFilter);
|
||||||
|
}, [initialFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialSelectedId) setSelectedId(initialSelectedId);
|
||||||
|
}, [initialSelectedId]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildInventoryRows({
|
buildInventoryRows({
|
||||||
@@ -137,12 +223,29 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
}),
|
}),
|
||||||
[nativeResults, nativeRunDir, snapshot.artifacts, storedArtifacts],
|
[nativeResults, nativeRunDir, snapshot.artifacts, storedArtifacts],
|
||||||
);
|
);
|
||||||
const rows = useMemo(
|
const rows = useMemo(() => {
|
||||||
() => sortInventoryRows(filterInventoryRows(allRows, filter), sort),
|
const normalizedQuery = searchQuery.trim().toLocaleLowerCase(locale);
|
||||||
[allRows, filter, sort],
|
const filteredRows = filterInventoryRows(allRows, filter);
|
||||||
);
|
const searchedRows = normalizedQuery
|
||||||
|
? filteredRows.filter((row) => [
|
||||||
|
row.title,
|
||||||
|
row.setName,
|
||||||
|
row.slot,
|
||||||
|
row.mainStat,
|
||||||
|
row.equipped,
|
||||||
|
...row.substats,
|
||||||
|
].some((value) => value.toLocaleLowerCase(locale).includes(normalizedQuery)))
|
||||||
|
: filteredRows;
|
||||||
|
return sortInventoryRows(searchedRows, sort);
|
||||||
|
}, [allRows, filter, locale, searchQuery, sort]);
|
||||||
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null;
|
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0] ?? null;
|
||||||
const promotionConfirming = Boolean(selectedRow && promotionConfirmingId === selectedRow.id);
|
const promotionConfirming = Boolean(selectedRow && promotionConfirmingId === selectedRow.id);
|
||||||
|
const deletionConfirming = Boolean(selectedRow && deletionConfirmingId === selectedRow.id);
|
||||||
|
const deletionTarget = resolveInventoryDeletionTarget(selectedRow, {
|
||||||
|
nativeResultRemoval: Boolean(repositories?.automation),
|
||||||
|
storeRecordRemoval: Boolean(repositories?.canRemoveArtifacts),
|
||||||
|
});
|
||||||
|
const canDeleteSelected = deletionTarget !== null;
|
||||||
const reviewEditing = Boolean(selectedRow && reviewEditingId === selectedRow.id && reviewDraft);
|
const reviewEditing = Boolean(selectedRow && reviewEditingId === selectedRow.id && reviewDraft);
|
||||||
|
|
||||||
const requestSelectedPromotion = useCallback(() => {
|
const requestSelectedPromotion = useCallback(() => {
|
||||||
@@ -154,28 +257,114 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
const confirmSelectedPromotion = useCallback(async () => {
|
const confirmSelectedPromotion = useCallback(async () => {
|
||||||
if (!selectedRow?.canPromote || !selectedRow.runDir || !selectedRow.scanResultId || !repositories?.automation) return;
|
if (!selectedRow?.canPromote || !selectedRow.runDir || !selectedRow.scanResultId || !repositories?.automation) return;
|
||||||
setPromotionPending(true);
|
setPromotionPending(true);
|
||||||
setPromotionStatus("Promotion wird geprueft und geschrieben...");
|
setPromotionStatus(copy.promotionChecking);
|
||||||
try {
|
try {
|
||||||
const result = await repositories.automation.nativeScannerPromoteResults({
|
const result = await repositories.automation.nativeScannerPromoteResults({
|
||||||
runDir: selectedRow.runDir,
|
runDir: selectedRow.runDir,
|
||||||
resultIds: [selectedRow.scanResultId],
|
resultIds: [selectedRow.scanResultId],
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setPromotionStatus(result.error ?? "Promotion wurde blockiert.");
|
const message = result.error ?? copy.promotionBlocked;
|
||||||
|
setPromotionStatus(message);
|
||||||
|
toast.error(copy.promotionBlocked, { description: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPromotionStatus(`${result.promoted} Ergebnis promotet, ${result.added} neu, ${result.updated} aktualisiert. Log: ${result.logPath}`);
|
const message = copy.promotionResult(result.promoted, result.added, result.updated, result.logPath);
|
||||||
setPromotionConfirmingId("");
|
setPromotionStatus(message);
|
||||||
|
toast.success(copy.artifactSaved, {
|
||||||
|
description: result.added > 0 ? copy.artifactAddedToStore : copy.artifactUpdatedInStore,
|
||||||
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
|
setPromotionConfirmingId("");
|
||||||
} catch (promotionError) {
|
} catch (promotionError) {
|
||||||
setPromotionStatus(errorMessage(promotionError));
|
const message = errorMessage(promotionError);
|
||||||
|
setPromotionStatus(message);
|
||||||
|
toast.error(copy.saveFailed, { description: message });
|
||||||
} finally {
|
} finally {
|
||||||
setPromotionPending(false);
|
setPromotionPending(false);
|
||||||
}
|
}
|
||||||
}, [refresh, repositories, selectedRow]);
|
}, [copy, refresh, repositories, selectedRow, toast]);
|
||||||
|
|
||||||
|
const requestSelectedDeletion = useCallback(() => {
|
||||||
|
if (!canDeleteSelected || !selectedRow) return;
|
||||||
|
setPromotionConfirmingId("");
|
||||||
|
setDeletionRemoveLinkedStoreRecord(false);
|
||||||
|
setDeletionConfirmingId(selectedRow.id);
|
||||||
|
}, [canDeleteSelected, selectedRow]);
|
||||||
|
|
||||||
|
const cancelSelectedDeletion = useCallback(() => {
|
||||||
|
setDeletionConfirmingId("");
|
||||||
|
setDeletionRemoveLinkedStoreRecord(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmSelectedDeletion = useCallback(async () => {
|
||||||
|
if (!canDeleteSelected || !selectedRow || !deletionTarget || !repositories) return;
|
||||||
|
setDeletionPending(true);
|
||||||
|
setDeletionStatus(deletionTarget === "store" ? copy.localStoreRemovalInProgress : copy.deletionInProgress);
|
||||||
|
try {
|
||||||
|
if (deletionTarget === "store") {
|
||||||
|
const storeRecordId = selectedRow.promotionRecordId;
|
||||||
|
if (!storeRecordId) return;
|
||||||
|
const result = await repositories.artifacts.removeOne(storeRecordId);
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.error ?? copy.localStoreRemovalBlocked;
|
||||||
|
setDeletionStatus(message);
|
||||||
|
toast.error(copy.localStoreRemovalFailed, { description: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = result.removed > 0 ? copy.localStoreRecordRemoved : copy.localStoreRecordAlreadyRemoved;
|
||||||
|
setDeletionStatus(message);
|
||||||
|
if (result.removed > 0) {
|
||||||
|
toast.success(copy.localStoreRecordRemoved, { description: copy.nothingInGenshinChanged });
|
||||||
|
} else {
|
||||||
|
toast.info(copy.localStoreRecordAlreadyRemoved, { description: copy.nothingInGenshinChanged });
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
setDeletionConfirmingId("");
|
||||||
|
setDeletionRemoveLinkedStoreRecord(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedRow.runDir || !selectedRow.scanResultId || !repositories.automation) return;
|
||||||
|
const result = await repositories.automation.nativeScannerDeleteResult({
|
||||||
|
runDir: selectedRow.runDir,
|
||||||
|
resultId: selectedRow.scanResultId,
|
||||||
|
removeLinkedStoreRecord: deletionRemoveLinkedStoreRecord,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.error ?? copy.localRemovalBlocked;
|
||||||
|
setDeletionStatus(message);
|
||||||
|
toast.error(copy.localRemovalFailed, { description: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = [copy.scanResultItem];
|
||||||
|
if (result.deletedCrop) removed.push(copy.cropImageItem);
|
||||||
|
if (result.deletedStoreRecord) removed.push(copy.linkedStoreRecordItem);
|
||||||
|
const warningText = result.warnings.join(" ");
|
||||||
|
const message = result.alreadyDeleted
|
||||||
|
? copy.localScanAlreadyRemoved
|
||||||
|
: copy.localScanRemoved(removed, warningText);
|
||||||
|
setDeletionStatus(message);
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
toast.info(copy.localScanRemovedWithNotes, { description: message });
|
||||||
|
} else {
|
||||||
|
toast.success(copy.localScanRemovedSuccess, { description: copy.nothingInGenshinChanged });
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
setDeletionConfirmingId("");
|
||||||
|
setDeletionRemoveLinkedStoreRecord(false);
|
||||||
|
} catch (deletionError) {
|
||||||
|
const message = errorMessage(deletionError);
|
||||||
|
setDeletionStatus(message);
|
||||||
|
toast.error(deletionTarget === "store" ? copy.localStoreRemovalFailed : copy.localRemovalFailed, { description: message });
|
||||||
|
} finally {
|
||||||
|
setDeletionPending(false);
|
||||||
|
}
|
||||||
|
}, [canDeleteSelected, copy, deletionRemoveLinkedStoreRecord, deletionTarget, refresh, repositories, selectedRow, toast]);
|
||||||
|
|
||||||
const startSelectedReview = useCallback(() => {
|
const startSelectedReview = useCallback(() => {
|
||||||
if (!selectedRow || selectedRow.source !== "native" || !selectedRow.needsReview) return;
|
if (!selectedRow || selectedRow.source !== "native" || !selectedRow.needsReview) return;
|
||||||
|
setDeletionConfirmingId("");
|
||||||
setReviewEditingId(selectedRow.id);
|
setReviewEditingId(selectedRow.id);
|
||||||
setReviewDraft({
|
setReviewDraft({
|
||||||
name: selectedRow.title,
|
name: selectedRow.title,
|
||||||
@@ -199,7 +388,7 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
const submitSelectedReview = useCallback(async (action: "approve" | "reject") => {
|
const submitSelectedReview = useCallback(async (action: "approve" | "reject") => {
|
||||||
if (!selectedRow?.runDir || !selectedRow.scanResultId || !reviewDraft || !repositories?.automation) return;
|
if (!selectedRow?.runDir || !selectedRow.scanResultId || !reviewDraft || !repositories?.automation) return;
|
||||||
setReviewPending(true);
|
setReviewPending(true);
|
||||||
setReviewStatus(action === "approve" ? "Korrektur wird validiert..." : "Ergebnis wird abgelehnt...");
|
setReviewStatus(action === "approve" ? copy.reviewValidating : copy.reviewRejecting);
|
||||||
try {
|
try {
|
||||||
const artifact: NativeScannerReviewArtifactInput = {
|
const artifact: NativeScannerReviewArtifactInput = {
|
||||||
name: reviewDraft.name,
|
name: reviewDraft.name,
|
||||||
@@ -220,40 +409,52 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
note: reviewDraft.note,
|
note: reviewDraft.note,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setReviewStatus(result.error ?? "Review wurde blockiert.");
|
const message = result.error ?? copy.reviewBlocked;
|
||||||
|
setReviewStatus(message);
|
||||||
|
toast.error(copy.reviewBlocked, { description: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReviewStatus(action === "approve"
|
const message = action === "approve"
|
||||||
? `Review freigegeben. ${result.correctedFields.length} Feld(er) korrigiert. Eval-Sample: ${result.evalSampleSaved ? "gespeichert" : "ohne OCR-Payload"}.`
|
? copy.reviewApproved(result.correctedFields.length, result.evalSampleSaved)
|
||||||
: "Ergebnis wurde abgelehnt und bleibt von Promotion ausgeschlossen.");
|
: copy.reviewRejected;
|
||||||
|
setReviewStatus(message);
|
||||||
|
if (action === "approve") {
|
||||||
|
toast.success(copy.correctionApproved, { description: copy.correctionApprovedDescription });
|
||||||
|
} else {
|
||||||
|
toast.info(copy.resultRejected, { description: copy.resultRejectedDescription });
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
setReviewEditingId("");
|
setReviewEditingId("");
|
||||||
setReviewDraft(null);
|
setReviewDraft(null);
|
||||||
await refresh();
|
|
||||||
} catch (reviewError) {
|
} catch (reviewError) {
|
||||||
setReviewStatus(errorMessage(reviewError));
|
const message = errorMessage(reviewError);
|
||||||
|
setReviewStatus(message);
|
||||||
|
toast.error(copy.reviewFailed, { description: message });
|
||||||
} finally {
|
} finally {
|
||||||
setReviewPending(false);
|
setReviewPending(false);
|
||||||
}
|
}
|
||||||
}, [refresh, repositories, reviewDraft, selectedRow]);
|
}, [copy, refresh, repositories, reviewDraft, selectedRow, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
setSelectedPreview(null);
|
setSelectedPreview(null);
|
||||||
|
setPreviewLoading(false);
|
||||||
const automation = repositories?.automation;
|
const automation = repositories?.automation;
|
||||||
if (!automation) {
|
if (!automation) {
|
||||||
setPreviewStatus("Electron bridge nicht verfuegbar.");
|
setPreviewStatus(copy.electronBridgeUnavailable);
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!selectedRow?.runDir || !selectedRow.imagePath || selectedRow.source !== "native") {
|
if (!selectedRow?.runDir || !selectedRow.imagePath || selectedRow.source !== "native") {
|
||||||
setPreviewStatus(selectedRow?.imagePath ? "Preview nur fuer native Run-Crops verfuegbar." : "");
|
setPreviewStatus(selectedRow?.imagePath ? copy.nativePreviewOnly : "");
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreviewStatus("Preview wird geladen...");
|
setPreviewStatus(copy.previewLoading);
|
||||||
|
setPreviewLoading(true);
|
||||||
void automation.nativeScannerLoadImage({
|
void automation.nativeScannerLoadImage({
|
||||||
runDir: selectedRow.runDir,
|
runDir: selectedRow.runDir,
|
||||||
imagePath: selectedRow.imagePath,
|
imagePath: selectedRow.imagePath,
|
||||||
@@ -268,18 +469,23 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
});
|
});
|
||||||
setPreviewStatus(`${result.width}x${result.height}`);
|
setPreviewStatus(`${result.width}x${result.height}`);
|
||||||
} else {
|
} else {
|
||||||
setPreviewStatus(result?.error ?? "Preview konnte nicht geladen werden.");
|
setPreviewStatus(result?.error ?? copy.previewCouldNotLoad);
|
||||||
}
|
}
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
if (!canceled) setPreviewStatus(errorMessage(error));
|
if (!canceled) setPreviewStatus(errorMessage(error));
|
||||||
|
}).finally(() => {
|
||||||
|
if (!canceled) setPreviewLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}, [repositories, selectedRow?.id, selectedRow?.imagePath, selectedRow?.runDir, selectedRow?.source]);
|
}, [copy, repositories, selectedRow?.id, selectedRow?.imagePath, selectedRow?.runDir, selectedRow?.source]);
|
||||||
|
|
||||||
const reviewCount = allRows.filter((row) => row.needsReview).length;
|
const reviewCount = allRows.filter((row) => row.needsReview).length;
|
||||||
|
const evaluatedCount = allRows.filter((row) => row.valueStatus === "evaluated").length;
|
||||||
|
const unknownValueCount = allRows.filter((row) => row.valueStatus === "unknown" || row.valueStatus === "deferred").length;
|
||||||
|
const nonFiveStarCount = allRows.filter((row) => row.rarity !== null && row.rarity >= 1 && row.rarity <= 4).length;
|
||||||
const nativeCount = allRows.filter((row) => row.source === "native").length;
|
const nativeCount = allRows.filter((row) => row.source === "native").length;
|
||||||
const storedCount = allRows.filter((row) => row.source === "store").length;
|
const storedCount = allRows.filter((row) => row.source === "store").length;
|
||||||
const catalogSummary = useMemo(
|
const catalogSummary = useMemo(
|
||||||
@@ -295,21 +501,30 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
[nativeDataStatus, nativeResults, promotionSummary],
|
[nativeDataStatus, nativeResults, promotionSummary],
|
||||||
);
|
);
|
||||||
const statusText = loading
|
const statusText = loading
|
||||||
? "Inventory wird geladen..."
|
? copy.inventoryLoading
|
||||||
: error || `${rows.length}/${allRows.length} Eintraege sichtbar`;
|
: error || copy.visibleEntries(rows.length, allRows.length);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
selectedRow,
|
selectedRow,
|
||||||
selectedPreview,
|
selectedPreview,
|
||||||
previewStatus,
|
previewStatus,
|
||||||
|
previewLoading,
|
||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
|
searchQuery,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
statusText,
|
statusText,
|
||||||
promotionStatus,
|
promotionStatus,
|
||||||
promotionPending,
|
promotionPending,
|
||||||
promotionConfirming,
|
promotionConfirming,
|
||||||
|
deletionStatus,
|
||||||
|
deletionPending,
|
||||||
|
deletionConfirming,
|
||||||
|
deletionRemoveLinkedStoreRecord,
|
||||||
|
canDeleteSelected,
|
||||||
|
deletionTarget,
|
||||||
reviewEditing,
|
reviewEditing,
|
||||||
reviewPending,
|
reviewPending,
|
||||||
reviewStatus,
|
reviewStatus,
|
||||||
@@ -318,29 +533,39 @@ export function useInventoryViewModel({ snapshot }: InventoryViewProps): Invento
|
|||||||
nativeCount,
|
nativeCount,
|
||||||
storedCount,
|
storedCount,
|
||||||
reviewCount,
|
reviewCount,
|
||||||
|
evaluatedCount,
|
||||||
catalogSummary,
|
catalogSummary,
|
||||||
promotionSummary,
|
promotionSummary,
|
||||||
pipelineSummary,
|
pipelineSummary,
|
||||||
filterOptions: [
|
filterOptions: [
|
||||||
{ id: "all", label: "Alle", count: allRows.length },
|
{ id: "all", label: copy.filterLabel("all"), count: allRows.length },
|
||||||
{ id: "native", label: "Native", count: nativeCount },
|
{ id: "native", label: copy.filterLabel("native"), count: nativeCount },
|
||||||
{ id: "parsed", label: "Gelesen", count: allRows.length - reviewCount },
|
{ id: "parsed", label: copy.filterLabel("parsed"), count: allRows.length - reviewCount },
|
||||||
{ id: "review", label: "Review", count: reviewCount },
|
{ id: "review", label: copy.filterLabel("review"), count: reviewCount },
|
||||||
{ id: "promotable", label: "Speicherbar", count: promotionSummary.ready },
|
{ id: "evaluated", label: copy.filterLabel("evaluated"), count: evaluatedCount },
|
||||||
{ id: "stored", label: "Store", count: storedCount },
|
{ id: "unknown", label: copy.filterLabel("unknown"), count: unknownValueCount },
|
||||||
|
{ id: "non_five_star", label: copy.filterLabel("non_five_star"), count: nonFiveStarCount },
|
||||||
|
{ id: "promotable", label: copy.filterLabel("promotable"), count: promotionSummary.ready },
|
||||||
|
{ id: "stored", label: copy.filterLabel("stored"), count: storedCount },
|
||||||
],
|
],
|
||||||
sortOptions: [
|
sortOptions: [
|
||||||
{ id: "newest", label: "Neueste" },
|
{ id: "newest", label: copy.sortLabel("newest") },
|
||||||
{ id: "name", label: "Name" },
|
{ id: "name", label: copy.sortLabel("name") },
|
||||||
{ id: "status", label: "Status" },
|
{ id: "status", label: copy.sortLabel("status") },
|
||||||
|
{ id: "value", label: copy.sortLabel("value") },
|
||||||
],
|
],
|
||||||
setFilter,
|
setFilter,
|
||||||
setSort,
|
setSort,
|
||||||
|
setSearchQuery,
|
||||||
setSelectedId,
|
setSelectedId,
|
||||||
refresh,
|
refresh,
|
||||||
requestSelectedPromotion,
|
requestSelectedPromotion,
|
||||||
cancelSelectedPromotion,
|
cancelSelectedPromotion,
|
||||||
confirmSelectedPromotion,
|
confirmSelectedPromotion,
|
||||||
|
requestSelectedDeletion,
|
||||||
|
cancelSelectedDeletion,
|
||||||
|
setDeletionRemoveLinkedStoreRecord,
|
||||||
|
confirmSelectedDeletion,
|
||||||
startSelectedReview,
|
startSelectedReview,
|
||||||
cancelSelectedReview,
|
cancelSelectedReview,
|
||||||
setReviewDraft,
|
setReviewDraft,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user