commit e76d88e0c74fe222f6bf407f52b388f60943edae Author: AzuTear Date: Sun Jul 5 20:31:01 2026 +0200 chore: initialize repository baseline Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37fac22 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize line endings: store LF in the repo, check out native on Windows. +* text=auto eol=lf + +# Binary assets must never be line-ending converted. +*.traineddata binary +*.png binary +*.jpg binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccbf4ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Builds +dist/ +dist-electron/ +outputs/dist/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Local runtime data +*.local +.env +.env.* + +# OS/editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Temporary work +work/ + +# Local session/runtime state +.claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4574b3c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md + +This file defines how AI coding agents should work in this repository. + +## Core Principles + +1. Correctness comes before speed. +2. Preserve the scanner-first product goal: reduce artifact decisions, guide hopping, and manual bookkeeping. +3. Prefer small, reviewable changes over broad rewrites. +4. Do not invent Genshin game data, API behavior, OCR confidence, or optimization rules without documenting the assumption. +5. Keep all game interaction read-only by default. Never add delete, feed, enhance, memory-read, hook, or game-modification behavior. +6. Keep user work and local app state intact. +7. Validate Electron main-process changes with a build and, when practical, a manual Smart Capture smoke test. + +## Read Before Write + +Before editing code, inspect the relevant source files and the project documents: + +- `docs/PROJECT.md` +- `docs/ARCHITECTURE.md` +- `docs/CONVENTIONS.md` +- `docs/DECISIONS.md` +- `docs/CHECKLISTS.md` + +Use `rg` or `rg --files` for search. + +## Product Safety + +The app may capture the Genshin window or screen and may later support optional input automation. Treat those areas as high risk. + +- Capture must not require memory reads, hooks, injection, or game file modification. +- Input automation, if added, must be opt-in, reversible, whitelisted, and stoppable. +- The app must never delete, feed, enhance, or spend in-game resources. +- Uncertain OCR data must remain reviewable instead of being silently trusted. + +## Implementation Expectations + +- Keep Electron main-process code focused on OS integration, capture, and IPC. +- Keep React code focused on UI state and presentation. +- Keep parsing, scoring, and recommendation logic in `src/lib`. +- Keep domain types in `src/types`. +- Prefer deterministic parsers and data packages over hardcoded one-off exceptions. +- When OCR requires heuristics, record the limitation in docs or code comments. + +## Validation + +Run the strongest practical checks for the change: + +- `npm run lint` +- `npm test` +- `npm run build` + +For scanner changes, also manually verify Smart Capture against an open artifact detail view when possible. + +## Definition Of Done + +A task is done when: + +- The requested behavior exists. +- The implementation follows the documented architecture and conventions. +- Relevant checks passed or skipped checks are explained. +- Scanner/OCR changes fail safely into review or uncertainty. +- The final summary names the changed areas and remaining risks. diff --git a/README.md b/README.md new file mode 100644 index 0000000..983aee3 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Genshin Artifact Assistant + +Local Windows-first Electron app for scanning Genshin Impact artifacts, triaging them, and suggesting simple character builds without depending on Inventory Kamera or Genshin Optimizer as the main workflow. + +## Current MVP + +- Electron + React + TypeScript app shell. +- Dark purple fintech/glassmorphism UI direction. +- Genshin capture source discovery. +- Smart Capture that focuses Genshin, hides the app, captures the primary screen, and restores the app. +- Artifact detail-panel crop detection with focused OCR regions. +- OCR parser for artifact name, slot, main stat, substats, set, equipped character, confidence, and review notes. +- Demo triage and build suggestion views. +- Read-only overlay preview shell. + +## Documentation + +This project uses the engineering template from `https://git.noveria.net/bao/template` adapted to this app: + +- [AGENTS.md](AGENTS.md) +- [docs/PROJECT.md](docs/PROJECT.md) +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- [docs/CONVENTIONS.md](docs/CONVENTIONS.md) +- [docs/DECISIONS.md](docs/DECISIONS.md) +- [docs/CHECKLISTS.md](docs/CHECKLISTS.md) +- [docs/workflow.md](docs/workflow.md) + +## Run + +```powershell +npm install +npm run dev +``` + +Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge. + +### Automatischer Scan: als Administrator starten + +Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen: + +- `npm run dev:admin` (oder Doppelklick auf `dev-admin.cmd`) - öffnet aus einem normalen Terminal heraus einen UAC-Prompt und startet danach `npm run dev` in einem neuen Administrator-Fenster. +- Alternativ: Terminal per Rechtsklick "Als Administrator ausführen" öffnen und darin normal `npm run dev` starten. + +Der UAC-Prompt lässt sich nicht dauerhaft abschalten; das ist Windows-Design. Die gepackte App fordert Admin-Rechte über `requestedExecutionLevel: requireAdministrator` selbst an. Die App zeigt im Scanner-Header und in der Scanner-Diagnose nur an, ob sie gerade erhöht läuft - sie startet sich nicht selbst neu. + +## Validate + +```powershell +npm run lint +npm test +npm run build +``` + +## Safety Boundaries + +- No memory reads. +- No hooks or process injection. +- No game file modification. +- No automatic delete, enhance, feeding, or resource-spending actions. +- In-game lock/marking is a future opt-in module and remains disabled in this MVP. diff --git a/data/presets.json b/data/presets.json new file mode 100644 index 0000000..6d3a059 --- /dev/null +++ b/data/presets.json @@ -0,0 +1,120 @@ +{ + "sets": { + "marechaussee_hunter": "Marechaussee Hunter", + "golden_troupe": "Golden Troupe", + "emblem_of_severed_fate": "Emblem of Severed Fate", + "deepwood_memories": "Deepwood Memories", + "gilded_dreams": "Gilded Dreams", + "viridescent_venerer": "Viridescent Venerer", + "noblesse_oblige": "Noblesse Oblige", + "heart_of_depth": "Heart of Depth" + }, + "characters": [ + { + "id": "neuvillette", + "name": "Neuvillette", + "role": "main_dps", + "recommendedSets": ["marechaussee_hunter"], + "alternativeSets": ["heart_of_depth"], + "mainStats": { + "sands": ["HP%"], + "goblet": ["Hydro DMG Bonus", "HP%"], + "circlet": ["CRIT Rate", "CRIT DMG", "HP%"] + }, + "substatWeights": { + "CRIT Rate": 1.25, + "CRIT DMG": 1.2, + "HP%": 1, + "Energy Recharge": 0.55, + "Elemental Mastery": 0.15, + "ATK%": 0.05 + }, + "erTarget": 130, + "explanation": "HP scaling Hydro carry. Prioritize Marechaussee, HP%, crit, and enough ER for smooth rotations." + }, + { + "id": "furina", + "name": "Furina", + "role": "sub_dps", + "recommendedSets": ["golden_troupe"], + "alternativeSets": ["emblem_of_severed_fate", "noblesse_oblige"], + "mainStats": { + "sands": ["HP%", "Energy Recharge"], + "goblet": ["HP%", "Hydro DMG Bonus"], + "circlet": ["CRIT Rate", "CRIT DMG", "HP%"] + }, + "substatWeights": { + "Energy Recharge": 1.15, + "CRIT Rate": 1.05, + "CRIT DMG": 1, + "HP%": 0.95, + "Elemental Mastery": 0.1, + "ATK%": 0.05 + }, + "erTarget": 180, + "explanation": "Off-field HP scaling support/sub-DPS. Golden Troupe and ER comfort matter a lot." + }, + { + "id": "raiden_shogun", + "name": "Raiden Shogun", + "role": "main_dps", + "recommendedSets": ["emblem_of_severed_fate"], + "alternativeSets": ["gilded_dreams"], + "mainStats": { + "sands": ["Energy Recharge", "ATK%"], + "goblet": ["Electro DMG Bonus", "ATK%"], + "circlet": ["CRIT Rate", "CRIT DMG"] + }, + "substatWeights": { + "Energy Recharge": 1.2, + "CRIT Rate": 1.15, + "CRIT DMG": 1.1, + "ATK%": 0.75, + "Elemental Mastery": 0.25 + }, + "erTarget": 220, + "explanation": "Burst-focused carry. Emblem, ER, crit, and ATK are the safe default priorities." + }, + { + "id": "nahida", + "name": "Nahida", + "role": "support", + "recommendedSets": ["deepwood_memories"], + "alternativeSets": ["gilded_dreams"], + "mainStats": { + "sands": ["Elemental Mastery"], + "goblet": ["Elemental Mastery", "Dendro DMG Bonus"], + "circlet": ["Elemental Mastery", "CRIT Rate", "CRIT DMG"] + }, + "substatWeights": { + "Elemental Mastery": 1.25, + "CRIT Rate": 0.85, + "CRIT DMG": 0.8, + "Energy Recharge": 0.45, + "ATK%": 0.1 + }, + "explanation": "Dendro enabler. Deepwood is the default team value; EM pieces are usually worth checking." + }, + { + "id": "kazuha", + "name": "Kaedehara Kazuha", + "role": "support", + "recommendedSets": ["viridescent_venerer"], + "alternativeSets": ["gilded_dreams"], + "mainStats": { + "sands": ["Elemental Mastery", "Energy Recharge"], + "goblet": ["Elemental Mastery"], + "circlet": ["Elemental Mastery"] + }, + "substatWeights": { + "Elemental Mastery": 1.2, + "Energy Recharge": 1, + "CRIT Rate": 0.15, + "CRIT DMG": 0.1, + "ATK%": 0.05 + }, + "erTarget": 160, + "explanation": "Swirl support. Viridescent and EM mainstats are the no-brainer target." + } + ] +} diff --git a/dev-admin.cmd b/dev-admin.cmd new file mode 100644 index 0000000..67dfd40 --- /dev/null +++ b/dev-admin.cmd @@ -0,0 +1,23 @@ +@echo off +rem Startet den Dev-Modus mit Administratorrechten (ein UAC-Prompt erscheint). +rem Noetig, weil Genshin erhoeht laeuft: Windows (UIPI) verwirft sonst alle +rem simulierten Maus-Eingaben an das Spiel - SendInput meldet trotzdem Erfolg. +rem Der Punkt hinter %~dp0 verhindert, dass der abschliessende Backslash das +rem schliessende Anfuehrungszeichen escaped. +rem -NoExit haelt das erhoehte (innere) Fenster offen, selbst wenn das Skript +rem einen Fehler wirft oder npm run dev sofort wieder beendet. +rem +rem Dieses AEUSSERE Fenster (das du beim Doppelklick oder ueber +rem "npm run dev:admin" siehst) schloss sich frueher sofort, sobald +rem Start-Process zurueckkehrte - auch wenn UAC abgelehnt wurde oder die +rem Elevation ganz fehlschlug, ohne dass davon irgendetwas sichtbar war. +rem try/catch + timeout zeigen jetzt den Fehler und halten das Fenster kurz offen. +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$script='%~dp0scripts\dev-admin-start.ps1'; $project='%~dp0.'; try { Start-Process powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File',$script,'-ProjectRoot',$project) -Verb RunAs -ErrorAction Stop; Write-Host ''; Write-Host 'UAC-Abfrage gestartet. Bitte bestaetigen - danach oeffnet sich ein neues Administrator-Fenster mit npm run dev.' -ForegroundColor Green } catch { Write-Host ''; Write-Host 'Admin-Start fehlgeschlagen oder UAC-Abfrage abgelehnt:' -ForegroundColor Red; Write-Host $_.Exception.Message -ForegroundColor Red }" +echo. +echo Dieses Fenster kannst du jetzt schliessen (Taste druecken oder 15s warten). Das eigentliche Programm laeuft im neuen Administrator-Fenster. +rem timeout statt pause/choice: pause und choice warten unter umgeleiteter +rem Standardeingabe (z.B. beim Testen ueber ein Skript) fuer immer, weil sie +rem auf ein echtes Konsolen-Handle angewiesen sind. timeout erkennt eine +rem umgeleitete Eingabe explizit und bricht sofort ab statt zu haengen, waehrend +rem es bei einem echten Doppelklick normal 15s wartet oder bei Tastendruck endet. +timeout /t 15 >nul 2>&1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..776da75 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,144 @@ +# Architecture + +This document describes the structure, boundaries, flows, and technical rules of the system. + +## High-Level Overview + +Genshin Artifact Assistant is a local desktop application. Electron owns OS integration, screen capture, IPC, and overlay windows. React owns the interactive UI. Domain logic for OCR parsing, scoring, scanner state, and recommendations lives in TypeScript modules under `src/lib`. + +```mermaid +flowchart LR + User["User"] + Genshin["Genshin Impact Window"] + Electron["Electron Main Process"] + React["React Renderer"] + Parser["OCR Parser and Scoring"] + LocalData["Local Snapshot / Future SQLite"] + + User --> React + React --> Electron + Electron --> Genshin + Electron --> React + React --> Parser + Parser --> React + React --> LocalData +``` + +## Architecture Style + +| Field | Value | +| --- | --- | +| Style | Desktop modular monolith | +| Primary reason | One local app with tight UI, capture, and parsing feedback loops | +| Main trade-off | Faster iteration now, but scanner heuristics must stay isolated to avoid UI coupling | + +## Folder Structure + +```text +/ + electron/ Electron main process and preload bridge + src/ React app, domain types, parser/scoring logic + data/ Seed or package data + docs/ Project documentation and decisions + dist/ Generated renderer build + dist-electron/ Generated Electron build + outputs/ Packaged app output +``` + +## Module Responsibilities + +| Module | Responsibility | +| --- | --- | +| `electron/main.ts` | Window lifecycle, capture source listing, Smart Capture, OCR crop generation, overlay window IPC, persistent PowerShell input/capture helper, JSON artifact store | +| `electron/preload.cjs` | Safe renderer bridge exposed as `window.assistantApi` | +| `src/lib/artifactStore.ts` | Pure signature/id/record helpers for the persistent artifact store | +| `src/App.tsx` | Main app shell, scan view, triage view, build view, overlay preview | +| `src/lib/artifactOcrParser.ts` | Converts OCR output into a parsed artifact candidate with confidence and notes | +| `src/lib/fuzzyMatch.ts` | Generic fuzzy string matching for OCR text against known game data | +| `src/lib/scoring.ts` | Recommendation and build scoring logic | +| `src/lib/demoData.ts` | Temporary local demo snapshot | +| `src/data/genshinGameData.json` | Generated local dictionary of characters, artifact sets, slots, and stats | +| `scripts/generate-genshin-data.cjs` | Regenerates the local Genshin dictionary from `genshin-db` | +| `src/types/*` | Shared app, capture, and domain contracts | + +## Dependency Rules + +- Renderer code calls Electron only through the preload bridge. +- Electron main process must not import React renderer modules. +- Pure parsing and scoring modules must not depend on Electron APIs. +- OCR uncertainty must be represented in data, not hidden in UI only. +- Generated folders must not be treated as source of truth. + +## Smart Capture Flow + +```mermaid +sequenceDiagram + participant UI as React Scan UI + participant Bridge as Preload Bridge + participant Main as Electron Main + participant Game as Genshin Window + participant OCR as OCR Worker + + UI->>Bridge: captureSource(sourceId, 0, focusGenshin=true) + Bridge->>Main: IPC capture:captureSource + Main->>Main: Hide app window + Main->>Game: Focus Genshin window + Main->>Main: Capture primary screen via GDI + Main->>Main: Detect artifact detail panel + Main->>OCR: OCR focused crops + OCR-->>Main: Text and confidence + Main->>Main: Restore app window + Main-->>UI: Full capture, detail preview, crops, OCR results + UI->>UI: Parse candidate and show result +``` + +## Data Flow + +| Data | Source | Owner | Consumers | +| --- | --- | --- | --- | +| Capture sources | Electron desktopCapturer | Electron main | Scan UI | +| Screenshot | Windows GDI / desktopCapturer | Electron main | Cropper, OCR, UI preview | +| OCR crops | Electron main | Electron main | Details modal, parser | +| Game dictionary | `genshin-db` generated JSON | `src/data/genshinGameData.json` | OCR parser | +| Parsed artifact candidate | OCR parser | Renderer domain logic | Result panel, future local DB | +| Review samples | User action in Scan UI | Electron userData `review-samples.jsonl` | Future regression tests and OCR training | +| Stored artifacts | Manual/automatic scans | Electron userData `artifact-store.json` (dedupe by content signature) | Future triage, recommendations, SQLite migration | +| Recommendations | Scoring module | Renderer domain logic | Triage and builds views | + +## Scan Modes + +**Manual scan** is read-only: the user clicks artifacts in Genshin, the app repeatedly runs Smart Capture, deduplicates by content signature, persists new artifacts to the local store, and saves review samples when crops/OCR are missing, total confidence is low, field confidence is low, or parser notes indicate incomplete data. + +**Automatic grid scan** is user-triggered input automation limited to clicking detected inventory tiles and wheel-scrolling the inventory. Safety and reliability rules: + +- All input goes through one persistent PowerShell helper process (`input-helper.ps1` in userData) that compiles the Win32 interop once and speaks JSON over stdin/stdout (ops: ping, focus, cursor, click, scroll, capture). Mouse movement is sent as iterated relative SendInput deltas (what a real mouse produces): Genshin tracks the cursor via raw input and snaps the OS cursor back to its own position every frame, so SetCursorPos/absolute moves silently stop working once the game owns the cursor. The helper verifies the cursor reached the target and refuses to click otherwise. +- Failsafe: before every click and scroll the renderer polls cursor position and ESC state. Holding ESC or moving the mouse away from the last automated position aborts the scan immediately; the Stop button also aborts. Only the `GetAsyncKeyState` held-down bit (0x8000) is used - the "pressed since last call" bit fires for stale ESC presses from normal Genshin menu navigation and caused false aborts. +- SendInput's return value is checked: zero injected events (UIPI, e.g. elevated Genshin vs. non-elevated app) aborts with an explicit hint instead of silently clicking into nothing. +- Click verification: after each click the parsed detail-panel signature should change. An unchanged signature is a soft miss (it can also mean two OCR-identical neighbor pieces, common among +0 artifacts), so it is retried once with a small offset, logged with the stuck artifact name, and then skipped - never fatal on its own. The scan aborts only when the first ~6 clicks of page 1 produce nothing new (diagnosis hint: elevated Genshin blocks SendInput via UIPI, or grid coordinates are wrong) or a later page yields zero new artifacts. +- Scan stats separate clicked (click attempts), parsed (readable captures), stored (persisted), review (review samples), duplicates, and misses, so "scanned" cannot be mistaken for "successfully read". +- Scrolling sends one wheel notch per grid row with the cursor anchored over the inventory (assumption: roughly one row per notch; overlap is absorbed by dedupe, and a page without new artifacts stops the scan). +- The scan never deletes, enhances, feeds, locks, or spends anything; it only selects tiles to read them. + +Parsed artifacts from both modes are persisted into `artifact-store.json` keyed by a content signature that excludes the equipped character, so re-equipping updates a record instead of duplicating it. Leveling an artifact currently creates a new record (documented limitation until rescan-merge exists). + +## Security And Safety + +| Area | Policy | +| --- | --- | +| Game access | Screen capture only; no memory reads, hooks, or process injection | +| Automation | Future feature only, opt-in and reversible | +| Data privacy | Local-first; no upload path in scanner MVP | +| Secrets | No cookies or API tokens required for scanner MVP | +| Unsafe actions | Never delete, feed, enhance, or spend resources | + +## Error Handling + +- Capture failures should surface in the scan status row. +- Missing Electron bridge should explain that browser preview cannot capture Genshin. +- OCR failures should leave the capture available and mark parsed fields as unknown. +- Low-confidence data should go to review instead of silent acceptance. +- Parsed artifact fields carry individual confidence and source metadata so the UI can show uncertainty per field. + +## Performance + +Current OCR is prototype-grade and may be slower than the target scanner. Two batch-scan bottlenecks were removed: input/capture no longer spawn a PowerShell process (and recompile Win32 interop) per action, and the Tesseract worker is created once and reused across captures. The eventual batch scanner should still move expensive capture/OCR/build work into workers or a native sidecar. diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md new file mode 100644 index 0000000..3678290 --- /dev/null +++ b/docs/CHECKLISTS.md @@ -0,0 +1,37 @@ +# Checklists + +## Scanner Change + +- [ ] The expected crop or capture behavior is clear. +- [ ] Genshin is not accessed through memory reads, hooks, injection, or game files. +- [ ] Capture failures are shown to the user. +- [ ] OCR uncertainty remains inspectable in Details. +- [ ] Parser output does not silently trust low-confidence text. +- [ ] `npm run lint` passes. +- [ ] `npm test` passes when parser/scoring logic changed. +- [ ] `npm run build` passes. +- [ ] Manual Smart Capture is tested when possible. + +## UI Change + +- [ ] The main workflow remains visible without unnecessary scrolling. +- [ ] Debug or secondary information is moved behind buttons/modals where appropriate. +- [ ] Disabled states prevent actions without required data. +- [ ] Text fits in controls and panels. +- [ ] Desktop viewport is checked manually. +- [ ] `npm run build` passes. + +## Parser Or Scoring Change + +- [ ] Known-good OCR samples still parse correctly. +- [ ] Ambiguous data becomes unknown or review, not false certainty. +- [ ] Character, set, stat, and slot dictionaries are updated deliberately. +- [ ] Tests cover changed behavior when practical. + +## Release Or Packaging + +- [ ] Production build succeeds. +- [ ] Electron preload bridge is copied to `dist-electron/preload.cjs`. +- [ ] App starts outside browser preview. +- [ ] Smart Capture bridge is available. +- [ ] No generated debug artifacts are included accidentally. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md new file mode 100644 index 0000000..980bd95 --- /dev/null +++ b/docs/CONVENTIONS.md @@ -0,0 +1,50 @@ +# Conventions + +This document defines project engineering standards. + +## Naming + +- Use Genshin domain language where it makes behavior clearer: artifact, slot, set, main stat, substat, triage, build. +- Name booleans as predicates such as `isScanning`, `bridgeReady`, or `isGenshinCandidate`. +- Prefer explicit scanner names such as `createArtifactCrops` over generic names such as `processImage`. + +## File Organization + +- Keep Electron OS integration in `electron/`. +- Keep React components in `src/`, with extraction when `App.tsx` becomes hard to review. +- Keep pure domain logic in `src/lib/`. +- Keep shared contracts in `src/types/`. +- Keep generated outputs in `dist/`, `dist-electron/`, and `outputs/`. + +## UI Rules + +- The scan page should prioritize the capture workspace over secondary status content. +- Details and debug information belong in modals or secondary panels. +- Avoid long, overfilled cards on scanner pages. +- The design direction is dark purple fintech glassmorphism with premium, focused controls. +- Disable buttons when their required data does not exist. + +## Scanner Rules + +- Prefer focused crops over full-screen OCR. +- Confidence and raw OCR details must remain inspectable. +- Heuristics should fail safely into unknown fields or review notes. +- Do not add irreversible game actions. + +## TypeScript Rules + +- Keep strict type checks passing. +- Do not use `any` for capture, OCR, artifact, or recommendation contracts unless a boundary genuinely requires it. +- Parser functions should be deterministic and testable. + +## Testing + +- Use unit tests for parser and scoring logic. +- Use build/type checks for Electron IPC contract changes. +- Use manual Smart Capture smoke tests for crop and capture changes. + +## Documentation + +- Update `docs/PROJECT.md` when product scope changes. +- Update `docs/ARCHITECTURE.md` when module boundaries or flows change. +- Add an ADR to `docs/DECISIONS.md` for durable technical trade-offs. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..43c00f5 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,135 @@ +# Decisions + +This document contains Architecture Decision Records. + +## ADR Index + +| ID | Title | Status | Date | +| --- | --- | --- | --- | +| ADR-001 | Build a local Electron app first | Accepted | 2026-07-04 | +| ADR-002 | Use screen capture as the primary scanner source | Accepted | 2026-07-04 | +| ADR-003 | Keep APIs and GOOD compatibility optional | Accepted | 2026-07-04 | +| ADR-004 | Treat in-game marking as a later opt-in feature | Accepted | 2026-07-04 | +| ADR-005 | Use a generated Genshin data package for OCR matching | Accepted | 2026-07-04 | +| ADR-006 | Persistent input helper and JSON artifact store before SQLite | Accepted | 2026-07-04 | + +## ADR-001: Build A Local Electron App First + +### Status + +Accepted + +### Context + +The product needs a Windows desktop UI, local screen capture, possible overlay windows, and future optional input automation. + +### Decision + +Use Electron with React and TypeScript for the MVP. + +### Consequences + +- Fast UI iteration and easy local packaging. +- Electron main-process code must be treated as a separate boundary from renderer code. +- Native or Rust sidecars can be added later for high-performance capture/OCR work. + +## ADR-002: Use Screen Capture As The Primary Scanner Source + +### Status + +Accepted + +### Context + +The user wants an app that works without Inventory Kamera, Genshin Optimizer, Enka, or HoYoLAB as core dependencies. + +### Decision + +Use local screen capture as the primary source. Current Smart Capture focuses Genshin, hides the app, captures the primary screen through Windows GDI, detects the artifact detail panel, then OCRs focused crops. + +### Consequences + +- The app remains offline-first. +- OCR and crop reliability are core product risks. +- UI language, resolution, HDR, and game layout changes need explicit test coverage. + +## ADR-003: Keep APIs And GOOD Compatibility Optional + +### Status + +Accepted + +### Context + +External APIs and existing optimizer formats can speed up setup, but should not define the main user workflow. + +### Decision + +Keep Enka, HoYoLAB, Akasha, Genshin Optimizer, and GOOD import/export as optional future compatibility layers. + +### Consequences + +- The app can work without external accounts or cookies. +- Data package and scanner quality become more important. +- Compatibility can be added when it helps testing, migration, or export. + +## ADR-004: Treat In-Game Marking As A Later Opt-In Feature + +### Status + +Accepted + +### Context + +Locking or marking artifacts in game may save time, but input automation increases ToS and misclick risk. + +### Decision + +Do not ship in-game marking in the scanner MVP. If implemented later, it must be off by default, reversible, whitelisted, previewed before execution, and stoppable with ESC or user mouse movement. + +### Consequences + +- Early scanner work stays lower risk. +- App-internal triage remains the first decision layer. +- No delete, feed, enhance, or resource-spending automation is allowed. + +## ADR-005: Use A Generated Genshin Data Package For OCR Matching + +### Status + +Accepted + +### Context + +Hardcoded arrays for characters and artifact sets caused repeated scanner failures whenever the user tested a newer character, set, or artifact name. + +### Decision + +Generate `src/data/genshinGameData.json` from `genshin-db` and use it as the local matching dictionary for artifact sets, artifact piece names, characters, slots, main stats, and substats. + +### Consequences + +- The scanner can recognize new characters and sets as soon as the local data package is regenerated from an updated `genshin-db`. +- Parser logic stays generic and testable instead of growing one-off fixes. +- OCR still needs good crops and text quality; the data package improves recognition but cannot solve unreadable screenshots by itself. + +## ADR-006: Persistent Input Helper And JSON Artifact Store Before SQLite + +### Status + +Accepted + +### Context + +Per-action PowerShell scripts recompiled the Win32 interop for every click, scroll, and capture (1-2s each) and one `Marshal::SizeOf` call was broken in Windows PowerShell 5.1, so SendInput clicks silently never executed. Scan results were also not persisted anywhere; only review samples reached disk. Adding `better-sqlite3` (native module) was considered too heavy for this step. + +### Decision + +Run one persistent PowerShell helper process (compiled once, JSON protocol over stdin/stdout) for focus, cursor/ESC state, click, scroll, and GDI capture. Persist parsed artifacts into `artifact-store.json` in userData, deduplicated by a content signature that excludes the equipped character. Keep SQLite as the planned future store; the JSON store is the migration source. + +### Consequences + +- Batch scans become fast enough to be testable and the failsafe (ESC or user mouse movement aborts) can poll cheaply between actions. +- Automated clicks are verified by checking that the parsed detail signature changed; repeated failures abort with a diagnosis hint instead of clicking blindly. +- Leveling an artifact changes its signature and creates a new record; rescan-merge is an open follow-up. +- If the helper process dies it is respawned on the next request; pending requests fail loudly instead of hanging. diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..ee2824c --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,242 @@ +# Project + +This document is the source of truth for project intent, scope, runtime facts, and operational expectations. + +For implementation structure, see [ARCHITECTURE.md](ARCHITECTURE.md). For engineering standards, see [CONVENTIONS.md](CONVENTIONS.md). + +## Project Identity + +| Field | Value | +| --- | --- | +| Project name | Genshin Artifact Assistant | +| Status | Scanner rebuild in progress | +| Platform | Windows desktop | +| Target users | Genshin Impact players who want artifact decisions without complex optimizer setup | +| Runtime | Electron app with React UI and TypeScript | + +## Vision + +Genshin Artifact Assistant should make artifact management feel like a no-brainer. The user opens Genshin, runs a scan, and receives simple decisions: what is good, who can use it, what can probably be marked as trash, and which builds are currently available. + +The app is not intended to replace deep min-max tools. It prioritizes time savings, confidence, and understandable recommendations over perfect theorycrafting. + +## Goals + +- Make artifact scanning stable enough that a normal user can trust it without babysitting every click. +- Build one local canonical Genshin data package for artifact sets, pieces, slots, stats, and characters. +- Parse artifact name, slot, main stat, substats, set, equipped state, and confidence deterministically against that package. +- Save weak or failed reads automatically as review samples and turn corrections into reusable local fixes. +- Keep the app offline-first and usable without Genshin Optimizer, Inventory Kamera, Enka, or HoYoLAB. +- Re-introduce recommendations only after the scanner base is trustworthy. + +## Non-Goals + +- No memory reads, process hooks, game modification, packet inspection, or anti-cheat bypassing. +- No automatic deleting, feeding, enhancing, or spending resources. +- No advanced formula editor or full power-user optimizer in the MVP. +- No cloud sync by default. + +## Functional Requirements + +| ID | Requirement | Priority | Status | +| --- | --- | --- | --- | +| FR-001 | List capture sources and automatically prefer the detected Genshin window when available. | Must | Prototype | +| FR-002 | Read one currently opened artifact reliably from the local screen and show its parsed result. | Must | Prototype | +| FR-003 | Generate and maintain a local canonical Genshin data package for sets, pieces, slots, stats, characters, aliases, and UI profiles. | Must | In progress | +| FR-004 | Parse artifact fields only through deterministic matching, validation, and derivation against the canonical package. | Must | In progress | +| FR-005 | Run a stable automatic inventory scan: detect grid, click tile, verify detail change, parse, store, continue, scroll, resume. | Must | Prototype | +| FR-006 | Save low-confidence, failed, conflicting, or stale scans automatically as review samples with reason codes. | Must | Prototype | +| FR-007 | Apply local learned fixes from review corrections before every new parse. | Must | Prototype | +| FR-008 | Keep the scan UI operator-friendly: main preview first, debug in modals or drawers, completion summary after scan. | Must | In progress | +| FR-009 | Provide account-level artifact triage after scanner trust is acceptable. | Should | Pending | +| FR-010 | Provide 1-3 simple build suggestions per character from owned artifacts after scanner trust is acceptable. | Should | Pending | +| FR-011 | Farming overlay for reward scans. | Later | Prototype shell | + +## Non-Functional Requirements + +| Category | Requirement | Measurement | +| --- | --- | --- | +| Safety | Never perform irreversible in-game actions. | Code review and manual test | +| Performance | Single artifact read should feel interactive and batch scan should not stall on false progress. | Capture latency monitored manually; auto-scan stops on blocked verification | +| Privacy | Captures and parsed data stay local by default. | No remote upload in scanner path | +| Reliability | Uncertain OCR must be visible to the user. | Confidence and details view | +| 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 | + +## Tech Stack + +| Area | Choice | Notes | +| --- | --- | --- | +| Desktop shell | Electron | Windows local app and overlay windows | +| Frontend | React + TypeScript + Vite | UI and client state | +| Styling | CSS with dark purple glassmorphism system | Premium fintech-inspired visual direction | +| OCR | Tesseract.js prototype plus deterministic normalization/derivation | OCR alone is not trusted as the decision source | +| Capture | Electron desktopCapturer plus Windows GDI Smart Capture | GDI path is used for Genshin Smart Capture reliability | +| Input automation | PowerShell sidecar prototype now, native sidecar planned | Current sidecar is good for proving behavior, not the final production path | +| Tests | Vitest + TypeScript checks | Current validation baseline; regression samples must expand | +| Packaging | electron-builder | Configured in `package.json` | + +## Runtime + +| Environment | Entry Point | Notes | +| --- | --- | --- | +| Local dev | `npm run dev` | Starts Vite and Electron | +| Local dev with automation | `npm run dev:admin` | Required when `GenshinImpact.exe` is elevated; Windows blocks lower-integrity cursor/click input | +| Production build | `npm run build` | Builds React and Electron main process | +| Preview | `npm run preview` | Browser preview only; capture bridge is unavailable | + +## Current State Review + +### What already works + +- The app can enumerate capture sources and often identify the Genshin window automatically. +- Single-artifact capture is no longer blind full-screen OCR; it produces detail crops, OCR blocks, parsed fields, confidence, and notes. +- A local canonical data package already exists in `src/data/genshinGameData.json`, generated from `genshin-db`. +- The parser already uses known sets, pieces, slots, stat aliases, set aliases, character aliases, and derived slot/set mapping. +- Review samples, learned replacements, parser notes, and stored artifacts already persist locally. +- The auto-scan loop is no longer a naive click spammer: it has preflight, verification, miss handling, page fingerprinting, and stop conditions. + +### What is still structurally weak + +- The scan experience is still partly orchestrated from `src/App.tsx`, which makes behavior changes harder than they should be. +- The current PowerShell input sidecar is serviceable for experimentation but not a strong production base for long-running, low-jitter auto-scan. +- OCR quality is still inconsistent enough that some fields are recovered by fallback and derivation more often than they should be. +- Learned fixes currently focus on text replacements; they do not yet update crop offsets, UI profile variants, or scanner targeting rules in a structured way. +- The scan page is cleaner than before, but it still exposes too much operator/debug state in the main flow. +- Recommendations and build logic exist, but the scanner is not yet reliable enough to make them the core focus. + +### Current product conclusion + +The app should stop behaving like an OCR demo with extra features around it. The next phase is a scanner product rebuild: canonical data first, scan engine second, learning loop third, recommendations later. + +## Product Direction + +- Artifact scanning is the first-class feature. +- Character optimization returns only after scan quality is trustworthy. +- Team building stays out of the critical path until artifact ingestion is stable. +- Inventory Kamera remains a reference for scan choreography and page movement, not a runtime dependency. +- Self-learning stays deterministic and local first: review samples, aliases, crop offsets, and UI profile tuning before any ML retraining discussion. + +## Execution Plan + +### Phase 0 - Stabilize the operator surface + +Outcome: +- Scan page reduced to source, main preview, result panel, primary scan actions, and compact status. +- Diagnostics, logs, crops, confidence breakdown, review queue, and learning internals moved behind modal or drawer entry points. +- Scan completion popup summarizes scanned, stored, duplicates, review samples, blocked reason, and elapsed time. + +Status: +- In progress + +### Phase 1 - Canonical game data package + +Outcome: +- `scripts/generate-genshin-data.cjs` emits one stricter package contract for: + - artifact sets + - artifact pieces + - slot-by-piece mapping + - stats and allowed mains by slot + - characters + - aliases + - UI profiles + - source version metadata +- Parser regression tests run against saved review samples and known bad cases. +- Parser stops "free guessing" outside the canonical package. + +Status: +- In progress + +### Phase 2 - Deterministic parser hardening + +Outcome: +- Name, slot, set, main stat, and equipped fields are parsed through layered validation: + 1. direct OCR cleanup + 2. alias normalization + 3. exact package match + 4. constrained fuzzy match + 5. safe derivation from piece/slot/value references +- Main stat/value inference is tightened with slot constraints and reference tables. +- Bad parses automatically generate structured review reasons. + +Status: +- In progress + +### Phase 3 - Scanner core rebuild + +Outcome: +- Auto-scan becomes a dedicated engine with explicit states: + - preflight + - grid detection + - click target + - wait stable + - detail verify + - parse + - store or review + - next tile + - row scroll + - resume or stop +- Progress counts only when a new verified artifact or duplicate signature is confirmed. +- Repeated pages, unchanged detail cards, blocked cursor movement, and scroll failures stop the scan with diagnosis instead of producing fake progress. + +Status: +- In progress + +### Phase 4 - Input automation replacement + +Outcome: +- Replace the production automation path with a persistent Windows sidecar dedicated to: + - focus + - move + - click + - scroll + - capture + - probe +- Session probe decides which input mode works before auto-scan is unlocked. +- Auto-scan never starts on a session that cannot prove one successful detail-card change. + +Status: +- Planned + +### Phase 5 - Learning loop that actually compounds + +Outcome: +- Weak scans save themselves as review samples automatically. +- User corrections update local: + - text replacements + - alias maps + - crop offsets + - UI profile adjustments + - constrained set/piece/slot fixes +- "Apply learned fixes" runs before every parse. +- Review samples become both parser regression fixtures and learning inputs. + +Status: +- Planned + +### Phase 6 - Recommendations come back on top of a trusted scanner + +Outcome: +- Account snapshot and build suggestions are only promoted once scan quality is high enough to trust owned artifacts. +- Recommendations explain uncertainty and surface conflicts instead of pretending perfect certainty. + +Status: +- Deferred until scanner trust is acceptable + +## Immediate Next Implementation Order + +1. Finish scan-page cleanup so the main operator view is no longer noisy. +2. Tighten the game data generator and parser contract, then backfill regression tests from real bad samples. +3. Continue moving auto-scan behavior out of `App.tsx` and into isolated scanner modules. +4. Replace or wrap the current PowerShell sidecar with a more stable long-lived automation process. +5. Extend the learning system from text-only fixes into crop/UI profile tuning. +6. Resume recommendation work only when scan accuracy is consistently trustworthy. + +## Open Questions + +| Question | Status | +| --- | --- | +| Should the production input sidecar be Rust/C++ first, or a transitional Node native addon, for the next iteration? | 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 | +| Which Genshin UI languages should be supported after English once the scanner contract is stable? | Open | diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md new file mode 100644 index 0000000..42499b4 --- /dev/null +++ b/docs/PROMPTS.md @@ -0,0 +1,37 @@ +# Prompts + +## Scanner Bug Report + +Use this when reporting a capture/OCR issue: + +```text +Genshin resolution: +Display scaling: +Selected source: +Expected crop: +Actual crop: +Screenshot of scan page: +Screenshot of Details modal: +OCR candidates text: +``` + +## Feature Request + +```text +Goal: +User workflow: +Must-have behavior: +Nice-to-have behavior: +Safety constraints: +Validation idea: +``` + +## Review Request + +```text +Please review this change for: +- scanner safety +- parser correctness +- UI clarity +- validation gaps +``` diff --git a/docs/branching.md b/docs/branching.md new file mode 100644 index 0000000..ccbb713 --- /dev/null +++ b/docs/branching.md @@ -0,0 +1,10 @@ +# Branching + +This project is currently developed locally. When it becomes a Git repository, use focused branches: + +- `feature/` for new user-facing features. +- `fix/` for bug fixes. +- `docs/` for documentation-only work. +- `scanner/` for capture, OCR, crop, or parser work. + +Keep branches small enough to review and validate quickly. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..1275e20 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,25 @@ +# Release Process + +## Local Release Readiness + +Before packaging: + +1. Run `npm run lint`. +2. Run `npm test`. +3. Run `npm run build`. +4. Start the Electron app. +5. Run a manual Smart Capture smoke test. + +## Packaging + +The package configuration lives in `package.json` under `build`. + +Future packaging command: + +```powershell +npx electron-builder --win +``` + +## Rollback + +Until releases are formalized, rollback means returning to the last known working project folder or Git commit. Do not overwrite user data or local snapshots during rollback. diff --git a/docs/repository-setup.md b/docs/repository-setup.md new file mode 100644 index 0000000..e687383 --- /dev/null +++ b/docs/repository-setup.md @@ -0,0 +1,29 @@ +# Repository Setup + +## Install + +```powershell +npm install +``` + +## Development + +```powershell +npm run dev +``` + +Use the Electron window for scanner work. Browser preview does not expose the capture bridge. + +## Validation + +```powershell +npm run lint +npm test +npm run build +``` + +## Notes + +- Genshin should be visible on the primary display for the current Smart Capture path. +- The app is Windows-first. +- Scanner OCR is still prototype-grade. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..efec057 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,47 @@ +# Development Workflow + +## 1. Clarify Scope + +Define whether the task affects scanner capture, OCR parsing, recommendations, UI, documentation, or packaging. Scanner and automation work require extra safety review. + +## 2. Inspect Existing Code + +Use `rg` and read the relevant files before editing. For this project, likely starting points are: + +- `electron/main.ts` +- `electron/preload.cjs` +- `src/App.tsx` +- `src/lib/artifactOcrParser.ts` +- `src/lib/scoring.ts` +- `src/styles/global.css` + +## 3. Implement Small Changes + +Keep edits focused. Avoid mixing UI redesign, scanner logic, parser logic, and documentation unless the request explicitly spans them. + +## 4. Validate + +Default validation: + +```powershell +npm run lint +npm test +npm run build +``` + +For scanner changes, restart Electron after build or after changing `electron/main.ts`, because the main process does not hot reload reliably. + +## 5. Manual Smoke Test + +For Smart Capture: + +1. Open Genshin in borderless/windowed mode. +2. Open the artifact inventory detail view. +3. Click `Sources` and confirm Genshin is selected or available. +4. Click `Smart Capture`. +5. Confirm the preview shows the artifact detail panel. +6. Open `Details` and inspect crops/OCR. + +## 6. Review Outcome + +Document important scanner heuristics, accepted limitations, and future fixes in `docs/DECISIONS.md` or this workflow when they become durable. diff --git a/electron/bootstrap/index.ts b/electron/bootstrap/index.ts new file mode 100644 index 0000000..d62d7a9 --- /dev/null +++ b/electron/bootstrap/index.ts @@ -0,0 +1,2 @@ +export * from "./ipcBootstrap.js"; +export * from "./repositoryContext.js"; diff --git a/electron/bootstrap/ipcBootstrap.ts b/electron/bootstrap/ipcBootstrap.ts new file mode 100644 index 0000000..511dc16 --- /dev/null +++ b/electron/bootstrap/ipcBootstrap.ts @@ -0,0 +1,98 @@ +import { registerAppHandlers } from "../ipc/appHandlers.js"; +import { registerCaptureHandlers } from "../ipc/captureHandlers.js"; +import { registerPersistenceHandlers } from "../ipc/persistenceHandlers.js"; +import type { + BooleanResult, + FocusGenshinResult, + RuntimeInfo, + LoadScannerLearningRulesResult, + SaveScannerLearningRulesResult, + ScannerLearningRulePayload, + CaptureOptions, + CaptureResult, + CaptureSourceInfo, + ClickResult, + AutomationGuard, + ScrollResult, + SaveResultWithPath, + SaveSnapshotResult, + GoodDatabase, + ScannerStatusPayload, +} from "../../src/types/global.js"; +import type { + ArtifactStoreRepositoryPort, + ReviewSamplesRepositoryPort, + ReviewSampleListResult, +} from "../repositories/contracts.js"; +import type { AppSnapshot } from "../../src/types/domain.js"; + +interface AppHandlersDependencies { + focusMainWindow: () => BooleanResult; + moveMainWindowOffGenshin: () => Promise; + focusGenshinForScanStart: () => Promise; + publishScannerStatus: (status: ScannerStatusPayload) => Promise; + readRuntimeInfo: () => Promise; + loadSnapshotFromDisk: () => Promise; + saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise; + runMockScan: () => Promise; + showOverlayWindow: () => Promise; + hideOverlayWindow: () => Promise; +} + +type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort; +type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort; + +interface PersistenceHandlersDependencies { + getArtifactStoreRepository: ArtifactStoreAccessor; + getReviewSamplesRepository: ReviewSamplesAccessor; + artifactStorePath: () => string; + reviewSamplesPath: () => string; + loadReviewSamples: (limit?: number) => Promise; + loadScannerLearningRules: () => Promise; + writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise; + exportGood: (payload: GoodDatabase) => Promise; +} + +interface CaptureHandlersDependencies { + listSources: () => Promise; + captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + clickScreen: (x: number, y: number) => Promise; + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + getAutomationGuard: () => Promise; +} + +interface IpcBootstrapDependencies extends AppHandlersDependencies, PersistenceHandlersDependencies, CaptureHandlersDependencies {} + +export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) { + registerAppHandlers({ + focusMainWindow: dependencies.focusMainWindow, + moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin, + focusGenshinForScanStart: dependencies.focusGenshinForScanStart, + publishScannerStatus: dependencies.publishScannerStatus, + getRuntimeInfo: dependencies.readRuntimeInfo, + loadSnapshot: dependencies.loadSnapshotFromDisk, + saveSnapshot: dependencies.saveSnapshotToDisk, + runMockScan: dependencies.runMockScan, + showOverlay: dependencies.showOverlayWindow, + hideOverlay: dependencies.hideOverlayWindow, + }); + + registerPersistenceHandlers({ + getArtifactStoreRepository: dependencies.getArtifactStoreRepository, + getReviewSamplesRepository: dependencies.getReviewSamplesRepository, + artifactStorePath: dependencies.artifactStorePath, + reviewSamplesPath: dependencies.reviewSamplesPath, + loadReviewSamples: dependencies.loadReviewSamples, + loadScannerLearningRules: dependencies.loadScannerLearningRules, + writeScannerLearningRules: dependencies.writeScannerLearningRules, + exportGood: dependencies.exportGood, + }); + + registerCaptureHandlers({ + listSources: dependencies.listSources, + captureSource: dependencies.captureSource, + clickScreen: dependencies.clickScreen, + scrollScreen: dependencies.scrollScreen, + getAutomationGuard: dependencies.getAutomationGuard, + }); +} diff --git a/electron/bootstrap/repositoryContext.ts b/electron/bootstrap/repositoryContext.ts new file mode 100644 index 0000000..f6241f4 --- /dev/null +++ b/electron/bootstrap/repositoryContext.ts @@ -0,0 +1,40 @@ +import { + JsonArtifactStoreRepository, + JsonSnapshotRepository, + ReviewSamplesRepository, + ScannerLearningRepository, + type ArtifactStoreRepositoryPort, + type ReviewSamplesRepositoryPort, + type ScannerLearningRepositoryPort, + type SnapshotRepositoryPort, +} from "../repositories/index.js"; + +import path from "node:path"; +export interface RepositoryContext { + artifactStorePath: string; + reviewSamplesPath: string; + scannerLearningPath: string; + snapshotPath: string; + artifactStoreRepository: ArtifactStoreRepositoryPort; + reviewSamplesRepository: ReviewSamplesRepositoryPort; + scannerLearningRepository: ScannerLearningRepositoryPort; + snapshotRepository: SnapshotRepositoryPort; +} + +export function createRepositoryContext(userDataPath: string): RepositoryContext { + const artifactStorePath = path.join(userDataPath, "artifact-store.json"); + const reviewSamplesPath = path.join(userDataPath, "review-samples.jsonl"); + const scannerLearningPath = path.join(userDataPath, "scanner-learning.json"); + const snapshotPath = path.join(userDataPath, "snapshot.json"); + + return { + artifactStorePath, + reviewSamplesPath, + scannerLearningPath, + snapshotPath, + artifactStoreRepository: new JsonArtifactStoreRepository(userDataPath), + reviewSamplesRepository: new ReviewSamplesRepository(userDataPath), + scannerLearningRepository: new ScannerLearningRepository(userDataPath), + snapshotRepository: new JsonSnapshotRepository(userDataPath), + }; +} diff --git a/electron/ipc/appHandlers.ts b/electron/ipc/appHandlers.ts new file mode 100644 index 0000000..ee76b82 --- /dev/null +++ b/electron/ipc/appHandlers.ts @@ -0,0 +1,51 @@ +import { ipcMain } from "electron"; +import type { + BooleanResult, + FocusGenshinResult, + RuntimeInfo, + SaveSnapshotResult, + ScannerStatusPayload, +} from "../../src/types/global.js"; +import type { AppSnapshot } from "../../src/types/domain.js"; + +interface AppCommandDependencies { + focusMainWindow: () => BooleanResult; + moveMainWindowOffGenshin: () => Promise; + focusGenshinForScanStart: () => Promise; + publishScannerStatus: (status: ScannerStatusPayload) => Promise; + getRuntimeInfo: () => Promise; + loadSnapshot: () => Promise; + saveSnapshot: (snapshot: AppSnapshot) => Promise; + runMockScan: () => Promise; + showOverlay: () => Promise; + hideOverlay: () => Promise; +} + +export function registerAppHandlers({ + focusMainWindow, + moveMainWindowOffGenshin, + focusGenshinForScanStart, + publishScannerStatus, + getRuntimeInfo, + loadSnapshot, + saveSnapshot, + runMockScan, + showOverlay, + hideOverlay, +}: AppCommandDependencies) { + ipcMain.handle("app:focusMainWindow", async () => focusMainWindow()); + ipcMain.handle("automation:focusGenshin", async () => { + await moveMainWindowOffGenshin(); + return focusGenshinForScanStart(); + }); + ipcMain.handle("scanner:publishStatus", async (_event, status: ScannerStatusPayload) => { + await publishScannerStatus(status); + return { ok: true }; + }); + ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo()); + ipcMain.handle("snapshot:load", async () => loadSnapshot()); + ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot)); + ipcMain.handle("scan:runMock", async () => runMockScan()); + ipcMain.handle("overlay:show", () => showOverlay()); + ipcMain.handle("overlay:hide", () => hideOverlay()); +} diff --git a/electron/ipc/captureHandlers.ts b/electron/ipc/captureHandlers.ts new file mode 100644 index 0000000..3e03b32 --- /dev/null +++ b/electron/ipc/captureHandlers.ts @@ -0,0 +1,26 @@ +import { ipcMain } from "electron"; +import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } from "../../src/types/global.js"; + +interface CaptureCommandDependencies { + listSources: () => Promise; + captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + clickScreen: (x: number, y: number) => Promise; + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + getAutomationGuard: () => Promise; +} + +export function registerCaptureHandlers({ + listSources, + captureSource, + clickScreen, + scrollScreen, + getAutomationGuard, +}: CaptureCommandDependencies) { + ipcMain.handle("capture:listSources", async () => listSources()); + ipcMain.handle("capture:captureSource", async (_event, sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => { + return captureSource(sourceId, delayMs, focusGenshin, options); + }); + ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y)); + ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY)); + ipcMain.handle("automation:getGuard", async () => getAutomationGuard()); +} diff --git a/electron/ipc/persistenceHandlers.ts b/electron/ipc/persistenceHandlers.ts new file mode 100644 index 0000000..1cb48dd --- /dev/null +++ b/electron/ipc/persistenceHandlers.ts @@ -0,0 +1,84 @@ +import { ipcMain } from "electron"; +import type { + ArtifactStoreLoadResult, + ArtifactStoreRepositoryPort, + ArtifactStoreSaveResult, + ReviewSampleListResult, + ReviewSamplePayload, + ReviewSamplesRepositoryPort, +} from "../repositories/contracts.js"; +import type { + GoodDatabase, + LoadScannerLearningRulesResult, + SaveScannerLearningRulesResult, + ScannerLearningRulePayload, + SaveResultWithPath, +} from "../../src/types/global.js"; +import type { StoredArtifactRecord } from "../../src/types/storage.js"; + +type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort; +type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort; + +interface PersistenceDependencies { + getArtifactStoreRepository: ArtifactStoreAccessor; + getReviewSamplesRepository: ReviewSamplesAccessor; + artifactStorePath: () => string; + reviewSamplesPath: () => string; + loadReviewSamples: (limit?: number) => Promise; + loadScannerLearningRules: () => Promise; + writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise; + exportGood: (payload: GoodDatabase) => Promise; +} + +export function registerPersistenceHandlers({ + getArtifactStoreRepository, + getReviewSamplesRepository, + artifactStorePath, + reviewSamplesPath, + loadReviewSamples, + loadScannerLearningRules, + writeScannerLearningRules, + exportGood, +}: PersistenceDependencies) { + ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => { + try { + return await getReviewSamplesRepository().append(sample); + } catch { + const filePath = reviewSamplesPath(); + return { ok: false, path: filePath }; + } + }); + + ipcMain.handle("review:loadSamples", async (_event, limit = 50) => { + return loadReviewSamples(Number(limit) || 50); + }); + + ipcMain.handle("scanner:loadLearningRules", async () => { + return loadScannerLearningRules(); + }); + + ipcMain.handle("scanner:saveLearningRules", async (_event, rules: ScannerLearningRulePayload) => { + return writeScannerLearningRules(rules); + }); + + ipcMain.handle("artifacts:load", async () => { + try { + return (await getArtifactStoreRepository().loadAll()) as ArtifactStoreLoadResult; + } catch { + return { ok: false, artifacts: [], total: 0, path: artifactStorePath() }; + } + }); + + ipcMain.handle("artifacts:saveMany", async (_event, records: StoredArtifactRecord[]) => { + try { + const safeRecords = Array.isArray(records) ? records : []; + return (await getArtifactStoreRepository().saveMany(safeRecords)) as ArtifactStoreSaveResult; + } catch { + return { ok: false, added: 0, updated: 0, total: 0, path: artifactStorePath() }; + } + }); + + ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => { + return exportGood(payload); + }); +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..2f7749e --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,1097 @@ +import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron"; +import fs from "node:fs/promises"; +import http, { type Server } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createWorker } from "tesseract.js"; +import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js"; +import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js"; +import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js"; +import type { AppSnapshot } from "../src/types/domain.js"; +import type { + CaptureOptions, + CaptureResult, + GoodDatabase, + SaveResultWithPath, + ScannerLearningRulePayload, + ScannerStatusPayload, +} from "../src/types/global.js"; +import type { + ArtifactStoreRepositoryPort, + ReviewSamplesRepositoryPort, + ScannerLearningRepositoryPort, +} from "./repositories/index.js"; + +// Chromium's renderer sandbox can refuse to fully initialize (or silently +// crash the GPU/renderer process) when the hosting process runs with a full +// Administrator token - a well-known Electron-on-Windows-elevation quirk. +// This app already requires elevation for input automation and never loads +// untrusted remote content, so the renderer sandbox has little security +// value here; disabling it avoids "works normally, fails only when run as +// admin" failures with no visible error. Must run before app is ready. +app.commandLine.appendSwitch("no-sandbox"); +app.commandLine.appendSwitch("disable-gpu-sandbox"); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); + +let mainWindow: BrowserWindow | null = null; +let overlayWindow: BrowserWindow | null = null; +let registeredHotkeys: Record = {}; +let devControlServer: Server | null = null; +let scannerDevStatus: ScannerStatusPayload = { + running: false, + reviewStatus: "", + captureStatus: "", + selectedSource: null, + stats: {}, + summary: null, + snapshotArtifacts: 0, + snapshotCharacters: 0, + snapshotRecommendations: 0, + snapshotBuilds: 0, + grid: null, + automationLog: [], + runtimeInfo: null, + storedTotal: null, + learningRuleCount: 0, + updatedAt: null, +}; +let repositoryContext: RepositoryContext | null = null; +let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null; +let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null; +let scannerLearningRepository: ScannerLearningRepositoryPort | null = null; +let inputHelperService: InputHelperService | null = null; + +function getInputHelperService() { + if (!inputHelperService) { + throw new Error("Input-helper service has not been initialized."); + } + return inputHelperService; +} + +function getRepositoryContext() { + if (!repositoryContext) { + throw new Error("Repository context has not been initialized."); + } + return repositoryContext; +} + +function artifactStorePath() { + return getRepositoryContext().artifactStorePath; +} + +function reviewSamplesPath() { + return getRepositoryContext().reviewSamplesPath; +} + +function scannerLearningPath() { + return getRepositoryContext().scannerLearningPath; +} + +function getReviewSamplesRepository() { + if (!reviewSamplesRepository) { + throw new Error("Review-sample repository is not initialized."); + } + return reviewSamplesRepository; +} + +async function loadReviewSamples(limit = 50) { + try { + const repository = getReviewSamplesRepository(); + return repository.list(Math.max(1, Math.min(200, Number(limit) || 50))); + } catch { + return { ok: true, samples: [], total: 0, path: reviewSamplesPath() }; + } +} + +async function loadScannerLearningRules() { + try { + return await getScannerLearningRepository().load(); + } catch { + return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} } }; + } +} + +function getScannerLearningRepository() { + if (!scannerLearningRepository) { + throw new Error("Scanner-learning repository is not initialized."); + } + return scannerLearningRepository; +} + +async function writeScannerLearningRules(rules: ScannerLearningRulePayload) { + const safeRules = rules && typeof rules === "object" ? rules : {}; + try { + return await getScannerLearningRepository().save(safeRules as { textReplacements?: Record }); + } catch { + return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} }, total: 0 }; + } +} + +function getArtifactStoreRepository() { + if (!artifactStoreRepository) { + throw new Error("Artifact store repository is not initialized."); + } + return artifactStoreRepository; +} + +function getSnapshotRepository() { + const context = getRepositoryContext(); + if (!context.snapshotRepository) { + throw new Error("Snapshot repository is not initialized."); + } + return context.snapshotRepository; +} + +function exportPath(fileName: string) { + return path.join(app.getPath("userData"), "exports", fileName); +} + +async function loadSnapshotFromDisk() { + try { + return await getSnapshotRepository().load(); + } catch { + return null; + } +} + +async function saveSnapshotToDisk(snapshot: AppSnapshot) { + try { + return await getSnapshotRepository().save(snapshot); + } catch { + return { ok: false, path: "" }; + } +} + +async function publishScannerStatus(status: ScannerStatusPayload) { + scannerDevStatus = { ...status, updatedAt: new Date().toISOString() }; + return { ok: true }; +} + +async function readRuntimeInfo() { + try { + const result = await getInputHelperService().getRuntimeInfo(); + return { + ok: true, + isElevated: result.isElevated, + platform: result.platform, + hotkeys: registeredHotkeys, + genshinFound: result.genshinFound, + genshinHwnd: result.genshinHwnd ?? undefined, + targetProcess: result.targetProcess, + foregroundProcess: result.foregroundProcess, + foregroundHwnd: result.foregroundHwnd ?? undefined, + helperPid: result.helperPid, + }; + } catch { + return { ok: false, isElevated: false, platform: process.platform, hotkeys: registeredHotkeys }; + } +} + +async function focusGenshinWindow() { + try { + const result = await getInputHelperService().focusGenshinWindow(); + return result; + } catch { + return { focused: false, alreadyForeground: false, genshinFound: false }; + } +} + +async function focusGenshinForScanStart() { + try { + return await getInputHelperService().focusGenshinForScanStart(); + } catch { + return { focused: false, alreadyForeground: false, genshinFound: false }; + } +} + +async function getGenshinWindowBounds() { + try { + return await getInputHelperService().getGenshinWindowBounds(); + } catch { + return null; + } +} + +// Our own dashboard window defaulted to Electron's normal placement, which +// (on this exact reported bug) ended up sitting directly on top of Genshin's +// fullscreen window on the same monitor - so every simulated click that +// looked correct (focused, on-target, injected) was actually landing on our +// own window, not the game, since mouse hit-testing goes by which window is +// topmost at that screen pixel, not by which window has keyboard focus. If a +// second display exists and isn't the one Genshin occupies, move the +// dashboard there so it can never cover the grid we're about to click. +async function moveMainWindowOffGenshin() { + if (!mainWindow || mainWindow.isDestroyed()) return; + const genshinBounds = await getGenshinWindowBounds(); + const displays = screen.getAllDisplays(); + if (displays.length < 2) return; + + const genshinCenter = genshinBounds + ? { x: genshinBounds.x + genshinBounds.width / 2, y: genshinBounds.y + genshinBounds.height / 2 } + : null; + const genshinDisplay = genshinCenter + ? screen.getDisplayNearestPoint(genshinCenter) + : screen.getPrimaryDisplay(); + + const otherDisplay = displays.find((d) => d.id !== genshinDisplay.id); + if (!otherDisplay) return; + + const currentBounds = mainWindow.getBounds(); + const alreadyOnOtherDisplay = screen.getDisplayMatching(currentBounds).id === otherDisplay.id; + if (alreadyOnOtherDisplay) return; + + const area = otherDisplay.workArea; + const width = Math.min(currentBounds.width, area.width - 40); + const height = Math.min(currentBounds.height, area.height - 40); + mainWindow.setBounds({ + x: Math.round(area.x + (area.width - width) / 2), + y: Math.round(area.y + (area.height - height) / 2), + width: Math.round(width), + height: Math.round(height), + }); +} + +async function runMockScan() { + return null; +} + +async function showOverlayWindow() { + createOverlayWindow(); + return { ok: true }; +} + +async function hideOverlayWindow() { + overlayWindow?.close(); + return { ok: true }; +} + +async function listCaptureSources() { + const displays = screen.getAllDisplays(); + const maxSize = displays.reduce( + (size, display) => ({ + width: Math.max(size.width, display.size.width), + height: Math.max(size.height, display.size.height), + }), + { width: 1920, height: 1080 }, + ); + + const sources = await desktopCapturer.getSources({ + types: ["window", "screen"], + thumbnailSize: maxSize, + fetchWindowIcons: true, + }); + + return sources.map((source) => ({ + id: source.id, + name: source.name, + isGenshinCandidate: isLikelyGenshinSourceName(source.name), + thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(), + })); +} + +async function clickScreenCommand(x: number, y: number) { + return getInputHelperService().clickScreen(Math.round(x), Math.round(y)); +} + +async function scrollScreenCommand(notches: number, anchorX?: number, anchorY?: number) { + return getInputHelperService().scrollScreen(notches, anchorX, anchorY); +} + +async function getAutomationGuardCommand() { + const result = await getInputHelperService().getAutomationGuard(); + return { + ...result, + ok: true, + // Preserve historical automation metadata shape expected by callers. + isElevated: typeof result.isElevated === "boolean" ? result.isElevated : false, + }; +} + +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() { + return getInputHelperService().capturePrimaryScreenViaGdi(); +} + +async function captureSourceFromGdi(sourceId: string, sourceName: string, options: CaptureOptions = {}) { + const gdi = await capturePrimaryScreenViaGdi(); + const sourceImage = nativeImage.createFromDataURL(gdi.dataUrl); + return await buildCaptureResult(sourceImage, sourceId, sourceName, gdi.captureTarget, options); +} + +function createMainWindow() { + Menu.setApplicationMenu(null); + + mainWindow = new BrowserWindow({ + width: 1320, + height: 860, + minWidth: 1120, + minHeight: 720, + backgroundColor: "#090711", + title: "Genshin Artifact Assistant", + show: false, + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.setMenuBarVisibility(false); + mainWindow.on("closed", () => { + mainWindow = null; + }); + mainWindow.once("ready-to-show", () => { + void moveMainWindowOffGenshin(); + focusMainWindow(); + }); + mainWindow.webContents.once("did-finish-load", () => { + setTimeout(() => focusMainWindow(), 350); + }); + + if (isDev) { + mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!); + } else { + mainWindow.loadFile(path.join(__dirname, "../dist/index.html")); + } +} + +function focusMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) return { ok: false }; + + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + // Genshin often keeps foreground focus after a scan click. Toggling + // always-on-top for one tick nudges Windows to surface the dashboard again + // without leaving it pinned above other apps. + mainWindow.setAlwaysOnTop(true, "screen-saver"); + mainWindow.focus(); + setTimeout(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setAlwaysOnTop(false); + mainWindow.focus(); + }, 250); + return { ok: true }; +} + +function sendScannerCommand(command: "start-auto" | "stop" | "probe-click") { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send("scanner:command", command); +} + +function registerScannerHotkeys() { + globalShortcut.unregisterAll(); + registeredHotkeys = { + "Ctrl+Shift+S": globalShortcut.register("CommandOrControl+Shift+S", () => sendScannerCommand("start-auto")), + "Ctrl+Shift+X": globalShortcut.register("CommandOrControl+Shift+X", () => sendScannerCommand("stop")), + F8: globalShortcut.register("F8", () => sendScannerCommand("start-auto")), + F9: globalShortcut.register("F9", () => sendScannerCommand("stop")), + }; +} + +function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) { + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + res.end(JSON.stringify(payload)); +} + +function startDevControlServer() { + if (!isDev || devControlServer) return; + + devControlServer = http.createServer((req, res) => { + if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) { + writeDevJson(res, 403, { ok: false, error: "local only" }); + return; + } + + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname === "/health") { + writeDevJson(res, 200, { ok: true, hotkeys: registeredHotkeys, hasWindow: Boolean(mainWindow && !mainWindow.isDestroyed()) }); + return; + } + if (url.pathname === "/scanner/start") { + sendScannerCommand("start-auto"); + writeDevJson(res, 200, { ok: true, command: "start-auto" }); + return; + } + if (url.pathname === "/scanner/stop") { + sendScannerCommand("stop"); + writeDevJson(res, 200, { ok: true, command: "stop" }); + return; + } + if (url.pathname === "/scanner/probe") { + sendScannerCommand("probe-click"); + writeDevJson(res, 200, { ok: true, command: "probe-click" }); + return; + } + if (url.pathname === "/automation/click") { + const x = Number(url.searchParams.get("x")); + const y = Number(url.searchParams.get("y")); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + writeDevJson(res, 400, { ok: false, error: "x and y query params are required" }); + return; + } + getInputHelperService() + .clickScreen(Math.round(x), Math.round(y)) + .then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload })) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + if (url.pathname === "/scanner/status") { + writeDevJson(res, 200, { ok: true, status: scannerDevStatus }); + return; + } + if (url.pathname === "/review/samples") { + loadReviewSamples(Number(url.searchParams.get("limit") ?? 20)) + .then((payload: unknown) => writeDevJson(res, 200, payload)) + .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); + return; + } + + writeDevJson(res, 404, { ok: false, error: "unknown endpoint" }); + }); + + devControlServer.listen(17317, "127.0.0.1"); +} + +function createOverlayWindow() { + if (overlayWindow) { + overlayWindow.show(); + return; + } + + const display = screen.getPrimaryDisplay(); + overlayWindow = new BrowserWindow({ + x: display.workArea.x, + y: display.workArea.y, + width: display.workArea.width, + height: display.workArea.height, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + focusable: false, + webPreferences: { + preload: path.join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + overlayWindow.setIgnoreMouseEvents(true, { forward: true }); + + if (isDev) { + overlayWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL!}?overlay=1`); + } else { + overlayWindow.loadFile(path.join(__dirname, "../dist/index.html"), { + query: { overlay: "1" }, + }); + } + + overlayWindow.on("closed", () => { + overlayWindow = null; + }); +} + +function dataUrlToBuffer(dataUrl: string) { + const base64 = dataUrl.replace(/^data:image\/png;base64,/, ""); + return Buffer.from(base64, "base64"); +} + +// One shared OCR worker. Creating a Tesseract worker per capture added ~1s +// to every artifact during batch scans. +let ocrWorkerPromise: ReturnType | null = null; + +function getOcrWorker() { + if (!ocrWorkerPromise) { + ocrWorkerPromise = createWorker("eng"); + } + return ocrWorkerPromise; +} + +async function resetOcrWorker() { + const broken = ocrWorkerPromise; + ocrWorkerPromise = null; + if (broken) { + try { + const worker = await broken; + await worker.terminate(); + } catch { + // Worker never initialized; nothing to clean up. + } + } +} + +async function runOcrOnCrops(crops: Array<{ id: string; label: string; dataUrl: string }>) { + try { + const worker = await getOcrWorker(); + const results = []; + for (const crop of crops) { + const recognized = await worker.recognize(dataUrlToBuffer(crop.dataUrl)); + results.push({ + id: crop.id, + label: crop.label, + text: cleanOcrText(crop.id, recognized.data.text), + confidence: Math.round(recognized.data.confidence), + }); + } + return results; + } catch (error) { + await resetOcrWorker(); + throw error; + } +} + +async function runOcrOnCropsWithTimeout(crops: Array<{ id: string; label: string; dataUrl: string }>, timeoutMs = 6500) { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + runOcrOnCrops(crops).then((ocr) => ({ ocr, timedOut: false })), + new Promise<{ ocr: Awaited>; timedOut: boolean }>((resolve) => { + timeout = setTimeout(() => resolve({ ocr: [], timedOut: true }), timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function cleanOcrText(cropId: string, text: string) { + const normalized = text + .replace(/[“”]/g, '"') + .replace(/[’]/g, "'") + .replace(/\r/g, "") + .split("\n") + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter(Boolean); + + if (cropId === "artifact-footer") { + const equipped = normalized.find((line) => /equipped/i.test(line)); + if (!equipped) return ""; + + const match = /equipped\s*:?\s*([A-Za-z][A-Za-z'\-\s]{1,32})/i.exec(equipped); + return match ? `Equipped: ${match[1].replace(/[^A-Za-z'\-\s]/g, "").trim()}` : equipped; + } + + if (cropId === "artifact-title") { + return normalized + .filter((line) => /[A-Za-z]/.test(line)) + .slice(0, 2) + .join("\n"); + } + + if (cropId === "artifact-main-stat") { + return normalized + .filter((line) => /[A-Za-z0-9]/.test(line)) + .slice(0, 3) + .join("\n"); + } + + if (cropId === "artifact-substats") { + return normalized + .filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line)) + .slice(0, 5) + .join("\n"); + } + + if (cropId === "inventory-count") { + return normalized + .map((line) => line.replace(/[^0-9/]/g, "")) + .find((line) => /[0-9]/.test(line)) ?? ""; + } + + return normalized.join("\n"); +} + +function parseInventoryCount(ocr: Array<{ id: string; text: string; confidence: number }>) { + const entry = ocr.find((item) => item.id === "inventory-count"); + if (!entry?.text) return { current: 0, total: 0, confidence: 0, source: "missing" as const, text: "" }; + const cleaned = entry.text.replace(/[^0-9/]/g, ""); + const match = /^(\d{1,4})\/(\d{3,4})$/.exec(cleaned); + if (match) { + return { + current: Number(match[1]), + total: Number(match[2]), + confidence: Math.max(0, Math.min(100, entry.confidence)), + source: "ocr" as const, + text: cleaned, + }; + } + + const fallback = cleaned.match(/(\d{1,4})(\d{4})$/); + if (fallback) { + return { + current: Number(fallback[1]), + total: Number(fallback[2]), + confidence: Math.max(0, Math.min(84, entry.confidence)), + source: "ocr" as const, + text: cleaned, + }; + } + + return { current: 0, total: 0, confidence: 0, source: "missing" as const, text: cleaned }; +} + +function isArtifactTitleOrange(bitmap: Buffer, index: number) { + const blue = bitmap[index]; + const green = bitmap[index + 1]; + const red = bitmap[index + 2]; + + return red >= 135 && green >= 70 && green <= 155 && blue <= 95 && red > green + 35 && green > blue + 20; +} + +function isEquippedFooterYellow(bitmap: Buffer, index: number) { + const blue = bitmap[index]; + const green = bitmap[index + 1]; + const red = bitmap[index + 2]; + + return red >= 220 && green >= 185 && blue >= 125 && red > blue + 35 && green > blue + 20; +} + +function isSetTitleGreen(bitmap: Buffer, index: number) { + const blue = bitmap[index]; + const green = bitmap[index + 1]; + const red = bitmap[index + 2]; + + return green >= 110 && red <= 170 && blue <= 160 && green > red + 12 && green > blue + 10; +} + +function isArtifactTextColor(bitmap: Buffer, index: number) { + const blue = bitmap[index]; + const green = bitmap[index + 1]; + const red = bitmap[index + 2]; + + return green >= 180 && red >= 140 && blue <= 95 && green > blue + 35 && red > blue + 10; +} + +function waitDelay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.floor(ms)))); +} + +type CropTemplate = { id: string; label: string; rect: Electron.Rectangle }; + +function getCaptureSourceListOptions() { + const displays = screen.getAllDisplays(); + const maxSize = displays.reduce( + (size, display) => ({ + width: Math.max(size.width, display.size.width), + height: Math.max(size.height, display.size.height), + }), + { width: 1920, height: 1080 }, + ); + + return { maxSize, fetchWindowIcons: true }; +} + +async function getAllSources() { + const { maxSize, fetchWindowIcons } = getCaptureSourceListOptions(); + return desktopCapturer.getSources({ types: ["window", "screen"], thumbnailSize: maxSize, fetchWindowIcons }); +} + +async function findCaptureSourceById(sourceId: string) { + const sources = await getAllSources(); + return sources.find((source) => source.id === sourceId) ?? null; +} + +function clampCaptureRect(rect: Electron.Rectangle, imageSize: { width: number; height: number }) { + const clamped = { + x: Math.max(0, Math.min(imageSize.width - 1, rect.x)), + y: Math.max(0, Math.min(imageSize.height - 1, rect.y)), + }; + const maxWidth = Math.max(1, imageSize.width - clamped.x); + const maxHeight = Math.max(1, imageSize.height - clamped.y); + return { + ...clamped, + width: Math.max(1, Math.min(maxWidth, rect.width)), + height: Math.max(1, Math.min(maxHeight, rect.height)), + }; +} + +function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) { + const safeRect = clampCaptureRect(rect, imageSize); + return sourceImage.crop(safeRect).toDataURL(); +} + +function createCrops( + sourceImage: NativeImage, + imageSize: { width: number; height: number }, + detailRect: Electron.Rectangle, + inventoryRect: Electron.Rectangle, +) { + const templates: CropTemplate[] = [ + { + id: "artifact-title", + label: "Artifact title", + rect: { + x: Math.round(detailRect.x + detailRect.width * 0.055), + y: Math.round(detailRect.y + detailRect.height * 0.05), + width: Math.round(detailRect.width * 0.82), + height: Math.round(detailRect.height * 0.16), + }, + }, + { + id: "artifact-main-stat", + label: "Main stat", + rect: { + x: Math.round(detailRect.x + detailRect.width * 0.055), + y: Math.round(detailRect.y + detailRect.height * 0.20), + width: Math.round(detailRect.width * 0.82), + height: Math.round(detailRect.height * 0.18), + }, + }, + { + id: "artifact-substats", + label: "Substats", + rect: { + x: Math.round(detailRect.x + detailRect.width * 0.055), + y: Math.round(detailRect.y + detailRect.height * 0.41), + width: Math.round(detailRect.width * 0.82), + height: Math.round(detailRect.height * 0.25), + }, + }, + { + id: "artifact-footer", + label: "Footer", + rect: { + x: Math.round(detailRect.x + detailRect.width * 0.055), + y: Math.round(detailRect.y + detailRect.height * 0.78), + width: Math.round(detailRect.width * 0.82), + height: Math.round(detailRect.height * 0.16), + }, + }, + ]; + + if (inventoryRect.width > 120 && inventoryRect.height > 80) { + templates.push({ + id: "inventory-count", + label: "Inventory count", + rect: { + x: Math.round(inventoryRect.x + inventoryRect.width * 0.62), + y: Math.round(inventoryRect.y + inventoryRect.height * 0.02), + width: Math.round(inventoryRect.width * 0.34), + height: Math.round(inventoryRect.height * 0.09), + }, + }); + } + + return templates + .map((template) => ({ + ...template, + rect: clampCaptureRect(template.rect, imageSize), + dataUrl: imageCropDataUrl(sourceImage, template.rect, imageSize), + })) + .filter((crop) => crop.rect.width > 0 && crop.rect.height > 0) + .map((crop) => ({ + ...crop, + rect: { + x: crop.rect.x, + y: crop.rect.y, + width: crop.rect.width, + height: crop.rect.height, + }, + })); +} + +function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) { + const { width, height } = imageSize; + const sampleStrideX = width > 2200 ? 4 : 3; + const sampleStrideY = height > 1400 ? 4 : 3; + const candidates: Array<{ x: number; y: number }> = []; + + const search = { + x0: Math.floor(width * 0.40), + x1: Math.floor(width * 0.98), + y0: Math.floor(height * 0.04), + y1: Math.floor(height * 0.83), + }; + + for (let y = search.y0; y < search.y1; y += sampleStrideY) { + const rowOffset = y * width * 4; + for (let x = search.x0; x < search.x1; x += sampleStrideX) { + const index = rowOffset + x * 4; + if (isArtifactTitleOrange(bitmap, index) || isSetTitleGreen(bitmap, index) || isArtifactTextColor(bitmap, index)) { + candidates.push({ x, y }); + } + } + } + + if (candidates.length >= 180) { + const xValues = candidates.map((item) => item.x); + const yValues = candidates.map((item) => item.y); + const xMin = Math.min(...xValues); + const xMax = Math.max(...xValues); + const yMin = Math.min(...yValues); + const yMax = Math.max(...yValues); + const spanX = Math.max(1, xMax - xMin); + const spanY = Math.max(1, yMax - yMin); + const widthGuess = Math.max(Math.round(width * 0.30), Math.min(Math.round(width * 0.52), Math.round(spanX * 3.6))); + const left = Math.max(Math.round(width * 0.42), Math.round((xMin + xMax) / 2 - widthGuess * 0.52)); + const top = Math.max(0, Math.min(height - 1, Math.round(yMin - spanY * 0.4))); + const heightGuess = Math.max(Math.round(height * 0.58), Math.min(Math.round(height * 0.74), Math.round(spanY * 4.1))); + return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize); + } + + if (width > 0 && height > 0) { + return clampCaptureRect( + { + x: Math.round(width * 0.50), + y: Math.round(height * 0.08), + width: Math.round(width * 0.46), + height: Math.round(height * 0.74), + }, + imageSize, + ); + } + + return { x: 0, y: 0, width, height }; +} + +function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) { + const { width, height } = imageSize; + const preferredWidth = Math.max(140, Math.round(width * 0.48)); + const x = Math.round(width * 0.03); + const y = Math.round(detailRect.y + detailRect.height * 0.09); + const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04)); + const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth)); + const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth; + return clampCaptureRect( + { + x, + y, + width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)), + height: Math.max(140, Math.round(height * 0.70)), + }, + imageSize, + ); +} + +function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) { + const inventoryRect = inferInventoryRect(imageSize, detailRect); + const cols = 5; + if (inventoryRect.width < 160 || inventoryRect.height < 140) { + return { + centers: [], + rows: 0, + cols: 0, + confidence: 0, + source: "missing" as const, + }; + } + + const cellWidth = Math.max(56, Math.round(inventoryRect.width / cols)); + const stepX = Math.round(cellWidth * 0.96); + const stepY = Math.round(cellWidth * 1.03); + const visibleRows = Math.max(2, Math.min(6, Math.round(inventoryRect.height / Math.max(stepY, 1)))); + + const startX = inventoryRect.x + Math.max(6, Math.round(stepX * 0.45)); + const startY = inventoryRect.y + Math.max(6, Math.round(stepY * 0.45)); + const centers = []; + for (let row = 0; row < visibleRows; row++) { + for (let col = 0; col < cols; col++) { + const x = startX + col * stepX; + const y = startY + row * stepY; + if (x < imageSize.width && y < imageSize.height) { + centers.push({ x, y, row, col }); + } + } + } + + const trimmed = centers.filter((center) => center.x > 0 && center.y > 0); + return { + centers: trimmed, + rows: visibleRows, + cols, + confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36, + source: "detected" as const, + }; +} + +async function buildCaptureResult( + sourceImage: NativeImage, + sourceId: string, + sourceName: string, + captureTarget: CaptureResult["captureTarget"], + options: CaptureOptions = {}, +) { + const size = sourceImage.getSize(); + if (!size.width || !size.height) { + throw new Error("Capture produced an empty image."); + } + + const bitmap = sourceImage.getBitmap(); + const detailRect = inferDetailRect(bitmap, size); + const inventoryRect = inferInventoryRect(size, detailRect); + const crops = createCrops(sourceImage, size, detailRect, inventoryRect); + const croppedPayload = crops.map((crop) => ({ + id: crop.id, + label: crop.label, + dataUrl: crop.dataUrl, + })); + const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload); + + const count = parseInventoryCount(recognized.ocr); + return { + id: sourceId, + name: sourceName, + width: size.width, + height: size.height, + dataUrl: sourceImage.toDataURL(), + capturedAt: new Date().toISOString(), + captureTarget, + detailDataUrl: imageCropDataUrl(sourceImage, detailRect, size), + inventoryDataUrl: imageCropDataUrl(sourceImage, inventoryRect, size), + ocr: recognized.ocr, + ocrTimedOut: recognized.timedOut, + ocrSkipped: Boolean(options.skipOcr), + crops: crops.map((crop) => ({ + id: crop.id, + label: crop.label, + rect: { + x: crop.rect.x, + y: crop.rect.y, + width: crop.rect.width, + height: crop.rect.height, + }, + dataUrl: crop.dataUrl, + })), + inventoryGrid: inferInventoryGrid(size, detailRect), + inventoryCount: count, + }; +} + +async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions): Promise { + if (!Number.isFinite(delayMs) || delayMs < 0) { + delayMs = 0; + } + + await waitDelay(Math.floor(delayMs)); + + if (focusGenshin) { + await focusGenshinForScanStart(); + } + + const source = await findCaptureSourceById(sourceId); + if (!source) { + throw new Error("Capture source not found."); + } + + const isGenshinCandidate = isLikelyGenshinSourceName(source.name); + if (isGenshinCandidate) { + try { + return await captureSourceFromGdi(sourceId, source.name, options ?? {}); + } catch { + // Fall back to desktop thumbnail capture for robustness in low-permission + // or transient capture failures. OCR will still produce a best-effort result. + } + } + + const sourceImage = source.thumbnail; + if (sourceImage.isEmpty()) { + return await captureSourceFromGdi(sourceId, source.name, options ?? {}); + } + + return await buildCaptureResult( + sourceImage, + sourceId, + source.name, + sourceId.startsWith("screen:") ? "desktop-source" : "genshin-client", + options ?? {}, + ); +} + +async function exportGood(payload: GoodDatabase): Promise { + const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`; + const filePath = exportPath(fileNameSafe); + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); + return { ok: true, path: filePath }; + } catch { + return { ok: false, path: filePath }; + } +} + +function initializeAppLifecycle() { + app.whenReady().then(() => { + const userDataPath = app.getPath("userData"); + repositoryContext = createRepositoryContext(userDataPath); + artifactStoreRepository = repositoryContext.artifactStoreRepository; + reviewSamplesRepository = repositoryContext.reviewSamplesRepository; + scannerLearningRepository = repositoryContext.scannerLearningRepository; + inputHelperService = createInputHelperService({ userDataPath }); + + registerIpcHandlers({ + focusMainWindow: () => focusMainWindow(), + moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(), + focusGenshinForScanStart: () => focusGenshinForScanStart(), + publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status), + readRuntimeInfo: () => readRuntimeInfo(), + loadSnapshotFromDisk: () => loadSnapshotFromDisk(), + saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot), + runMockScan: () => runMockScan(), + showOverlayWindow: () => showOverlayWindow(), + hideOverlayWindow: () => hideOverlayWindow(), + getArtifactStoreRepository: () => getArtifactStoreRepository(), + getReviewSamplesRepository: () => getReviewSamplesRepository(), + artifactStorePath: () => artifactStorePath(), + reviewSamplesPath: () => reviewSamplesPath(), + loadReviewSamples: (limit?: number) => loadReviewSamples(limit), + loadScannerLearningRules: () => loadScannerLearningRules(), + writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules), + exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload), + listSources: () => listCaptureSources(), + captureSource: ( + id: string, + delayMs?: number, + focus?: boolean, + captureOptions?: CaptureOptions, + ) => captureSource(id, delayMs, focus, captureOptions), + clickScreen: (x: number, y: number) => clickScreenCommand(x, y), + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => scrollScreenCommand(notches, anchorX, anchorY), + getAutomationGuard: () => getAutomationGuardCommand(), + }); + + createMainWindow(); + registerScannerHotkeys(); + startDevControlServer(); + }); + + app.on("activate", () => { + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow(); + } + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + app.on("will-quit", async () => { + globalShortcut.unregisterAll(); + if (devControlServer) { + devControlServer.close(); + devControlServer = null; + } + await resetOcrWorker(); + inputHelperService?.dispose(); + }); +} + +initializeAppLifecycle(); + diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000..c79e304 --- /dev/null +++ b/electron/preload.cjs @@ -0,0 +1,30 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("assistantApi", { + loadSnapshot: () => ipcRenderer.invoke("snapshot:load"), + saveSnapshot: (snapshot) => ipcRenderer.invoke("snapshot:save", snapshot), + runMockScan: () => ipcRenderer.invoke("scan:runMock"), + listCaptureSources: () => ipcRenderer.invoke("capture:listSources"), + captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options), + clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y), + scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY), + getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"), + focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), + focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), + getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"), + saveReviewSample: (sample) => ipcRenderer.invoke("review:saveSample", sample), + loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit), + loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"), + saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules), + loadArtifacts: () => ipcRenderer.invoke("artifacts:load"), + saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records), + exportGood: (payload) => ipcRenderer.invoke("good:export", payload), + publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status), + showOverlay: () => ipcRenderer.invoke("overlay:show"), + hideOverlay: () => ipcRenderer.invoke("overlay:hide"), + onScannerCommand: (callback) => { + const listener = (_event, command) => callback(command); + ipcRenderer.on("scanner:command", listener); + return () => ipcRenderer.removeListener("scanner:command", listener); + }, +}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..3102d5d --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,33 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js"; +import type { StoredArtifactRecord } from "../src/types/storage.js"; +import type { AppSnapshot } from "../src/types/domain.js"; + +contextBridge.exposeInMainWorld("assistantApi", { + loadSnapshot: () => ipcRenderer.invoke("snapshot:load"), + saveSnapshot: (snapshot: AppSnapshot) => ipcRenderer.invoke("snapshot:save", snapshot), + runMockScan: () => ipcRenderer.invoke("scan:runMock"), + listCaptureSources: () => ipcRenderer.invoke("capture:listSources"), + captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options), + clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y), + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY), + getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"), + focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"), + focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"), + getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"), + saveReviewSample: (sample: ReviewSamplePayload) => ipcRenderer.invoke("review:saveSample", sample), + loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit), + loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"), + saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules), + loadArtifacts: () => ipcRenderer.invoke("artifacts:load"), + saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records), + exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload), + publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status), + showOverlay: () => ipcRenderer.invoke("overlay:show"), + hideOverlay: () => ipcRenderer.invoke("overlay:hide"), + onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => { + const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command); + ipcRenderer.on("scanner:command", listener); + return () => ipcRenderer.removeListener("scanner:command", listener); + }, +}); diff --git a/electron/repositories/artifactStoreRepository.ts b/electron/repositories/artifactStoreRepository.ts new file mode 100644 index 0000000..bf0797d --- /dev/null +++ b/electron/repositories/artifactStoreRepository.ts @@ -0,0 +1,192 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { StoredArtifactRecord } from "../../src/types/storage.js"; +import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js"; +import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js"; +interface ArtifactStoreFile { + version?: number; + artifacts?: StoredArtifactRecord[]; +} + +export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort { + private readonly filePath: string; + + constructor(userDataPath: string, fileName = "artifact-store.json") { + this.filePath = path.join(userDataPath, fileName); + } + + async loadAll(): Promise { + const records = await this.loadMap(); + const artifacts = [...records.values()].sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? "")); + return { ok: true, artifacts, total: artifacts.length, path: this.filePath }; + } + + async loadMap(): Promise> { + try { + const raw = JSON.parse(await fs.readFile(this.filePath, "utf8")) as ArtifactStoreFile; + const records = Array.isArray(raw.artifacts) ? raw.artifacts : []; + const cleaned = records + .filter((record) => record?.id && !isObviousGarbageRecord(record)) + .map((record) => normalizeStoredArtifactRecordForLoad(record)); + const store = new Map(cleaned.map((record) => [record.id, record])); + const storeChanged = cleaned.length !== records.length || cleaned.some((record, index) => JSON.stringify(record) !== JSON.stringify(records[index])); + if (storeChanged) await this.writeRecords([...store.values()]); + return store; + } catch { + return new Map(); + } + } + + async saveMany(records: StoredArtifactRecord[]): Promise { + const store = await this.loadMap(); + const now = new Date().toISOString(); + let added = 0; + let updated = 0; + + for (const record of records ?? []) { + if (!record?.id || isObviousGarbageRecord(record)) continue; + const existing = store.get(record.id); + if (existing) { + store.set(record.id, { + ...existing, + ...record, + firstSeenAt: existing.firstSeenAt ?? now, + lastSeenAt: now, + timesSeen: (existing.timesSeen ?? 1) + 1, + confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0), + // A later confident scan clears the review flag; an uncertain rescan + // must not downgrade an already confirmed artifact. + needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview), + source: resolveStoredArtifactSource(existing.source, record.source), + }); + updated++; + } else { + const mergeCandidate = [...store.values()].find((candidate) => shouldMergeArtifactRecords(candidate, record)); + if (mergeCandidate) { + const merged = mergeArtifactRecords(mergeCandidate, record, now); + if (mergeCandidate.id !== merged.id) store.delete(mergeCandidate.id); + store.set(merged.id, merged); + updated++; + } else { + store.set(record.id, { ...record, firstSeenAt: now, lastSeenAt: now, timesSeen: 1 }); + added++; + } + } + } + + await this.writeRecords([...store.values()]); + return { ok: true, added, updated, total: store.size, path: this.filePath }; + } + + private async writeRecords(records: StoredArtifactRecord[]) { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8"); + } +} + +function artifactMergeKey(record: StoredArtifactRecord) { + return [record.name, record.slot, record.setName, record.mainStat, record.mainValue, record.level ?? ""] + .map((value) => `${value ?? ""}`.trim().toLowerCase()) + .join("::"); +} + +function artifactFamilyKey(record: StoredArtifactRecord) { + return [record.name, record.slot, record.setName, record.mainStat] + .map((value) => `${value ?? ""}`.trim().toLowerCase()) + .join("::"); +} + +function artifactQualityScore(record: StoredArtifactRecord) { + const corePenalty = + (record.name === "Unknown artifact" ? 40 : 0) + + (record.slot === "Unknown slot" ? 35 : 0) + + (record.setName === "Unknown set" ? 35 : 0) + + (record.mainStat === "Unknown main stat" ? 40 : 0) + + (record.mainValue === "?" ? 20 : 0); + + return (record.confidence ?? 0) + + Math.min(20, (record.substats?.length ?? 0) * 5) + + (record.needsReview ? -12 : 8) + + (record.equipped && record.equipped !== "Not detected" ? 2 : 0) + - corePenalty; +} + +function isObviousGarbageRecord(record: StoredArtifactRecord) { + return ( + !record?.id + || record.name === "Unknown artifact" + || record.slot === "Unknown slot" + || record.setName === "Unknown set" + || record.mainStat === "Unknown main stat" + || record.mainValue === "?" + || (record.substats?.length ?? 0) === 0 + || (record.confidence ?? 0) < 30 + ); +} + +function parseArtifactNumericValue(value: string) { + const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, "").replace("%", "").trim()); + return Number.isFinite(numeric) ? numeric : 0; +} + +function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) { + const normalizedTimesSeen = Math.max(1, Math.round(record.timesSeen ?? 1)); + const reviewOnly = isReviewOnlyArtifactSource(record.source); + return { + ...record, + timesSeen: reviewOnly ? 1 : normalizedTimesSeen, + firstSeenAt: record.firstSeenAt ?? record.lastSeenAt, + }; +} + +function shouldMergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord) { + if (existing.id === incoming.id) return true; + + const overlap = (incoming.substats ?? []).filter((substat: string) => (existing.substats ?? []).includes(substat)).length; + const overlapThreshold = Math.min(2, Math.min(existing.substats?.length ?? 0, incoming.substats?.length ?? 0)); + const qualityGap = Math.abs(artifactQualityScore(existing) - artifactQualityScore(incoming)) >= 8; + + if (artifactMergeKey(existing) === artifactMergeKey(incoming)) { + return ( + overlap >= overlapThreshold + || existing.needsReview + || incoming.needsReview + || (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0) + || qualityGap + ); + } + + if (artifactFamilyKey(existing) !== artifactFamilyKey(incoming)) return false; + + const sameOrBetterLevel = (incoming.level ?? 0) >= (existing.level ?? 0); + const sameOrBetterMainValue = parseArtifactNumericValue(incoming.mainValue) >= parseArtifactNumericValue(existing.mainValue); + return sameOrBetterLevel && sameOrBetterMainValue && ( + overlap >= overlapThreshold + || existing.needsReview + || incoming.needsReview + || (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0) + || qualityGap + ); +} + +function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord, now: string): StoredArtifactRecord { + const incomingPreferred = artifactQualityScore(incoming) >= artifactQualityScore(existing); + const preferred = incomingPreferred ? incoming : existing; + const secondary = incomingPreferred ? existing : incoming; + const preferredSubstats = (preferred.substats?.length ?? 0) >= (secondary.substats?.length ?? 0) ? preferred.substats : secondary.substats; + + return { + ...secondary, + ...preferred, + id: incomingPreferred ? incoming.id : existing.id, + level: Math.max(existing.level ?? 0, incoming.level ?? 0), + substats: [...(preferredSubstats ?? [])], + equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped, + confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0), + needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview), + source: resolveStoredArtifactSource(existing.source, incoming.source), + firstSeenAt: existing.firstSeenAt ?? now, + lastSeenAt: now, + timesSeen: (existing.timesSeen ?? 1) + 1, + }; +} diff --git a/electron/repositories/contracts.ts b/electron/repositories/contracts.ts new file mode 100644 index 0000000..eb06177 --- /dev/null +++ b/electron/repositories/contracts.ts @@ -0,0 +1,77 @@ +import type { + AutomationGuard, + CaptureOptions, + CaptureResult, + CaptureSourceInfo, + ClickResult, + ArtifactStoreLoadResult, + ArtifactStoreSaveResult, + ReviewSampleListResult, + ReviewSamplePayload, + LoadScannerLearningRulesResult, + SaveScannerLearningRulesResult, + SaveResultWithPath, + FocusGenshinResult, + RuntimeInfo, + BooleanResult, + ScrollResult, + ScannerStatusPayload, + ScannerLearningRulePayload, +} from "../../src/types/global.js"; +import type { StoredArtifactRecord } from "../../src/types/storage.js"; +import type { AppSnapshot } from "../../src/types/domain.js"; + +export type { + ArtifactStoreLoadResult, + ArtifactStoreSaveResult, + ReviewSampleListResult, + ReviewSamplePayload, + LoadScannerLearningRulesResult, + SaveScannerLearningRulesResult, +} from "../../src/types/global.js"; + +export interface ArtifactStoreRepositoryPort { + loadAll(): Promise; + loadMap(): Promise>; + saveMany(records: StoredArtifactRecord[]): Promise; +} + +export interface ReviewSamplesRepositoryPort { + list(limit?: number): Promise; + append(sample: ReviewSamplePayload): Promise; +} + +export type ScannerLearningRules = ScannerLearningRulePayload; +export interface ScannerLearningLoadResult extends LoadScannerLearningRulesResult {} +export interface ScannerLearningSaveResult extends SaveScannerLearningRulesResult {} + +export interface ScannerLearningRepositoryPort { + load(): Promise; + save(rules: ScannerLearningRules): Promise; +} + +export interface SnapshotRepositoryPort { + load(): Promise; + save(snapshot: AppSnapshot): Promise; +} + +export interface RuntimeRepositoryPort { + focusMainWindow(): Promise; + moveMainWindowOffGenshin(): Promise; + focusGenshinForScanStart(): Promise; + publishScannerStatus(status: ScannerStatusPayload): Promise; + getRuntimeInfo(): Promise; + showOverlay: () => Promise; + hideOverlay: () => Promise; +} + +export interface AutomationRepositoryPort { + getAutomationGuard(): Promise; + clickScreen(x: number, y: number): Promise; + scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; +} + +export interface CaptureRepositoryPort { + listSources(): Promise; + captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise; +} diff --git a/electron/repositories/index.ts b/electron/repositories/index.ts new file mode 100644 index 0000000..b6f028b --- /dev/null +++ b/electron/repositories/index.ts @@ -0,0 +1,5 @@ +export * from "./contracts.js"; +export { JsonArtifactStoreRepository } from "./artifactStoreRepository.js"; +export { ReviewSamplesRepository } from "./reviewSamplesRepository.js"; +export { ScannerLearningRepository } from "./scannerLearningRepository.js"; +export { JsonSnapshotRepository } from "./snapshotRepository.js"; diff --git a/electron/repositories/reviewSamplesRepository.ts b/electron/repositories/reviewSamplesRepository.ts new file mode 100644 index 0000000..3ff8f4d --- /dev/null +++ b/electron/repositories/reviewSamplesRepository.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js"; +import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js"; + +export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort { + private readonly filePath: string; + + constructor(userDataPath: string, fileName = "review-samples.jsonl") { + this.filePath = path.join(userDataPath, fileName); + } + + async list(limit = 50): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const lines = raw.split(/\r?\n/).filter(Boolean); + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); + const samples = lines + .slice(-safeLimit) + .map((line) => { + try { + return JSON.parse(line) as ReviewSampleRecord; + } catch { + return null; + } + }) + .filter(Boolean) as ReviewSampleRecord[]; + return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath }; + } catch { + return { ok: true, samples: [], total: 0, path: this.filePath }; + } + } + + async append(sample: ReviewSamplePayload): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.appendFile(this.filePath, `${JSON.stringify({ savedAt: new Date().toISOString(), sample })}\n`, "utf8"); + return { ok: true, path: this.filePath }; + } +} diff --git a/electron/repositories/scannerLearningRepository.ts b/electron/repositories/scannerLearningRepository.ts new file mode 100644 index 0000000..ec282a9 --- /dev/null +++ b/electron/repositories/scannerLearningRepository.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ScannerLearningLoadResult, ScannerLearningRepositoryPort, ScannerLearningRules, ScannerLearningSaveResult } from "./contracts.js"; + +export class ScannerLearningRepository implements ScannerLearningRepositoryPort { + private readonly filePath: string; + + constructor(userDataPath: string, fileName = "scanner-learning.json") { + this.filePath = path.join(userDataPath, fileName); + } + + async load(): Promise { + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as ScannerLearningRules; + return { + ok: true, + path: this.filePath, + rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} }, + }; + } catch { + return { ok: true, path: this.filePath, rules: { textReplacements: {} } }; + } + } + + async save(rules: ScannerLearningRules): Promise { + const current = await this.load(); + const nextTextReplacements = { + ...((current.rules as { textReplacements?: Record })?.textReplacements ?? {}), + ...((rules as { textReplacements?: Record })?.textReplacements ?? {}), + }; + const payload: ScannerLearningRules = { textReplacements: nextTextReplacements }; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8"); + return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length }; + } +} diff --git a/electron/repositories/snapshotRepository.ts b/electron/repositories/snapshotRepository.ts new file mode 100644 index 0000000..15f5825 --- /dev/null +++ b/electron/repositories/snapshotRepository.ts @@ -0,0 +1,27 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { SnapshotRepositoryPort } from "./contracts.js"; +import type { SaveResultWithPath } from "../../src/types/global.js"; +import type { AppSnapshot } from "../../src/types/domain.js"; + +export class JsonSnapshotRepository implements SnapshotRepositoryPort { + private readonly filePath: string; + + constructor(userDataPath: string, fileName = "snapshot.json") { + this.filePath = path.join(userDataPath, fileName); + } + + async load() { + try { + return JSON.parse(await fs.readFile(this.filePath, "utf8")) as AppSnapshot; + } catch { + return null; + } + } + + async save(snapshot: AppSnapshot): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile(this.filePath, JSON.stringify(snapshot, null, 2), "utf8"); + return { ok: true, path: this.filePath }; + } +} diff --git a/electron/services/inputHelper.ts b/electron/services/inputHelper.ts new file mode 100644 index 0000000..80e46dd --- /dev/null +++ b/electron/services/inputHelper.ts @@ -0,0 +1,622 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + AutomationGuard, + ClickResult, + FocusGenshinResult, + GdiCaptureResult, + HelperOperationResponse, + WindowBounds, + RuntimeInfo, + ScrollResult, +} from "../../src/types/global.js"; + +const INPUT_HELPER_SCRIPT = String.raw` +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +$signature = @" +[DllImport("user32.dll")] +public static extern bool SetProcessDPIAware(); +[DllImport("shcore.dll")] +public static extern int SetProcessDpiAwareness(int value); +[DllImport("user32.dll")] +public static extern bool SetCursorPos(int X, int Y); +[DllImport("user32.dll")] +public static extern bool GetCursorPos(out POINT lpPoint); +[DllImport("user32.dll", SetLastError=true)] +public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); +[DllImport("user32.dll", SetLastError=true)] +public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); +[DllImport("user32.dll")] +public static extern short GetAsyncKeyState(int vKey); +[DllImport("user32.dll", SetLastError=true)] +public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); +[DllImport("user32.dll")] +public static extern bool SetForegroundWindow(IntPtr hWnd); +[DllImport("user32.dll")] +public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); +[DllImport("user32.dll")] +public static extern IntPtr GetForegroundWindow(); +[DllImport("user32.dll")] +public static extern bool IsWindow(IntPtr hWnd); +[DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + +[StructLayout(LayoutKind.Sequential)] +public struct POINT { public int X; public int Y; } + +[StructLayout(LayoutKind.Sequential)] +public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + +[StructLayout(LayoutKind.Sequential)] +public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; } + +[StructLayout(LayoutKind.Sequential)] +public struct INPUT { public int type; public MOUSEINPUT mi; } +"@ +Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native +# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the +# same symptom): the older SetProcessDPIAware() only applies a single, +# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin +# on one display, this app's window on a differently-scaled second display), +# that single scale factor is wrong for whichever monitor didn't set it, +# silently shifting every SetCursorPos/click coordinate off-target even +# though cursor readback still matches what we asked for (both go through the +# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2. +try { + [Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null +} catch { + [Native.InputHelper]::SetProcessDPIAware() | Out-Null +} +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# SizeOf must receive a struct instance: passing the type object throws in +# Windows PowerShell 5.1 (RuntimeType cannot be marshalled). +$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT)) +$genshinHwnd = [IntPtr]::Zero + +function Send-MouseInput { + param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0) + $mouseInput = New-Object Native.InputHelper+INPUT + $mouseInput.type = 0 + $mouseInput.mi.dx = $dx + $mouseInput.mi.dy = $dy + if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData } + $mouseInput.mi.dwFlags = $flags + return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) +} + +# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves +# with bare SetCursorPos, then clicks via the InputSimulator library's +# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE +# SendInput call (two INPUT structs in the same array) - back-to-back with no +# artificial delay between them, unlike two separate SendInput calls with a +# Start-Sleep in between. Returns the number of injected events (2 = ok). +function Send-MouseClickBatch { + $down = New-Object Native.InputHelper+INPUT + $down.type = 0 + $down.mi.dwFlags = 0x0002 + $up = New-Object Native.InputHelper+INPUT + $up.type = 0 + $up.mi.dwFlags = 0x0004 + return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize) +} + +function Get-CursorPoint { + $pt = New-Object Native.InputHelper+POINT + [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null + return $pt +} + +function Get-ProcessNameFromHwnd { + param([IntPtr]$hwnd) + if ($hwnd -eq [IntPtr]::Zero) { return "" } + $pidValue = [uint32]0 + [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null + if ($pidValue -eq 0) { return "" } + try { + return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName + } catch { + return "" + } +} + +function Get-CurrentProcessElevation { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-ForegroundInfo { + $hwnd = [Native.InputHelper]::GetForegroundWindow() + return @{ + foregroundHwnd = $hwnd.ToInt64() + foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + } +} + +function Get-CursorState { + $pt = New-Object Native.InputHelper+POINT + [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null + # Only 0x8000 (key is held down right now). The 0x0001 "pressed since last + # call" bit is unreliable and fires for ESC presses that happened long + # before the scan (ESC is used constantly to navigate Genshin menus). + $esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0 + $enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0 + $f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0 + return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 } +} + +function Get-GenshinClientBounds { + $hwnd = Find-GenshinWindow + if ($hwnd -eq [IntPtr]::Zero) { return $null } + + $rect = New-Object Native.InputHelper+RECT + if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null } + + $topLeft = New-Object Native.InputHelper+POINT + $topLeft.X = 0 + $topLeft.Y = 0 + if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null } + + $width = $rect.Right - $rect.Left + $height = $rect.Bottom - $rect.Top + if ($width -le 0 -or $height -le 0) { return $null } + + return @{ + Left = $topLeft.X + Top = $topLeft.Y + Width = $width + Height = $height + } +} + +function Find-GenshinWindow { + if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd } + $proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1 + if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero } + return $script:genshinHwnd +} + +function Focus-GenshinWindow { + $hwnd = Find-GenshinWindow + $info = @{ + hwnd = $hwnd.ToInt64() + focused = $false + alreadyForeground = $false + foregroundProcess = "" + targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + } + if ($hwnd -eq [IntPtr]::Zero) { return $info } + + $info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd) + if (-not $info.alreadyForeground) { + [Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null + # A previous version tapped ALT (keybd_event) right before this call to + # satisfy Windows' "who's allowed to change the foreground window" + # eligibility check. That tap has a side effect in most Win32 apps: a + # bare ALT press/release toggles menu-mnemonic navigation mode (verified + # live - it left a real app's menu bar highlighted after just this call), + # which then swallows the next several keyboard/mouse events as menu + # navigation instead of routing them to the app - looking exactly like + # "clicks/keys report success but do nothing". This app and Genshin run + # at the same (elevated) integrity level, so plain SetForegroundWindow + # already succeeds without the ALT tap - confirmed with a standalone + # compiled test against a live target window. + $info.setForegroundResult = [Native.InputHelper]::SetForegroundWindow($hwnd) + Start-Sleep -Milliseconds 140 + } + + $foreground = [Native.InputHelper]::GetForegroundWindow() + $info.focused = ($foreground -eq $hwnd) + $info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground + return $info +} + +while ($true) { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { break } + if ($line.Trim().Length -eq 0) { continue } + $response = @{ id = ""; ok = $true } + try { + $cmd = $line | ConvertFrom-Json + $response.id = "$($cmd.id)" + switch ("$($cmd.op)") { + "ping" { + $response.pong = $true + } + "cursor" { + $state = Get-CursorState + $response.cursorX = $state.cursorX + $response.cursorY = $state.cursorY + $response.escapePressed = $state.escapePressed + $response.enterPressed = $state.enterPressed + $response.f9Pressed = $state.f9Pressed + } + "runtime" { + $response.isElevated = Get-CurrentProcessElevation + $hwnd = Find-GenshinWindow + $foregroundInfo = Get-ForegroundInfo + $response.genshinFound = ($hwnd -ne [IntPtr]::Zero) + $response.genshinHwnd = $hwnd.ToInt64() + $response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd + $response.foregroundProcess = $foregroundInfo.foregroundProcess + $response.foregroundHwnd = $foregroundInfo.foregroundHwnd + $response.helperPid = $PID + } + "focus" { + $focusInfo = Focus-GenshinWindow + $response.focused = $focusInfo.focused + $response.alreadyForeground = $focusInfo.alreadyForeground + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.targetProcess = $focusInfo.targetProcess + $response.genshinFound = ($focusInfo.hwnd -ne 0) + $response.setForegroundResult = $focusInfo.setForegroundResult + } + "click" { + $focusInfo = Focus-GenshinWindow + if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { + Start-Sleep -Milliseconds 120 + } + $targetX = [int]$cmd.x + $targetY = [int]$cmd.y + # Matches Inventory Kamera's verified-working sequence exactly: bare + # SetCursorPos immediately followed by a click, with NO extra move + # event and NO artificial delay between moving and clicking - IK's + # Navigation.Click(x, y) does SetCursor() then Click() back-to-back, + # zero gap. Settling delays only happen after the click, in the scan + # loop. Down+up are sent as one SendInput call (see + # Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick(). + [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null + $point = Get-CursorPoint + $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) + $clickEventsSent = 0 + if ($onTarget) { + $clickEventsSent = Send-MouseClickBatch + } + $state = Get-CursorState + $response.cursorX = $state.cursorX + $response.cursorY = $state.cursorY + $response.escapePressed = $state.escapePressed + $response.enterPressed = $state.enterPressed + $response.f9Pressed = $state.f9Pressed + $response.moved = $onTarget + $response.focused = $focusInfo.focused + $response.alreadyForeground = $focusInfo.alreadyForeground + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.targetProcess = $focusInfo.targetProcess + $response.isElevated = Get-CurrentProcessElevation + # Never report a click unless the cursor is verifiably on the target. + # Real acceptance is proven later by the detail-panel fingerprint. + $response.clicked = ($onTarget -and $clickEventsSent -ge 2) + $response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2) + } + "scroll" { + $focusInfo = Focus-GenshinWindow + if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { + Start-Sleep -Milliseconds 120 + } + if ($null -ne $cmd.x -and $null -ne $cmd.y) { + [Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null + Start-Sleep -Milliseconds 30 + } + $point = Get-CursorPoint + $response.cursorX = $point.X + $response.cursorY = $point.Y + $response.focused = $focusInfo.focused + $response.foregroundProcess = $focusInfo.foregroundProcess + $response.isElevated = Get-CurrentProcessElevation + $notches = [int]$cmd.notches + $stepDelta = 120 + if ($notches -lt 0) { $stepDelta = -120 } + $count = [Math]::Abs($notches) + if ($count -gt 60) { $count = 60 } + $sentTotal = 0 + for ($i = 0; $i -lt $count; $i++) { + $sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta + Start-Sleep -Milliseconds 45 + } + $response.notchesSent = $sentTotal + $response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0)) + } + "bounds" { + $clientBounds = Get-GenshinClientBounds + if ($null -eq $clientBounds) { + $response.found = $false + } else { + $response.found = $true + $response.left = $clientBounds.Left + $response.top = $clientBounds.Top + $response.width = $clientBounds.Width + $response.height = $clientBounds.Height + } + } + "capture" { + $clientBounds = Get-GenshinClientBounds + if ($null -eq $clientBounds) { + $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $clientBounds = @{ + Left = $screenBounds.Left + Top = $screenBounds.Top + Width = $screenBounds.Width + Height = $screenBounds.Height + } + $response.captureTarget = "primary-screen" + } else { + $response.captureTarget = "genshin-client" + } + $bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size) + $capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png") + $bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + $response.path = $capturePath + $response.width = $clientBounds.Width + $response.height = $clientBounds.Height + $response.originX = $clientBounds.Left + $response.originY = $clientBounds.Top + } + default { + $response.ok = $false + $response.error = "unknown op" + } + } + } catch { + $response.ok = $false + $response.error = $_.Exception.Message + } + Write-Output (ConvertTo-Json $response -Compress) +} +`; + +class InputHelperClient { + private child: ChildProcessWithoutNullStreams | null = null; + private pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); + private buffer = ""; + private nextId = 1; + private starting: Promise | null = null; + private disposed = false; + + constructor(private readonly scriptUserDataPath: string) {} + + private async ensureStarted() { + if (this.child) return; + if (this.disposed) throw new Error("Input helper disposed"); + if (!this.starting) { + this.starting = this.start().finally(() => { + this.starting = null; + }); + } + await this.starting; + } + + private async start() { + const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8"); + + const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.handleStdout(chunk)); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", () => undefined); + child.on("exit", () => { + this.child = null; + this.buffer = ""; + for (const entry of this.pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error("Input helper exited")); + } + this.pending.clear(); + }); + this.child = child; + + // First request compiles the Win32 interop; give it extra time. + await this.send("ping", {}, 20000); + } + + private handleStdout(chunk: string) { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + newlineIndex = this.buffer.indexOf("\n"); + if (!line.startsWith("{")) continue; + try { + const message = JSON.parse(line) as HelperOperationResponse; + const entry = this.pending.get(String(message.id)); + if (!entry) continue; + this.pending.delete(String(message.id)); + clearTimeout(entry.timer); + if (message.ok) entry.resolve(message); + else entry.reject(new Error(message.error || "Input helper command failed")); + } catch { + // Ignore non-JSON noise on stdout. + } + } + } + + private send(op: string, params: Record, timeoutMs: number) { + return new Promise((resolve, reject) => { + const child = this.child; + if (!child?.stdin.writable) { + reject(new Error("Input helper is not running")); + return; + } + const id = String(this.nextId++); + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Input helper timed out on ${op}`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`); + }); + } + + request(op: string, params: Record = {}, timeoutMs = 8000) { + return this.ensureStarted().then(() => this.send(op, params, timeoutMs)); + } + + dispose() { + this.disposed = true; + this.child?.kill(); + this.child = null; + } +} + +export interface InputHelperService { + getRuntimeInfo(): Promise; + focusGenshinWindow(): Promise; + focusGenshinForScanStart(): Promise; + getGenshinWindowBounds(): Promise; + clickScreen(x: number, y: number): Promise; + scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; + getAutomationGuard(): Promise; + capturePrimaryScreenViaGdi(): Promise; + dispose(): void; +} + +export function createInputHelperService(options: { userDataPath: string }): InputHelperService { + const inputHelper = new InputHelperClient(options.userDataPath); + + async function request(op: string, params: Record = {}, timeoutMs = 8000) { + return inputHelper.request(op, params, timeoutMs); + } + + async function getRuntimeInfo() { + const result = await request("runtime", {}, 4000); + return { + ok: true, + isElevated: Boolean(result.isElevated), + platform: process.platform, + genshinFound: Boolean(result.genshinFound), + genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined, + targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, + foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, + foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined, + helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined, + }; + } + + async function focusGenshinWindow() { + const result = await request("focus", {}, 6000); + return { + focused: Boolean(result.focused), + alreadyForeground: Boolean(result.alreadyForeground), + genshinFound: Boolean(result.genshinFound), + foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, + targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, + setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined, + }; + } + + async function focusGenshinForScanStart() { + let result = await focusGenshinWindow(); + if (result.focused) return result; + await new Promise((resolve) => setTimeout(resolve, 700)); + result = await focusGenshinWindow(); + return result; + } + + async function getGenshinWindowBounds() { + const result = await request("bounds", {}, 4000); + if (!result.found) return null; + return { + x: Number(result.left), + y: Number(result.top), + width: Number(result.width), + height: Number(result.height), + }; + } + + async function clickScreen(x: number, y: number) { + const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse; + return { + ok: true, + x: Math.round(x), + y: Math.round(y), + 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), + moved: Boolean(result.moved), + clicked: Boolean(result.clicked), + inputBlocked: Boolean(result.inputBlocked), + focused: Boolean(result.focused), + alreadyForeground: Boolean(result.alreadyForeground), + foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, + targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, + isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined, + }; + } + + async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) { + const safeNotches = Math.max(-60, Math.min(60, Math.round(notches))); + const params: Record = { notches: safeNotches }; + if (typeof anchorX === "number" && typeof anchorY === "number") { + params.x = Math.round(anchorX); + params.y = Math.round(anchorY); + } + const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse; + return { + ok: true, + notchesSent: Number(result.notchesSent ?? 0), + inputBlocked: Boolean(result.inputBlocked), + isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined, + }; + } + + async function getAutomationGuard() { + const result = await request("cursor", {}, 4000); + return { + ok: true, + cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined, + cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined, + escapePressed: Boolean(result.escapePressed), + enterPressed: Boolean(result.enterPressed), + f9Pressed: Boolean(result.f9Pressed), + }; + } + + async function capturePrimaryScreenViaGdi() { + const result = await request("capture", {}, 15000); + const capturePath = String(result.path); + const buffer = await fs.readFile(capturePath); + await fs.unlink(capturePath).catch(() => undefined); + const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : ""; + const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client" + ? captureTargetRaw + : "primary-screen"; + + return { + dataUrl: `data:image/png;base64,${buffer.toString("base64")}`, + width: Number(result.width), + height: Number(result.height), + originX: Number(result.originX), + originY: Number(result.originY), + captureTarget, + }; + } + + return { + getRuntimeInfo, + focusGenshinWindow, + focusGenshinForScanStart, + getGenshinWindowBounds, + clickScreen, + scrollScreen, + getAutomationGuard, + capturePrimaryScreenViaGdi, + dispose: () => inputHelper.dispose(), + }; +} diff --git a/eng.traineddata b/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/eng.traineddata differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..71fe12d --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Genshin Artifact Assistant + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..571c409 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4898 @@ +{ + "name": "genshin-artifact-assistant", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genshin-artifact-assistant", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "electron": "33.2.1", + "genshin-db": "^5.2.12", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tesseract.js": "^7.0.0", + "vite": "^6.0.3" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "concurrently": "^9.1.0", + "cross-env": "^7.0.3", + "typescript": "^5.7.2", + "vitest": "^2.1.8", + "wait-on": "^8.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron": { + "version": "33.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz", + "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", + "license": "ISC" + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.9.0.tgz", + "integrity": "sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==", + "license": "MIT" + }, + "node_modules/genshin-db": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/genshin-db/-/genshin-db-5.2.12.tgz", + "integrity": "sha512-h0yY0bFJxADa85aa7JVj1KggqwRkgBjhoEvISSyC7u9GpVjilWSwnZw2s5Z+5+2GfEE9KV+7oSpwQfO+IzllZg==", + "license": "MIT", + "dependencies": { + "fuzzysort": "^1.1.4", + "pako": "^2.0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz", + "integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==", + "license": "Apache-2.0" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wait-on": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", + "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.12.1", + "joi": "^18.0.1", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..295de94 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "genshin-artifact-assistant", + "version": "0.1.0", + "private": true, + "description": "Local Windows assistant for scanning Genshin artifacts and suggesting no-brainer builds.", + "main": "dist-electron/main.js", + "type": "module", + "scripts": { + "predev": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\kill-stale-instances.ps1 && tsc -p tsconfig.electron.json && copy electron\\preload.cjs dist-electron\\preload.cjs", + "dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"", + "dev:admin": ".\\dev-admin.cmd", + "build": "tsc && vite build && tsc -p tsconfig.electron.json && copy electron\\\\preload.cjs dist-electron\\\\preload.cjs", + "preview": "vite preview --host 127.0.0.1", + "start": "electron .", + "lint": "tsc --noEmit", + "test": "vitest run", + "data:genshin": "node scripts/generate-genshin-data.cjs" + }, + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "electron": "33.2.1", + "genshin-db": "^5.2.12", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tesseract.js": "^7.0.0", + "vite": "^6.0.3" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "concurrently": "^9.1.0", + "cross-env": "^7.0.3", + "typescript": "^5.7.2", + "vitest": "^2.1.8", + "wait-on": "^8.0.1" + }, + "build": { + "appId": "local.genshin.artifact-assistant", + "productName": "Genshin Artifact Assistant", + "directories": { + "output": "outputs/dist" + }, + "files": [ + "dist/**/*", + "dist-electron/**/*", + "package.json" + ], + "win": { + "target": "nsis", + "requestedExecutionLevel": "requireAdministrator" + } + } +} diff --git a/scripts/dev-admin-start.ps1 b/scripts/dev-admin-start.ps1 new file mode 100644 index 0000000..fe13513 --- /dev/null +++ b/scripts/dev-admin-start.ps1 @@ -0,0 +1,87 @@ +param( + [string]$ProjectRoot +) + +# Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd), +# aber ein sauberer try/catch mit klarer Meldung ist trotzdem besser als ein +# roher Stacktrace, wenn z.B. der Dev-Port schon belegt ist. +$ErrorActionPreference = "Stop" + +try { + $project = (Resolve-Path -LiteralPath $ProjectRoot).Path + + Write-Host "Projekt: $project" + + # A UAC-elevated process gets its environment rebuilt fresh from the + # registry; it does NOT inherit PATH edits that only exist in the calling + # (non-elevated) shell session (e.g. a version manager that only patched + # the current terminal). If node/npm resolve normally but not here, that + # is almost always the cause - fail with a clear message instead of a + # cryptic "npm is not recognized" a few lines down. + $npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $npmCommand) { $npmCommand = Get-Command npm -ErrorAction SilentlyContinue } + $nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue + if (-not $nodeCommand) { $nodeCommand = Get-Command node -ErrorAction SilentlyContinue } + if (-not $npmCommand -or -not $nodeCommand) { + Write-Host "" + Write-Host "FEHLER: npm/node wurden in diesem administrativen Fenster nicht gefunden." -ForegroundColor Red + Write-Host "npm gefunden: $([bool]$npmCommand) node gefunden: $([bool]$nodeCommand)" -ForegroundColor Red + Write-Host "Ein 'Als Administrator ausfuehren'-Prozess bekommt seine Umgebung frisch aus der Registry -" -ForegroundColor Red + Write-Host "PATH-Aenderungen, die nur in deiner normalen (nicht-elevierten) Sitzung gelten (z.B. nvm/Volta" -ForegroundColor Red + Write-Host "ohne dauerhafte Registry-Eintragung), sind hier nicht sichtbar, obwohl 'npm run dev' normal funktioniert." -ForegroundColor Red + Write-Host "PATH in diesem Fenster:" -ForegroundColor DarkGray + Write-Host $env:PATH -ForegroundColor DarkGray + Write-Host "" + Write-Host "Loesung: node/npm dauerhaft im PATH eintragen (System-Umgebungsvariablen, nicht nur die Sitzung)," -ForegroundColor Yellow + Write-Host "z.B. ueber die Windows-Systemsteuerung 'Umgebungsvariablen bearbeiten' oder 'setx PATH ...'." -ForegroundColor Yellow + throw "npm/node nicht im administrativen PATH gefunden." + } + + # Kill stale instances of THIS project BEFORE checking the port: a leftover + # electron/vite/node/input-helper cluster from a previous run (e.g. a window + # force-closed via Task Manager, which skips main.ts's graceful will-quit + # cleanup and orphans the spawned input-helper.ps1 child) is the most common + # reason a fresh start silently talks to an old instance instead of + # replacing it. Shared with "npm run dev" itself via the predev hook, so + # both start paths get the same guarantee. + & (Join-Path $PSScriptRoot "kill-stale-instances.ps1") + + $devPort = 5173 + $portInUse = $null + try { + $portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1 + } catch { + # Get-NetTCPConnection kann auf manchen Systemen fehlen; das ist kein Fehler. + $portInUse = $null + } + if ($portInUse) { + $portOwner = Get-Process -Id $portInUse.OwningProcess -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "WARNUNG: Port $devPort ist noch belegt (Prozess: $($portOwner.ProcessName), PID $($portInUse.OwningProcess)) - auch nach dem Beenden alter Instanzen dieses Projekts." -ForegroundColor Yellow + Write-Host "Das ist wahrscheinlich ein unabhaengiger Prozess (z.B. ein anderes Projekt auf Port $devPort)." -ForegroundColor Yellow + Write-Host "'npm run dev' weicht dann auf einen anderen Port aus oder verbindet sich mit dem falschen Server." -ForegroundColor Yellow + Write-Host "Falls noetig: Prozess PID $($portInUse.OwningProcess) manuell beenden und dieses Fenster neu starten." -ForegroundColor Yellow + Write-Host "" + } + + Set-Location -LiteralPath $project + Write-Host "Starte npm run dev (Administrator)..." + npm run dev + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0) { + Write-Host "" + Write-Host "npm run dev wurde mit Fehlercode $exitCode beendet." -ForegroundColor Red + Write-Host "Die Ausgabe oben zeigt die genaue Ursache (z.B. belegter Port, fehlende Abhaengigkeiten)." -ForegroundColor Red + } else { + Write-Host "" + Write-Host "npm run dev wurde beendet (Fenster schliessen oder Strg+C fuer Neustart)." + } +} catch { + Write-Host "" + Write-Host "Fehler beim Start: $($_.Exception.Message)" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray +} + +Write-Host "" +Write-Host "Dieses Fenster bleibt offen (siehe Meldungen oben). Schliessen mit Enter oder dem Fenster-X." diff --git a/scripts/generate-genshin-data.cjs b/scripts/generate-genshin-data.cjs new file mode 100644 index 0000000..b61191a --- /dev/null +++ b/scripts/generate-genshin-data.cjs @@ -0,0 +1,233 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const genshin = require('genshin-db'); + +const opts = { matchCategories: true, verboseCategories: true, resultLanguage: 'English', queryLanguages: ['English'] }; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +const characters = asArray(genshin.characters('names', opts)) + .map((entry) => ({ + id: entry.id, + name: entry.name, + rarity: entry.rarity, + element: entry.elementText, + weapon: entry.weaponText, + })) + .filter((entry) => entry.name) + .sort((a, b) => a.name.localeCompare(b.name)); + +const artifacts = asArray(genshin.artifacts('names', opts)) + .map((entry) => ({ + id: entry.id, + name: entry.name, + rarityList: entry.rarityList ?? [], + pieces: [entry.flower, entry.plume, entry.sands, entry.goblet, entry.circlet] + .filter(Boolean) + .map((piece) => ({ name: piece.name, relicType: piece.relicType })), + })) + .filter((entry) => entry.name) + .sort((a, b) => a.name.localeCompare(b.name)); + +const artifactPieces = artifacts + .flatMap((set) => + set.pieces.map((piece) => ({ + name: piece.name, + setName: set.name, + slot: slotFromRelicType(piece.relicType), + relicType: piece.relicType, + })), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + +const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot])); +const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName])); + +const mainStats = [ + 'Elemental Mastery', + 'Energy Recharge', + 'CRIT Rate', + 'CRIT DMG', + 'Healing Bonus', + 'ATK%', + 'HP%', + 'DEF%', + 'ATK', + 'HP', + 'DEF', + 'Hydro DMG Bonus', + 'Pyro DMG Bonus', + 'Electro DMG Bonus', + 'Cryo DMG Bonus', + 'Dendro DMG Bonus', + 'Anemo DMG Bonus', + 'Geo DMG Bonus', + 'Physical DMG Bonus', +]; + +const substats = ['CRIT DMG', 'CRIT Rate', 'Energy Recharge', 'Elemental Mastery', 'ATK', 'ATK%', 'HP', 'HP%', 'DEF', 'DEF%']; +const mainStatsBySlot = { + 'Flower of Life': ['HP'], + 'Plume of Death': ['ATK'], + 'Sands of Eon': ['HP%', 'ATK%', 'DEF%', 'Energy Recharge', 'Elemental Mastery'], + 'Goblet of Eonothem': [ + 'HP%', + 'ATK%', + 'DEF%', + 'Elemental Mastery', + 'Hydro DMG Bonus', + 'Pyro DMG Bonus', + 'Electro DMG Bonus', + 'Cryo DMG Bonus', + 'Dendro DMG Bonus', + 'Anemo DMG Bonus', + 'Geo DMG Bonus', + 'Physical DMG Bonus', + ], + 'Circlet of Logos': ['HP%', 'ATK%', 'DEF%', 'Elemental Mastery', 'CRIT Rate', 'CRIT DMG', 'Healing Bonus'], +}; + +const mainStatValueReferences = { + 'Flower of Life': [{ stat: 'HP', base: 717, max: 4780 }], + 'Plume of Death': [{ stat: 'ATK', base: 47, max: 311 }], + 'Sands of Eon': [ + { stat: 'HP%', base: 7.0, max: 46.6 }, + { stat: 'ATK%', base: 7.0, max: 46.6 }, + { stat: 'DEF%', base: 8.7, max: 58.3 }, + { stat: 'Energy Recharge', base: 7.8, max: 51.8 }, + { stat: 'Elemental Mastery', base: 28, max: 187 }, + ], + 'Goblet of Eonothem': [ + { stat: 'HP%', base: 7.0, max: 46.6 }, + { stat: 'ATK%', base: 7.0, max: 46.6 }, + { stat: 'DEF%', base: 8.7, max: 58.3 }, + { stat: 'Elemental Mastery', base: 28, max: 187 }, + { stat: 'Hydro DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Pyro DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Electro DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Cryo DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Dendro DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Anemo DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Geo DMG Bonus', base: 7.0, max: 46.6 }, + { stat: 'Physical DMG Bonus', base: 8.7, max: 58.3 }, + ], + 'Circlet of Logos': [ + { stat: 'HP%', base: 7.0, max: 46.6 }, + { stat: 'ATK%', base: 7.0, max: 46.6 }, + { stat: 'DEF%', base: 8.7, max: 58.3 }, + { stat: 'Elemental Mastery', base: 28, max: 187 }, + { stat: 'CRIT Rate', base: 4.7, max: 31.1 }, + { stat: 'CRIT DMG', base: 9.3, max: 62.2 }, + { stat: 'Healing Bonus', base: 5.4, max: 35.9 }, + ], +}; + +const data = { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + source: { + package: 'genshin-db', + version: require('genshin-db/package.json').version, + resultLanguage: opts.resultLanguage, + }, + sourceVersions: { + genshinDb: require('genshin-db/package.json').version, + resultLanguage: opts.resultLanguage, + }, + sourceVersion: `genshin-db@${require('genshin-db/package.json').version}`, + characters, + artifactSets: artifacts, + artifactPieces, + slotByPiece, + setByPiece, + slots: ['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos'], + mainStats, + mainStatsBySlot, + mainStatValueReferences, + substats, + stats: { + main: mainStats, + mainBySlot: mainStatsBySlot, + sub: substats, + }, + aliases: { + stats: { + 'Crit Damage': 'CRIT DMG', + 'Critical Damage': 'CRIT DMG', + 'Crit Rate': 'CRIT Rate', + 'Critical Rate': 'CRIT Rate', + 'Energy Recharge %': 'Energy Recharge', + 'Elemental Master': 'Elemental Mastery', + 'Elemental Masterie': 'Elemental Mastery', + 'Heal Bonus': 'Healing Bonus', + }, + textReplacements: { + 'CIT DMG': 'CRIT DMG', + 'CRIT DMG+I': 'CRIT DMG+1', + 'CRIT Rate+Z': 'CRIT Rate+2', + 'Energv Recharge': 'Energy Recharge', + 'Elemental Masterv': 'Elemental Mastery', + 'Equipped;': 'Equipped:', + }, + slotAliases: { + 'Sands of Eon Vi': 'Sands of Eon', + 'Sands of Eon V': 'Sands of Eon', + 'Sands of Eon 2': 'Sands of Eon', + 'Flower of Life 2': 'Flower of Life', + 'Flower of Lif': 'Flower of Life', + 'Goblet of Eonotherm': 'Goblet of Eonothem', + 'Goblet of Eonothemn': 'Goblet of Eonothem', + 'Circlet of Logas': 'Circlet of Logos', + 'Circlet of Loges': 'Circlet of Logos', + }, + setAliases: { + 'Viridescent Venere': 'Viridescent Venerer', + 'Maiden Beloved:': 'Maiden Beloved', + 'Gladiators Finale': "Gladiator's Finale", + }, + pieceAliases: { + 'A Note in Springs Leich': "A Note in Spring's Leich", + 'Viridescent Vencrers Vessel': "Viridescent Venerer's Vessel", + 'Holy Crown of the Believer ': 'Holy Crown of the Believer', + }, + characterAliases: { + 'Citlall': 'Citlali', + 'Sandrone ': 'Sandrone', + 'Qiqi ': 'Qiqi', + }, + }, + uiProfiles: { + artifactDetailEn: { + language: 'English', + supportedResolutions: ['1920x1080', '2560x1440', '3840x2160'], + detailPanel: { x: 0.5, y: 0.05, width: 0.45, height: 0.9 }, + note: 'Relative profile used as scanner contract; runtime crops may tune offsets from review samples.', + }, + }, +}; + +const outPath = path.join(process.cwd(), 'src', 'data', 'genshinGameData.json'); +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, JSON.stringify(data, null, 2) + '\n'); +console.log(`generated ${outPath}`); +console.log(`${characters.length} characters, ${artifacts.length} artifact sets`); +console.log(`${artifactPieces.length} artifact pieces, ${mainStats.length} main stats, ${substats.length} substats`); + +function slotFromRelicType(relicType) { + switch (relicType) { + case 'EQUIP_BRACER': + return 'Flower of Life'; + case 'EQUIP_NECKLACE': + return 'Plume of Death'; + case 'EQUIP_SHOES': + return 'Sands of Eon'; + case 'EQUIP_RING': + return 'Goblet of Eonothem'; + case 'EQUIP_DRESS': + return 'Circlet of Logos'; + default: + return ''; + } +} diff --git a/scripts/kill-stale-instances.ps1 b/scripts/kill-stale-instances.ps1 new file mode 100644 index 0000000..d69bbfd --- /dev/null +++ b/scripts/kill-stale-instances.ps1 @@ -0,0 +1,40 @@ +# Beendet alle laufenden Prozesse dieses Projekts (Electron, Node/Vite, und +# einen verwaisten lokalen Input-Helper-Kindprozess), bevor eine neue Instanz +# startet. +# +# Warum das noetig ist: Ein normales Schliessen der App (X-Button) raeumt +# sauber auf (main.ts: app.on("will-quit") beendet den gespawnten +# input-helper.ps1-Kindprozess). Wird die App aber gewaltsam beendet - z.B. +# ueber Task-Manager "Task beenden" oder ein Skript, das Stop-Process -Force +# auf den Electron-Prozess anwendet - greift dieser Cleanup-Handler NICHT, +# und der Kindprozess (PowerShell mit dem Input-Helper) bleibt als Waise +# aktiv, obwohl das Hauptfenster laengst weg ist. Dieses Skript faengt genau +# das ab: es wird vor JEDEM Start (npm run dev und npm run dev:admin, ueber +# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos +# weg, egal wie sie beendet wurden. + +$ErrorActionPreference = "Stop" +$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe" + +$killed = 0 + +Get-CimInstance Win32_Process | + Where-Object { + ($_.Name -eq "electron.exe" -and $_.ExecutablePath -eq $electronPath) -or + ($_.Name -eq "node.exe" -and $_.CommandLine -like "*$project*") -or + ($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*") + } | + ForEach-Object { + Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))" + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + $killed++ + } + +if ($killed -gt 0) { + # Windows braucht einen Moment, um Ports/Handles wirklich freizugeben. + Start-Sleep -Milliseconds 500 + Write-Host "$killed alte Prozess(e) beendet." +} else { + Write-Host "Keine alten Instanzen gefunden." +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6eb35d8 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,6 @@ +import { AppPage } from "./pages/AppPage"; + +export function App() { + return ; +} + diff --git a/src/data/genshinGameData.json b/src/data/genshinGameData.json new file mode 100644 index 0000000..3a14cdd --- /dev/null +++ b/src/data/genshinGameData.json @@ -0,0 +1,5301 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-07-04T17:41:26.015Z", + "source": { + "package": "genshin-db", + "version": "5.2.12", + "resultLanguage": "English" + }, + "sourceVersions": { + "genshinDb": "5.2.12", + "resultLanguage": "English" + }, + "sourceVersion": "genshin-db@5.2.12", + "characters": [ + { + "id": 10000005, + "name": "Aether", + "rarity": 5, + "element": "None", + "weapon": "Sword" + }, + { + "id": 10000121, + "name": "Aino", + "rarity": 4, + "element": "Hydro", + "weapon": "Claymore" + }, + { + "id": 10000038, + "name": "Albedo", + "rarity": 5, + "element": "Geo", + "weapon": "Sword" + }, + { + "id": 10000078, + "name": "Alhaitham", + "rarity": 5, + "element": "Dendro", + "weapon": "Sword" + }, + { + "id": 10000062, + "name": "Aloy", + "rarity": 5, + "element": "Cryo", + "weapon": "Bow" + }, + { + "id": 10000021, + "name": "Amber", + "rarity": 4, + "element": "Pyro", + "weapon": "Bow" + }, + { + "id": 10000057, + "name": "Arataki Itto", + "rarity": 5, + "element": "Geo", + "weapon": "Claymore" + }, + { + "id": 10000096, + "name": "Arlecchino", + "rarity": 5, + "element": "Pyro", + "weapon": "Polearm" + }, + { + "id": 10000082, + "name": "Baizhu", + "rarity": 5, + "element": "Dendro", + "weapon": "Catalyst" + }, + { + "id": 10000014, + "name": "Barbara", + "rarity": 4, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000024, + "name": "Beidou", + "rarity": 4, + "element": "Electro", + "weapon": "Claymore" + }, + { + "id": 10000032, + "name": "Bennett", + "rarity": 4, + "element": "Pyro", + "weapon": "Sword" + }, + { + "id": 10000072, + "name": "Candace", + "rarity": 4, + "element": "Hydro", + "weapon": "Polearm" + }, + { + "id": 10000088, + "name": "Charlotte", + "rarity": 4, + "element": "Cryo", + "weapon": "Catalyst" + }, + { + "id": 10000104, + "name": "Chasca", + "rarity": 5, + "element": "Anemo", + "weapon": "Bow" + }, + { + "id": 10000090, + "name": "Chevreuse", + "rarity": 4, + "element": "Pyro", + "weapon": "Polearm" + }, + { + "id": 10000094, + "name": "Chiori", + "rarity": 5, + "element": "Geo", + "weapon": "Sword" + }, + { + "id": 10000036, + "name": "Chongyun", + "rarity": 4, + "element": "Cryo", + "weapon": "Claymore" + }, + { + "id": 10000107, + "name": "Citlali", + "rarity": 5, + "element": "Cryo", + "weapon": "Catalyst" + }, + { + "id": 10000098, + "name": "Clorinde", + "rarity": 5, + "element": "Electro", + "weapon": "Sword" + }, + { + "id": 10000067, + "name": "Collei", + "rarity": 4, + "element": "Dendro", + "weapon": "Bow" + }, + { + "id": 10000125, + "name": "Columbina", + "rarity": 5, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000071, + "name": "Cyno", + "rarity": 5, + "element": "Electro", + "weapon": "Polearm" + }, + { + "id": 10000115, + "name": "Dahlia", + "rarity": 4, + "element": "Hydro", + "weapon": "Sword" + }, + { + "id": 10000079, + "name": "Dehya", + "rarity": 5, + "element": "Pyro", + "weapon": "Claymore" + }, + { + "id": 10000016, + "name": "Diluc", + "rarity": 5, + "element": "Pyro", + "weapon": "Claymore" + }, + { + "id": 10000039, + "name": "Diona", + "rarity": 4, + "element": "Cryo", + "weapon": "Bow" + }, + { + "id": 10000068, + "name": "Dori", + "rarity": 4, + "element": "Electro", + "weapon": "Claymore" + }, + { + "id": 10000123, + "name": "Durin", + "rarity": 5, + "element": "Pyro", + "weapon": "Sword" + }, + { + "id": 10000099, + "name": "Emilie", + "rarity": 5, + "element": "Dendro", + "weapon": "Polearm" + }, + { + "id": 10000112, + "name": "Escoffier", + "rarity": 5, + "element": "Cryo", + "weapon": "Polearm" + }, + { + "id": 10000051, + "name": "Eula", + "rarity": 5, + "element": "Cryo", + "weapon": "Claymore" + }, + { + "id": 10000076, + "name": "Faruzan", + "rarity": 4, + "element": "Anemo", + "weapon": "Bow" + }, + { + "id": 10000031, + "name": "Fischl", + "rarity": 4, + "element": "Electro", + "weapon": "Bow" + }, + { + "id": 10000120, + "name": "Flins", + "rarity": 5, + "element": "Electro", + "weapon": "Polearm" + }, + { + "id": 10000085, + "name": "Freminet", + "rarity": 4, + "element": "Cryo", + "weapon": "Claymore" + }, + { + "id": 10000089, + "name": "Furina", + "rarity": 5, + "element": "Hydro", + "weapon": "Sword" + }, + { + "id": 10000092, + "name": "Gaming", + "rarity": 4, + "element": "Pyro", + "weapon": "Claymore" + }, + { + "id": 10000037, + "name": "Ganyu", + "rarity": 5, + "element": "Cryo", + "weapon": "Bow" + }, + { + "id": 10000055, + "name": "Gorou", + "rarity": 4, + "element": "Geo", + "weapon": "Bow" + }, + { + "id": 10000046, + "name": "Hu Tao", + "rarity": 5, + "element": "Pyro", + "weapon": "Polearm" + }, + { + "id": 10000110, + "name": "Iansan", + "rarity": 4, + "element": "Electro", + "weapon": "Polearm" + }, + { + "id": 10000113, + "name": "Ifa", + "rarity": 4, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000127, + "name": "Illuga", + "rarity": 4, + "element": "Geo", + "weapon": "Polearm" + }, + { + "id": 10000116, + "name": "Ineffa", + "rarity": 5, + "element": "Electro", + "weapon": "Polearm" + }, + { + "id": 10000124, + "name": "Jahoda", + "rarity": 4, + "element": "Anemo", + "weapon": "Bow" + }, + { + "id": 10000003, + "name": "Jean", + "rarity": 5, + "element": "Anemo", + "weapon": "Sword" + }, + { + "id": 10000100, + "name": "Kachina", + "rarity": 4, + "element": "Geo", + "weapon": "Polearm" + }, + { + "id": 10000047, + "name": "Kaedehara Kazuha", + "rarity": 5, + "element": "Anemo", + "weapon": "Sword" + }, + { + "id": 10000015, + "name": "Kaeya", + "rarity": 4, + "element": "Cryo", + "weapon": "Sword" + }, + { + "id": 10000002, + "name": "Kamisato Ayaka", + "rarity": 5, + "element": "Cryo", + "weapon": "Sword" + }, + { + "id": 10000066, + "name": "Kamisato Ayato", + "rarity": 5, + "element": "Hydro", + "weapon": "Sword" + }, + { + "id": 10000081, + "name": "Kaveh", + "rarity": 4, + "element": "Dendro", + "weapon": "Claymore" + }, + { + "id": 10000042, + "name": "Keqing", + "rarity": 5, + "element": "Electro", + "weapon": "Sword" + }, + { + "id": 10000101, + "name": "Kinich", + "rarity": 5, + "element": "Dendro", + "weapon": "Claymore" + }, + { + "id": 10000061, + "name": "Kirara", + "rarity": 4, + "element": "Dendro", + "weapon": "Sword" + }, + { + "id": 10000029, + "name": "Klee", + "rarity": 5, + "element": "Pyro", + "weapon": "Catalyst" + }, + { + "id": 10000056, + "name": "Kujou Sara", + "rarity": 4, + "element": "Electro", + "weapon": "Bow" + }, + { + "id": 10000065, + "name": "Kuki Shinobu", + "rarity": 4, + "element": "Electro", + "weapon": "Sword" + }, + { + "id": 10000108, + "name": "Lan Yan", + "rarity": 4, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000119, + "name": "Lauma", + "rarity": 5, + "element": "Dendro", + "weapon": "Catalyst" + }, + { + "id": 10000074, + "name": "Layla", + "rarity": 4, + "element": "Cryo", + "weapon": "Sword" + }, + { + "id": 10000130, + "name": "Linnea", + "rarity": 5, + "element": "Geo", + "weapon": "Bow" + }, + { + "id": 10000006, + "name": "Lisa", + "rarity": 4, + "element": "Electro", + "weapon": "Catalyst" + }, + { + "id": 10000129, + "name": "Lohen", + "rarity": 5, + "element": "Cryo", + "weapon": "Polearm" + }, + { + "id": 10000007, + "name": "Lumine", + "rarity": 5, + "element": "None", + "weapon": "Sword" + }, + { + "id": 10000083, + "name": "Lynette", + "rarity": 4, + "element": "Anemo", + "weapon": "Sword" + }, + { + "id": 10000084, + "name": "Lyney", + "rarity": 5, + "element": "Pyro", + "weapon": "Bow" + }, + { + "id": 10000117, + "name": "Manekin", + "rarity": 5, + "element": "None", + "weapon": "Sword" + }, + { + "id": 10000118, + "name": "Manekina", + "rarity": 5, + "element": "None", + "weapon": "Sword" + }, + { + "id": 10000106, + "name": "Mavuika", + "rarity": 5, + "element": "Pyro", + "weapon": "Claymore" + }, + { + "id": 10000080, + "name": "Mika", + "rarity": 4, + "element": "Cryo", + "weapon": "Polearm" + }, + { + "id": 10000041, + "name": "Mona", + "rarity": 5, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000102, + "name": "Mualani", + "rarity": 5, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000073, + "name": "Nahida", + "rarity": 5, + "element": "Dendro", + "weapon": "Catalyst" + }, + { + "id": 10000091, + "name": "Navia", + "rarity": 5, + "element": "Geo", + "weapon": "Claymore" + }, + { + "id": 10000122, + "name": "Nefer", + "rarity": 5, + "element": "Dendro", + "weapon": "Catalyst" + }, + { + "id": 10000087, + "name": "Neuvillette", + "rarity": 5, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000131, + "name": "Nicole", + "rarity": 5, + "element": "Pyro", + "weapon": "Catalyst" + }, + { + "id": 10000070, + "name": "Nilou", + "rarity": 5, + "element": "Hydro", + "weapon": "Sword" + }, + { + "id": 10000027, + "name": "Ningguang", + "rarity": 4, + "element": "Geo", + "weapon": "Catalyst" + }, + { + "id": 10000034, + "name": "Noelle", + "rarity": 4, + "element": "Geo", + "weapon": "Claymore" + }, + { + "id": 10000105, + "name": "Ororon", + "rarity": 4, + "element": "Electro", + "weapon": "Bow" + }, + { + "id": 10000132, + "name": "Prune", + "rarity": 4, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000035, + "name": "Qiqi", + "rarity": 5, + "element": "Cryo", + "weapon": "Sword" + }, + { + "id": 10000052, + "name": "Raiden Shogun", + "rarity": 5, + "element": "Electro", + "weapon": "Polearm" + }, + { + "id": 10000020, + "name": "Razor", + "rarity": 4, + "element": "Electro", + "weapon": "Claymore" + }, + { + "id": 10000045, + "name": "Rosaria", + "rarity": 4, + "element": "Cryo", + "weapon": "Polearm" + }, + { + "id": 10000133, + "name": "Sandrone", + "rarity": 5, + "element": "Cryo", + "weapon": "Claymore" + }, + { + "id": 10000054, + "name": "Sangonomiya Kokomi", + "rarity": 5, + "element": "Hydro", + "weapon": "Catalyst" + }, + { + "id": 10000053, + "name": "Sayu", + "rarity": 4, + "element": "Anemo", + "weapon": "Claymore" + }, + { + "id": 10000097, + "name": "Sethos", + "rarity": 4, + "element": "Electro", + "weapon": "Bow" + }, + { + "id": 10000063, + "name": "Shenhe", + "rarity": 5, + "element": "Cryo", + "weapon": "Polearm" + }, + { + "id": 10000059, + "name": "Shikanoin Heizou", + "rarity": 4, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000095, + "name": "Sigewinne", + "rarity": 5, + "element": "Hydro", + "weapon": "Bow" + }, + { + "id": 10000114, + "name": "Skirk", + "rarity": 5, + "element": "Cryo", + "weapon": "Sword" + }, + { + "id": 10000043, + "name": "Sucrose", + "rarity": 4, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000033, + "name": "Tartaglia", + "rarity": 5, + "element": "Hydro", + "weapon": "Bow" + }, + { + "id": 10000050, + "name": "Thoma", + "rarity": 4, + "element": "Pyro", + "weapon": "Polearm" + }, + { + "id": 10000069, + "name": "Tighnari", + "rarity": 5, + "element": "Dendro", + "weapon": "Bow" + }, + { + "id": 10000111, + "name": "Varesa", + "rarity": 5, + "element": "Electro", + "weapon": "Catalyst" + }, + { + "id": 10000128, + "name": "Varka", + "rarity": 5, + "element": "Anemo", + "weapon": "Claymore" + }, + { + "id": 10000022, + "name": "Venti", + "rarity": 5, + "element": "Anemo", + "weapon": "Bow" + }, + { + "id": 10000075, + "name": "Wanderer", + "rarity": 5, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000086, + "name": "Wriothesley", + "rarity": 5, + "element": "Cryo", + "weapon": "Catalyst" + }, + { + "id": 10000023, + "name": "Xiangling", + "rarity": 4, + "element": "Pyro", + "weapon": "Polearm" + }, + { + "id": 10000093, + "name": "Xianyun", + "rarity": 5, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000026, + "name": "Xiao", + "rarity": 5, + "element": "Anemo", + "weapon": "Polearm" + }, + { + "id": 10000103, + "name": "Xilonen", + "rarity": 5, + "element": "Geo", + "weapon": "Sword" + }, + { + "id": 10000025, + "name": "Xingqiu", + "rarity": 4, + "element": "Hydro", + "weapon": "Sword" + }, + { + "id": 10000044, + "name": "Xinyan", + "rarity": 4, + "element": "Pyro", + "weapon": "Claymore" + }, + { + "id": 10000058, + "name": "Yae Miko", + "rarity": 5, + "element": "Electro", + "weapon": "Catalyst" + }, + { + "id": 10000048, + "name": "Yanfei", + "rarity": 4, + "element": "Pyro", + "weapon": "Catalyst" + }, + { + "id": 10000077, + "name": "Yaoyao", + "rarity": 4, + "element": "Dendro", + "weapon": "Polearm" + }, + { + "id": 10000060, + "name": "Yelan", + "rarity": 5, + "element": "Hydro", + "weapon": "Bow" + }, + { + "id": 10000049, + "name": "Yoimiya", + "rarity": 5, + "element": "Pyro", + "weapon": "Bow" + }, + { + "id": 10000109, + "name": "Yumemizuki Mizuki", + "rarity": 5, + "element": "Anemo", + "weapon": "Catalyst" + }, + { + "id": 10000064, + "name": "Yun Jin", + "rarity": 4, + "element": "Geo", + "weapon": "Polearm" + }, + { + "id": 10000030, + "name": "Zhongli", + "rarity": 5, + "element": "Geo", + "weapon": "Polearm" + }, + { + "id": 10000126, + "name": "Zibai", + "rarity": 5, + "element": "Geo", + "weapon": "Sword" + } + ], + "artifactSets": [ + { + "id": 15044, + "name": "A Day Carved From Rising Winds", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Windborne Flower's Spruchdichtung", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Dawn's Brilliant Oath", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "A Note in Spring's Leich", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Heldenepos's Unspoken Tale", + "relicType": "EQUIP_RING" + }, + { + "name": "Minnesang of Love and Lament", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10010, + "name": "Adventurer", + "rarityList": [ + 1, + 2, + 3 + ], + "pieces": [ + { + "name": "Adventurer's Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Adventurer's Tail Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Adventurer's Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Adventurer's Golden Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Adventurer's Bandana", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15014, + "name": "Archaic Petra", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Flower of Creviced Cliff", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Feather of Jagged Peaks", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Sundial of Enduring Jade", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Goblet of Chiseled Crag", + "relicType": "EQUIP_RING" + }, + { + "name": "Mask of Solitude Basalt", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15043, + "name": "Aubade of Morningstar and Moon", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Moonlit Offering's Opulent Dream", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Moonlit Offering's Parting Light", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moonlit Offering's Final Hour", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moonlit Offering's Libation", + "relicType": "EQUIP_RING" + }, + { + "name": "Moonlit Offering's Silver Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10005, + "name": "Berserker", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Berserker's Rose", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Berserker's Indigo Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Berserker's Timepiece", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Berserker's Bone Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Berserker's Battle Mask", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 14001, + "name": "Blizzard Strayer", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Snowswept Memory", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Icebreaker's Resolve", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Frozen Homeland's Demise", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Frost-Weaved Dignity", + "relicType": "EQUIP_RING" + }, + { + "name": "Broken Rime's Echo", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15008, + "name": "Bloodstained Chivalry", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Bloodstained Flower of Iron", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Bloodstained Black Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Bloodstained Final Hour", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Bloodstained Chevalier's Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Bloodstained Iron Mask", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10002, + "name": "Brave Heart", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Medal of the Brave", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Prospect of the Brave", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Fortitude of the Brave", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Outset of the Brave", + "relicType": "EQUIP_RING" + }, + { + "name": "Crown of the Brave", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15045, + "name": "Celestial Gift", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Heavensent Fragrance", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Heavensent Demise", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Heavensent Decree", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Heavensent Reward", + "relicType": "EQUIP_RING" + }, + { + "name": "Heavensent Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15006, + "name": "Crimson Witch of Flames", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Witch's Flower of Blaze", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Witch's Ever-Burning Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Witch's End Time", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Witch's Heart Flames", + "relicType": "EQUIP_RING" + }, + { + "name": "Witch's Scorching Hat", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15025, + "name": "Deepwood Memories", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Labyrinth Wayfarer", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Scholar of Vines", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "A Time of Insight", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Lamp of the Lost", + "relicType": "EQUIP_RING" + }, + { + "name": "Laurel Coronet", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10003, + "name": "Defender's Will", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Guardian's Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Guardian's Sigil", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Guardian's Clock", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Guardian's Vessel", + "relicType": "EQUIP_RING" + }, + { + "name": "Guardian's Band", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15027, + "name": "Desert Pavilion Chronicle", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "The First Days of the City of Kings", + "relicType": "EQUIP_BRACER" + }, + { + "name": "End of the Golden Realm", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Timepiece of the Lost Path", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Defender of the Enchanting Dream", + "relicType": "EQUIP_RING" + }, + { + "name": "Legacy of the Desert High-Born", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15046, + "name": "Disenchantment in Deep Shadow", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Iridescence That Ceased Amidst Glory", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Sharpness That Ceased Upon Wondrous Creation", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moment That Ceased Upon Waking From Grand Dreams", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Ovations That Ceased Upon Festivity", + "relicType": "EQUIP_RING" + }, + { + "name": "Pendulum That Ceased Amidst a Great Fall", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15024, + "name": "Echoes of an Offering", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Soulscent Bloom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Jade Leaf", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Symbol of Felicitation", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Chalice of the Font", + "relicType": "EQUIP_RING" + }, + { + "name": "Flowing Rings", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15020, + "name": "Emblem of Severed Fate", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Magnificent Tsuba", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Sundered Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Storm Cage", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Scarlet Vessel", + "relicType": "EQUIP_RING" + }, + { + "name": "Ornate Kabuto", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15040, + "name": "Finale of the Deep Galleries", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Deep Gallery's Echoing Song", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Deep Gallery's Distant Pact", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Deep Gallery's Moment of Oblivion", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Deep Gallery's Bestowed Banquet", + "relicType": "EQUIP_RING" + }, + { + "name": "Deep Gallery's Lost Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15028, + "name": "Flower of Paradise Lost", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Ay-Khanoum's Myriad", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Wilting Feast", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "A Moment Congealed", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Secret-Keeper's Magic Bottle", + "relicType": "EQUIP_RING" + }, + { + "name": "Amethyst Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15035, + "name": "Fragment of Harmonic Whimsy", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Harmonious Symphony Prelude", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Ancient Sea's Nocturnal Musing", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "The Grand Jape of the Turning of Fate", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Ichor Shower Rhapsody", + "relicType": "EQUIP_RING" + }, + { + "name": "Whimsical Dance of the Withered", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10008, + "name": "Gambler", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Gambler's Brooch", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gambler's Feather Accessory", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Gambler's Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Gambler's Dice Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Gambler's Earrings", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15026, + "name": "Gilded Dreams", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Dreaming Steelbloom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Feather of Judgment", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "The Sunken Years", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Honeyed Final Feast", + "relicType": "EQUIP_RING" + }, + { + "name": "Shadow of the Sand King", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15001, + "name": "Gladiator's Finale", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Gladiator's Nostalgia", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gladiator's Destiny", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Gladiator's Longing", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Gladiator's Intoxication", + "relicType": "EQUIP_RING" + }, + { + "name": "Gladiator's Triumphus", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15032, + "name": "Golden Troupe", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Golden Song's Variation", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Golden Bird's Shedding", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Golden Era's Prelude", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Golden Night's Bustle", + "relicType": "EQUIP_RING" + }, + { + "name": "Golden Troupe's Reward", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15016, + "name": "Heart of Depth", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Gilded Corsage", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gust of Nostalgia", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Copper Compass", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Goblet of Thundering Deep", + "relicType": "EQUIP_RING" + }, + { + "name": "Wine-Stained Tricorne", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15021, + "name": "Husk of Opulent Dreams", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Bloom Times", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Plume of Luxury", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Song of Life", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Calabash of Awakening", + "relicType": "EQUIP_RING" + }, + { + "name": "Skeletal Hat", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10007, + "name": "Instructor", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Instructor's Brooch", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Instructor's Feather Accessory", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Instructor's Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Instructor's Tea Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Instructor's Cap", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 14003, + "name": "Lavawalker", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Lavawalker's Resolution", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lavawalker's Salvation", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Lavawalker's Torment", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Lavawalker's Epiphany", + "relicType": "EQUIP_RING" + }, + { + "name": "Lavawalker's Wisdom", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15039, + "name": "Long Night's Oath", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Lightkeeper's Pledge", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Nightingale's Tail Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Undying One's Mourning Bell", + "relicType": "EQUIP_SHOES" + }, + { + "name": "A Horn Unwinded", + "relicType": "EQUIP_RING" + }, + { + "name": "Dyed Tassel", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10011, + "name": "Lucky Dog", + "rarityList": [ + 1, + 2, + 3 + ], + "pieces": [ + { + "name": "Lucky Dog's Clover", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lucky Dog's Eagle Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Lucky Dog's Hourglass", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Lucky Dog's Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Lucky Dog's Silver Circlet", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 14004, + "name": "Maiden Beloved", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Maiden's Distant Love", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Maiden's Heart-stricken Infatuation", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Maiden's Passing Youth", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Maiden's Fleeting Leisure", + "relicType": "EQUIP_RING" + }, + { + "name": "Maiden's Fading Beauty", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15031, + "name": "Marechaussee Hunter", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Hunter's Brooch", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Masterpiece's Overture", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moment of Judgment", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Forgotten Vessel", + "relicType": "EQUIP_RING" + }, + { + "name": "Veteran's Visage", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10006, + "name": "Martial Artist", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Martial Artist's Red Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Martial Artist's Feather Accessory", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Martial Artist's Water Hourglass", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Martial Artist's Wine Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Martial Artist's Bandana", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15041, + "name": "Night of the Sky's Unveiling", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Bloom of the Mind's Desire", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Feather of Indelible Sin", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Revelation's Toll", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Vessel of Plenty", + "relicType": "EQUIP_RING" + }, + { + "name": "Crown of the Befallen", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15034, + "name": "Nighttime Whispers in the Echoing Woods", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Selfless Floral Accessory", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Honest Quill", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Faithful Hourglass", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Magnanimous Ink Bottle", + "relicType": "EQUIP_RING" + }, + { + "name": "Compassionate Ladies' Hat", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15007, + "name": "Noblesse Oblige", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Royal Flora", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Royal Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Royal Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Royal Silver Urn", + "relicType": "EQUIP_RING" + }, + { + "name": "Royal Masque", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15029, + "name": "Nymph's Dream", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Odyssean Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Wicked Mage's Plumule", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Nymph's Constancy", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Heroes' Tea Party", + "relicType": "EQUIP_RING" + }, + { + "name": "Fell Dragon's Monocle", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15038, + "name": "Obsidian Codex", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Reckoning of the Xenogenic", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Root of the Spirit-Marrow", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Myths of the Night Realm", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Pre-Banquet of the Contenders", + "relicType": "EQUIP_RING" + }, + { + "name": "Crown of the Saints", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15022, + "name": "Ocean-Hued Clam", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Sea-Dyed Blossom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Deep Palace's Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Cowry of Parting", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Pearl Cage", + "relicType": "EQUIP_RING" + }, + { + "name": "Crown of Watatsumi", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15018, + "name": "Pale Flame", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Stainless Bloom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Wise Doctor's Pinion", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moment of Cessation", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Surpassing Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Mocking Mask", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15010, + "name": "Prayers for Destiny", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Tiara of Torrents", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15009, + "name": "Prayers for Illumination", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Tiara of Flame", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15011, + "name": "Prayers for Wisdom", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Tiara of Thunder", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15013, + "name": "Prayers to Springtime", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Tiara of Frost", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10001, + "name": "Resolution of Sojourner", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Heart of Comradeship", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Feather of Homecoming", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Sundial of the Sojourner", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Goblet of the Sojourner", + "relicType": "EQUIP_RING" + }, + { + "name": "Crown of Parting", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15015, + "name": "Retracing Bolide", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Summer Night's Bloom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Summer Night's Finale", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Summer Night's Moment", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Summer Night's Waterballoon", + "relicType": "EQUIP_RING" + }, + { + "name": "Summer Night's Mask", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10012, + "name": "Scholar", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Scholar's Bookmark", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Scholar's Quill Pen", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Scholar's Clock", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Scholar's Ink Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Scholar's Lens", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15037, + "name": "Scroll of the Hero of Cinder City", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Beast Tamer's Talisman", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Mountain Ranger's Marker", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Mystic's Gold Dial", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Wandering Scholar's Claw Cup", + "relicType": "EQUIP_RING" + }, + { + "name": "Demon-Warrior's Feather Mask", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15019, + "name": "Shimenawa's Reminiscence", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Entangling Bloom", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Shaft of Remembrance", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Morning Dew's Moment", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Hopeful Heart", + "relicType": "EQUIP_RING" + }, + { + "name": "Capricious Visage", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15042, + "name": "Silken Moon's Serenade", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Crystal Tear of the Wanderer", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Pristine Plume of the Blessed", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Frost Devotee's Delirium", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Joyous Glory of the Pure", + "relicType": "EQUIP_RING" + }, + { + "name": "Holy Crown of the Believer", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15033, + "name": "Song of Days Past", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Forgotten Oath of Days Past", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Recollection of Days Past", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Echoing Sound From Days Past", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Promised Dream of Days Past", + "relicType": "EQUIP_RING" + }, + { + "name": "Poetry of Days Past", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15017, + "name": "Tenacity of the Millelith", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Flower of Accolades", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Ceremonial War-Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Orichalceous Time-Dial", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Noble's Pledging Vessel", + "relicType": "EQUIP_RING" + }, + { + "name": "General's Ancient Helm", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10009, + "name": "The Exile", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Exile's Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Exile's Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Exile's Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Exile's Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Exile's Circlet", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15005, + "name": "Thundering Fury", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Thunderbird's Mercy", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Survivor of Catastrophe", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Hourglass of Thunder", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Omen of Thunderstorm", + "relicType": "EQUIP_RING" + }, + { + "name": "Thunder Summoner's Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 14002, + "name": "Thundersoother", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Thundersoother's Heart", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Thundersoother's Plume", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Hour of Soothing Thunder", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Thundersoother's Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Thundersoother's Diadem", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10004, + "name": "Tiny Miracle", + "rarityList": [ + 3, + 4 + ], + "pieces": [ + { + "name": "Tiny Miracle's Flower", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Tiny Miracle's Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Tiny Miracle's Hourglass", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Tiny Miracle's Goblet", + "relicType": "EQUIP_RING" + }, + { + "name": "Tiny Miracle's Earrings", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 10013, + "name": "Traveling Doctor", + "rarityList": [ + 1, + 2, + 3 + ], + "pieces": [ + { + "name": "Traveling Doctor's Silver Lotus", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Traveling Doctor's Owl Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Traveling Doctor's Pocket Watch", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Traveling Doctor's Medicine Pot", + "relicType": "EQUIP_RING" + }, + { + "name": "Traveling Doctor's Handkerchief", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15036, + "name": "Unfinished Reverie", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Dark Fruit of Bright Flowers", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Faded Emerald Tail", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moment of Attainment", + "relicType": "EQUIP_SHOES" + }, + { + "name": "The Wine-Flask Over Which the Plan Was Hatched", + "relicType": "EQUIP_RING" + }, + { + "name": "Crownless Crown", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15023, + "name": "Vermillion Hereafter", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Flowering Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Feather of Nascent Light", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Solar Relic", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moment of the Pact", + "relicType": "EQUIP_RING" + }, + { + "name": "Thundering Poise", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15002, + "name": "Viridescent Venerer", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "In Remembrance of Viridescent Fields", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Viridescent Arrow Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Viridescent Venerer's Determination", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Viridescent Venerer's Vessel", + "relicType": "EQUIP_RING" + }, + { + "name": "Viridescent Venerer's Diadem", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15030, + "name": "Vourukasha's Glow", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Stamen of Khvarena's Origin", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Vibrant Pinion", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Ancient Abscission", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Feast of Boundless Joy", + "relicType": "EQUIP_RING" + }, + { + "name": "Heart of Khvarena's Brilliance", + "relicType": "EQUIP_DRESS" + } + ] + }, + { + "id": 15003, + "name": "Wanderer's Troupe", + "rarityList": [ + 4, + 5 + ], + "pieces": [ + { + "name": "Troupe's Dawnlight", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Bard's Arrow Feather", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Concert's Final Hour", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Wanderer's String-Kettle", + "relicType": "EQUIP_RING" + }, + { + "name": "Conductor's Top Hat", + "relicType": "EQUIP_DRESS" + } + ] + } + ], + "artifactPieces": [ + { + "name": "A Horn Unwinded", + "setName": "Long Night's Oath", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "A Moment Congealed", + "setName": "Flower of Paradise Lost", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "A Note in Spring's Leich", + "setName": "A Day Carved From Rising Winds", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "A Time of Insight", + "setName": "Deepwood Memories", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Adventurer's Bandana", + "setName": "Adventurer", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Adventurer's Flower", + "setName": "Adventurer", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Adventurer's Golden Goblet", + "setName": "Adventurer", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Adventurer's Pocket Watch", + "setName": "Adventurer", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Adventurer's Tail Feather", + "setName": "Adventurer", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Amethyst Crown", + "setName": "Flower of Paradise Lost", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Ancient Abscission", + "setName": "Vourukasha's Glow", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Ancient Sea's Nocturnal Musing", + "setName": "Fragment of Harmonic Whimsy", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Ay-Khanoum's Myriad", + "setName": "Flower of Paradise Lost", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Bard's Arrow Feather", + "setName": "Wanderer's Troupe", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Beast Tamer's Talisman", + "setName": "Scroll of the Hero of Cinder City", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Berserker's Battle Mask", + "setName": "Berserker", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Berserker's Bone Goblet", + "setName": "Berserker", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Berserker's Indigo Feather", + "setName": "Berserker", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Berserker's Rose", + "setName": "Berserker", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Berserker's Timepiece", + "setName": "Berserker", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Bloodstained Black Plume", + "setName": "Bloodstained Chivalry", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Bloodstained Chevalier's Goblet", + "setName": "Bloodstained Chivalry", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Bloodstained Final Hour", + "setName": "Bloodstained Chivalry", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Bloodstained Flower of Iron", + "setName": "Bloodstained Chivalry", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Bloodstained Iron Mask", + "setName": "Bloodstained Chivalry", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Bloom of the Mind's Desire", + "setName": "Night of the Sky's Unveiling", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Bloom Times", + "setName": "Husk of Opulent Dreams", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Broken Rime's Echo", + "setName": "Blizzard Strayer", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Calabash of Awakening", + "setName": "Husk of Opulent Dreams", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Capricious Visage", + "setName": "Shimenawa's Reminiscence", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Ceremonial War-Plume", + "setName": "Tenacity of the Millelith", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Chalice of the Font", + "setName": "Echoes of an Offering", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Compassionate Ladies' Hat", + "setName": "Nighttime Whispers in the Echoing Woods", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Concert's Final Hour", + "setName": "Wanderer's Troupe", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Conductor's Top Hat", + "setName": "Wanderer's Troupe", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Copper Compass", + "setName": "Heart of Depth", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Cowry of Parting", + "setName": "Ocean-Hued Clam", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Crown of Parting", + "setName": "Resolution of Sojourner", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crown of the Befallen", + "setName": "Night of the Sky's Unveiling", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crown of the Brave", + "setName": "Brave Heart", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crown of the Saints", + "setName": "Obsidian Codex", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crown of Watatsumi", + "setName": "Ocean-Hued Clam", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crownless Crown", + "setName": "Unfinished Reverie", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Crystal Tear of the Wanderer", + "setName": "Silken Moon's Serenade", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Dark Fruit of Bright Flowers", + "setName": "Unfinished Reverie", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Dawn's Brilliant Oath", + "setName": "A Day Carved From Rising Winds", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Deep Gallery's Bestowed Banquet", + "setName": "Finale of the Deep Galleries", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Deep Gallery's Distant Pact", + "setName": "Finale of the Deep Galleries", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Deep Gallery's Echoing Song", + "setName": "Finale of the Deep Galleries", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Deep Gallery's Lost Crown", + "setName": "Finale of the Deep Galleries", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Deep Gallery's Moment of Oblivion", + "setName": "Finale of the Deep Galleries", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Deep Palace's Plume", + "setName": "Ocean-Hued Clam", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Defender of the Enchanting Dream", + "setName": "Desert Pavilion Chronicle", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Demon-Warrior's Feather Mask", + "setName": "Scroll of the Hero of Cinder City", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Dreaming Steelbloom", + "setName": "Gilded Dreams", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Dyed Tassel", + "setName": "Long Night's Oath", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Echoing Sound From Days Past", + "setName": "Song of Days Past", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "End of the Golden Realm", + "setName": "Desert Pavilion Chronicle", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Entangling Bloom", + "setName": "Shimenawa's Reminiscence", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Exile's Circlet", + "setName": "The Exile", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Exile's Feather", + "setName": "The Exile", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Exile's Flower", + "setName": "The Exile", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Exile's Goblet", + "setName": "The Exile", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Exile's Pocket Watch", + "setName": "The Exile", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Faded Emerald Tail", + "setName": "Unfinished Reverie", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Faithful Hourglass", + "setName": "Nighttime Whispers in the Echoing Woods", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Feast of Boundless Joy", + "setName": "Vourukasha's Glow", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Feather of Homecoming", + "setName": "Resolution of Sojourner", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Feather of Indelible Sin", + "setName": "Night of the Sky's Unveiling", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Feather of Jagged Peaks", + "setName": "Archaic Petra", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Feather of Judgment", + "setName": "Gilded Dreams", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Feather of Nascent Light", + "setName": "Vermillion Hereafter", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Fell Dragon's Monocle", + "setName": "Nymph's Dream", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Flower of Accolades", + "setName": "Tenacity of the Millelith", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Flower of Creviced Cliff", + "setName": "Archaic Petra", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Flowering Life", + "setName": "Vermillion Hereafter", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Flowing Rings", + "setName": "Echoes of an Offering", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Forgotten Oath of Days Past", + "setName": "Song of Days Past", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Forgotten Vessel", + "setName": "Marechaussee Hunter", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Fortitude of the Brave", + "setName": "Brave Heart", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Frost Devotee's Delirium", + "setName": "Silken Moon's Serenade", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Frost-Weaved Dignity", + "setName": "Blizzard Strayer", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Frozen Homeland's Demise", + "setName": "Blizzard Strayer", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Gambler's Brooch", + "setName": "Gambler", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gambler's Dice Cup", + "setName": "Gambler", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Gambler's Earrings", + "setName": "Gambler", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Gambler's Feather Accessory", + "setName": "Gambler", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Gambler's Pocket Watch", + "setName": "Gambler", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "General's Ancient Helm", + "setName": "Tenacity of the Millelith", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Gilded Corsage", + "setName": "Heart of Depth", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gladiator's Destiny", + "setName": "Gladiator's Finale", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Gladiator's Intoxication", + "setName": "Gladiator's Finale", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Gladiator's Longing", + "setName": "Gladiator's Finale", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Gladiator's Nostalgia", + "setName": "Gladiator's Finale", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Gladiator's Triumphus", + "setName": "Gladiator's Finale", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Goblet of Chiseled Crag", + "setName": "Archaic Petra", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Goblet of the Sojourner", + "setName": "Resolution of Sojourner", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Goblet of Thundering Deep", + "setName": "Heart of Depth", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Golden Bird's Shedding", + "setName": "Golden Troupe", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Golden Era's Prelude", + "setName": "Golden Troupe", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Golden Night's Bustle", + "setName": "Golden Troupe", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Golden Song's Variation", + "setName": "Golden Troupe", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Golden Troupe's Reward", + "setName": "Golden Troupe", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Guardian's Band", + "setName": "Defender's Will", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Guardian's Clock", + "setName": "Defender's Will", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Guardian's Flower", + "setName": "Defender's Will", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Guardian's Sigil", + "setName": "Defender's Will", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Guardian's Vessel", + "setName": "Defender's Will", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Gust of Nostalgia", + "setName": "Heart of Depth", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Harmonious Symphony Prelude", + "setName": "Fragment of Harmonic Whimsy", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Heart of Comradeship", + "setName": "Resolution of Sojourner", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Heart of Khvarena's Brilliance", + "setName": "Vourukasha's Glow", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Heavensent Crown", + "setName": "Celestial Gift", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Heavensent Decree", + "setName": "Celestial Gift", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Heavensent Demise", + "setName": "Celestial Gift", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Heavensent Fragrance", + "setName": "Celestial Gift", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Heavensent Reward", + "setName": "Celestial Gift", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Heldenepos's Unspoken Tale", + "setName": "A Day Carved From Rising Winds", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Heroes' Tea Party", + "setName": "Nymph's Dream", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Holy Crown of the Believer", + "setName": "Silken Moon's Serenade", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Honest Quill", + "setName": "Nighttime Whispers in the Echoing Woods", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Honeyed Final Feast", + "setName": "Gilded Dreams", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Hopeful Heart", + "setName": "Shimenawa's Reminiscence", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Hour of Soothing Thunder", + "setName": "Thundersoother", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Hourglass of Thunder", + "setName": "Thundering Fury", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Hunter's Brooch", + "setName": "Marechaussee Hunter", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Icebreaker's Resolve", + "setName": "Blizzard Strayer", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Ichor Shower Rhapsody", + "setName": "Fragment of Harmonic Whimsy", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "In Remembrance of Viridescent Fields", + "setName": "Viridescent Venerer", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Instructor's Brooch", + "setName": "Instructor", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Instructor's Cap", + "setName": "Instructor", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Instructor's Feather Accessory", + "setName": "Instructor", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Instructor's Pocket Watch", + "setName": "Instructor", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Instructor's Tea Cup", + "setName": "Instructor", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Iridescence That Ceased Amidst Glory", + "setName": "Disenchantment in Deep Shadow", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Jade Leaf", + "setName": "Echoes of an Offering", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Joyous Glory of the Pure", + "setName": "Silken Moon's Serenade", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Labyrinth Wayfarer", + "setName": "Deepwood Memories", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lamp of the Lost", + "setName": "Deepwood Memories", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Laurel Coronet", + "setName": "Deepwood Memories", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Lavawalker's Epiphany", + "setName": "Lavawalker", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Lavawalker's Resolution", + "setName": "Lavawalker", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lavawalker's Salvation", + "setName": "Lavawalker", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Lavawalker's Torment", + "setName": "Lavawalker", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Lavawalker's Wisdom", + "setName": "Lavawalker", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Legacy of the Desert High-Born", + "setName": "Desert Pavilion Chronicle", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Lightkeeper's Pledge", + "setName": "Long Night's Oath", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lucky Dog's Clover", + "setName": "Lucky Dog", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Lucky Dog's Eagle Feather", + "setName": "Lucky Dog", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Lucky Dog's Goblet", + "setName": "Lucky Dog", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Lucky Dog's Hourglass", + "setName": "Lucky Dog", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Lucky Dog's Silver Circlet", + "setName": "Lucky Dog", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Magnanimous Ink Bottle", + "setName": "Nighttime Whispers in the Echoing Woods", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Magnificent Tsuba", + "setName": "Emblem of Severed Fate", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Maiden's Distant Love", + "setName": "Maiden Beloved", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Maiden's Fading Beauty", + "setName": "Maiden Beloved", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Maiden's Fleeting Leisure", + "setName": "Maiden Beloved", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Maiden's Heart-stricken Infatuation", + "setName": "Maiden Beloved", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Maiden's Passing Youth", + "setName": "Maiden Beloved", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Martial Artist's Bandana", + "setName": "Martial Artist", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Martial Artist's Feather Accessory", + "setName": "Martial Artist", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Martial Artist's Red Flower", + "setName": "Martial Artist", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Martial Artist's Water Hourglass", + "setName": "Martial Artist", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Martial Artist's Wine Cup", + "setName": "Martial Artist", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Mask of Solitude Basalt", + "setName": "Archaic Petra", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Masterpiece's Overture", + "setName": "Marechaussee Hunter", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Medal of the Brave", + "setName": "Brave Heart", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Minnesang of Love and Lament", + "setName": "A Day Carved From Rising Winds", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Mocking Mask", + "setName": "Pale Flame", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Moment of Attainment", + "setName": "Unfinished Reverie", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moment of Cessation", + "setName": "Pale Flame", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moment of Judgment", + "setName": "Marechaussee Hunter", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moment of the Pact", + "setName": "Vermillion Hereafter", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Moment That Ceased Upon Waking From Grand Dreams", + "setName": "Disenchantment in Deep Shadow", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moonlit Offering's Final Hour", + "setName": "Aubade of Morningstar and Moon", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Moonlit Offering's Libation", + "setName": "Aubade of Morningstar and Moon", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Moonlit Offering's Opulent Dream", + "setName": "Aubade of Morningstar and Moon", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Moonlit Offering's Parting Light", + "setName": "Aubade of Morningstar and Moon", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Moonlit Offering's Silver Crown", + "setName": "Aubade of Morningstar and Moon", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Morning Dew's Moment", + "setName": "Shimenawa's Reminiscence", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Mountain Ranger's Marker", + "setName": "Scroll of the Hero of Cinder City", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Mystic's Gold Dial", + "setName": "Scroll of the Hero of Cinder City", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Myths of the Night Realm", + "setName": "Obsidian Codex", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Nightingale's Tail Feather", + "setName": "Long Night's Oath", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Noble's Pledging Vessel", + "setName": "Tenacity of the Millelith", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Nymph's Constancy", + "setName": "Nymph's Dream", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Odyssean Flower", + "setName": "Nymph's Dream", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Omen of Thunderstorm", + "setName": "Thundering Fury", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Orichalceous Time-Dial", + "setName": "Tenacity of the Millelith", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Ornate Kabuto", + "setName": "Emblem of Severed Fate", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Outset of the Brave", + "setName": "Brave Heart", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Ovations That Ceased Upon Festivity", + "setName": "Disenchantment in Deep Shadow", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Pearl Cage", + "setName": "Ocean-Hued Clam", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Pendulum That Ceased Amidst a Great Fall", + "setName": "Disenchantment in Deep Shadow", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Plume of Luxury", + "setName": "Husk of Opulent Dreams", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Poetry of Days Past", + "setName": "Song of Days Past", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Pre-Banquet of the Contenders", + "setName": "Obsidian Codex", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Pristine Plume of the Blessed", + "setName": "Silken Moon's Serenade", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Promised Dream of Days Past", + "setName": "Song of Days Past", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Prospect of the Brave", + "setName": "Brave Heart", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Reckoning of the Xenogenic", + "setName": "Obsidian Codex", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Recollection of Days Past", + "setName": "Song of Days Past", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Revelation's Toll", + "setName": "Night of the Sky's Unveiling", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Root of the Spirit-Marrow", + "setName": "Obsidian Codex", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Royal Flora", + "setName": "Noblesse Oblige", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Royal Masque", + "setName": "Noblesse Oblige", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Royal Plume", + "setName": "Noblesse Oblige", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Royal Pocket Watch", + "setName": "Noblesse Oblige", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Royal Silver Urn", + "setName": "Noblesse Oblige", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Scarlet Vessel", + "setName": "Emblem of Severed Fate", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Scholar of Vines", + "setName": "Deepwood Memories", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Scholar's Bookmark", + "setName": "Scholar", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Scholar's Clock", + "setName": "Scholar", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Scholar's Ink Cup", + "setName": "Scholar", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Scholar's Lens", + "setName": "Scholar", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Scholar's Quill Pen", + "setName": "Scholar", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Sea-Dyed Blossom", + "setName": "Ocean-Hued Clam", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Secret-Keeper's Magic Bottle", + "setName": "Flower of Paradise Lost", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Selfless Floral Accessory", + "setName": "Nighttime Whispers in the Echoing Woods", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Shadow of the Sand King", + "setName": "Gilded Dreams", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Shaft of Remembrance", + "setName": "Shimenawa's Reminiscence", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Sharpness That Ceased Upon Wondrous Creation", + "setName": "Disenchantment in Deep Shadow", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Skeletal Hat", + "setName": "Husk of Opulent Dreams", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Snowswept Memory", + "setName": "Blizzard Strayer", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Solar Relic", + "setName": "Vermillion Hereafter", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Song of Life", + "setName": "Husk of Opulent Dreams", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Soulscent Bloom", + "setName": "Echoes of an Offering", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Stainless Bloom", + "setName": "Pale Flame", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Stamen of Khvarena's Origin", + "setName": "Vourukasha's Glow", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Storm Cage", + "setName": "Emblem of Severed Fate", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Summer Night's Bloom", + "setName": "Retracing Bolide", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Summer Night's Finale", + "setName": "Retracing Bolide", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Summer Night's Mask", + "setName": "Retracing Bolide", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Summer Night's Moment", + "setName": "Retracing Bolide", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Summer Night's Waterballoon", + "setName": "Retracing Bolide", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Sundered Feather", + "setName": "Emblem of Severed Fate", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Sundial of Enduring Jade", + "setName": "Archaic Petra", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Sundial of the Sojourner", + "setName": "Resolution of Sojourner", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Surpassing Cup", + "setName": "Pale Flame", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Survivor of Catastrophe", + "setName": "Thundering Fury", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Symbol of Felicitation", + "setName": "Echoes of an Offering", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "The First Days of the City of Kings", + "setName": "Desert Pavilion Chronicle", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "The Grand Jape of the Turning of Fate", + "setName": "Fragment of Harmonic Whimsy", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "The Sunken Years", + "setName": "Gilded Dreams", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "The Wine-Flask Over Which the Plan Was Hatched", + "setName": "Unfinished Reverie", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Thunder Summoner's Crown", + "setName": "Thundering Fury", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Thunderbird's Mercy", + "setName": "Thundering Fury", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Thundering Poise", + "setName": "Vermillion Hereafter", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Thundersoother's Diadem", + "setName": "Thundersoother", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Thundersoother's Goblet", + "setName": "Thundersoother", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Thundersoother's Heart", + "setName": "Thundersoother", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Thundersoother's Plume", + "setName": "Thundersoother", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Tiara of Flame", + "setName": "Prayers for Illumination", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Tiara of Frost", + "setName": "Prayers to Springtime", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Tiara of Thunder", + "setName": "Prayers for Wisdom", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Tiara of Torrents", + "setName": "Prayers for Destiny", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Timepiece of the Lost Path", + "setName": "Desert Pavilion Chronicle", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Tiny Miracle's Earrings", + "setName": "Tiny Miracle", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Tiny Miracle's Feather", + "setName": "Tiny Miracle", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Tiny Miracle's Flower", + "setName": "Tiny Miracle", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Tiny Miracle's Goblet", + "setName": "Tiny Miracle", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Tiny Miracle's Hourglass", + "setName": "Tiny Miracle", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Traveling Doctor's Handkerchief", + "setName": "Traveling Doctor", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Traveling Doctor's Medicine Pot", + "setName": "Traveling Doctor", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Traveling Doctor's Owl Feather", + "setName": "Traveling Doctor", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Traveling Doctor's Pocket Watch", + "setName": "Traveling Doctor", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Traveling Doctor's Silver Lotus", + "setName": "Traveling Doctor", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Troupe's Dawnlight", + "setName": "Wanderer's Troupe", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Undying One's Mourning Bell", + "setName": "Long Night's Oath", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Vessel of Plenty", + "setName": "Night of the Sky's Unveiling", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Veteran's Visage", + "setName": "Marechaussee Hunter", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Vibrant Pinion", + "setName": "Vourukasha's Glow", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Viridescent Arrow Feather", + "setName": "Viridescent Venerer", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Viridescent Venerer's Determination", + "setName": "Viridescent Venerer", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Viridescent Venerer's Diadem", + "setName": "Viridescent Venerer", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Viridescent Venerer's Vessel", + "setName": "Viridescent Venerer", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Wanderer's String-Kettle", + "setName": "Wanderer's Troupe", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Wandering Scholar's Claw Cup", + "setName": "Scroll of the Hero of Cinder City", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Whimsical Dance of the Withered", + "setName": "Fragment of Harmonic Whimsy", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Wicked Mage's Plumule", + "setName": "Nymph's Dream", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Wilting Feast", + "setName": "Flower of Paradise Lost", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Windborne Flower's Spruchdichtung", + "setName": "A Day Carved From Rising Winds", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Wine-Stained Tricorne", + "setName": "Heart of Depth", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + }, + { + "name": "Wise Doctor's Pinion", + "setName": "Pale Flame", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Witch's End Time", + "setName": "Crimson Witch of Flames", + "slot": "Sands of Eon", + "relicType": "EQUIP_SHOES" + }, + { + "name": "Witch's Ever-Burning Plume", + "setName": "Crimson Witch of Flames", + "slot": "Plume of Death", + "relicType": "EQUIP_NECKLACE" + }, + { + "name": "Witch's Flower of Blaze", + "setName": "Crimson Witch of Flames", + "slot": "Flower of Life", + "relicType": "EQUIP_BRACER" + }, + { + "name": "Witch's Heart Flames", + "setName": "Crimson Witch of Flames", + "slot": "Goblet of Eonothem", + "relicType": "EQUIP_RING" + }, + { + "name": "Witch's Scorching Hat", + "setName": "Crimson Witch of Flames", + "slot": "Circlet of Logos", + "relicType": "EQUIP_DRESS" + } + ], + "slotByPiece": { + "A Horn Unwinded": "Goblet of Eonothem", + "A Moment Congealed": "Sands of Eon", + "A Note in Spring's Leich": "Sands of Eon", + "A Time of Insight": "Sands of Eon", + "Adventurer's Bandana": "Circlet of Logos", + "Adventurer's Flower": "Flower of Life", + "Adventurer's Golden Goblet": "Goblet of Eonothem", + "Adventurer's Pocket Watch": "Sands of Eon", + "Adventurer's Tail Feather": "Plume of Death", + "Amethyst Crown": "Circlet of Logos", + "Ancient Abscission": "Sands of Eon", + "Ancient Sea's Nocturnal Musing": "Plume of Death", + "Ay-Khanoum's Myriad": "Flower of Life", + "Bard's Arrow Feather": "Plume of Death", + "Beast Tamer's Talisman": "Flower of Life", + "Berserker's Battle Mask": "Circlet of Logos", + "Berserker's Bone Goblet": "Goblet of Eonothem", + "Berserker's Indigo Feather": "Plume of Death", + "Berserker's Rose": "Flower of Life", + "Berserker's Timepiece": "Sands of Eon", + "Bloodstained Black Plume": "Plume of Death", + "Bloodstained Chevalier's Goblet": "Goblet of Eonothem", + "Bloodstained Final Hour": "Sands of Eon", + "Bloodstained Flower of Iron": "Flower of Life", + "Bloodstained Iron Mask": "Circlet of Logos", + "Bloom of the Mind's Desire": "Flower of Life", + "Bloom Times": "Flower of Life", + "Broken Rime's Echo": "Circlet of Logos", + "Calabash of Awakening": "Goblet of Eonothem", + "Capricious Visage": "Circlet of Logos", + "Ceremonial War-Plume": "Plume of Death", + "Chalice of the Font": "Goblet of Eonothem", + "Compassionate Ladies' Hat": "Circlet of Logos", + "Concert's Final Hour": "Sands of Eon", + "Conductor's Top Hat": "Circlet of Logos", + "Copper Compass": "Sands of Eon", + "Cowry of Parting": "Sands of Eon", + "Crown of Parting": "Circlet of Logos", + "Crown of the Befallen": "Circlet of Logos", + "Crown of the Brave": "Circlet of Logos", + "Crown of the Saints": "Circlet of Logos", + "Crown of Watatsumi": "Circlet of Logos", + "Crownless Crown": "Circlet of Logos", + "Crystal Tear of the Wanderer": "Flower of Life", + "Dark Fruit of Bright Flowers": "Flower of Life", + "Dawn's Brilliant Oath": "Plume of Death", + "Deep Gallery's Bestowed Banquet": "Goblet of Eonothem", + "Deep Gallery's Distant Pact": "Plume of Death", + "Deep Gallery's Echoing Song": "Flower of Life", + "Deep Gallery's Lost Crown": "Circlet of Logos", + "Deep Gallery's Moment of Oblivion": "Sands of Eon", + "Deep Palace's Plume": "Plume of Death", + "Defender of the Enchanting Dream": "Goblet of Eonothem", + "Demon-Warrior's Feather Mask": "Circlet of Logos", + "Dreaming Steelbloom": "Flower of Life", + "Dyed Tassel": "Circlet of Logos", + "Echoing Sound From Days Past": "Sands of Eon", + "End of the Golden Realm": "Plume of Death", + "Entangling Bloom": "Flower of Life", + "Exile's Circlet": "Circlet of Logos", + "Exile's Feather": "Plume of Death", + "Exile's Flower": "Flower of Life", + "Exile's Goblet": "Goblet of Eonothem", + "Exile's Pocket Watch": "Sands of Eon", + "Faded Emerald Tail": "Plume of Death", + "Faithful Hourglass": "Sands of Eon", + "Feast of Boundless Joy": "Goblet of Eonothem", + "Feather of Homecoming": "Plume of Death", + "Feather of Indelible Sin": "Plume of Death", + "Feather of Jagged Peaks": "Plume of Death", + "Feather of Judgment": "Plume of Death", + "Feather of Nascent Light": "Plume of Death", + "Fell Dragon's Monocle": "Circlet of Logos", + "Flower of Accolades": "Flower of Life", + "Flower of Creviced Cliff": "Flower of Life", + "Flowering Life": "Flower of Life", + "Flowing Rings": "Circlet of Logos", + "Forgotten Oath of Days Past": "Flower of Life", + "Forgotten Vessel": "Goblet of Eonothem", + "Fortitude of the Brave": "Sands of Eon", + "Frost Devotee's Delirium": "Sands of Eon", + "Frost-Weaved Dignity": "Goblet of Eonothem", + "Frozen Homeland's Demise": "Sands of Eon", + "Gambler's Brooch": "Flower of Life", + "Gambler's Dice Cup": "Goblet of Eonothem", + "Gambler's Earrings": "Circlet of Logos", + "Gambler's Feather Accessory": "Plume of Death", + "Gambler's Pocket Watch": "Sands of Eon", + "General's Ancient Helm": "Circlet of Logos", + "Gilded Corsage": "Flower of Life", + "Gladiator's Destiny": "Plume of Death", + "Gladiator's Intoxication": "Goblet of Eonothem", + "Gladiator's Longing": "Sands of Eon", + "Gladiator's Nostalgia": "Flower of Life", + "Gladiator's Triumphus": "Circlet of Logos", + "Goblet of Chiseled Crag": "Goblet of Eonothem", + "Goblet of the Sojourner": "Goblet of Eonothem", + "Goblet of Thundering Deep": "Goblet of Eonothem", + "Golden Bird's Shedding": "Plume of Death", + "Golden Era's Prelude": "Sands of Eon", + "Golden Night's Bustle": "Goblet of Eonothem", + "Golden Song's Variation": "Flower of Life", + "Golden Troupe's Reward": "Circlet of Logos", + "Guardian's Band": "Circlet of Logos", + "Guardian's Clock": "Sands of Eon", + "Guardian's Flower": "Flower of Life", + "Guardian's Sigil": "Plume of Death", + "Guardian's Vessel": "Goblet of Eonothem", + "Gust of Nostalgia": "Plume of Death", + "Harmonious Symphony Prelude": "Flower of Life", + "Heart of Comradeship": "Flower of Life", + "Heart of Khvarena's Brilliance": "Circlet of Logos", + "Heavensent Crown": "Circlet of Logos", + "Heavensent Decree": "Sands of Eon", + "Heavensent Demise": "Plume of Death", + "Heavensent Fragrance": "Flower of Life", + "Heavensent Reward": "Goblet of Eonothem", + "Heldenepos's Unspoken Tale": "Goblet of Eonothem", + "Heroes' Tea Party": "Goblet of Eonothem", + "Holy Crown of the Believer": "Circlet of Logos", + "Honest Quill": "Plume of Death", + "Honeyed Final Feast": "Goblet of Eonothem", + "Hopeful Heart": "Goblet of Eonothem", + "Hour of Soothing Thunder": "Sands of Eon", + "Hourglass of Thunder": "Sands of Eon", + "Hunter's Brooch": "Flower of Life", + "Icebreaker's Resolve": "Plume of Death", + "Ichor Shower Rhapsody": "Goblet of Eonothem", + "In Remembrance of Viridescent Fields": "Flower of Life", + "Instructor's Brooch": "Flower of Life", + "Instructor's Cap": "Circlet of Logos", + "Instructor's Feather Accessory": "Plume of Death", + "Instructor's Pocket Watch": "Sands of Eon", + "Instructor's Tea Cup": "Goblet of Eonothem", + "Iridescence That Ceased Amidst Glory": "Flower of Life", + "Jade Leaf": "Plume of Death", + "Joyous Glory of the Pure": "Goblet of Eonothem", + "Labyrinth Wayfarer": "Flower of Life", + "Lamp of the Lost": "Goblet of Eonothem", + "Laurel Coronet": "Circlet of Logos", + "Lavawalker's Epiphany": "Goblet of Eonothem", + "Lavawalker's Resolution": "Flower of Life", + "Lavawalker's Salvation": "Plume of Death", + "Lavawalker's Torment": "Sands of Eon", + "Lavawalker's Wisdom": "Circlet of Logos", + "Legacy of the Desert High-Born": "Circlet of Logos", + "Lightkeeper's Pledge": "Flower of Life", + "Lucky Dog's Clover": "Flower of Life", + "Lucky Dog's Eagle Feather": "Plume of Death", + "Lucky Dog's Goblet": "Goblet of Eonothem", + "Lucky Dog's Hourglass": "Sands of Eon", + "Lucky Dog's Silver Circlet": "Circlet of Logos", + "Magnanimous Ink Bottle": "Goblet of Eonothem", + "Magnificent Tsuba": "Flower of Life", + "Maiden's Distant Love": "Flower of Life", + "Maiden's Fading Beauty": "Circlet of Logos", + "Maiden's Fleeting Leisure": "Goblet of Eonothem", + "Maiden's Heart-stricken Infatuation": "Plume of Death", + "Maiden's Passing Youth": "Sands of Eon", + "Martial Artist's Bandana": "Circlet of Logos", + "Martial Artist's Feather Accessory": "Plume of Death", + "Martial Artist's Red Flower": "Flower of Life", + "Martial Artist's Water Hourglass": "Sands of Eon", + "Martial Artist's Wine Cup": "Goblet of Eonothem", + "Mask of Solitude Basalt": "Circlet of Logos", + "Masterpiece's Overture": "Plume of Death", + "Medal of the Brave": "Flower of Life", + "Minnesang of Love and Lament": "Circlet of Logos", + "Mocking Mask": "Circlet of Logos", + "Moment of Attainment": "Sands of Eon", + "Moment of Cessation": "Sands of Eon", + "Moment of Judgment": "Sands of Eon", + "Moment of the Pact": "Goblet of Eonothem", + "Moment That Ceased Upon Waking From Grand Dreams": "Sands of Eon", + "Moonlit Offering's Final Hour": "Sands of Eon", + "Moonlit Offering's Libation": "Goblet of Eonothem", + "Moonlit Offering's Opulent Dream": "Flower of Life", + "Moonlit Offering's Parting Light": "Plume of Death", + "Moonlit Offering's Silver Crown": "Circlet of Logos", + "Morning Dew's Moment": "Sands of Eon", + "Mountain Ranger's Marker": "Plume of Death", + "Mystic's Gold Dial": "Sands of Eon", + "Myths of the Night Realm": "Sands of Eon", + "Nightingale's Tail Feather": "Plume of Death", + "Noble's Pledging Vessel": "Goblet of Eonothem", + "Nymph's Constancy": "Sands of Eon", + "Odyssean Flower": "Flower of Life", + "Omen of Thunderstorm": "Goblet of Eonothem", + "Orichalceous Time-Dial": "Sands of Eon", + "Ornate Kabuto": "Circlet of Logos", + "Outset of the Brave": "Goblet of Eonothem", + "Ovations That Ceased Upon Festivity": "Goblet of Eonothem", + "Pearl Cage": "Goblet of Eonothem", + "Pendulum That Ceased Amidst a Great Fall": "Circlet of Logos", + "Plume of Luxury": "Plume of Death", + "Poetry of Days Past": "Circlet of Logos", + "Pre-Banquet of the Contenders": "Goblet of Eonothem", + "Pristine Plume of the Blessed": "Plume of Death", + "Promised Dream of Days Past": "Goblet of Eonothem", + "Prospect of the Brave": "Plume of Death", + "Reckoning of the Xenogenic": "Flower of Life", + "Recollection of Days Past": "Plume of Death", + "Revelation's Toll": "Sands of Eon", + "Root of the Spirit-Marrow": "Plume of Death", + "Royal Flora": "Flower of Life", + "Royal Masque": "Circlet of Logos", + "Royal Plume": "Plume of Death", + "Royal Pocket Watch": "Sands of Eon", + "Royal Silver Urn": "Goblet of Eonothem", + "Scarlet Vessel": "Goblet of Eonothem", + "Scholar of Vines": "Plume of Death", + "Scholar's Bookmark": "Flower of Life", + "Scholar's Clock": "Sands of Eon", + "Scholar's Ink Cup": "Goblet of Eonothem", + "Scholar's Lens": "Circlet of Logos", + "Scholar's Quill Pen": "Plume of Death", + "Sea-Dyed Blossom": "Flower of Life", + "Secret-Keeper's Magic Bottle": "Goblet of Eonothem", + "Selfless Floral Accessory": "Flower of Life", + "Shadow of the Sand King": "Circlet of Logos", + "Shaft of Remembrance": "Plume of Death", + "Sharpness That Ceased Upon Wondrous Creation": "Plume of Death", + "Skeletal Hat": "Circlet of Logos", + "Snowswept Memory": "Flower of Life", + "Solar Relic": "Sands of Eon", + "Song of Life": "Sands of Eon", + "Soulscent Bloom": "Flower of Life", + "Stainless Bloom": "Flower of Life", + "Stamen of Khvarena's Origin": "Flower of Life", + "Storm Cage": "Sands of Eon", + "Summer Night's Bloom": "Flower of Life", + "Summer Night's Finale": "Plume of Death", + "Summer Night's Mask": "Circlet of Logos", + "Summer Night's Moment": "Sands of Eon", + "Summer Night's Waterballoon": "Goblet of Eonothem", + "Sundered Feather": "Plume of Death", + "Sundial of Enduring Jade": "Sands of Eon", + "Sundial of the Sojourner": "Sands of Eon", + "Surpassing Cup": "Goblet of Eonothem", + "Survivor of Catastrophe": "Plume of Death", + "Symbol of Felicitation": "Sands of Eon", + "The First Days of the City of Kings": "Flower of Life", + "The Grand Jape of the Turning of Fate": "Sands of Eon", + "The Sunken Years": "Sands of Eon", + "The Wine-Flask Over Which the Plan Was Hatched": "Goblet of Eonothem", + "Thunder Summoner's Crown": "Circlet of Logos", + "Thunderbird's Mercy": "Flower of Life", + "Thundering Poise": "Circlet of Logos", + "Thundersoother's Diadem": "Circlet of Logos", + "Thundersoother's Goblet": "Goblet of Eonothem", + "Thundersoother's Heart": "Flower of Life", + "Thundersoother's Plume": "Plume of Death", + "Tiara of Flame": "Circlet of Logos", + "Tiara of Frost": "Circlet of Logos", + "Tiara of Thunder": "Circlet of Logos", + "Tiara of Torrents": "Circlet of Logos", + "Timepiece of the Lost Path": "Sands of Eon", + "Tiny Miracle's Earrings": "Circlet of Logos", + "Tiny Miracle's Feather": "Plume of Death", + "Tiny Miracle's Flower": "Flower of Life", + "Tiny Miracle's Goblet": "Goblet of Eonothem", + "Tiny Miracle's Hourglass": "Sands of Eon", + "Traveling Doctor's Handkerchief": "Circlet of Logos", + "Traveling Doctor's Medicine Pot": "Goblet of Eonothem", + "Traveling Doctor's Owl Feather": "Plume of Death", + "Traveling Doctor's Pocket Watch": "Sands of Eon", + "Traveling Doctor's Silver Lotus": "Flower of Life", + "Troupe's Dawnlight": "Flower of Life", + "Undying One's Mourning Bell": "Sands of Eon", + "Vessel of Plenty": "Goblet of Eonothem", + "Veteran's Visage": "Circlet of Logos", + "Vibrant Pinion": "Plume of Death", + "Viridescent Arrow Feather": "Plume of Death", + "Viridescent Venerer's Determination": "Sands of Eon", + "Viridescent Venerer's Diadem": "Circlet of Logos", + "Viridescent Venerer's Vessel": "Goblet of Eonothem", + "Wanderer's String-Kettle": "Goblet of Eonothem", + "Wandering Scholar's Claw Cup": "Goblet of Eonothem", + "Whimsical Dance of the Withered": "Circlet of Logos", + "Wicked Mage's Plumule": "Plume of Death", + "Wilting Feast": "Plume of Death", + "Windborne Flower's Spruchdichtung": "Flower of Life", + "Wine-Stained Tricorne": "Circlet of Logos", + "Wise Doctor's Pinion": "Plume of Death", + "Witch's End Time": "Sands of Eon", + "Witch's Ever-Burning Plume": "Plume of Death", + "Witch's Flower of Blaze": "Flower of Life", + "Witch's Heart Flames": "Goblet of Eonothem", + "Witch's Scorching Hat": "Circlet of Logos" + }, + "setByPiece": { + "A Horn Unwinded": "Long Night's Oath", + "A Moment Congealed": "Flower of Paradise Lost", + "A Note in Spring's Leich": "A Day Carved From Rising Winds", + "A Time of Insight": "Deepwood Memories", + "Adventurer's Bandana": "Adventurer", + "Adventurer's Flower": "Adventurer", + "Adventurer's Golden Goblet": "Adventurer", + "Adventurer's Pocket Watch": "Adventurer", + "Adventurer's Tail Feather": "Adventurer", + "Amethyst Crown": "Flower of Paradise Lost", + "Ancient Abscission": "Vourukasha's Glow", + "Ancient Sea's Nocturnal Musing": "Fragment of Harmonic Whimsy", + "Ay-Khanoum's Myriad": "Flower of Paradise Lost", + "Bard's Arrow Feather": "Wanderer's Troupe", + "Beast Tamer's Talisman": "Scroll of the Hero of Cinder City", + "Berserker's Battle Mask": "Berserker", + "Berserker's Bone Goblet": "Berserker", + "Berserker's Indigo Feather": "Berserker", + "Berserker's Rose": "Berserker", + "Berserker's Timepiece": "Berserker", + "Bloodstained Black Plume": "Bloodstained Chivalry", + "Bloodstained Chevalier's Goblet": "Bloodstained Chivalry", + "Bloodstained Final Hour": "Bloodstained Chivalry", + "Bloodstained Flower of Iron": "Bloodstained Chivalry", + "Bloodstained Iron Mask": "Bloodstained Chivalry", + "Bloom of the Mind's Desire": "Night of the Sky's Unveiling", + "Bloom Times": "Husk of Opulent Dreams", + "Broken Rime's Echo": "Blizzard Strayer", + "Calabash of Awakening": "Husk of Opulent Dreams", + "Capricious Visage": "Shimenawa's Reminiscence", + "Ceremonial War-Plume": "Tenacity of the Millelith", + "Chalice of the Font": "Echoes of an Offering", + "Compassionate Ladies' Hat": "Nighttime Whispers in the Echoing Woods", + "Concert's Final Hour": "Wanderer's Troupe", + "Conductor's Top Hat": "Wanderer's Troupe", + "Copper Compass": "Heart of Depth", + "Cowry of Parting": "Ocean-Hued Clam", + "Crown of Parting": "Resolution of Sojourner", + "Crown of the Befallen": "Night of the Sky's Unveiling", + "Crown of the Brave": "Brave Heart", + "Crown of the Saints": "Obsidian Codex", + "Crown of Watatsumi": "Ocean-Hued Clam", + "Crownless Crown": "Unfinished Reverie", + "Crystal Tear of the Wanderer": "Silken Moon's Serenade", + "Dark Fruit of Bright Flowers": "Unfinished Reverie", + "Dawn's Brilliant Oath": "A Day Carved From Rising Winds", + "Deep Gallery's Bestowed Banquet": "Finale of the Deep Galleries", + "Deep Gallery's Distant Pact": "Finale of the Deep Galleries", + "Deep Gallery's Echoing Song": "Finale of the Deep Galleries", + "Deep Gallery's Lost Crown": "Finale of the Deep Galleries", + "Deep Gallery's Moment of Oblivion": "Finale of the Deep Galleries", + "Deep Palace's Plume": "Ocean-Hued Clam", + "Defender of the Enchanting Dream": "Desert Pavilion Chronicle", + "Demon-Warrior's Feather Mask": "Scroll of the Hero of Cinder City", + "Dreaming Steelbloom": "Gilded Dreams", + "Dyed Tassel": "Long Night's Oath", + "Echoing Sound From Days Past": "Song of Days Past", + "End of the Golden Realm": "Desert Pavilion Chronicle", + "Entangling Bloom": "Shimenawa's Reminiscence", + "Exile's Circlet": "The Exile", + "Exile's Feather": "The Exile", + "Exile's Flower": "The Exile", + "Exile's Goblet": "The Exile", + "Exile's Pocket Watch": "The Exile", + "Faded Emerald Tail": "Unfinished Reverie", + "Faithful Hourglass": "Nighttime Whispers in the Echoing Woods", + "Feast of Boundless Joy": "Vourukasha's Glow", + "Feather of Homecoming": "Resolution of Sojourner", + "Feather of Indelible Sin": "Night of the Sky's Unveiling", + "Feather of Jagged Peaks": "Archaic Petra", + "Feather of Judgment": "Gilded Dreams", + "Feather of Nascent Light": "Vermillion Hereafter", + "Fell Dragon's Monocle": "Nymph's Dream", + "Flower of Accolades": "Tenacity of the Millelith", + "Flower of Creviced Cliff": "Archaic Petra", + "Flowering Life": "Vermillion Hereafter", + "Flowing Rings": "Echoes of an Offering", + "Forgotten Oath of Days Past": "Song of Days Past", + "Forgotten Vessel": "Marechaussee Hunter", + "Fortitude of the Brave": "Brave Heart", + "Frost Devotee's Delirium": "Silken Moon's Serenade", + "Frost-Weaved Dignity": "Blizzard Strayer", + "Frozen Homeland's Demise": "Blizzard Strayer", + "Gambler's Brooch": "Gambler", + "Gambler's Dice Cup": "Gambler", + "Gambler's Earrings": "Gambler", + "Gambler's Feather Accessory": "Gambler", + "Gambler's Pocket Watch": "Gambler", + "General's Ancient Helm": "Tenacity of the Millelith", + "Gilded Corsage": "Heart of Depth", + "Gladiator's Destiny": "Gladiator's Finale", + "Gladiator's Intoxication": "Gladiator's Finale", + "Gladiator's Longing": "Gladiator's Finale", + "Gladiator's Nostalgia": "Gladiator's Finale", + "Gladiator's Triumphus": "Gladiator's Finale", + "Goblet of Chiseled Crag": "Archaic Petra", + "Goblet of the Sojourner": "Resolution of Sojourner", + "Goblet of Thundering Deep": "Heart of Depth", + "Golden Bird's Shedding": "Golden Troupe", + "Golden Era's Prelude": "Golden Troupe", + "Golden Night's Bustle": "Golden Troupe", + "Golden Song's Variation": "Golden Troupe", + "Golden Troupe's Reward": "Golden Troupe", + "Guardian's Band": "Defender's Will", + "Guardian's Clock": "Defender's Will", + "Guardian's Flower": "Defender's Will", + "Guardian's Sigil": "Defender's Will", + "Guardian's Vessel": "Defender's Will", + "Gust of Nostalgia": "Heart of Depth", + "Harmonious Symphony Prelude": "Fragment of Harmonic Whimsy", + "Heart of Comradeship": "Resolution of Sojourner", + "Heart of Khvarena's Brilliance": "Vourukasha's Glow", + "Heavensent Crown": "Celestial Gift", + "Heavensent Decree": "Celestial Gift", + "Heavensent Demise": "Celestial Gift", + "Heavensent Fragrance": "Celestial Gift", + "Heavensent Reward": "Celestial Gift", + "Heldenepos's Unspoken Tale": "A Day Carved From Rising Winds", + "Heroes' Tea Party": "Nymph's Dream", + "Holy Crown of the Believer": "Silken Moon's Serenade", + "Honest Quill": "Nighttime Whispers in the Echoing Woods", + "Honeyed Final Feast": "Gilded Dreams", + "Hopeful Heart": "Shimenawa's Reminiscence", + "Hour of Soothing Thunder": "Thundersoother", + "Hourglass of Thunder": "Thundering Fury", + "Hunter's Brooch": "Marechaussee Hunter", + "Icebreaker's Resolve": "Blizzard Strayer", + "Ichor Shower Rhapsody": "Fragment of Harmonic Whimsy", + "In Remembrance of Viridescent Fields": "Viridescent Venerer", + "Instructor's Brooch": "Instructor", + "Instructor's Cap": "Instructor", + "Instructor's Feather Accessory": "Instructor", + "Instructor's Pocket Watch": "Instructor", + "Instructor's Tea Cup": "Instructor", + "Iridescence That Ceased Amidst Glory": "Disenchantment in Deep Shadow", + "Jade Leaf": "Echoes of an Offering", + "Joyous Glory of the Pure": "Silken Moon's Serenade", + "Labyrinth Wayfarer": "Deepwood Memories", + "Lamp of the Lost": "Deepwood Memories", + "Laurel Coronet": "Deepwood Memories", + "Lavawalker's Epiphany": "Lavawalker", + "Lavawalker's Resolution": "Lavawalker", + "Lavawalker's Salvation": "Lavawalker", + "Lavawalker's Torment": "Lavawalker", + "Lavawalker's Wisdom": "Lavawalker", + "Legacy of the Desert High-Born": "Desert Pavilion Chronicle", + "Lightkeeper's Pledge": "Long Night's Oath", + "Lucky Dog's Clover": "Lucky Dog", + "Lucky Dog's Eagle Feather": "Lucky Dog", + "Lucky Dog's Goblet": "Lucky Dog", + "Lucky Dog's Hourglass": "Lucky Dog", + "Lucky Dog's Silver Circlet": "Lucky Dog", + "Magnanimous Ink Bottle": "Nighttime Whispers in the Echoing Woods", + "Magnificent Tsuba": "Emblem of Severed Fate", + "Maiden's Distant Love": "Maiden Beloved", + "Maiden's Fading Beauty": "Maiden Beloved", + "Maiden's Fleeting Leisure": "Maiden Beloved", + "Maiden's Heart-stricken Infatuation": "Maiden Beloved", + "Maiden's Passing Youth": "Maiden Beloved", + "Martial Artist's Bandana": "Martial Artist", + "Martial Artist's Feather Accessory": "Martial Artist", + "Martial Artist's Red Flower": "Martial Artist", + "Martial Artist's Water Hourglass": "Martial Artist", + "Martial Artist's Wine Cup": "Martial Artist", + "Mask of Solitude Basalt": "Archaic Petra", + "Masterpiece's Overture": "Marechaussee Hunter", + "Medal of the Brave": "Brave Heart", + "Minnesang of Love and Lament": "A Day Carved From Rising Winds", + "Mocking Mask": "Pale Flame", + "Moment of Attainment": "Unfinished Reverie", + "Moment of Cessation": "Pale Flame", + "Moment of Judgment": "Marechaussee Hunter", + "Moment of the Pact": "Vermillion Hereafter", + "Moment That Ceased Upon Waking From Grand Dreams": "Disenchantment in Deep Shadow", + "Moonlit Offering's Final Hour": "Aubade of Morningstar and Moon", + "Moonlit Offering's Libation": "Aubade of Morningstar and Moon", + "Moonlit Offering's Opulent Dream": "Aubade of Morningstar and Moon", + "Moonlit Offering's Parting Light": "Aubade of Morningstar and Moon", + "Moonlit Offering's Silver Crown": "Aubade of Morningstar and Moon", + "Morning Dew's Moment": "Shimenawa's Reminiscence", + "Mountain Ranger's Marker": "Scroll of the Hero of Cinder City", + "Mystic's Gold Dial": "Scroll of the Hero of Cinder City", + "Myths of the Night Realm": "Obsidian Codex", + "Nightingale's Tail Feather": "Long Night's Oath", + "Noble's Pledging Vessel": "Tenacity of the Millelith", + "Nymph's Constancy": "Nymph's Dream", + "Odyssean Flower": "Nymph's Dream", + "Omen of Thunderstorm": "Thundering Fury", + "Orichalceous Time-Dial": "Tenacity of the Millelith", + "Ornate Kabuto": "Emblem of Severed Fate", + "Outset of the Brave": "Brave Heart", + "Ovations That Ceased Upon Festivity": "Disenchantment in Deep Shadow", + "Pearl Cage": "Ocean-Hued Clam", + "Pendulum That Ceased Amidst a Great Fall": "Disenchantment in Deep Shadow", + "Plume of Luxury": "Husk of Opulent Dreams", + "Poetry of Days Past": "Song of Days Past", + "Pre-Banquet of the Contenders": "Obsidian Codex", + "Pristine Plume of the Blessed": "Silken Moon's Serenade", + "Promised Dream of Days Past": "Song of Days Past", + "Prospect of the Brave": "Brave Heart", + "Reckoning of the Xenogenic": "Obsidian Codex", + "Recollection of Days Past": "Song of Days Past", + "Revelation's Toll": "Night of the Sky's Unveiling", + "Root of the Spirit-Marrow": "Obsidian Codex", + "Royal Flora": "Noblesse Oblige", + "Royal Masque": "Noblesse Oblige", + "Royal Plume": "Noblesse Oblige", + "Royal Pocket Watch": "Noblesse Oblige", + "Royal Silver Urn": "Noblesse Oblige", + "Scarlet Vessel": "Emblem of Severed Fate", + "Scholar of Vines": "Deepwood Memories", + "Scholar's Bookmark": "Scholar", + "Scholar's Clock": "Scholar", + "Scholar's Ink Cup": "Scholar", + "Scholar's Lens": "Scholar", + "Scholar's Quill Pen": "Scholar", + "Sea-Dyed Blossom": "Ocean-Hued Clam", + "Secret-Keeper's Magic Bottle": "Flower of Paradise Lost", + "Selfless Floral Accessory": "Nighttime Whispers in the Echoing Woods", + "Shadow of the Sand King": "Gilded Dreams", + "Shaft of Remembrance": "Shimenawa's Reminiscence", + "Sharpness That Ceased Upon Wondrous Creation": "Disenchantment in Deep Shadow", + "Skeletal Hat": "Husk of Opulent Dreams", + "Snowswept Memory": "Blizzard Strayer", + "Solar Relic": "Vermillion Hereafter", + "Song of Life": "Husk of Opulent Dreams", + "Soulscent Bloom": "Echoes of an Offering", + "Stainless Bloom": "Pale Flame", + "Stamen of Khvarena's Origin": "Vourukasha's Glow", + "Storm Cage": "Emblem of Severed Fate", + "Summer Night's Bloom": "Retracing Bolide", + "Summer Night's Finale": "Retracing Bolide", + "Summer Night's Mask": "Retracing Bolide", + "Summer Night's Moment": "Retracing Bolide", + "Summer Night's Waterballoon": "Retracing Bolide", + "Sundered Feather": "Emblem of Severed Fate", + "Sundial of Enduring Jade": "Archaic Petra", + "Sundial of the Sojourner": "Resolution of Sojourner", + "Surpassing Cup": "Pale Flame", + "Survivor of Catastrophe": "Thundering Fury", + "Symbol of Felicitation": "Echoes of an Offering", + "The First Days of the City of Kings": "Desert Pavilion Chronicle", + "The Grand Jape of the Turning of Fate": "Fragment of Harmonic Whimsy", + "The Sunken Years": "Gilded Dreams", + "The Wine-Flask Over Which the Plan Was Hatched": "Unfinished Reverie", + "Thunder Summoner's Crown": "Thundering Fury", + "Thunderbird's Mercy": "Thundering Fury", + "Thundering Poise": "Vermillion Hereafter", + "Thundersoother's Diadem": "Thundersoother", + "Thundersoother's Goblet": "Thundersoother", + "Thundersoother's Heart": "Thundersoother", + "Thundersoother's Plume": "Thundersoother", + "Tiara of Flame": "Prayers for Illumination", + "Tiara of Frost": "Prayers to Springtime", + "Tiara of Thunder": "Prayers for Wisdom", + "Tiara of Torrents": "Prayers for Destiny", + "Timepiece of the Lost Path": "Desert Pavilion Chronicle", + "Tiny Miracle's Earrings": "Tiny Miracle", + "Tiny Miracle's Feather": "Tiny Miracle", + "Tiny Miracle's Flower": "Tiny Miracle", + "Tiny Miracle's Goblet": "Tiny Miracle", + "Tiny Miracle's Hourglass": "Tiny Miracle", + "Traveling Doctor's Handkerchief": "Traveling Doctor", + "Traveling Doctor's Medicine Pot": "Traveling Doctor", + "Traveling Doctor's Owl Feather": "Traveling Doctor", + "Traveling Doctor's Pocket Watch": "Traveling Doctor", + "Traveling Doctor's Silver Lotus": "Traveling Doctor", + "Troupe's Dawnlight": "Wanderer's Troupe", + "Undying One's Mourning Bell": "Long Night's Oath", + "Vessel of Plenty": "Night of the Sky's Unveiling", + "Veteran's Visage": "Marechaussee Hunter", + "Vibrant Pinion": "Vourukasha's Glow", + "Viridescent Arrow Feather": "Viridescent Venerer", + "Viridescent Venerer's Determination": "Viridescent Venerer", + "Viridescent Venerer's Diadem": "Viridescent Venerer", + "Viridescent Venerer's Vessel": "Viridescent Venerer", + "Wanderer's String-Kettle": "Wanderer's Troupe", + "Wandering Scholar's Claw Cup": "Scroll of the Hero of Cinder City", + "Whimsical Dance of the Withered": "Fragment of Harmonic Whimsy", + "Wicked Mage's Plumule": "Nymph's Dream", + "Wilting Feast": "Flower of Paradise Lost", + "Windborne Flower's Spruchdichtung": "A Day Carved From Rising Winds", + "Wine-Stained Tricorne": "Heart of Depth", + "Wise Doctor's Pinion": "Pale Flame", + "Witch's End Time": "Crimson Witch of Flames", + "Witch's Ever-Burning Plume": "Crimson Witch of Flames", + "Witch's Flower of Blaze": "Crimson Witch of Flames", + "Witch's Heart Flames": "Crimson Witch of Flames", + "Witch's Scorching Hat": "Crimson Witch of Flames" + }, + "slots": [ + "Flower of Life", + "Plume of Death", + "Sands of Eon", + "Goblet of Eonothem", + "Circlet of Logos" + ], + "mainStats": [ + "Elemental Mastery", + "Energy Recharge", + "CRIT Rate", + "CRIT DMG", + "Healing Bonus", + "ATK%", + "HP%", + "DEF%", + "ATK", + "HP", + "DEF", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Physical DMG Bonus" + ], + "mainStatsBySlot": { + "Flower of Life": [ + "HP" + ], + "Plume of Death": [ + "ATK" + ], + "Sands of Eon": [ + "HP%", + "ATK%", + "DEF%", + "Energy Recharge", + "Elemental Mastery" + ], + "Goblet of Eonothem": [ + "HP%", + "ATK%", + "DEF%", + "Elemental Mastery", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Physical DMG Bonus" + ], + "Circlet of Logos": [ + "HP%", + "ATK%", + "DEF%", + "Elemental Mastery", + "CRIT Rate", + "CRIT DMG", + "Healing Bonus" + ] + }, + "mainStatValueReferences": { + "Flower of Life": [ + { + "stat": "HP", + "base": 717, + "max": 4780 + } + ], + "Plume of Death": [ + { + "stat": "ATK", + "base": 47, + "max": 311 + } + ], + "Sands of Eon": [ + { + "stat": "HP%", + "base": 7, + "max": 46.6 + }, + { + "stat": "ATK%", + "base": 7, + "max": 46.6 + }, + { + "stat": "DEF%", + "base": 8.7, + "max": 58.3 + }, + { + "stat": "Energy Recharge", + "base": 7.8, + "max": 51.8 + }, + { + "stat": "Elemental Mastery", + "base": 28, + "max": 187 + } + ], + "Goblet of Eonothem": [ + { + "stat": "HP%", + "base": 7, + "max": 46.6 + }, + { + "stat": "ATK%", + "base": 7, + "max": 46.6 + }, + { + "stat": "DEF%", + "base": 8.7, + "max": 58.3 + }, + { + "stat": "Elemental Mastery", + "base": 28, + "max": 187 + }, + { + "stat": "Hydro DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Pyro DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Electro DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Cryo DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Dendro DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Anemo DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Geo DMG Bonus", + "base": 7, + "max": 46.6 + }, + { + "stat": "Physical DMG Bonus", + "base": 8.7, + "max": 58.3 + } + ], + "Circlet of Logos": [ + { + "stat": "HP%", + "base": 7, + "max": 46.6 + }, + { + "stat": "ATK%", + "base": 7, + "max": 46.6 + }, + { + "stat": "DEF%", + "base": 8.7, + "max": 58.3 + }, + { + "stat": "Elemental Mastery", + "base": 28, + "max": 187 + }, + { + "stat": "CRIT Rate", + "base": 4.7, + "max": 31.1 + }, + { + "stat": "CRIT DMG", + "base": 9.3, + "max": 62.2 + }, + { + "stat": "Healing Bonus", + "base": 5.4, + "max": 35.9 + } + ] + }, + "substats": [ + "CRIT DMG", + "CRIT Rate", + "Energy Recharge", + "Elemental Mastery", + "ATK", + "ATK%", + "HP", + "HP%", + "DEF", + "DEF%" + ], + "stats": { + "main": [ + "Elemental Mastery", + "Energy Recharge", + "CRIT Rate", + "CRIT DMG", + "Healing Bonus", + "ATK%", + "HP%", + "DEF%", + "ATK", + "HP", + "DEF", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Physical DMG Bonus" + ], + "mainBySlot": { + "Flower of Life": [ + "HP" + ], + "Plume of Death": [ + "ATK" + ], + "Sands of Eon": [ + "HP%", + "ATK%", + "DEF%", + "Energy Recharge", + "Elemental Mastery" + ], + "Goblet of Eonothem": [ + "HP%", + "ATK%", + "DEF%", + "Elemental Mastery", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Physical DMG Bonus" + ], + "Circlet of Logos": [ + "HP%", + "ATK%", + "DEF%", + "Elemental Mastery", + "CRIT Rate", + "CRIT DMG", + "Healing Bonus" + ] + }, + "sub": [ + "CRIT DMG", + "CRIT Rate", + "Energy Recharge", + "Elemental Mastery", + "ATK", + "ATK%", + "HP", + "HP%", + "DEF", + "DEF%" + ] + }, + "aliases": { + "stats": { + "Crit Damage": "CRIT DMG", + "Critical Damage": "CRIT DMG", + "Crit Rate": "CRIT Rate", + "Critical Rate": "CRIT Rate", + "Energy Recharge %": "Energy Recharge", + "Elemental Master": "Elemental Mastery", + "Elemental Masterie": "Elemental Mastery", + "Heal Bonus": "Healing Bonus" + }, + "textReplacements": { + "CIT DMG": "CRIT DMG", + "CRIT DMG+I": "CRIT DMG+1", + "CRIT Rate+Z": "CRIT Rate+2", + "Energv Recharge": "Energy Recharge", + "Elemental Masterv": "Elemental Mastery", + "Equipped;": "Equipped:" + }, + "slotAliases": { + "Sands of Eon Vi": "Sands of Eon", + "Sands of Eon V": "Sands of Eon", + "Sands of Eon 2": "Sands of Eon", + "Flower of Life 2": "Flower of Life", + "Flower of Lif": "Flower of Life", + "Goblet of Eonotherm": "Goblet of Eonothem", + "Goblet of Eonothemn": "Goblet of Eonothem", + "Circlet of Logas": "Circlet of Logos", + "Circlet of Loges": "Circlet of Logos" + }, + "setAliases": { + "Viridescent Venere": "Viridescent Venerer", + "Maiden Beloved:": "Maiden Beloved", + "Gladiators Finale": "Gladiator's Finale" + }, + "pieceAliases": { + "A Note in Springs Leich": "A Note in Spring's Leich", + "Viridescent Vencrers Vessel": "Viridescent Venerer's Vessel", + "Holy Crown of the Believer ": "Holy Crown of the Believer" + }, + "characterAliases": { + "Citlall": "Citlali", + "Sandrone ": "Sandrone", + "Qiqi ": "Qiqi" + } + }, + "uiProfiles": { + "artifactDetailEn": { + "language": "English", + "supportedResolutions": [ + "1920x1080", + "2560x1440", + "3840x2160" + ], + "detailPanel": { + "x": 0.5, + "y": 0.05, + "width": 0.45, + "height": 0.9 + }, + "note": "Relative profile used as scanner contract; runtime crops may tune offsets from review samples." + } + } +} diff --git a/src/features/app/hooks/useAppControllerActions.ts b/src/features/app/hooks/useAppControllerActions.ts new file mode 100644 index 0000000..f00e3f1 --- /dev/null +++ b/src/features/app/hooks/useAppControllerActions.ts @@ -0,0 +1,54 @@ +import { useCallback } from "react"; +import type { CaptureOptions, CaptureResult } from "../../../types/global"; +import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService"; +import type { AppControllerContext } from "../services/appControllerService"; + +interface AppControllerActionsInput { + appControllerContext: AppControllerContext; +} + +export interface AppControllerActionsResult { + loadStoredArtifactSnapshot: () => Promise; + refreshCaptureSources: () => Promise; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + runDemoScan: () => Promise; + exportCurrentGood: () => Promise; + showOverlay: () => Promise; +} + +export function useAppControllerActions({ + appControllerContext, +}: AppControllerActionsInput): AppControllerActionsResult { + const loadStoredArtifactSnapshot = useCallback(async () => { + await loadStoredArtifactSnapshotAction(appControllerContext); + }, [appControllerContext]); + + const refreshCaptureSources = useCallback(async () => { + await refreshCaptureSourcesAction(appControllerContext); + }, [appControllerContext]); + + const captureSelectedSource = useCallback(async (delayMs = 0, focusGenshin = false, options?: CaptureOptions) => { + return captureSelectedSourceAction(appControllerContext, delayMs, focusGenshin, options); + }, [appControllerContext]); + + const runDemoScan = useCallback(async () => { + await runDemoScanAction(appControllerContext, captureSelectedSource); + }, [appControllerContext, captureSelectedSource]); + + const exportCurrentGood = useCallback(async () => { + await exportCurrentGoodAction(appControllerContext); + }, [appControllerContext]); + + const showOverlay = useCallback(async () => { + await showOverlayAction(appControllerContext); + }, [appControllerContext]); + + return { + loadStoredArtifactSnapshot, + refreshCaptureSources, + captureSelectedSource, + runDemoScan, + exportCurrentGood, + showOverlay, + }; +} diff --git a/src/features/app/hooks/useAppControllerLifecycle.ts b/src/features/app/hooks/useAppControllerLifecycle.ts new file mode 100644 index 0000000..15ad104 --- /dev/null +++ b/src/features/app/hooks/useAppControllerLifecycle.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import { initializeAppControllerStateAction } from "../services/appControllerService"; +import type { AppControllerContext } from "../services/appControllerService"; + +interface AppControllerLifecycleInput { + appControllerContext: AppControllerContext; +} + +export function useAppControllerLifecycle({ appControllerContext }: AppControllerLifecycleInput) { + useEffect(() => { + void initializeAppControllerStateAction(appControllerContext); + }, [appControllerContext.artifactRepo, appControllerContext.captureRepo, appControllerContext.snapshotRepo, appControllerContext.isOverlay]); +} + diff --git a/src/features/app/hooks/useAppControllerState.ts b/src/features/app/hooks/useAppControllerState.ts new file mode 100644 index 0000000..a648c7c --- /dev/null +++ b/src/features/app/hooks/useAppControllerState.ts @@ -0,0 +1,92 @@ +import { useMemo, useState } from "react"; +import type { AppSnapshot } from "../../../types/domain"; +import type { CaptureResult, CaptureSourceInfo } from "../../../types/global"; +import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories"; +import { createAppMetrics } from "../services/appMetricsService"; +import type { AppControllerResult } from "../types"; +import { createInitialSnapshot, createAppControllerContext } from "../services/appControllerService"; +import { useAppControllerActions } from "./useAppControllerActions"; +import { useAppControllerLifecycle } from "./useAppControllerLifecycle"; +import { type NavigationId } from "../../layout/types"; + +export function useAppControllerState(): AppControllerResult { + const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1"; + const repositories = useMemo(() => createRendererRepositories(), []); + + const [snapshot, setSnapshot] = useState(createInitialSnapshot); + const [activeView, setActiveView] = useState("scan"); + const [isScanning, setIsScanning] = useState(false); + const [captureSources, setCaptureSources] = useState([]); + const [selectedSourceId, setSelectedSourceId] = useState(""); + const [latestCapture, setLatestCapture] = useState(null); + const [topbarStatus, setTopbarStatus] = useState( + repositories + ? "Electron bridge connected. Refresh sources after Genshin is open." + : "Electron bridge missing. Open the Electron app window, not the browser preview URL.", + ); + const appControllerContext = useMemo( + () => + createAppControllerContext(repositories, { + isOverlay, + selectedSourceId, + snapshot, + setCaptureSources, + setSelectedSourceId, + setTopbarStatus, + setSnapshot, + setLatestCapture, + setIsScanning, + }), + [ + repositories, + isOverlay, + selectedSourceId, + snapshot, + setCaptureSources, + setSelectedSourceId, + setTopbarStatus, + setSnapshot, + setLatestCapture, + setIsScanning, + ], + ); + + const bridgeReady = Boolean(repositories?.isAvailable); + const canExportGood = Boolean(repositories?.canExportGood); + const canShowOverlay = Boolean(repositories?.canShowOverlay); + const { cards: metricCards } = useMemo(() => createAppMetrics(snapshot), [snapshot]); + + useAppControllerLifecycle({ appControllerContext }); + + const { + loadStoredArtifactSnapshot, + refreshCaptureSources, + captureSelectedSource, + runDemoScan, + exportCurrentGood, + showOverlay, + } = useAppControllerActions({ appControllerContext }); + + return { + isOverlay, + snapshot, + activeView, + setActiveView, + isScanning, + captureSources, + selectedSourceId, + setSelectedSourceId, + latestCapture, + topbarStatus, + bridgeReady, + canExportGood, + canShowOverlay, + metricCards, + loadStoredArtifactSnapshot, + refreshCaptureSources, + captureSelectedSource, + runDemoScan, + exportCurrentGood, + showOverlay, + }; +} diff --git a/src/features/app/services/appControllerService.ts b/src/features/app/services/appControllerService.ts new file mode 100644 index 0000000..f5ed865 --- /dev/null +++ b/src/features/app/services/appControllerService.ts @@ -0,0 +1,215 @@ +import { createDemoSnapshot, getPresets } from "../../../lib/demoData"; +import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot"; +import { exportGood } from "../../../lib/goodFormat"; +import { runAccountScan } from "../../../lib/scanner"; +import type { AppSnapshot } from "../../../types/domain"; +import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global"; +import type { + ArtifactRepositoryPort, + CaptureRepositoryPort, + OverlayRepositoryPort, + RendererRepositories, + ScanExportPort, + SnapshotRepositoryPort, +} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import type { Dispatch, SetStateAction } from "react"; + +export interface AppControllerContext { + artifactRepo?: ArtifactRepositoryPort; + captureRepo?: CaptureRepositoryPort; + exportRepo?: ScanExportPort; + overlayRepo?: OverlayRepositoryPort; + snapshotRepo?: SnapshotRepositoryPort; + isOverlay: boolean; + selectedSourceId: string; + snapshot: AppSnapshot; + setCaptureSources: Dispatch>; + setSelectedSourceId: (value: string) => void; + setTopbarStatus: (value: string) => void; + setSnapshot: Dispatch>; + setLatestCapture?: Dispatch>; + setIsScanning?: Dispatch>; +} + +interface AppControllerUIState { + isOverlay: boolean; + selectedSourceId: string; + snapshot: AppSnapshot; + setCaptureSources: Dispatch>; + setSelectedSourceId: (value: string) => void; + setTopbarStatus: (value: string) => void; + setSnapshot: Dispatch>; + setLatestCapture?: Dispatch>; + setIsScanning?: Dispatch>; +} + +export function createAppControllerContext( + repositories: RendererRepositories | null, + uiState: AppControllerUIState, +): AppControllerContext { + return { + artifactRepo: repositories?.artifacts, + captureRepo: repositories?.capture, + exportRepo: repositories?.export, + overlayRepo: repositories?.overlay, + snapshotRepo: repositories?.snapshot, + isOverlay: uiState.isOverlay, + selectedSourceId: uiState.selectedSourceId, + snapshot: uiState.snapshot, + setCaptureSources: uiState.setCaptureSources, + setSelectedSourceId: uiState.setSelectedSourceId, + setTopbarStatus: uiState.setTopbarStatus, + setSnapshot: uiState.setSnapshot, + setLatestCapture: uiState.setLatestCapture, + setIsScanning: uiState.setIsScanning, + }; +} + +export function loadStoredArtifactSnapshotAction(context: AppControllerContext): Promise { + const { artifactRepo, setSnapshot } = context; + if (!artifactRepo?.loadAll) { + return Promise.resolve(); + } + return artifactRepo.loadAll().then((result: ArtifactStoreLoadResult) => { + if (!result?.ok || result.artifacts.length === 0) return; + const nextSnapshot = createLocalAccountSnapshot(result.artifacts, getPresets()); + if (!nextSnapshot) return; + setSnapshot(nextSnapshot); + }); +} + +export async function initializeAppControllerStateAction(context: AppControllerContext): Promise { + const { snapshotRepo, isOverlay, setTopbarStatus, setSnapshot } = context; + try { + await loadStoredArtifactSnapshotAction(context); + if (isOverlay) { + return; + } + + const stored = await snapshotRepo?.load(); + if (stored) { + setSnapshot((current) => + current.artifacts.length > 0 && current.scanEvents[0]?.id === "local-db-loaded" ? current : stored, + ); + } + await refreshCaptureSourcesAction(context); + } catch (error) { + setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed."); + } +} + +export async function refreshCaptureSourcesAction(context: AppControllerContext): Promise { + const { captureRepo, selectedSourceId, setCaptureSources, setSelectedSourceId, setTopbarStatus } = context; + if (!captureRepo?.listSources) { + setTopbarStatus("Electron bridge missing. Die Browser-Vorschau kann Genshin nicht scannen - Electron-App verwenden."); + return; + } + try { + const sources = await captureRepo.listSources(); + setCaptureSources(sources); + const currentStillExists = sources.some((source: CaptureSourceInfo) => source.id === selectedSourceId); + const genshinCandidate = sources.find((source: CaptureSourceInfo) => source.isGenshinCandidate); + const current = currentStillExists ? sources.find((source: CaptureSourceInfo) => source.id === selectedSourceId) : null; + const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0]; + if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id); + setTopbarStatus( + `${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`, + ); + } catch (error) { + setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources."); + } +} + +export async function captureSelectedSourceAction( + context: AppControllerContext, + delayMs = 0, + focusGenshin = false, + options?: CaptureOptions, +): Promise { + const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context; + if (!captureRepo?.captureSource) { + setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app."); + return null; + } + if (!selectedSourceId) { + setTopbarStatus("No capture source selected. Press Sources after Genshin is open."); + return null; + } + if (!setLatestCapture) { + setTopbarStatus("Capture state cannot be updated."); + return null; + } + try { + if (delayMs > 0) { + setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`); + } + const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options); + setLatestCapture(capture); + const ocrStatus = capture.ocrSkipped + ? "OCR skipped for fast scan." + : capture.ocrTimedOut + ? "OCR timed out; review sample needed." + : "OCR handoff is next."; + setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`); + return capture; + } catch (error) { + setTopbarStatus(error instanceof Error ? error.message : "Capture failed."); + return null; + } +} + +export async function runDemoScanAction( + context: AppControllerContext, + captureSelectedSource: () => Promise, +): Promise { + const { snapshotRepo, setSnapshot, setIsScanning, setTopbarStatus } = context; + if (!setIsScanning) { + return; + } + setIsScanning(true); + try { + await captureSelectedSource(); + const next = await (snapshotRepo?.runMockScan() ?? Promise.resolve(null)); + const resolved = next ?? (await runAccountScan()); + setSnapshot(resolved); + await snapshotRepo?.save(resolved); + } catch (error) { + setTopbarStatus(error instanceof Error ? error.message : "Demo scan failed."); + } finally { + setIsScanning(false); + } +} + +export function createInitialSnapshot(): AppSnapshot { + return createDemoSnapshot(); +} + +export function exportCurrentGoodAction(context: AppControllerContext): Promise { + const { exportRepo, snapshot, setTopbarStatus } = context; + if (!exportRepo?.exportGood) { + setTopbarStatus("GOOD-Export ist nur in der Electron-App verfuegbar."); + return Promise.resolve(); + } + if (snapshot.artifacts.length === 0) { + setTopbarStatus("Keine Artifacts zum Exportieren vorhanden."); + return Promise.resolve(); + } + return exportRepo + .exportGood(exportGood(snapshot.artifacts)) + .then((result: SaveResultWithPath) => { + setTopbarStatus(result.ok ? `GOOD exportiert: ${result.path}` : "GOOD-Export fehlgeschlagen."); + }); +} + +export async function showOverlayAction(context: AppControllerContext): Promise { + const { overlayRepo, setTopbarStatus } = context; + if (!overlayRepo?.show) { + setTopbarStatus("Overlay braucht die Electron-Bridge."); + return; + } + try { + await overlayRepo.show(); + } catch { + setTopbarStatus("Overlay konnte nicht gestartet werden."); + } +} diff --git a/src/features/app/services/appMetricsService.ts b/src/features/app/services/appMetricsService.ts new file mode 100644 index 0000000..01b7996 --- /dev/null +++ b/src/features/app/services/appMetricsService.ts @@ -0,0 +1,43 @@ +import type { ArtifactVerdict, AppSnapshot } from "../../../types/domain"; +import { summarizeSnapshot } from "../../../lib/snapshotSummary"; +import type { AppMetricCard } from "../../layout/types"; + +const verdictLabelByType: Record = { + keep: "Keep", + maybe_level: "Test level", + character_specific: "Character-specific", + trash_candidate: "Trash", + needs_review: "Needs review", +}; + +export interface AppTopbarSafetyCounts { + keep: number; + maybe: number; + trash: number; + review: number; +} + +export interface AppMetricsModel { + topbarSafety: AppTopbarSafetyCounts; + cards: AppMetricCard[]; +} + +export function createAppMetrics(snapshot: AppSnapshot): AppMetricsModel { + const summary = summarizeSnapshot(snapshot); + const recommendations = snapshot.recommendations; + const topbarSafety: AppTopbarSafetyCounts = { + keep: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "keep").length, + maybe: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "maybe_level").length, + trash: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length, + review: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length, + }; + + const cards: AppMetricCard[] = [ + { label: verdictLabelByType.keep, value: summary.artifacts, detail: `${topbarSafety.review} brauchen Review` }, + { label: "Behalten", value: summary.useful, detail: `${topbarSafety.keep + topbarSafety.maybe} / ${snapshot.artifacts.length} usable` }, + { label: "Trash-Kandidaten", value: summary.trash, detail: "wird nie automatisch geloescht" }, + { label: "Build-Optionen", value: summary.builds, detail: "Top-Vorschlaege verfuegbar" }, + ]; + + return { topbarSafety, cards }; +} diff --git a/src/features/app/types.ts b/src/features/app/types.ts new file mode 100644 index 0000000..f3d1efa --- /dev/null +++ b/src/features/app/types.ts @@ -0,0 +1,26 @@ +import type { AppSnapshot } from "../../types/domain"; +import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global"; +import { type AppMetricCard, type NavigationId } from "../layout/types"; + +export interface AppControllerResult { + activeView: NavigationId; + setActiveView: (value: NavigationId) => void; + isOverlay: boolean; + isScanning: boolean; + snapshot: AppSnapshot; + captureSources: CaptureSourceInfo[]; + selectedSourceId: string; + setSelectedSourceId: (value: string) => void; + latestCapture: CaptureResult | null; + topbarStatus: string; + bridgeReady: boolean; + canExportGood: boolean; + canShowOverlay: boolean; + metricCards: AppMetricCard[]; + refreshCaptureSources: () => Promise; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + runDemoScan: () => Promise; + exportCurrentGood: () => Promise; + loadStoredArtifactSnapshot: () => Promise; + showOverlay: () => Promise; +} diff --git a/src/features/app/useAppController.ts b/src/features/app/useAppController.ts new file mode 100644 index 0000000..31b5326 --- /dev/null +++ b/src/features/app/useAppController.ts @@ -0,0 +1,8 @@ +import type { AppControllerResult } from "./types"; +import { useAppControllerState } from "./hooks/useAppControllerState"; + +export function useAppController(): AppControllerResult { + return useAppControllerState(); +} + + diff --git a/src/features/builds/BuildsView.tsx b/src/features/builds/BuildsView.tsx new file mode 100644 index 0000000..0fdad96 --- /dev/null +++ b/src/features/builds/BuildsView.tsx @@ -0,0 +1,62 @@ +import { AlertTriangle } from "lucide-react"; +import { useBuildCardModel } from "./hooks/useBuildCardModel"; +import { useBuildsViewModel } from "./hooks/useBuildsViewModel"; +import type { BuildCardProps, BuildsViewProps } from "./types"; + +function BuildCard({ build, snapshot }: BuildCardProps) { + const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact])); + const { + characterName, + qualityLabel, + roundedScore, + explanation, + artifactRows, + warnings, + hasWarnings, + } = useBuildCardModel({ + build, + characters: snapshot.characters, + artifactLookup, + }); + + return ( +
+
+
+

{qualityLabel}

+

{characterName}

+
+ {roundedScore} +
+

{explanation}

+
+ {artifactRows.map((artifact) => ( +
+ {artifact.slot} + {artifact.setName} + {artifact.details} +
+ ))} +
+ {hasWarnings && ( +
+ {warnings.map((warning) => ( + {warning} + ))} +
+ )} +
+ ); +} + +export function BuildsView({ snapshot }: BuildsViewProps) { + const { visibleBuilds } = useBuildsViewModel({ snapshot }); + + return ( +
+ {visibleBuilds.map(({ build }) => ( + + ))} +
+ ); +} diff --git a/src/features/builds/hooks/useBuildCardModel.ts b/src/features/builds/hooks/useBuildCardModel.ts new file mode 100644 index 0000000..86df677 --- /dev/null +++ b/src/features/builds/hooks/useBuildCardModel.ts @@ -0,0 +1,49 @@ +import type { BuildSuggestion } from "../../../types/domain"; +import type { Artifact, Character } from "../../../types/domain"; + +export interface BuildCardModel { + characterName: string; + qualityLabel: string; + roundedScore: number; + explanation: string; + hasWarnings: boolean; + artifactRows: Array<{ + id: string; + slot: string; + setName: string; + details: string; + }>; + warnings: string[]; +} + +interface UseBuildCardModelInput { + build: BuildSuggestion; + characters: Character[]; + artifactLookup: Map; +} + +export function useBuildCardModel({ + build, + characters, + artifactLookup, +}: UseBuildCardModelInput): BuildCardModel { + const character = characters.find((entry) => entry.id === build.characterId); + const artifacts = build.artifactIds + .map((id) => artifactLookup.get(id)) + .filter((artifact): artifact is Artifact => Boolean(artifact)); + + return { + characterName: character?.name ?? build.characterId, + qualityLabel: build.quality.replace("_", " "), + roundedScore: Math.round(build.score), + explanation: build.explanation, + hasWarnings: build.warnings.length > 0, + artifactRows: artifacts.map((artifact) => ({ + id: artifact.id, + slot: artifact.slot, + setName: artifact.setName, + details: `+${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`, + })), + warnings: build.warnings, + }; +} diff --git a/src/features/builds/hooks/useBuildsViewModel.ts b/src/features/builds/hooks/useBuildsViewModel.ts new file mode 100644 index 0000000..a9b926f --- /dev/null +++ b/src/features/builds/hooks/useBuildsViewModel.ts @@ -0,0 +1,18 @@ +import type { AppSnapshot } from "../../../types/domain"; +import type { BuildSuggestion } from "../../../types/domain"; + +interface UseBuildsViewModelInput { + snapshot: AppSnapshot; +} + +export interface BuildsViewModel { + visibleBuilds: Array<{ + build: BuildSuggestion; + }>; +} + +export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel { + return { + visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })), + }; +} diff --git a/src/features/builds/types.ts b/src/features/builds/types.ts new file mode 100644 index 0000000..a11bbd6 --- /dev/null +++ b/src/features/builds/types.ts @@ -0,0 +1,10 @@ +import type { AppSnapshot, BuildSuggestion } from "../../types/domain"; + +export interface BuildsViewProps { + snapshot: AppSnapshot; +} + +export interface BuildCardProps { + build: BuildSuggestion; + snapshot: AppSnapshot; +} diff --git a/src/features/common/verdictMeta.tsx b/src/features/common/verdictMeta.tsx new file mode 100644 index 0000000..bdaac7f --- /dev/null +++ b/src/features/common/verdictMeta.tsx @@ -0,0 +1,10 @@ +import { AlertTriangle, BadgeCheck, Lock, Sparkles, Trash2 } from "lucide-react"; +import type { ArtifactVerdict } from "../../types/domain"; + +export const verdictMeta: Record = { + keep: { label: "Keep", className: "pill keep", icon: }, + maybe_level: { label: "Test level", className: "pill maybe", icon: }, + character_specific: { label: "Character-specific", className: "pill specific", icon: }, + trash_candidate: { label: "Trash candidate", className: "pill trash", icon: }, + needs_review: { label: "Needs review", className: "pill review", icon: }, +}; diff --git a/src/features/layout/AppLayout.tsx b/src/features/layout/AppLayout.tsx new file mode 100644 index 0000000..170c776 --- /dev/null +++ b/src/features/layout/AppLayout.tsx @@ -0,0 +1,145 @@ +import { ShieldCheck } from "lucide-react"; +import type { + AppMetricsProps, + AppShellProps, + AppSidebarProps, + AppTopbarProps, +} from "./types"; +import { useAppSidebarModel } from "./hooks/useAppSidebarModel"; +import { useAppTopbarModel } from "./hooks/useAppTopbarModel"; + +export function AppSidebar({ activeView, items, onSelect }: AppSidebarProps) { + const { navigationItems } = useAppSidebarModel({ items, onSelect }); + + return ( + + ); +} + +export function AppTopbar({ + topbarStatus, + canExportGood, + canShowOverlay, + isScanning, + artifactCount, + onShowOverlay, + onDemoScan, + onExportGood, + exportIcon, + overlayIcon, + demoIcon, +}: AppTopbarProps) { + const { + handleShowOverlay, + handleExportGood, + handleDemoScan, + isExportDisabled, + isDemoDisabled, + isOverlayDisabled, + exportButtonLabel, + exportButtonTitle, + overlayButtonLabel, + overlayButtonTitle, + demoButtonLabel, + demoButtonTitle, + } = useAppTopbarModel({ + onExportGood, + onShowOverlay, + onDemoScan, + canExportGood, + canShowOverlay, + isScanning, + artifactCount, + }); + + return ( +
+
+

Lokaler Windows-Assistent

+

Scanne dein Inventar, triff einfache Artifact-Entscheidungen.

+
+
+ {topbarStatus && {topbarStatus}} + + + +
+
+ ); +} + +export function AppMetrics({ metricCards }: AppMetricsProps) { + return ( +
+ {metricCards.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+ ); +} + +export function AppShell({ sidebar, children }: AppShellProps) { + return ( +
+ {sidebar} +
{children}
+
+ ); +} diff --git a/src/features/layout/hooks/useAppSidebarModel.ts b/src/features/layout/hooks/useAppSidebarModel.ts new file mode 100644 index 0000000..c5fab4d --- /dev/null +++ b/src/features/layout/hooks/useAppSidebarModel.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import type { NavigationId } from "../types"; +import type { AppNavigationItem, AppSidebarItemAction } from "../types"; + +export interface AppSidebarModel { + navigationItems: AppSidebarItemAction[]; +} + +interface UseAppSidebarModelInput { + items: AppNavigationItem[]; + onSelect: (id: NavigationId) => void; +} + +export function useAppSidebarModel({ items, onSelect }: UseAppSidebarModelInput): AppSidebarModel { + const navigationItems = useMemo( + () => + items.map((item) => ({ + ...item, + onSelect: () => onSelect(item.id), + })), + [items, onSelect], + ); + + return { + navigationItems, + }; +} diff --git a/src/features/layout/hooks/useAppTopbarModel.ts b/src/features/layout/hooks/useAppTopbarModel.ts new file mode 100644 index 0000000..674d17b --- /dev/null +++ b/src/features/layout/hooks/useAppTopbarModel.ts @@ -0,0 +1,67 @@ +import { useCallback } from "react"; + +export interface AppTopbarModel { + handleShowOverlay: () => void; + handleExportGood: () => void; + handleDemoScan: () => void; + isExportDisabled: boolean; + isDemoDisabled: boolean; + isOverlayDisabled: boolean; + exportButtonLabel: string; + exportButtonTitle: string; + overlayButtonLabel: string; + overlayButtonTitle: string; + demoButtonLabel: string; + demoButtonTitle: string; +} + +interface UseAppTopbarModelInput { + onExportGood: () => Promise | void; + onShowOverlay: () => Promise; + onDemoScan: () => void; + canExportGood: boolean; + canShowOverlay: boolean; + isScanning: boolean; + artifactCount: number; +} + +export function useAppTopbarModel({ + onExportGood, + onShowOverlay, + onDemoScan, + canExportGood, + canShowOverlay, + isScanning, + artifactCount, +}: UseAppTopbarModelInput): AppTopbarModel { + const handleShowOverlay = useCallback(() => { + void onShowOverlay(); + }, [onShowOverlay]); + + const handleExportGood = useCallback(() => { + void onExportGood(); + }, [onExportGood]); + + const handleDemoScan = useCallback(() => { + onDemoScan(); + }, [onDemoScan]); + + const isExportDisabled = !canExportGood || artifactCount === 0; + const isDemoDisabled = isScanning; + const isOverlayDisabled = !canShowOverlay; + + return { + handleShowOverlay, + handleExportGood, + handleDemoScan, + isExportDisabled, + isDemoDisabled, + isOverlayDisabled, + exportButtonLabel: "GOOD Export", + exportButtonTitle: canExportGood ? "GOOD-Export starten." : "GOOD-Export ist nur in der Electron-App verfuegbar.", + overlayButtonLabel: "Overlay", + overlayButtonTitle: canShowOverlay ? "Overlay oeffnen." : "Overlay benoetigt die Electron-Bridge.", + demoButtonLabel: isScanning ? "Laedt..." : "Demo-Daten", + demoButtonTitle: "Laedt Beispieldaten fuer die Triage- und Build-Ansicht.", + }; +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts new file mode 100644 index 0000000..4b1df90 --- /dev/null +++ b/src/features/layout/index.ts @@ -0,0 +1,12 @@ +export { AppShell, AppMetrics, AppSidebar, AppTopbar } from "./AppLayout"; +export { + type AppMetricCard, + type AppNavigationItem, + type AppBarAction, + type NavigationId, + type AppSidebarProps, + type AppTopbarProps, + type AppMetricsProps, + type AppShellProps, +} from "./types"; +export { appNavigationItems } from "./navigation"; diff --git a/src/features/layout/navigation.ts b/src/features/layout/navigation.ts new file mode 100644 index 0000000..9d1854e --- /dev/null +++ b/src/features/layout/navigation.ts @@ -0,0 +1,10 @@ +import { Layers3, Radar, Wand2, Eye } from "lucide-react"; +import type { AppNavigationItem } from "./types"; + +export const appNavigationItems: AppNavigationItem[] = [ + { id: "scan", label: "Scan", icon: Radar }, + { id: "triage", label: "Triage", icon: Layers3 }, + { id: "builds", label: "Builds", icon: Wand2 }, + { id: "overlay", label: "Overlay", icon: Eye }, +]; + diff --git a/src/features/layout/types.ts b/src/features/layout/types.ts new file mode 100644 index 0000000..74ce470 --- /dev/null +++ b/src/features/layout/types.ts @@ -0,0 +1,60 @@ +import { type ComponentType, type ReactNode } from "react"; +import type { LucideProps } from "lucide-react"; + +export type NavigationId = "scan" | "triage" | "builds" | "overlay"; + +export interface AppNavigationItem { + id: NavigationId; + label: string; + icon: ComponentType; + disabled?: boolean; + disabledReason?: string; +} + +export interface AppSidebarItemAction extends AppNavigationItem { + onSelect: () => void; +} + +export interface AppMetricCard { + label: string; + value: number; + detail: string; +} + +export interface AppBarAction { + id: string; + icon?: ReactNode; + label: string; + disabled?: boolean; + onAction: () => void; + title?: string; +} + +export interface AppSidebarProps { + activeView: NavigationId; + items: AppNavigationItem[]; + onSelect: (id: NavigationId) => void; +} + +export interface AppTopbarProps { + topbarStatus: string; + canExportGood: boolean; + canShowOverlay: boolean; + isScanning: boolean; + artifactCount: number; + onExportGood: () => Promise | void; + onShowOverlay: () => Promise; + onDemoScan: () => void; + exportIcon?: ReactNode; + overlayIcon?: ReactNode; + demoIcon?: ReactNode; +} + +export interface AppMetricsProps { + metricCards: AppMetricCard[]; +} + +export interface AppShellProps { + sidebar: ReactNode; + children: ReactNode; +} diff --git a/src/features/overlay/OverlayViews.tsx b/src/features/overlay/OverlayViews.tsx new file mode 100644 index 0000000..0c02643 --- /dev/null +++ b/src/features/overlay/OverlayViews.tsx @@ -0,0 +1,93 @@ +import { Eye } from "lucide-react"; +import type { OverlayPreviewProps, OverlaySettingsProps } from "./types"; +import { useOverlayPreviewModel } from "./hooks/useOverlayPreviewModel"; +import { useOverlaySettingsModel } from "./hooks/useOverlaySettingsModel"; + +export function OverlaySettings({ + canShowOverlay, + onShowOverlay, +}: OverlaySettingsProps) { + const { + handleShowOverlay, + eyebrow, + title, + statusHeadline, + statusText, + buttonLabel, + buttonTitle, + } = useOverlaySettingsModel({ onShowOverlay }); + + return ( +
+
+
+

{eyebrow}

+

{title}

+
+ +
+
+
+ {statusHeadline} + {statusText} +
+ +
+
+ ); +} + +export function OverlayPreview({ snapshot }: OverlayPreviewProps) { + const { + artifactName, + artifactSlotLine, + artifactSubstatsText, + artifactMainStat, + characterNames, + scoreText, + metaClassName, + metaIcon, + metaLabel, + reason, + hasArtifact, + } = useOverlayPreviewModel({ snapshot }); + + return ( +
+
+
+
+

Reward scan

+

{artifactName}

+
+ {scoreText} +
+
+ {metaIcon} + {metaLabel} +
+ {hasArtifact ? ( +
+ {artifactSlotLine} + {artifactMainStat} + {artifactSubstatsText} +
+ ) : null} +
+ {characterNames.map((name) => ( + {name} + ))} +
+

{reason}

+
+
+ ); +} diff --git a/src/features/overlay/hooks/useOverlayPreviewModel.ts b/src/features/overlay/hooks/useOverlayPreviewModel.ts new file mode 100644 index 0000000..5d53d68 --- /dev/null +++ b/src/features/overlay/hooks/useOverlayPreviewModel.ts @@ -0,0 +1,54 @@ +import type { AppSnapshot, Recommendation } from "../../../types/domain"; +import type { ReactNode } from "react"; +import { verdictMeta } from "../../common/verdictMeta"; + +export interface OverlayPreviewModel { + candidate: Recommendation | null; + artifactName: string; + scoreText: string; + metaClassName: string; + metaLabel: string; + metaIcon: ReactNode; + artifactSlotLine: string; + artifactSubstatsText: string; + artifactMainStat: string; + characterNames: string[]; + reason: string; + hasArtifact: boolean; +} + +interface UseOverlayPreviewModelInput { + snapshot: AppSnapshot; +} + +export function useOverlayPreviewModel({ snapshot }: UseOverlayPreviewModelInput): OverlayPreviewModel { + const candidate = snapshot.recommendations.find((entry) => entry.verdict === "keep") + ?? snapshot.recommendations.find((entry) => entry.verdict === "character_specific") + ?? snapshot.recommendations.find((entry) => entry.verdict === "maybe_level") + ?? snapshot.recommendations[0] + ?? null; + + const artifact = snapshot.artifacts.find((entry) => entry.id === candidate?.artifactId); + const meta = verdictMeta[candidate?.verdict ?? "needs_review"]; + const characterNames = + candidate?.bestCharacters.map((id) => snapshot.characters.find((character) => character.id === id)?.name ?? id).slice(0, 3) ?? []; + const characterDisplayNames = characterNames.length > 0 ? characterNames : ["Review first"]; + + return { + candidate, + artifactName: artifact?.setName ?? "Artifact detected", + scoreText: candidate ? `${Math.round(candidate.score)}` : "--", + metaClassName: meta.className, + metaLabel: meta.label, + metaIcon: meta.icon, + artifactSlotLine: artifact ? `${artifact.slot} | +${artifact.level}` : "No artifact", + artifactSubstatsText: artifact?.substats + .slice(0, 3) + .map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`) + .join(" | ") ?? "", + artifactMainStat: artifact?.mainStat ?? "No main stat", + characterNames: characterDisplayNames, + reason: candidate?.reason ?? "Waiting for artifact detail view.", + hasArtifact: Boolean(artifact), + }; +} diff --git a/src/features/overlay/hooks/useOverlaySettingsModel.ts b/src/features/overlay/hooks/useOverlaySettingsModel.ts new file mode 100644 index 0000000..fe2799a --- /dev/null +++ b/src/features/overlay/hooks/useOverlaySettingsModel.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; + +export interface OverlaySettingsModel { + handleShowOverlay: () => void; + eyebrow: string; + title: string; + statusHeadline: string; + statusText: string; + buttonLabel: string; + buttonTitle: string; +} + +interface UseOverlaySettingsModelInput { + onShowOverlay: () => void; +} + +export function useOverlaySettingsModel({ onShowOverlay }: UseOverlaySettingsModelInput): OverlaySettingsModel { + const handleShowOverlay = useCallback(() => { + void onShowOverlay(); + }, [onShowOverlay]); + + return { + handleShowOverlay, + eyebrow: "Farming overlay", + title: "Read-only reward assistant", + statusHeadline: "Aktueller MVP-Status", + statusText: + "Click-through Preview mit echten lokalen Recommendations. Live Reward Scan und Auto-DB-Sync kommen danach.", + buttonLabel: "Show overlay preview", + buttonTitle: "Overlay oeffnen.", + }; +} diff --git a/src/features/overlay/types.ts b/src/features/overlay/types.ts new file mode 100644 index 0000000..56e36b8 --- /dev/null +++ b/src/features/overlay/types.ts @@ -0,0 +1,10 @@ +import type { AppSnapshot } from "../../types/domain"; + +export interface OverlaySettingsProps { + canShowOverlay: boolean; + onShowOverlay: () => void; +} + +export interface OverlayPreviewProps { + snapshot: AppSnapshot; +} diff --git a/src/features/scan/ScanView.tsx b/src/features/scan/ScanView.tsx new file mode 100644 index 0000000..26b391f --- /dev/null +++ b/src/features/scan/ScanView.tsx @@ -0,0 +1,9 @@ +import { ScanViewLayout } from "./components/ScanViewLayout"; +import { useScanViewController } from "./hooks/useScanViewController"; +import type { ScanViewProps } from "./types"; + +export function ScanView(props: ScanViewProps) { + const controller = useScanViewController(props); + + return ; +} diff --git a/src/features/scan/components/ScanMainSection.tsx b/src/features/scan/components/ScanMainSection.tsx new file mode 100644 index 0000000..600f9b4 --- /dev/null +++ b/src/features/scan/components/ScanMainSection.tsx @@ -0,0 +1,104 @@ +import { AlertTriangle, Camera, Eye } from "lucide-react"; +import { ArtifactResultCard } from "./ScanResultCards"; +import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel"; +import type { ScanMainSectionProps } from "./types"; + +export function ScanMainSection({ + latestCapture, + captureStatus, + parsedArtifact, + sourceLabel, + gridLabel, + inventoryLabel, + activeTargetCount, + storedTotal, + reviewSampleTotal, + learningRulesLoaded, + learningRuleCount, + setDetailsOpen, + autoScanRunning, + canOpenReviewQueue, + openReviewQueue, +}: ScanMainSectionProps) { + const { + canOpenDetails, + handleOpenDetails, + handleOpenReviewQueue, + captureImageSrc, + captureImageAlt, + hasCapture, + captureModeText, + resultHeading, + noArtifactText, + noCaptureMessage, + targetLabel, + dbLabel, + reviewLabel, + rulesLabel, + } = useScanMainSectionModel({ + latestCapture, + parsedArtifact, + activeTargetCount, + storedTotal, + reviewSampleTotal, + learningRulesLoaded, + learningRuleCount, + setDetailsOpen, + openReviewQueue, + }); + + return ( +
+
+
+ {hasCapture ? ( + {captureImageAlt} + ) : ( +
+ + Noch kein Bild + {noCaptureMessage} +
+ )} +
+
+
Quelle{sourceLabel}
+
Grid{gridLabel}
+
Inventar{inventoryLabel}
+
Modus{captureModeText}
+
+
+ + +
+ ); +} diff --git a/src/features/scan/components/ScanModalsSection.tsx b/src/features/scan/components/ScanModalsSection.tsx new file mode 100644 index 0000000..71378c3 --- /dev/null +++ b/src/features/scan/components/ScanModalsSection.tsx @@ -0,0 +1,57 @@ +import { ScanDiagnosticsModal } from "./modals/ScanDiagnosticsModal"; +import { ScanSettingsModal } from "./modals/ScanSettingsModal"; +import { ScanDetailsModal } from "./modals/ScanDetailsModal"; +import { ScanReviewQueueModal } from "./modals/ScanReviewQueueModal"; +import { ScanSummaryModal } from "./modals/ScanSummaryModal"; +import type { ScanModalsSectionProps } from "./types"; + +export function ScanModalsSection({ + captureStatus, + latestCapture, + diagnosticsOpen, + settingsOpen, + detailsOpen, + reviewQueueOpen, + controller, + setDiagnosticsOpen, + setSettingsOpen, + setDetailsOpen, + setReviewQueueOpen, + setScanSummary, +}: ScanModalsSectionProps) { + return ( + <> + + + + + + + ); +} diff --git a/src/features/scan/components/ScanResultCards.tsx b/src/features/scan/components/ScanResultCards.tsx new file mode 100644 index 0000000..e5e3670 --- /dev/null +++ b/src/features/scan/components/ScanResultCards.tsx @@ -0,0 +1,116 @@ +import type { ArtifactResultCardProps, FieldConfidenceListProps, ReviewSampleCardProps, ScanSummaryFooterProps } from "./types"; +import { useFieldConfidenceRowsModel, useReviewSampleCardModel, useScanResultCardModel } from "./hooks/useScanResultCardsModel"; +import { useScanSummaryFooterModel } from "./hooks/useScanSummaryFooterModel"; + +export function FieldConfidenceList({ parsedArtifact }: FieldConfidenceListProps) { + const rows = useFieldConfidenceRowsModel({ parsedArtifact }); + + return ( +
+ {rows.map(({ label, field, confidenceClassName }) => ( +
+ {label} + {field.confidence}% + {field.source} +
+ ))} +
+ ); +} + +export function ArtifactResultCard({ parsed }: ArtifactResultCardProps) { + const { quality, levelText, equippedText, substats, noSubstatsText } = useScanResultCardModel({ parsed }); + + return ( +
+
+ {parsed.setName} + {quality.label} +
+ {parsed.slot} +
+ Hauptwert + {parsed.mainStat} + {parsed.mainValue} +
+
+ {levelText} +
+
+ {substats.length > 0 ? substats.map((substat) => {substat}) : {noSubstatsText}} +
+
+ {equippedText} +
+
+ ); +} + +export function ReviewSampleCard({ entry }: ReviewSampleCardProps) { + const model = useReviewSampleCardModel({ entry }); + const { + artifactTitle, + reasonText, + sourceText, + captureTargetText, + resolutionText, + gridText, + ocrCountText, + parsedSlotText, + parsedMainText, + parsedSetText, + parsedEquippedText, + savedAtText, + showParsed, + ocrRows, + } = model; + + return ( +
+
+
+ {artifactTitle} + {reasonText} +
+ +
+
+ {sourceText} + {captureTargetText && {captureTargetText}} + {resolutionText} + {gridText} + {ocrCountText} +
+ {showParsed && ( +
+ {parsedSlotText} + {parsedMainText} + {parsedSetText} + {parsedEquippedText} +
+ )} + {ocrRows.length > 0 && ( +
+ {ocrRows.map((ocrRow) => ( + {ocrRow.text} + ))} +
+ )} +
+ ); +} + +export function ScanSummaryFooter({ devMode, scanSummary, storedTotal }: ScanSummaryFooterProps) { + const { summaryCopy, devCopy } = useScanSummaryFooterModel({ + devMode, + scanSummary, + storedTotal, + }); + + return ( + <> +

{summaryCopy}

+ {devCopy &&

{devCopy}

} + + ); +} diff --git a/src/features/scan/components/ScanTopControlsSection.tsx b/src/features/scan/components/ScanTopControlsSection.tsx new file mode 100644 index 0000000..94c3557 --- /dev/null +++ b/src/features/scan/components/ScanTopControlsSection.tsx @@ -0,0 +1,188 @@ +import { + AlertTriangle, + Camera, + Play, + Radar, + RefreshCw, + SlidersHorizontal, + Wrench, +} from "lucide-react"; +import type { ScanTopControlsSectionProps } from "./types"; +import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel"; + +export function ScanTopControlsSection({ + captureSources, + selectedSourceId, + setSelectedSourceId, + refreshCaptureSources, + captureSelectedSource, + bridgeReady, + isScanning, + controller, +}: ScanTopControlsSectionProps) { + const { + shouldShowGenshinSourceButton, + bridgeDisabled, + canStartAutoScan, + canStartManualScan, + canCaptureSingle, + autoScanRunning, + handleSourceChange, + selectGenshinSource, + openSettings, + openDiagnostics, + captureSingleArtifact, + stopScan, + runVisibleGridScan, + runAutoReviewScan, + bridgeStatusText, + bridgePillClass, + runtimeStatusText, + runtimePillClass, + playerStatusText, + autoScanButtonTitle, + autoScanButtonLabel, + manualScanButtonTitle, + captureSingleButtonTitle, + refreshCaptureSourcesTitle, + diagnosticsButtonTitle, + scanSetupButtonTitle, + showPlayerProgress, + progressWidth, + progressStats, + } = useScanTopControlsModel({ + captureSources, + selectedSourceId, + setSelectedSourceId, + captureSelectedSource, + bridgeReady, + isScanning, + controller, + }); + + return ( + <> +
+
+

Scanner

+

Artifact capture workspace

+

Grosse Vorschau vorn, klare Aktionen oben, Diagnose und Review nur bei Bedarf.

+
+
+ {bridgeStatusText} + {runtimeStatusText} +
+
+ + {!bridgeReady && ( +
+ + Die Capture-Verbindung fehlt. Bitte das Electron-App-Fenster verwenden - die Browser-Vorschau kann Genshin nicht scannen. +
+ )} + +
+
+ + + {shouldShowGenshinSourceButton && ( + + )} + + +
+ +
+ + + + {autoScanRunning && ( + + )} +
+ +

+ {playerStatusText} +

+ + {showPlayerProgress && ( +
+
+
+
+
+ {progressStats.map((entry) => ( + + {entry.value} {entry.label} + + ))} +
+
+ )} +
+ + ); +} diff --git a/src/features/scan/components/ScanViewLayout.tsx b/src/features/scan/components/ScanViewLayout.tsx new file mode 100644 index 0000000..c272dcb --- /dev/null +++ b/src/features/scan/components/ScanViewLayout.tsx @@ -0,0 +1,85 @@ +import { ScanMainSection } from "./ScanMainSection"; +import { ScanModalsSection } from "./ScanModalsSection"; +import { ScanTopControlsSection } from "./ScanTopControlsSection"; +import type { ScanViewLayoutProps } from "./types"; + +export function ScanViewLayout({ + captureSources, + selectedSourceId, + setSelectedSourceId, + latestCapture, + captureStatus, + refreshCaptureSources, + captureSelectedSource, + bridgeReady, + controller, + isScanning, +}: ScanViewLayoutProps) { + const { + setDetailsOpen, + setDiagnosticsOpen, + setSettingsOpen, + setReviewQueueOpen, + setScanSummary, + reviewSampleTotal, + autoScanRunning, + storedTotal, + learningRulesLoaded, + parsedArtifact, + learningRuleCount, + activeTargetCount, + canReadReviewQueue, + sourceLabel, + gridLabel, + inventoryLabel, + openReviewQueue, + } = controller; + + return ( +
+ + + + + +
+ ); +} diff --git a/src/features/scan/components/hooks/useScanMainSectionModel.ts b/src/features/scan/components/hooks/useScanMainSectionModel.ts new file mode 100644 index 0000000..c6830e9 --- /dev/null +++ b/src/features/scan/components/hooks/useScanMainSectionModel.ts @@ -0,0 +1,81 @@ +import { useCallback } from "react"; +import type { ScanMainSectionProps } from "../types"; + +export interface ScanMainSectionModel { + canOpenDetails: boolean; + handleOpenDetails: () => void; + handleOpenReviewQueue: () => void; + captureImageSrc: string; + captureImageAlt: string; + hasCapture: boolean; + captureModeText: string; + resultHeading: string; + noArtifactText: string; + noCaptureMessage: string; + targetLabel: string; + dbLabel: string; + reviewLabel: string; + rulesLabel: string; +} + +type UseScanMainSectionModelProps = Pick< + ScanMainSectionProps, + | "latestCapture" + | "parsedArtifact" + | "activeTargetCount" + | "storedTotal" + | "reviewSampleTotal" + | "learningRulesLoaded" + | "learningRuleCount" + | "setDetailsOpen" + | "openReviewQueue" +>; + +export function useScanMainSectionModel({ + latestCapture, + parsedArtifact, + activeTargetCount, + storedTotal, + reviewSampleTotal, + learningRulesLoaded, + learningRuleCount, + setDetailsOpen, + openReviewQueue, +}: UseScanMainSectionModelProps): ScanMainSectionModel { + const handleOpenDetails = useCallback(() => { + setDetailsOpen(true); + }, [setDetailsOpen]); + const handleOpenReviewQueue = useCallback(() => { + void openReviewQueue(); + }, [openReviewQueue]); + const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length); + const captureImageSrc = latestCapture ? latestCapture.detailDataUrl ?? latestCapture.dataUrl : ""; + const captureImageAlt = latestCapture + ? `Latest capture from ${latestCapture.name}` + : "Latest capture is not available yet"; + const captureModeText = latestCapture ? "Erkannt" : "Warte"; + const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact"; + const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan."; + const noCaptureMessage = noArtifactText; + const targetLabel = `Ziel ${activeTargetCount}`; + const dbLabel = `DB ${storedTotal ?? "-"}`; + const reviewLabel = `Review ${reviewSampleTotal}`; + const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`; + + return { + canOpenDetails, + handleOpenDetails, + handleOpenReviewQueue, + captureImageSrc, + captureImageAlt, + hasCapture: Boolean(latestCapture), + captureModeText, + resultHeading, + noArtifactText, + noCaptureMessage, + targetLabel, + dbLabel, + reviewLabel, + rulesLabel, + }; +} diff --git a/src/features/scan/components/hooks/useScanResultCardsModel.ts b/src/features/scan/components/hooks/useScanResultCardsModel.ts new file mode 100644 index 0000000..95a0a10 --- /dev/null +++ b/src/features/scan/components/hooks/useScanResultCardsModel.ts @@ -0,0 +1,166 @@ +import type { ArtifactResultCardProps, FieldConfidenceListProps } from "../types"; +import type { ParsedArtifactCandidate, ParsedField } from "../../../../lib/artifactOcrParser"; +import type { ReviewSampleRecord } from "../../../../types/global"; + +export interface FieldConfidenceRowModel { + label: string; + field: ParsedField; + confidenceClassName: string; +} + +export interface ScanResultCardModel { + levelField: ParsedField; + quality: { + label: string; + className: string; + }; + fieldRows: FieldConfidenceRowModel[]; + levelText: string; + equippedText: string; + substats: string[]; + noSubstatsText: string; +} + +export interface ReviewSampleCardModel { + artifactTitle: string; + reasonText: string; + sourceText: string; + captureTargetText: string | null; + resolutionText: string; + gridText: string; + ocrCountText: string; + parsedSlotText: string; + parsedMainText: string; + parsedSetText: string; + parsedEquippedText: string; + savedAtText: string; + showParsed: boolean; + ocrRows: Array<{ id: string; label: string; text: string }>; +} + +export function useFieldConfidenceRowsModel({ parsedArtifact }: FieldConfidenceListProps): FieldConfidenceRowModel[] { + const rows: Array<[string, ParsedField]> = [ + ["Name", parsedArtifact.fields.name], + ["Slot", parsedArtifact.fields.slot], + ["Level", getLevelField(parsedArtifact)], + ["Main", mergeField(parsedArtifact.fields.mainStat, parsedArtifact.fields.mainValue)], + ["Set", parsedArtifact.fields.setName], + ["Equipped", parsedArtifact.fields.equipped], + ["Substats", parsedArtifact.fields.substats], + ]; + + return rows.map(([label, field]) => ({ + label, + field, + confidenceClassName: resolveFieldConfidenceClass(field), + })); +} + +export function useScanResultCardModel({ + parsed, +}: Pick): ScanResultCardModel { + const substats = parsed.substats; + + return { + levelField: getLevelField(parsed), + quality: resolveQuality(parsed.confidence), + fieldRows: [ + ["Name", parsed.fields.name], + ["Slot", parsed.fields.slot], + ["Level", getLevelField(parsed)], + ["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)], + ["Set", parsed.fields.setName], + ["Equipped", parsed.fields.equipped], + ["Substats", parsed.fields.substats], + ].map(([label, field]) => ({ + label, + field, + confidenceClassName: resolveFieldConfidenceClass(field), + })), + levelText: resolveLevelText(parsed), + equippedText: resolveEquippedText(parsed.equipped), + substats, + noSubstatsText: "Keine Substats gelesen", + }; +} + +export function useReviewSampleCardModel({ entry }: { entry: ReviewSampleRecord }): ReviewSampleCardModel { + const parsed = entry.sample?.parsed as Partial | undefined; + const capture = entry.sample?.capture; + const ocr = capture?.ocr ?? []; + const grid = capture?.inventoryGrid; + + const date = new Date(entry.savedAt); + const savedAtText = Number.isNaN(date.getTime()) ? "Unbekannter Zeitpunkt" : date.toLocaleString(); + + return { + artifactTitle: parsed?.name || "Unparsed capture", + reasonText: entry.sample?.reason || "manual", + sourceText: capture?.name || "Unknown source", + captureTargetText: capture?.captureTarget || null, + resolutionText: `${capture?.width ?? "?"}x${capture?.height ?? "?"}`, + gridText: grid ? `${grid.cols}x${grid.rows} grid ${grid.confidence}%` : "no grid", + ocrCountText: `${ocr.length} OCR fields`, + parsedSlotText: parsed?.slot || "Unknown slot", + parsedMainText: `${parsed?.mainStat || "Unknown main"} ${parsed?.mainValue || ""}`, + parsedSetText: parsed?.setName || "Unknown set", + parsedEquippedText: parsed?.equipped || "Not detected", + savedAtText, + showParsed: Boolean(parsed), + ocrRows: ocr.slice(0, 4).map((item) => ({ + id: item.id, + label: item.label, + text: `${item.label}: ${item.confidence}%`, + })), + }; +} + +function getLevelField(parsedArtifact: ParsedArtifactCandidate): ParsedField { + const fieldLevel = parsedArtifact.fields.level; + if (fieldLevel?.value) { + return fieldLevel; + } + + const derivedLevel = parsedArtifact.level; + return { + value: `${derivedLevel}`, + confidence: derivedLevel > 0 ? 80 : 0, + source: derivedLevel > 0 ? "derived" : "missing", + }; +} + +function resolveQuality(confidence: number): { label: string; className: string } { + return confidence >= 85 + ? { label: "Sauber gelesen", className: "good" } + : confidence >= 70 + ? { label: "Etwas unsicher", className: "mid" } + : { label: "Unsicher - bitte prüfen", className: "low" }; +} + +function mergeField(primary: ParsedField, secondary: ParsedField): ParsedField { + const confidence = primary.value && secondary.value + ? Math.round((primary.confidence + secondary.confidence) / 2) + : Math.min(primary.confidence, secondary.confidence); + const source = primary.source === secondary.source + ? primary.source + : primary.source === "missing" + ? secondary.source + : primary.source; + return { + value: `${primary.value} ${secondary.value}`.trim(), + confidence, + source, + }; +} + +function resolveFieldConfidenceClass(field: ParsedField): string { + return field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high"; +} + +function resolveLevelText(parsed: ParsedArtifactCandidate): string { + return parsed.level > 0 ? `Level +${parsed.level}` : "Level nicht erkannt"; +} + +function resolveEquippedText(equipped: string | null | undefined): string { + return equipped && equipped !== "Not detected" ? `Ausgerüstet: ${equipped}` : "Nicht Ausgerüstet / nicht erkannt"; +} diff --git a/src/features/scan/components/hooks/useScanSummaryFooterModel.ts b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts new file mode 100644 index 0000000..8dbb083 --- /dev/null +++ b/src/features/scan/components/hooks/useScanSummaryFooterModel.ts @@ -0,0 +1,25 @@ +import type { ScanSummaryFooterProps } from "../types"; + +export interface ScanSummaryFooterModel { + summaryCopy: string; + devCopy: string | null; +} + +export function useScanSummaryFooterModel({ + devMode, + scanSummary, + storedTotal, +}: ScanSummaryFooterProps): ScanSummaryFooterModel { + const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan" + ? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben." + : `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`; + + const devCopy = devMode + ? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}` + : null; + + return { + summaryCopy, + devCopy, + }; +} diff --git a/src/features/scan/components/hooks/useScanTopControlsModel.ts b/src/features/scan/components/hooks/useScanTopControlsModel.ts new file mode 100644 index 0000000..c4e8a6a --- /dev/null +++ b/src/features/scan/components/hooks/useScanTopControlsModel.ts @@ -0,0 +1,143 @@ +import { useCallback, useMemo } from "react"; +import type { ChangeEvent } from "react"; +import type { ScanTopControlsSectionProps } from "../types"; + +export interface ScanTopControlsModel { + shouldShowGenshinSourceButton: boolean; + bridgeDisabled: boolean; + canStartAutoScan: boolean; + canStartManualScan: boolean; + canCaptureSingle: boolean; + handleSourceChange: (event: ChangeEvent) => void; + selectGenshinSource: () => void; + openSettings: () => void; + openDiagnostics: () => void; + captureSingleArtifact: () => void; + stopScan: () => void; + runVisibleGridScan: () => void; + runAutoReviewScan: () => void; + bridgeStatusText: string; + bridgePillClass: string; + runtimeStatusText: string; + runtimePillClass: string; + playerStatusText: string; + autoScanButtonTitle: string; + autoScanButtonLabel: string; + manualScanButtonTitle: string; + captureSingleButtonTitle: string; + refreshCaptureSourcesTitle: string; + diagnosticsButtonTitle: string; + scanSetupButtonTitle: string; + showPlayerProgress: boolean; + progressWidth: number; + progressStats: Array<{ label: string; value: number | string; extraClass?: string }>; +} + +export function useScanTopControlsModel({ + captureSources, + selectedSourceId, + setSelectedSourceId, + captureSelectedSource, + bridgeReady, + isScanning, + controller, +}: ScanTopControlsSectionProps): ScanTopControlsModel { + const { + setSettingsOpen, + setDiagnosticsOpen, + requestScanStop, + runVisibleGridScan, + runAutoReviewScan, + autoScanRunning, + canCaptureSource, + canAutoScan, + hasSourceSelected, + requiresAdminForAutoScan, + reviewStatus, + runtimeInfo, + autoScanStats, + storedTotal, + scanProgressPercent, + genshinSource, + } = controller; + + const handleSourceChange = useCallback( + (event: ChangeEvent) => setSelectedSourceId(event.target.value), + [setSelectedSourceId], + ); + + const selectGenshinSource = useCallback(() => { + if (genshinSource) { + setSelectedSourceId(genshinSource.id); + } + }, [genshinSource, setSelectedSourceId]); + + const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]); + const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]); + const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]); + const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]); + const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt"; + const bridgePillClass = bridgeReady ? "elevated" : "standard"; + const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard"; + const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard"; + const playerStatusText = reviewStatus + || (runtimeInfo?.isElevated + ? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan." + : "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten."); + const autoScanButtonTitle = requiresAdminForAutoScan + ? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten." + : "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar."; + const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten"; + const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen."; + const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig."; + const refreshCaptureSourcesTitle = "Fenster- und Bildschirmquellen neu suchen"; + const diagnosticsButtonTitle = bridgeReady + ? "Scanner Diagnose oeffnen: Rechte, Grid-Erkennung und Logs." + : "Scanner Diagnose ist nur in der Electron-App vollstaendig nutzbar."; + const scanSetupButtonTitle = bridgeReady + ? "Scan-Ziel, Skip-Zeilen und Operator-Optionen anpassen." + : "Scan-Setup ist nur in der Electron-App vollstaendig nutzbar."; + const showPlayerProgress = autoScanRunning || autoScanStats.clicked > 0 || autoScanStats.parsed > 0; + const progressStats = useMemo( + () => [ + { label: "Klicks", value: autoScanStats.clicked }, + { label: "Positionen", value: autoScanStats.attempted }, + { label: "Verifiziert", value: autoScanStats.verified }, + { label: "Gespeichert", value: autoScanStats.stored }, + { label: "Review", value: autoScanStats.review }, + { label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" }, + ], + [autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal], + ); + + return { + shouldShowGenshinSourceButton: Boolean(genshinSource && selectedSourceId !== genshinSource.id), + bridgeDisabled: !bridgeReady || captureSources.length === 0, + canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan, + canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource, + canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource, + handleSourceChange, + selectGenshinSource, + openSettings, + openDiagnostics, + captureSingleArtifact, + stopScan, + runVisibleGridScan, + runAutoReviewScan, + bridgeStatusText, + bridgePillClass, + runtimeStatusText, + runtimePillClass, + playerStatusText, + autoScanButtonTitle, + autoScanButtonLabel, + manualScanButtonTitle, + captureSingleButtonTitle, + refreshCaptureSourcesTitle, + diagnosticsButtonTitle, + scanSetupButtonTitle, + showPlayerProgress, + progressWidth: scanProgressPercent, + progressStats, + }; +} diff --git a/src/features/scan/components/modals/ScanDetailsModal.tsx b/src/features/scan/components/modals/ScanDetailsModal.tsx new file mode 100644 index 0000000..30b2d53 --- /dev/null +++ b/src/features/scan/components/modals/ScanDetailsModal.tsx @@ -0,0 +1,83 @@ +import { FieldConfidenceList } from "../ScanResultCards"; +import type { ScanDetailsModalProps } from "./types"; +import { useScanDetailsModalModel } from "./hooks/useScanDetailsModalModel"; + +export function ScanDetailsModal({ + open, + latestCapture, + controller, + setDetailsOpen, +}: ScanDetailsModalProps) { + const { parsedArtifact } = controller; + const { + closeDetails, + stopPropagation, + parsedNotes, + showParsedNotes, + cropRows, + ocrRows, + debugText, + showCrops, + showOcr, + } = useScanDetailsModalModel({ + setDetailsOpen, + parsedArtifact, + latestCapture, + }); + + if (!open || !latestCapture) return null; + + return ( +
+
+
+
+

Dev

+

Crops, OCR & Confidence

+
+ +
+
+ {parsedArtifact && ( + <> + + {showParsedNotes && ( +
+ {parsedNotes.map((note) => {note})} +
+ )} + + )} + {showCrops && ( +
+ {cropRows.map((crop) => ( +
+ {crop.label} +
+ {crop.label} + {crop.x},{crop.y} - {crop.width}x{crop.height} +
+
+ ))} +
+ )} + {showOcr && ( +
+ OCR candidates + {ocrRows.map((entry) => ( +
+
+ {entry.label} + {entry.confidence}% confidence +
+
{entry.text}
+
+ ))} +
+ )} +

{debugText}

+
+
+
+ ); +} diff --git a/src/features/scan/components/modals/ScanDiagnosticsModal.tsx b/src/features/scan/components/modals/ScanDiagnosticsModal.tsx new file mode 100644 index 0000000..45bfe6b --- /dev/null +++ b/src/features/scan/components/modals/ScanDiagnosticsModal.tsx @@ -0,0 +1,172 @@ +import { AlertTriangle, Eye, Wrench } from "lucide-react"; +import type { ScanDiagnosticsModalProps } from "./types"; +import { useScanDiagnosticsModalModel } from "./hooks/useScanDiagnosticsModalModel"; + +export function ScanDiagnosticsModal({ + open, + captureStatus, + latestCapture, + controller, + setDetailsOpen, + setDiagnosticsOpen, +}: ScanDiagnosticsModalProps) { + const { + toggleDevMode, + } = controller; + + const { + closeDiagnostics, + openDetails, + handleSaveReviewSample, + stopPropagation, + canOpenDetails, + statusTitle, + rightsClassName, + rightsValue, + genshinClassName, + genshinValue, + shouldShowAdminBanner, + gridSourceClass, + gridMainValue, + gridMetaValue, + learningRulesText, + learningRulesSubtext, + autoScanModeLabel, + autoScanRunning, + fingerprintText, + runtimeRows, + scanLimitText, + scanTipText, + autoScanStatsLines, + playerProgress, + showDevRows, + reviewStatus, + automationLogLines, + canSaveReviewSample, + } = useScanDiagnosticsModalModel({ + setDetailsOpen, + setDiagnosticsOpen, + saveReviewSample: controller.saveReviewSample, + canSaveReviewSample: controller.canSaveReviewSample, + latestCapture, + controller, + captureStatus, + }); + + if (!open) return null; + + return ( +
+
+
+
+

Scanner Diagnose

+

Input, Grid & Lernstatus

+
+ +
+
+
+ +
+ +
+
+ App-Rechte + {rightsValue} +
+
+ Genshin + {genshinValue} +
+
+ {shouldShowAdminBanner && ( +

+ App laeuft im Standard-Modus. Auto-Scan braucht Administrator-Rechte: App schliessen und als Administrator neu starten (z.B. Terminal per Rechtsklick "Als Administrator ausfuehren" und darin "npm run dev"). +

+ )} + +
+ Tile grid + {gridMainValue} + {gridMetaValue} +
+ +
+ Learning + {learningRulesText} + {learningRulesSubtext} +
+ + {playerProgress.show && ( +
+ {autoScanModeLabel} + {autoScanStatsLines.map((entry) => ( + + {entry.value} + {entry.label} + + ))} +
+ )} + +
+ Fingerprint + {fingerprintText} + Active capture fingerprint used for deterministic duplicate guard checks. +
+ + {showDevRows && ( +
+ {runtimeRows.map((row) => ( + {row} + ))} +
+ )} + + {autoScanRunning && playerProgress.show && ( +
+
+
+
+
+ )} + + {reviewStatus && ( +

{reviewStatus}

+ )} + +
+ Automation +
+ {automationLogLines.length > 0 ? ( + automationLogLines.map((line, index) => ( + {line} + )) + ) : ( + No scan activity yet. + )} +
+
+ +
+ + +
+ +

{scanLimitText}

+

{scanTipText}

+
+
+
+ ); +} diff --git a/src/features/scan/components/modals/ScanReviewQueueModal.tsx b/src/features/scan/components/modals/ScanReviewQueueModal.tsx new file mode 100644 index 0000000..850dcb3 --- /dev/null +++ b/src/features/scan/components/modals/ScanReviewQueueModal.tsx @@ -0,0 +1,70 @@ +import { AlertTriangle } from "lucide-react"; +import { ReviewSampleCard } from "../ScanResultCards"; +import type { ScanReviewQueueModalProps } from "./types"; +import { useScanReviewQueueModalModel } from "./hooks/useScanReviewQueueModalModel"; + +export function ScanReviewQueueModal({ + open, + controller, + setReviewQueueOpen, +}: ScanReviewQueueModalProps) { + const { reviewSamples, reviewSampleTotal, reviewAnalysis, canReadReviewQueue, loadReviewQueue } = controller; + const { + closeReviewQueue, + refreshReviewQueue, + stopPropagation, + emptyText, + reviewAnalysisRows, + showReviewAnalysis, + } = useScanReviewQueueModalModel({ + setReviewQueueOpen, + loadReviewQueue, + reviewAnalysis, + reviewSampleTotal, + }); + + if (!open) return null; + + return ( +
+
+
+
+

Review Queue

+

Unsichere Scanner-Faelle

+
+ +
+
+
+ {reviewSampleTotal} + Samples gespeichert + +
+ {showReviewAnalysis && ( +
+ {reviewAnalysisRows.map((row) => ( +
+ {row.label}{row.value} +
+ ))} +
+ )} + {reviewSamples.length === 0 ? ( +
+ + {emptyText} + Unsichere Auto-Scan- oder OCR-Faelle erscheinen hier automatisch. +
+ ) : ( +
+ {reviewSamples.map((entry, index) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/features/scan/components/modals/ScanSettingsModal.tsx b/src/features/scan/components/modals/ScanSettingsModal.tsx new file mode 100644 index 0000000..aa79f35 --- /dev/null +++ b/src/features/scan/components/modals/ScanSettingsModal.tsx @@ -0,0 +1,99 @@ +import type { ScanSettingsModalProps } from "./types"; +import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel"; + +export function ScanSettingsModal({ + open, + latestCapture, + controller, + setSettingsOpen, +}: ScanSettingsModalProps) { + const { + scanLimit, + skipRows, + runtimeInfo, + activeTargetCount, + } = controller; + + const { + closeSettings, + handleScanLimitChange, + handleSkipRowsChange, + stopPropagation, + inventoryCountText, + inventoryClassName, + scanLimitClassName, + skipRowsClassName, + activeTargetText, + scanSummaryText, + } = useScanSettingsModalModel({ + setSettingsOpen, + setScanLimit: controller.setScanLimit, + setScanLimitTouched: controller.setScanLimitTouched, + setSkipRows: controller.setSkipRows, + latestCapture, + scanLimit, + skipRows, + activeTargetCount, + runtimeInfo, + }); + + if (!open) return null; + + return ( +
+
+
+
+

Scan-Setup

+

Operator-Einstellungen

+
+ +
+
+
+ + +

+ Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten. + Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst. +

+
+
+
+ Inventarzaehler + {inventoryCountText} +
+
+ Limit + {scanLimit} +
+
+ Skip + {skipRows} +
+

{activeTargetText}

+
+

{scanSummaryText}

+
+
+
+ ); +} diff --git a/src/features/scan/components/modals/ScanSummaryModal.tsx b/src/features/scan/components/modals/ScanSummaryModal.tsx new file mode 100644 index 0000000..2236dcc --- /dev/null +++ b/src/features/scan/components/modals/ScanSummaryModal.tsx @@ -0,0 +1,40 @@ +import { Play, AlertTriangle } from "lucide-react"; +import { ScanSummaryFooter } from "../ScanResultCards"; +import type { ScanSummaryModalProps } from "./types"; +import { useScanSummaryModalModel } from "./hooks/useScanSummaryModalModel"; + +export function ScanSummaryModal({ + open, + controller, + setScanSummary, + devMode, +}: ScanSummaryModalProps) { + const { closeSummary, stopPropagation, iconKind, summaryTitle, gridSummaryText } = useScanSummaryModalModel({ + setScanSummary, + scanSummary: controller.scanSummary, + }); + + if (!open || !controller.scanSummary) return null; + return ( +
+
+
+ {iconKind === "blocked" ? : } +
+
+

Scan-Ergebnis

+

{summaryTitle}

+ {gridSummaryText &&

{gridSummaryText}

} +
+
+
{controller.scanSummary.stored}neu gespeichert
+
{controller.scanSummary.review}unsicher (Review)
+
{controller.scanSummary.duplicates}Duplikate
+
{controller.scanSummary.verified}verifizierte Ansichten
+
+ + +
+
+ ); +} diff --git a/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts new file mode 100644 index 0000000..c705690 --- /dev/null +++ b/src/features/scan/components/modals/hooks/useScanDetailsModalModel.ts @@ -0,0 +1,61 @@ +import { useCallback, type MouseEvent } from "react"; +import type { ScanDetailsModalProps } from "../types"; +import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser"; + +export interface ScanDetailsModalModel { + closeDetails: () => void; + stopPropagation: (event: MouseEvent) => void; + parsedNotes: string[]; + showParsedNotes: boolean; + cropRows: Array<{ id: string; dataUrl: string; label: string; x: number; y: number; width: number; height: number }>; + ocrRows: Array<{ id: string; label: string; confidence: number; text: string }>; + debugText: string; + showCrops: boolean; + showOcr: boolean; +} + +interface UseScanDetailsModalModelInput { + setDetailsOpen: ScanDetailsModalProps["setDetailsOpen"]; + parsedArtifact: ScanDetailsModalProps["controller"]["parsedArtifact"]; + latestCapture: ScanDetailsModalProps["latestCapture"]; +} + +export function useScanDetailsModalModel({ + setDetailsOpen, + parsedArtifact, + latestCapture, +}: UseScanDetailsModalModelInput): ScanDetailsModalModel { + const closeDetails = useCallback(() => setDetailsOpen(false), [setDetailsOpen]); + const stopPropagation = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + const parsedNotes = (parsedArtifact?.notes ?? []) as ParsedArtifactCandidate["notes"]; + + const crops = latestCapture?.crops ?? []; + const ocr = latestCapture?.ocr ?? []; + + return { + closeDetails, + stopPropagation, + parsedNotes, + showParsedNotes: parsedNotes.length > 0, + cropRows: crops.map((crop) => ({ + id: crop.id, + dataUrl: crop.dataUrl, + label: crop.label, + x: crop.rect.x, + y: crop.rect.y, + width: crop.rect.width, + height: crop.rect.height, + })), + ocrRows: ocr.map((entry) => ({ + id: entry.id, + label: entry.label, + confidence: entry.confidence, + text: entry.text || "No text detected", + })), + debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`, + showCrops: crops.length > 0, + showOcr: ocr.length > 0, + }; +} diff --git a/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts new file mode 100644 index 0000000..9534871 --- /dev/null +++ b/src/features/scan/components/modals/hooks/useScanDiagnosticsModalModel.ts @@ -0,0 +1,185 @@ +import { detailFingerprint } from "../../../../lib/autoScanLoop"; +import { sourceVersion } from "../../../../lib/genshinData"; +import { useCallback, useMemo, type MouseEvent } from "react"; +import type { ScanDiagnosticsModalProps } from "../types"; + +export interface ScanDiagnosticsModelProgress { + width: number; + show: boolean; +} + +export interface ScanDiagnosticsModalModel { + closeDiagnostics: () => void; + openDetails: () => void; + handleSaveReviewSample: () => void; + stopPropagation: (event: MouseEvent) => void; + canOpenDetails: boolean; + statusTitle: string; + rightsClassName: string; + rightsValue: string; + genshinClassName: string; + genshinValue: string; + shouldShowAdminBanner: boolean; + gridSourceClass: string; + gridMainValue: string; + gridMetaValue: string; + learningRulesText: string; + learningRulesSubtext: string; + autoScanModeLabel: string; + fingerprintText: string; + runtimeRows: string[]; + scanLimitText: string; + scanTipText: string; + autoScanStatsLines: Array<{ label: string; value: number }>; + playerProgress: ScanDiagnosticsModelProgress; + autoScanRunning: boolean; + showDevRows: boolean; + reviewStatus: string; + automationLogLines: string[]; + canSaveReviewSample: boolean; +} + +interface UseScanDiagnosticsModalModelInput extends Pick< + ScanDiagnosticsModalProps, + | "setDetailsOpen" + | "setDiagnosticsOpen" + | "saveReviewSample" + | "canSaveReviewSample" + | "latestCapture" + | "controller" +> { + captureStatus: string; +} + +export function useScanDiagnosticsModalModel({ + setDetailsOpen, + setDiagnosticsOpen, + saveReviewSample, + canSaveReviewSample, + latestCapture, + controller, + captureStatus, +}: UseScanDiagnosticsModalModelInput): ScanDiagnosticsModalModel { + const closeDiagnostics = useCallback(() => setDiagnosticsOpen(false), [setDiagnosticsOpen]); + const openDetails = useCallback(() => setDetailsOpen(true), [setDetailsOpen]); + const handleSaveReviewSample = useCallback(() => { + if (canSaveReviewSample && controller.parsedArtifact) { + void saveReviewSample(); + } + }, [canSaveReviewSample, controller.parsedArtifact, saveReviewSample]); + const stopPropagation = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + + const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length); + const shouldShowAdminBanner = !controller.runtimeInfo?.isElevated; + const rightsClassName = controller.runtimeInfo?.isElevated ? "ok" : "blocked"; + const rightsValue = controller.runtimeInfo?.isElevated ? "Admin" : "Standard"; + const genshinClassName = controller.runtimeInfo?.genshinFound ? "ok" : "neutral"; + const genshinValue = controller.runtimeInfo?.genshinFound ? "Gefunden" : "Nicht gefunden"; + const statusTitle = controller.devMode ? "Dev-Ausgabe kompakt" : "Dev-Ausgabe erweitern"; + const autoScanModeLabel = controller.autoScanRunning ? "Scan active" : "Last scan"; + + const inventoryGrid = latestCapture?.inventoryGrid; + const gridSourceClass = inventoryGrid?.source ?? "missing"; + const gridMainValue = inventoryGrid + ? inventoryGrid?.source === "missing" + ? "not detected" + : `${inventoryGrid?.cols} x ${inventoryGrid?.rows}` + : "waiting"; + const gridMetaValue = inventoryGrid + ? `${inventoryGrid?.confidence}% confidence - ${inventoryGrid?.centers.length} click targets` + : "Run a capture once while the artifact inventory is visible."; + + const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading"; + const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`; + + const playerProgress = useMemo(() => { + const width = Math.min( + 100, + controller.autoScanStats.attempted > 0 + ? Math.round((controller.autoScanStats.verified / Math.max(1, controller.autoScanStats.attempted)) * 100) + : 0, + ); + const show = controller.autoScanRunning || controller.autoScanStats.clicked > 0 || controller.autoScanStats.parsed > 0; + return { width, show }; + }, [ + controller.autoScanRunning, + controller.autoScanStats.attempted, + controller.autoScanStats.clicked, + controller.autoScanStats.parsed, + controller.autoScanStats.verified, + ]); + + const autoScanStatsLines = useMemo( + () => [ + { label: "clicked", value: controller.autoScanStats.clicked }, + { label: "attempted", value: controller.autoScanStats.attempted }, + { label: "verified", value: controller.autoScanStats.verified }, + { label: "parsed", value: controller.autoScanStats.parsed }, + { label: "stored", value: controller.autoScanStats.stored }, + { label: "review", value: controller.autoScanStats.review }, + { label: "duplicates", value: controller.autoScanStats.duplicates }, + { label: "misses", value: controller.autoScanStats.misses }, + { label: "pages", value: controller.autoScanStats.pages }, + ], + [ + controller.autoScanStats.clicked, + controller.autoScanStats.attempted, + controller.autoScanStats.verified, + controller.autoScanStats.parsed, + controller.autoScanStats.stored, + controller.autoScanStats.review, + controller.autoScanStats.duplicates, + controller.autoScanStats.misses, + controller.autoScanStats.pages, + ], + ); + + const runtimeRows = useMemo(() => { + const selectedSourceLabel = controller.selectedSource + ? `Selected: ${controller.selectedSource.name}` + : "No source selected"; + const storedTotalLabel = controller.storedTotal !== null ? `DB: ${controller.storedTotal} Artifacts` : "DB: -"; + const runtime = controller.runtimeInfo + ? `${controller.runtimeInfo?.isElevated ? "admin" : "standard"} - target ${controller.runtimeInfo?.genshinFound ? controller.runtimeInfo?.targetProcess || "Genshin" : "missing"} - fg ${controller.runtimeInfo?.foregroundProcess || "unknown"}` + : "unknown"; + + return [selectedSourceLabel, storedTotalLabel, runtime, captureStatus, controller.reviewStatus].filter(Boolean); + }, [controller.selectedSource, controller.storedTotal, controller.runtimeInfo, captureStatus, controller.reviewStatus]); + + const fingerprintText = latestCapture ? detailFingerprint(latestCapture) : "--"; + const scanLimitText = controller.scanLimit ? `Scan-Limit: ${controller.scanLimit}` : "Keine Limitinfo."; + const scanTipText = "Tip: Die Auto-Scan Logik bleibt aktivierbar, aber Dev-Ansicht ist rein informativ."; + + return { + closeDiagnostics, + openDetails, + handleSaveReviewSample, + stopPropagation, + canOpenDetails, + statusTitle, + rightsClassName, + rightsValue, + genshinClassName, + genshinValue, + shouldShowAdminBanner, + gridSourceClass, + gridMainValue, + gridMetaValue, + learningRulesText, + learningRulesSubtext, + autoScanModeLabel, + fingerprintText, + runtimeRows, + scanLimitText, + scanTipText, + autoScanStatsLines, + playerProgress, + autoScanRunning: controller.autoScanRunning, + showDevRows: controller.devMode, + reviewStatus: controller.reviewStatus, + automationLogLines: controller.automationLog, + canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact), + }; +} diff --git a/src/features/scan/components/modals/hooks/useScanReviewQueueModalModel.ts b/src/features/scan/components/modals/hooks/useScanReviewQueueModalModel.ts new file mode 100644 index 0000000..0d850ef --- /dev/null +++ b/src/features/scan/components/modals/hooks/useScanReviewQueueModalModel.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo, type MouseEvent } from "react"; +import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis"; +import type { ScanReviewQueueModalProps } from "../types"; + +export interface ScanReviewQueueRow { + label: string; + value: string; +} + +export interface ScanReviewQueueModalModel { + closeReviewQueue: () => void; + refreshReviewQueue: () => void; + stopPropagation: (event: MouseEvent) => void; + emptyText: string; + reviewAnalysisRows: ScanReviewQueueRow[]; + showReviewAnalysis: boolean; +} + +interface UseScanReviewQueueModalModelInput extends Pick< + ScanReviewQueueModalProps, + "setReviewQueueOpen" | "loadReviewQueue" +> { + reviewAnalysis: ReviewSampleAnalysis; + reviewSampleTotal: number; +} + +export function useScanReviewQueueModalModel({ + setReviewQueueOpen, + loadReviewQueue, + reviewAnalysis, + reviewSampleTotal, +}: UseScanReviewQueueModalModelInput): ScanReviewQueueModalModel { + const closeReviewQueue = useCallback(() => setReviewQueueOpen(false), [setReviewQueueOpen]); + const refreshReviewQueue = useCallback(() => { + void loadReviewQueue(); + }, [loadReviewQueue]); + const stopPropagation = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + + const weakFieldsSummary = useMemo( + () => reviewAnalysis.weakFields.slice(0, 3).map((entry) => `${entry.field} ${entry.count}`).join(", "), + [reviewAnalysis.weakFields], + ); + const topReasonsSummary = useMemo( + () => reviewAnalysis.reasons.slice(0, 2).map((entry) => `${entry.reason} ${entry.count}`).join(", "), + [reviewAnalysis.reasons], + ); + + const reviewAnalysisRows = useMemo( + () => [ + { label: "Parsed", value: `${reviewAnalysis.withParsed}/${reviewAnalysis.total}` }, + { label: "Avg confidence", value: `${reviewAnalysis.averageConfidence}%` }, + { label: "Weak fields", value: weakFieldsSummary || "none" }, + { label: "Top reasons", value: topReasonsSummary || "none" }, + ], + [reviewAnalysis.averageConfidence, reviewAnalysis.total, reviewAnalysis.withParsed, topReasonsSummary, weakFieldsSummary], + ); + + return { + closeReviewQueue, + refreshReviewQueue, + stopPropagation, + emptyText: reviewSampleTotal > 0 ? "none" : "Keine Review-Samples", + reviewAnalysisRows, + showReviewAnalysis: reviewSampleTotal > 0, + }; +} diff --git a/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts b/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts new file mode 100644 index 0000000..6793c80 --- /dev/null +++ b/src/features/scan/components/modals/hooks/useScanSettingsModalModel.ts @@ -0,0 +1,74 @@ +import { useCallback, type ChangeEvent, type MouseEvent } from "react"; +import type { CaptureResult, RuntimeInfo } from "../../../../../types/global"; +import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession"; + +export interface ScanSettingsModalModel { + closeSettings: () => void; + handleScanLimitChange: (event: ChangeEvent) => void; + handleSkipRowsChange: (event: ChangeEvent) => void; + stopPropagation: (event: MouseEvent) => void; + inventoryCountText: string; + inventoryClassName: string; + scanLimitClassName: string; + skipRowsClassName: string; + activeTargetText: string; + scanSummaryText: string; +} + +export function useScanSettingsModalModel({ + setSettingsOpen, + setScanLimit, + setScanLimitTouched, + setSkipRows, + latestCapture, + scanLimit, + skipRows, + activeTargetCount, + runtimeInfo, +}: { + latestCapture: CaptureResult | null; + scanLimit: number; + skipRows: number; + activeTargetCount: number; + runtimeInfo: RuntimeInfo | null; + setSettingsOpen: (open: boolean) => void; + setScanLimit: (value: number) => void; + setScanLimitTouched: (touched: boolean) => void; + setSkipRows: (rows: number) => void; +}): ScanSettingsModalModel { + const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]); + const handleScanLimitChange = useCallback((event: ChangeEvent) => { + setScanLimitTouched(true); + setScanLimit(clampScanLimit(Number(event.target.value))); + }, [setScanLimit, setScanLimitTouched]); + const handleSkipRowsChange = useCallback((event: ChangeEvent) => { + setSkipRows(clampSkipRows(Number(event.target.value))); + }, [setSkipRows]); + const stopPropagation = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + const detectedInventoryCount = latestCapture?.inventoryCount?.current; + const detectedInventoryTotal = latestCapture?.inventoryCount?.total; + const inventoryClassName = detectedInventoryCount ? "ok" : "neutral"; + const inventoryCountText = detectedInventoryCount + ? `${detectedInventoryCount}/${detectedInventoryTotal || "?"}` + : "Noch nicht erkannt"; + const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard"; + const skipRowsClassName = skipRows > 0 ? "ok" : "standard"; + const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard"; + const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`; + const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt."; + + return { + closeSettings, + handleScanLimitChange, + handleSkipRowsChange, + stopPropagation, + inventoryCountText, + inventoryClassName, + scanLimitClassName, + skipRowsClassName, + activeTargetText, + scanSummaryText, + }; +} diff --git a/src/features/scan/components/modals/hooks/useScanSummaryModalModel.ts b/src/features/scan/components/modals/hooks/useScanSummaryModalModel.ts new file mode 100644 index 0000000..37c486f --- /dev/null +++ b/src/features/scan/components/modals/hooks/useScanSummaryModalModel.ts @@ -0,0 +1,37 @@ +import { useCallback, type MouseEvent } from "react"; +import type { ScanSummaryModalProps } from "../types"; + +export interface ScanSummaryModalModel { + closeSummary: () => void; + stopPropagation: (event: MouseEvent) => void; + iconKind: "running" | "blocked" | "stopped" | "finished"; + summaryTitle: string; + gridSummaryText: string | null; +} + +interface UseScanSummaryModalModelInput extends Pick { + scanSummary: ScanSummaryModalProps["controller"]["scanSummary"]; +} + +export function useScanSummaryModalModel({ + setScanSummary, + scanSummary, +}: UseScanSummaryModalModelInput): ScanSummaryModalModel { + const closeSummary = useCallback(() => setScanSummary(null), [setScanSummary]); + const stopPropagation = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + const isBlocked = scanSummary?.status === "blocked"; + const isStopped = scanSummary?.status === "stopped"; + const summaryTitle = isStopped ? "Scan gestoppt" : isBlocked ? "Scan abgebrochen" : "Scan fertig"; + const iconKind = isBlocked ? "blocked" : isStopped ? "stopped" : "finished"; + const gridSummaryText = scanSummary?.gridLabel ?? null; + + return { + closeSummary, + stopPropagation, + iconKind, + summaryTitle, + gridSummaryText, + }; +} diff --git a/src/features/scan/components/modals/types.ts b/src/features/scan/components/modals/types.ts new file mode 100644 index 0000000..df8477a --- /dev/null +++ b/src/features/scan/components/modals/types.ts @@ -0,0 +1,38 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { ScanViewProps, ScanViewControllerResult } from "../../types"; + +export interface ScanDetailsModalProps { + open: boolean; + latestCapture: ScanViewProps["latestCapture"]; + controller: Pick; + setDetailsOpen: (open: boolean) => void; +} + +export interface ScanDiagnosticsModalProps { + open: boolean; + captureStatus: string; + latestCapture: ScanViewProps["latestCapture"]; + controller: ScanViewControllerResult; + setDetailsOpen: (open: boolean) => void; + setDiagnosticsOpen: (open: boolean) => void; +} + +export interface ScanReviewQueueModalProps { + open: boolean; + controller: ScanViewControllerResult; + setReviewQueueOpen: (open: boolean) => void; +} + +export interface ScanSettingsModalProps { + open: boolean; + latestCapture: ScanViewProps["latestCapture"]; + controller: ScanViewControllerResult; + setSettingsOpen: (open: boolean) => void; +} + +export interface ScanSummaryModalProps { + open: boolean; + controller: Pick; + setScanSummary: Dispatch>; + devMode: boolean; +} diff --git a/src/features/scan/components/types.ts b/src/features/scan/components/types.ts new file mode 100644 index 0000000..28a4e0b --- /dev/null +++ b/src/features/scan/components/types.ts @@ -0,0 +1,80 @@ +import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import type { ReviewSampleRecord } from "../../../types/global"; +import type { ScanSummary } from "../../../lib/scannerSession"; +import type { ScanViewControllerResult, ScanViewProps } from "../types"; +import type { Dispatch, SetStateAction } from "react"; + +export interface FieldConfidenceListProps { + parsedArtifact: ParsedArtifactCandidate; +} + +export interface ArtifactResultCardProps { + parsed: ParsedArtifactCandidate; +} + +export interface ReviewSampleCardProps { + entry: ReviewSampleRecord; +} + +export interface ScanSummaryFooterProps { + devMode: boolean; + scanSummary: ScanSummary; + storedTotal: number | null; +} + +export interface ScanViewLayoutProps { + captureSources: ScanViewProps["captureSources"]; + selectedSourceId: string; + setSelectedSourceId: (value: string) => void; + latestCapture: ScanViewProps["latestCapture"]; + captureStatus: string; + refreshCaptureSources: () => Promise; + captureSelectedSource: ScanViewProps["captureSelectedSource"]; + bridgeReady: boolean; + controller: ScanViewControllerResult; + isScanning: boolean; +} + +export interface ScanMainSectionProps { + latestCapture: ScanViewProps["latestCapture"]; + captureStatus: string; + parsedArtifact: ScanViewControllerResult["parsedArtifact"]; + sourceLabel: string; + gridLabel: string; + inventoryLabel: string; + activeTargetCount: number; + storedTotal: ScanViewControllerResult["storedTotal"]; + reviewSampleTotal: number; + learningRulesLoaded: ScanViewControllerResult["learningRulesLoaded"]; + learningRuleCount: number; + canOpenReviewQueue: boolean; + autoScanRunning: boolean; + openReviewQueue: () => void; + setDetailsOpen: (value: boolean) => void; +} + +export interface ScanTopControlsSectionProps { + captureSources: ScanViewProps["captureSources"]; + selectedSourceId: string; + setSelectedSourceId: (value: string) => void; + refreshCaptureSources: () => Promise; + captureSelectedSource: ScanViewProps["captureSelectedSource"]; + bridgeReady: boolean; + isScanning: boolean; + controller: ScanViewControllerResult; +} + +export interface ScanModalsSectionProps { + captureStatus: string; + latestCapture: ScanViewProps["latestCapture"]; + diagnosticsOpen: boolean; + settingsOpen: boolean; + detailsOpen: boolean; + reviewQueueOpen: boolean; + controller: ScanViewControllerResult; + setDiagnosticsOpen: (open: boolean) => void; + setSettingsOpen: (open: boolean) => void; + setDetailsOpen: (open: boolean) => void; + setReviewQueueOpen: (open: boolean) => void; + setScanSummary: Dispatch>; +} diff --git a/src/features/scan/hooks/scanViewControllerService.ts b/src/features/scan/hooks/scanViewControllerService.ts new file mode 100644 index 0000000..d60e0c1 --- /dev/null +++ b/src/features/scan/hooks/scanViewControllerService.ts @@ -0,0 +1,109 @@ +import { shouldFlagArtifactForReview } from "../../../lib/scannerLearning"; +import type { ScannerLearningRules } from "../../../lib/scannerLearning"; +import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; +import type { ScanActionContext } from "./scanViewScanActions"; +import type { + ReviewStateContext, +} from "./scanViewReviewHelpers"; +import type { ArtifactRepositoryPort, ReviewSampleRepositoryPort, LearningRepositoryPort, AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord, RuntimeInfo } from "../../../types/global"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; + +export interface ReviewStateContextInput { + artifactRepo?: ArtifactRepositoryPort; + reviewSamplesRepo?: ReviewSampleRepositoryPort; + learningRepo?: LearningRepositoryPort; + onStoredArtifactsChanged?: () => Promise | void; + setReviewSampleTotal: Dispatch>; + setReviewSamples: Dispatch>; + setLearningRulesLoaded: Dispatch>; + setScannerLearningRules: Dispatch>; + setReviewStatus: Dispatch>; + setStoredTotal: Dispatch>; + appendAutomationLog: (line: string) => void; +} + +export interface ScanActionContextInput { + autoScanRunning: boolean; + setAutoScanRunning: Dispatch>; + isScanning: boolean; + stopVisibleScanRef: MutableRefObject; + selectedSourceId: string; + bridgeReady: boolean; + automationRepo?: AutomationRepositoryPort; + runtimeInfo?: RuntimeInfo | null; + runtimeRepo?: RuntimeRepositoryPort; + scanLimit: number; + skipRows: number; + detectedInventoryCount: number; + setScanSummary: Dispatch>; + setAutoScanStats: Dispatch>; + setReviewStatus: Dispatch>; + appendAutomationLog: (line: string) => void; + appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; + persistParsedArtifact: ( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate, + source: string, + needsReview: boolean, + ) => Promise; + saveReviewSample: ( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate | null, + reason?: string, + ) => Promise; + focusDashboard: () => Promise; + captureSelectedSource: ( + delayMs?: number, + focusGenshin?: boolean, + options?: CaptureOptions, + ) => Promise; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; +} + +export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext { + return { + artifactRepo: input.artifactRepo, + reviewSamplesRepo: input.reviewSamplesRepo, + learningRepo: input.learningRepo, + onStoredArtifactsChanged: input.onStoredArtifactsChanged, + setReviewSampleTotal: input.setReviewSampleTotal, + setReviewSamples: input.setReviewSamples, + setLearningRulesLoaded: input.setLearningRulesLoaded, + setScannerLearningRules: input.setScannerLearningRules, + setReviewStatus: input.setReviewStatus, + setStoredTotal: input.setStoredTotal, + appendAutomationLog: input.appendAutomationLog, + }; +} + +export function createScanActionContext(input: ScanActionContextInput): ScanActionContext { + return { + autoScanRunning: input.autoScanRunning, + setAutoScanRunning: input.setAutoScanRunning, + isScanning: input.isScanning, + stopVisibleScanRef: input.stopVisibleScanRef, + selectedSourceId: input.selectedSourceId, + bridgeReady: input.bridgeReady, + automationRepo: input.automationRepo, + runtimeInfo: input.runtimeInfo, + runtimeRepo: input.runtimeRepo, + scanLimit: input.scanLimit, + skipRows: input.skipRows, + detectedInventoryCount: input.detectedInventoryCount, + setScanSummary: input.setScanSummary, + setAutoScanStats: input.setAutoScanStats, + setReviewStatus: input.setReviewStatus, + appendAutomationLog: input.appendAutomationLog, + appendClickDiagnostics: input.appendClickDiagnostics, + parseArtifact: input.parseArtifact, + persistParsedArtifact: input.persistParsedArtifact, + shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false), + saveReviewSample: input.saveReviewSample, + focusDashboard: input.focusDashboard, + captureSelectedSource: input.captureSelectedSource, + captureFastSelectedSource: input.captureFastSelectedSource, + }; +} diff --git a/src/features/scan/hooks/scanViewReviewHelpers.ts b/src/features/scan/hooks/scanViewReviewHelpers.ts new file mode 100644 index 0000000..4c32869 --- /dev/null +++ b/src/features/scan/hooks/scanViewReviewHelpers.ts @@ -0,0 +1,342 @@ +import type { Dispatch, SetStateAction } from "react"; +import { + applyScannerLearningRules, + countScannerLearningRules, + deriveScannerLearningRules, + deriveScannerLearningRulesFromReviewSamples, + shouldFlagArtifactForReview, + type ScannerLearningRules, +} from "../../../lib/scannerLearning"; +import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality"; +import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore"; +import { + type ArtifactRepositoryPort, + type LearningRepositoryPort, + type ReviewSampleRepositoryPort, +} from "../../../infrastructure/repositories/rendererBridgeRepositories"; +import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global"; +import type { StoredArtifactRecord } from "../../../types/storage"; + +const REVIEW_SAMPLE_LIMIT_INITIAL = 120; +const REVIEW_SAMPLE_LIMIT_QUEUE = 80; + +export interface ReviewStateContext { + artifactRepo?: ArtifactRepositoryPort; + reviewSamplesRepo?: ReviewSampleRepositoryPort; + learningRepo?: LearningRepositoryPort; + onStoredArtifactsChanged?: () => Promise | void; + setReviewSampleTotal: Dispatch>; + setReviewSamples: Dispatch>; + setLearningRulesLoaded: Dispatch>; + setScannerLearningRules: Dispatch>; + setReviewStatus: Dispatch>; + setStoredTotal: Dispatch>; + appendAutomationLog: (line: string) => void; +} + +async function loadReviewSamplesForContext( + context: ReviewStateContext, + limit: number, +): Promise | null> { + return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null; +} + +async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) { + const result = await loadReviewSamplesForContext(context, limit); + if (result?.ok) { + context.setReviewSampleTotal(result.total); + } + return result; +} + +export function parseLearnedArtifact( + capture: CaptureResult | null, + scannerLearningRules: ScannerLearningRules, +): ParsedArtifactCandidate | null { + return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules)); +} + +export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null { + const capture = entry.sample?.capture; + if (!capture?.ocr) return null; + return { + id: `review-${entry.savedAt}`, + name: capture.name ?? "review-sample", + width: capture.width ?? 0, + height: capture.height ?? 0, + dataUrl: "", + capturedAt: capture.capturedAt ?? entry.savedAt, + captureTarget: capture.captureTarget, + crops: [], + inventoryDataUrl: capture.inventoryDataUrl, + ocr: capture.ocr, + inventoryGrid: capture.inventoryGrid, + }; +} + +function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) { + if (!record) return ""; + return [ + record.name, + record.slot, + record.level ?? 0, + record.mainStat, + record.mainValue, + record.setName, + record.equipped, + (record.substats ?? []).join("|"), + record.needsReview ? "review" : "clean", + ].join("::"); +} + +function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) { + if (!existing) return true; + if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false; + + const existingStrength = storedArtifactStrength(existing); + const incomingStrength = storedArtifactStrength(incoming); + const existingSubstats = existing.substats?.length ?? 0; + const incomingSubstats = incoming.substats?.length ?? 0; + const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped)); + const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped)); + + if (existing.needsReview && !incoming.needsReview) return true; + if (incomingSubstats > existingSubstats) return true; + if (!existingEquippedKnown && incomingEquippedKnown) return true; + if (incomingStrength >= existingStrength + 6) return true; + if (isReviewOnlyArtifactSource(existing.source)) return true; + return false; +} + +function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules { + return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } }; +} + +async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) { + const reviewResult = await loadReviewSamplesAndSetTotal(context, limit); + if (reviewResult?.ok) { + await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context); + } + return reviewResult; +} + +export async function recoverArtifactsFromReviewSamples( + samples: ReviewSampleRecord[], + rules: ScannerLearningRules, + context: ReviewStateContext, +): Promise { + const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context; + if (!artifactRepo?.saveMany || samples.length === 0) return 0; + + const existingResult = await artifactRepo.loadAll().catch(() => null); + const existingById = new Map( + (existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]), + ); + + const recoveredCandidates = samples.flatMap((entry) => { + const capture = createCaptureFromReviewSample(entry); + const parsed = parseLearnedArtifact(capture, rules); + if (!capture || !parsed) return []; + if (captureSourceRejectionReason(capture)) return []; + const needsReview = shouldFlagArtifactForReview(parsed); + if (!shouldPersistParsedArtifact(parsed, needsReview)) return []; + const stored = toStoredArtifact(parsed, "review-reprocess", needsReview); + return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : []; + }); + + const recoveredById = new Map(); + for (const record of recoveredCandidates) { + const existing = recoveredById.get(record.id); + if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) { + recoveredById.set(record.id, record); + } + } + const recovered = [...recoveredById.values()]; + + if (recovered.length === 0) return 0; + const result = await artifactRepo.saveMany(recovered).catch(() => null); + if (result?.ok) { + setStoredTotal(result.total); + void onStoredArtifactsChanged?.(); + appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`); + return result.added + result.updated; + } + return 0; +} + +export async function initializeLearningState(context: ReviewStateContext): Promise { + const { + learningRepo, + setLearningRulesLoaded, + setScannerLearningRules, + } = context; + + try { + const loadedRules = await learningRepo?.loadRules().catch(() => null); + const currentRules = getDefaultScannerRules(loadedRules); + + if (countScannerLearningRules(currentRules) > 0) { + setScannerLearningRules(currentRules); + await loadReviewSamplesAndRecover(context, currentRules); + setLearningRulesLoaded(true); + return; + } + + const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL); + const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []); + const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules; + if (countScannerLearningRules(derived) > 0) { + setScannerLearningRules(derived); + await learningRepo?.saveRules?.(derived).catch(() => null); + } else { + setScannerLearningRules(currentRules); + } + setLearningRulesLoaded(true); + } catch { + const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL); + const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []); + setScannerLearningRules(derived); + if (countScannerLearningRules(derived) > 0) { + await learningRepo?.saveRules?.(derived).catch(() => null); + } + if (reviewResult?.ok) { + await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context); + } + setLearningRulesLoaded(true); + } +} + +export async function mergeLearningRules( + nextRules: Partial | null | undefined, + currentRules: ScannerLearningRules, + context: ReviewStateContext, +) { + if (!nextRules || countScannerLearningRules(nextRules) === 0) return null; + const merged = { + textReplacements: { + ...currentRules.textReplacements, + ...(nextRules.textReplacements ?? {}), + }, + }; + context.setScannerLearningRules(merged); + const result = await context.learningRepo?.saveRules?.(merged).catch(() => null); + return { result, merged }; +} + +export async function persistParsedArtifact( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate, + source: string, + needsReview: boolean, + context: ReviewStateContext, +) { + const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context; + if (!artifactRepo?.saveMany) return false; + + const rejection = captureRejectionReason(capture, parsed); + if (rejection) { + appendAutomationLog(`persist skip: ${rejection}`); + return false; + } + if (!shouldPersistParsedArtifact(parsed, needsReview)) { + appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`); + return false; + } + try { + const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]); + if (result?.ok) { + setStoredTotal(result.total); + void onStoredArtifactsChanged?.(); + return true; + } + return false; + } catch { + return false; + } +} + +export async function saveReviewSample( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate | null, + reason: string, + scannerLearningRules: ScannerLearningRules, + context: ReviewStateContext, +): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> { + const { + reviewSamplesRepo, + setReviewStatus, + appendAutomationLog, + setReviewSamples, + onStoredArtifactsChanged, + } = context; + + if (!reviewSamplesRepo?.saveSample) { + setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar."); + return { result: null, recoveredParsed: null, recoveredToDb: false }; + } + if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false }; + + const result = await reviewSamplesRepo.saveSample({ + reason, + capture: { + id: capture.id, + name: capture.name, + width: capture.width, + height: capture.height, + dataUrl: capture.dataUrl, + detailDataUrl: capture.detailDataUrl, + inventoryDataUrl: capture.inventoryDataUrl, + captureTarget: capture.captureTarget, + capturedAt: capture.capturedAt, + crops: capture.crops?.map((crop: NonNullable[number]) => ({ + id: crop.id, + label: crop.label, + rect: crop.rect, + dataUrl: crop.dataUrl, + })), + inventoryGrid: capture.inventoryGrid, + inventoryCount: capture.inventoryCount, + ocr: capture.ocr, + }, + parsed, + }); + + const learned = deriveScannerLearningRules(capture, parsed); + const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context); + const learnedCount = countScannerLearningRules(learned); + const activeRules = learnedResult?.merged ?? scannerLearningRules; + const recoveredParsed = parseLearnedArtifact(capture, activeRules); + const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed); + let recoveredToDb = false; + if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) { + const extendedContext: ReviewStateContext = { + ...context, + artifactRepo: context.artifactRepo, + onStoredArtifactsChanged, + }; + recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext); + } + + setReviewStatus( + result?.path + ? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}` + : "Review-Sample konnte nicht gespeichert werden.", + ); + if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`); + if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`); + + const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE); + if (reviewResult?.ok) { + setReviewSamples(reviewResult.samples); + } + + return { result, recoveredParsed, recoveredToDb }; +} + +export async function loadReviewQueue(context: ReviewStateContext): Promise { + const { setReviewSamples } = context; + const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE); + if (!result?.ok) return; + setReviewSamples(result.samples); +} diff --git a/src/features/scan/hooks/scanViewScanActions.ts b/src/features/scan/hooks/scanViewScanActions.ts new file mode 100644 index 0000000..81b72b7 --- /dev/null +++ b/src/features/scan/hooks/scanViewScanActions.ts @@ -0,0 +1,294 @@ +import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner"; +import { captureRejectionReason } from "../../../lib/scannerCaptureQuality"; +import { runAutoScanLoop } from "../../../lib/autoScanLoop"; +import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; +import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils"; +import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories"; +import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global"; +import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import type { MutableRefObject } from "react"; +import type { Dispatch, SetStateAction } from "react"; + +export interface ScanActionContext { + autoScanRunning: boolean; + setAutoScanRunning: Dispatch>; + isScanning: boolean; + stopVisibleScanRef: MutableRefObject; + selectedSourceId: string; + bridgeReady: boolean; + automationRepo?: AutomationRepositoryPort; + runtimeRepo?: RuntimeRepositoryPort; + runtimeInfo?: RuntimeInfo | null; + scanLimit: number; + skipRows: number; + detectedInventoryCount: number; + setScanSummary: Dispatch>; + setAutoScanStats: Dispatch>; + setReviewStatus: Dispatch>; + appendAutomationLog: (line: string) => void; + appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; + persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise; + saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise; + shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean; + focusDashboard: () => Promise; +} + +function buildScanSignature(parsed: ParsedArtifactCandidate) { + return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`; +} + +export async function runAutoReviewScan(context: ScanActionContext): Promise { + const { + autoScanRunning, + selectedSourceId, + stopVisibleScanRef, + setAutoScanRunning, + setReviewStatus, + setScanSummary, + setAutoScanStats, + appendAutomationLog, + scanLimit, + detectedInventoryCount, + captureSelectedSource, + parseArtifact, + persistParsedArtifact, + saveReviewSample, + shouldFlagArtifactForReview, + focusDashboard, + } = context; + + if (autoScanRunning || !selectedSourceId) return; + + setAutoScanRunning(true); + stopVisibleScanRef.current = false; + setScanSummary(null); + setAutoScanStats(emptyAutoScanStats); + setReviewStatus("Manueller Scan laeuft. Klicke in Genshin auf ein anderes Artifact; nur neue Artifacts werden verarbeitet."); + + const seen = new Set(); + const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 }; + let idleTicks = 0; + const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount); + const maxIdleTicks = 90; + + while (!stopVisibleScanRef.current && stats.parsed < maxArtifacts && idleTicks < maxIdleTicks) { + const capture = await captureSelectedSource(0, true); + const rejection = captureRejectionReason(capture, parseArtifact(capture)); + const parsed = parseArtifact(capture); + + if (!capture || !parsed || rejection) { + if (capture && rejection) { + await saveReviewSample(capture, parsed, `manual:capture-rejected`); + stats.review++; + setAutoScanStats({ ...stats }); + } + idleTicks++; + setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`); + await wait(700); + continue; + } + + const signature = buildScanSignature(parsed); + if (seen.has(signature)) { + idleTicks++; + setReviewStatus(`Manueller Scan wartet auf ein neues Artifact... (${stats.parsed}/${maxArtifacts})`); + await wait(700); + continue; + } + + seen.add(signature); + idleTicks = 0; + stats.attempted++; + stats.verified++; + stats.parsed++; + + const reason = getAutoReviewReason(capture, parsed); + const needsReview = shouldFlagArtifactForReview(parsed); + if (reason) { + await saveReviewSample(capture, parsed, `manual:${reason}`); + stats.review++; + } + if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) { + stats.stored++; + } + setAutoScanStats({ ...stats }); + setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`); + await wait(700); + } + + setAutoScanRunning(false); + const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done"; + const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : ""; + await focusDashboard(); + setReviewStatus( + `Manueller Scan ${status}. ${stats.verified} neue Ansichten verifiziert, ${stats.parsed} Artifacts gelesen, ${stats.stored} in der Datenbank gespeichert, ${stats.review} Review-Samples.${idleSuffix}`, + ); + setScanSummary({ + mode: "Manueller Scan", + status, + ...stats, + targetCount: maxArtifacts, + gridLabel: "Nur neue, vom User angeklickte Artifacts wurden verarbeitet.", + }); + + appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`); +} + +export async function runVisibleGridScan(context: ScanActionContext): Promise { + const { + autoScanRunning, + bridgeReady, + selectedSourceId, + runtimeInfo, + automationRepo, + runtimeRepo, + stopVisibleScanRef, + setAutoScanRunning, + setScanSummary, + setAutoScanStats, + setReviewStatus, + appendAutomationLog, + appendClickDiagnostics, + captureSelectedSource, + captureFastSelectedSource, + parseArtifact, + persistParsedArtifact, + saveReviewSample, + shouldFlagArtifactForReview, + scanLimit, + skipRows, + detectedInventoryCount, + focusDashboard, + } = context; + + if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return; + + const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo); + if (requiresAdminForAutoScan) { + setReviewStatus( + "App laeuft nicht als Administrator. Bitte die App schliessen und als Administrator neu starten - Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.", + ); + setScanSummary({ + mode: "Automatischer Scan", + status: "blocked", + ...emptyAutoScanStats, + targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount), + gridLabel: "App laeuft nicht als Administrator. Neustart als Administrator noetig.", + }); + return; + } + + setAutoScanRunning(true); + stopVisibleScanRef.current = false; + setScanSummary(null); + setAutoScanStats(emptyAutoScanStats); + setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort."); + + const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null); + const adminBlockReason = automationBlockReason(freshRuntime); + if (adminBlockReason) { + setAutoScanRunning(false); + setReviewStatus(adminBlockReason); + appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt"); + setScanSummary({ + mode: "Automatischer Scan", + status: "blocked", + ...emptyAutoScanStats, + targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount), + gridLabel: adminBlockReason, + }); + return; + } + + if (freshRuntime) { + const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found"; + appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`); + } + + setReviewStatus("Genshin wird in den Vordergrund geholt..."); + const focusResult = await automationRepo?.focusGenshin().catch(() => null); + if (focusResult) { + appendAutomationLog( + `focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`, + ); + } + if (!focusResult?.focused) { + setAutoScanRunning(false); + const reason = !focusResult?.genshinFound + ? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten." + : "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten."; + setReviewStatus(reason); + setScanSummary({ + mode: "Automatischer Scan", + status: "blocked", + ...emptyAutoScanStats, + targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount), + gridLabel: reason, + }); + return; + } + + setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort."); + + const result = await runAutoScanLoop( + { + api: { + clickScreen: (x: number, y: number) => { + if (!automationRepo?.clickScreen) { + return Promise.resolve({ + ok: false, + x: 0, + y: 0, + clicked: false, + moved: false, + focused: false, + inputBlocked: false, + }); + } + return automationRepo.clickScreen(x, y); + }, + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => { + if (!automationRepo?.scrollScreen) { + return Promise.resolve({ ok: false, notchesSent: 0, inputBlocked: false }); + } + return automationRepo.scrollScreen(notches, anchorX, anchorY); + }, + getAutomationGuard: () => + automationRepo?.getAutomationGuard?.() ?? + Promise.resolve({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }), + }, + captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin), + captureFastSelectedSource, + parseArtifact, + persistParsedArtifact, + saveReviewSample, + getAutoReviewReason, + shouldFlagArtifactForReview, + appendAutomationLog, + appendClickDiagnostics, + setReviewStatus, + setAutoScanStats, + shouldStop: () => stopVisibleScanRef.current, + }, + { + scanLimit, + skipRows, + detectedInventoryCount, + }, + ); + + setAutoScanRunning(false); + if (result.blockedReason) appendAutomationLog(`stop: ${result.blockedReason}`); + await focusDashboard(); + setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`); + setScanSummary({ + mode: "Automatischer Scan", + status: result.status, + ...result.stats, + targetCount: result.targetCount, + gridLabel: result.gridLabel, + }); +} diff --git a/src/features/scan/hooks/useScanCommandListener.ts b/src/features/scan/hooks/useScanCommandListener.ts new file mode 100644 index 0000000..b9854c6 --- /dev/null +++ b/src/features/scan/hooks/useScanCommandListener.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; + +interface ScanCommandListenerInput { + automationRepo?: AutomationRepositoryPort; + autoScanRunning: boolean; + isScanning: boolean; + selectedSourceId: string; + requestScanStop: (reason: string) => void; + runVisibleGridScan: () => Promise; +} + +export function useScanCommandListener({ + automationRepo, + autoScanRunning, + isScanning, + selectedSourceId, + requestScanStop, + runVisibleGridScan, +}: ScanCommandListenerInput) { + useEffect(() => { + if (!automationRepo?.onCommand) return; + return automationRepo.onCommand((command: "start-auto" | "stop") => { + if (command === "stop") { + requestScanStop("Hotkey/Dev-Stop gedrueckt."); + return; + } + if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) { + void runVisibleGridScan(); + } + }); + }, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]); +} + diff --git a/src/features/scan/hooks/useScanRuntimeInfo.ts b/src/features/scan/hooks/useScanRuntimeInfo.ts new file mode 100644 index 0000000..7be4849 --- /dev/null +++ b/src/features/scan/hooks/useScanRuntimeInfo.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import type { RuntimeInfo } from "../../../types/global"; +import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; + +export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) { + const [runtimeInfo, setRuntimeInfo] = useState(null); + + useEffect(() => { + let mounted = true; + runtimeRepo?.getRuntimeInfo().then((result) => { + if (mounted) { + setRuntimeInfo(result); + } + }).catch(() => undefined); + + const timer = window.setInterval(() => { + runtimeRepo?.getRuntimeInfo().then((result) => { + if (mounted) setRuntimeInfo(result); + }).catch(() => undefined); + }, 5000); + + return () => { + mounted = false; + window.clearInterval(timer); + }; + }, [runtimeRepo]); + + return runtimeInfo; +} + diff --git a/src/features/scan/hooks/useScanSnapshotPublisher.ts b/src/features/scan/hooks/useScanSnapshotPublisher.ts new file mode 100644 index 0000000..907987b --- /dev/null +++ b/src/features/scan/hooks/useScanSnapshotPublisher.ts @@ -0,0 +1,75 @@ +import { useEffect } from "react"; +import { type AppSnapshot } from "../../../types/domain"; +import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; +import type { RuntimeInfo } from "../../../types/global"; +import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import type { CaptureResult } from "../../../types/global"; + +type InventoryGrid = NonNullable; + +interface ScanSnapshotPublisherInput { + autoScanRunning: boolean; + reviewStatus: string; + captureStatus: string; + selectedSourceName: string | null; + autoScanStats: AutoScanStats; + scanSummary: ScanSummary | null; + snapshot: AppSnapshot; + latestInventoryGrid: InventoryGrid | null | undefined; + automationLog: string[]; + runtimeInfo: RuntimeInfo | null; + storedTotal: number | null; + learningRuleCount: number; + snapshotRepo?: SnapshotRepositoryPort; +} + +export function useScanSnapshotPublisher({ + autoScanRunning, + reviewStatus, + captureStatus, + selectedSourceName, + autoScanStats, + scanSummary, + snapshot, + latestInventoryGrid, + automationLog, + runtimeInfo, + storedTotal, + learningRuleCount, + snapshotRepo, +}: ScanSnapshotPublisherInput) { + useEffect(() => { + void snapshotRepo?.publishScannerStatus({ + running: autoScanRunning, + reviewStatus, + captureStatus, + selectedSource: selectedSourceName, + stats: autoScanStats, + summary: scanSummary, + snapshotArtifacts: snapshot.artifacts.length, + snapshotCharacters: snapshot.characters.filter((character) => character.owned).length, + snapshotRecommendations: snapshot.recommendations.length, + snapshotBuilds: snapshot.builds.length, + grid: latestInventoryGrid ?? null, + automationLog: automationLog.slice(-12), + runtimeInfo, + storedTotal, + learningRuleCount, + updatedAt: new Date().toISOString(), + }).catch(() => undefined); + }, [ + autoScanRunning, + reviewStatus, + captureStatus, + selectedSourceName, + autoScanStats, + scanSummary, + snapshot, + latestInventoryGrid, + automationLog, + runtimeInfo, + storedTotal, + learningRuleCount, + snapshotRepo, + ]); +} diff --git a/src/features/scan/hooks/useScanViewActions.ts b/src/features/scan/hooks/useScanViewActions.ts new file mode 100644 index 0000000..56e665c --- /dev/null +++ b/src/features/scan/hooks/useScanViewActions.ts @@ -0,0 +1,296 @@ +import { useCallback, useEffect, useMemo } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions"; +import { + initializeLearningState, + loadReviewQueue as loadReviewQueueFromRepo, + persistParsedArtifact as persistParsedArtifactHelper, + saveReviewSample as saveReviewSampleHelper, +} from "./scanViewReviewHelpers"; +import { createReviewContext, createScanActionContext } from "./scanViewControllerService"; +import { useScanCommandListener } from "./useScanCommandListener"; +import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import type { ScannerLearningRules } from "../../../lib/scannerLearning"; +import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; +import type { + ArtifactRepositoryPort, + ReviewSampleRepositoryPort, + LearningRepositoryPort, + RuntimeRepositoryPort, + AutomationRepositoryPort, +} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession"; +import type { RuntimeInfo } from "../../../types/global"; + +type BooleanSetter = Dispatch>; +type NumberSetter = Dispatch>; +type StringSetter = Dispatch>; +type NumberOrNullSetter = Dispatch>; +type ScannerRulesSetter = Dispatch>; +type ReviewSamplesSetter = Dispatch>; +type ScanSummarySetter = Dispatch>; +type AutoScanStatsSetter = Dispatch>; + +interface ScanViewActionInput { + autoScanRunning: boolean; + setAutoScanRunning: BooleanSetter; + isScanning: boolean; + stopVisibleScanRef: MutableRefObject; + selectedSourceId: string; + bridgeReady: boolean; + automationRepo?: AutomationRepositoryPort; + runtimeInfo?: RuntimeInfo | null; + runtimeRepo?: RuntimeRepositoryPort; + scanLimit: number; + skipRows: number; + detectedInventoryCount: number; + setScanSummary: ScanSummarySetter; + setAutoScanStats: AutoScanStatsSetter; + setReviewStatus: StringSetter; + appendAutomationLog: (line: string) => void; + appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; + artifactRepo?: ArtifactRepositoryPort; + reviewSamplesRepo?: ReviewSampleRepositoryPort; + learningRepo?: LearningRepositoryPort; + onStoredArtifactsChanged?: (() => Promise) | (() => void); + setReviewSampleTotal: NumberSetter; + setReviewSamples: ReviewSamplesSetter; + setLearningRulesLoaded: BooleanSetter; + setScannerLearningRules: ScannerRulesSetter; + setStoredTotal: NumberOrNullSetter; + scannerLearningRules: ScannerLearningRules; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + latestCapture: CaptureResult | null; + parsedArtifact: ParsedArtifactCandidate | null; + canCaptureSource: boolean; + setReviewQueueOpen: BooleanSetter; +} + +export interface ScanViewActionResult { + requestScanStop: (reason?: string) => void; + saveReviewSample: ( + capture?: CaptureResult | null, + parsed?: ParsedArtifactCandidate | null, + reason?: string, + ) => Promise; + loadReviewQueue: () => Promise; + openReviewQueue: () => Promise; + runAutoReviewScan: () => Promise; + runVisibleGridScan: () => Promise; +} + +export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult { + const { + autoScanRunning, + setAutoScanRunning, + isScanning, + stopVisibleScanRef, + selectedSourceId, + bridgeReady, + automationRepo, + runtimeInfo, + runtimeRepo, + scanLimit, + skipRows, + detectedInventoryCount, + setScanSummary, + setAutoScanStats, + setReviewStatus, + appendAutomationLog, + appendClickDiagnostics, + parseArtifact, + artifactRepo, + reviewSamplesRepo, + learningRepo, + onStoredArtifactsChanged, + setReviewSampleTotal, + setReviewSamples, + setLearningRulesLoaded, + setScannerLearningRules, + setStoredTotal, + scannerLearningRules, + captureSelectedSource, + latestCapture, + parsedArtifact, + canCaptureSource, + setReviewQueueOpen, + } = input; + + const requestScanStop = useCallback((reason = "Stop angefordert.") => { + stopVisibleScanRef.current = true; + appendAutomationLog(`stop requested: ${reason}`); + setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`); + }, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]); + + const reviewContext = useMemo( + () => + createReviewContext({ + artifactRepo, + reviewSamplesRepo, + learningRepo, + onStoredArtifactsChanged, + setReviewSampleTotal, + setReviewSamples, + setLearningRulesLoaded, + setScannerLearningRules, + setReviewStatus, + setStoredTotal, + appendAutomationLog, + }), + [ + artifactRepo, + reviewSamplesRepo, + learningRepo, + onStoredArtifactsChanged, + setReviewSampleTotal, + setReviewSamples, + setLearningRulesLoaded, + setScannerLearningRules, + setReviewStatus, + setStoredTotal, + appendAutomationLog, + ], + ); + + useEffect(() => { + void initializeLearningState(reviewContext); + }, [reviewContext]); + + const focusDashboard = useCallback(async () => { + try { + await automationRepo?.focusMainWindow?.(); + } catch { + // Best-effort fallback; state remains in renderer. + } + }, [automationRepo?.focusMainWindow]); + + const parseArtifactAndPersist = useCallback( + async function parseArtifactAndPersist( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate, + source: string, + needsReview: boolean, + ) { + return persistParsedArtifactHelper(capture, parsed, source, needsReview, reviewContext); + }, + [reviewContext], + ); + + const handleSaveReviewSample = useCallback( + async function handleSaveReviewSample( + capture: CaptureResult | null = latestCapture, + parsed: ParsedArtifactCandidate | null = parsedArtifact, + reason = "manual", + ): Promise { + const result = await saveReviewSampleHelper( + capture, + parsed, + reason, + scannerLearningRules, + reviewContext, + ); + return result.result ? { ok: result.result.ok } : null; + }, + [latestCapture, parsedArtifact, reviewContext, scannerLearningRules], + ); + + const scanActionContext = useMemo( + () => + createScanActionContext({ + autoScanRunning, + setAutoScanRunning, + isScanning, + stopVisibleScanRef, + selectedSourceId, + bridgeReady, + automationRepo, + runtimeInfo, + runtimeRepo, + scanLimit, + skipRows, + detectedInventoryCount, + setScanSummary, + setAutoScanStats, + setReviewStatus, + appendAutomationLog, + appendClickDiagnostics, + parseArtifact, + persistParsedArtifact: parseArtifactAndPersist, + saveReviewSample: handleSaveReviewSample, + focusDashboard, + captureSelectedSource, + captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }), + }), + [ + autoScanRunning, + setAutoScanRunning, + isScanning, + stopVisibleScanRef, + selectedSourceId, + bridgeReady, + automationRepo, + runtimeInfo, + runtimeRepo, + scanLimit, + skipRows, + detectedInventoryCount, + setScanSummary, + setAutoScanStats, + setReviewStatus, + appendAutomationLog, + appendClickDiagnostics, + parseArtifact, + parseArtifactAndPersist, + handleSaveReviewSample, + focusDashboard, + captureSelectedSource, + ], + ); + + const loadReviewQueueAction = useCallback(async () => { + await loadReviewQueueFromRepo(reviewContext); + }, [reviewContext]); + + const openReviewQueueModal = useCallback(async () => { + await loadReviewQueueAction(); + setReviewQueueOpen(true); + }, [loadReviewQueueAction, setReviewQueueOpen]); + + const runAutoReviewScan = useCallback(async () => { + if (autoScanRunning || !selectedSourceId || !canCaptureSource) return; + await runAutoReviewScanAction(scanActionContext); + }, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]); + + const runVisibleGridScan = useCallback(async () => { + if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) { + return; + } + await runVisibleGridScanAction(scanActionContext); + }, [ + autoScanRunning, + bridgeReady, + selectedSourceId, + automationRepo?.clickScreen, + automationRepo?.scrollScreen, + scanActionContext, + ]); + + useScanCommandListener({ + automationRepo, + autoScanRunning, + isScanning, + selectedSourceId, + requestScanStop, + runVisibleGridScan, + }); + + return { + requestScanStop, + saveReviewSample: handleSaveReviewSample, + loadReviewQueue: loadReviewQueueAction, + openReviewQueue: openReviewQueueModal, + runAutoReviewScan, + runVisibleGridScan, + }; +} diff --git a/src/features/scan/hooks/useScanViewController.ts b/src/features/scan/hooks/useScanViewController.ts new file mode 100644 index 0000000..78e3998 --- /dev/null +++ b/src/features/scan/hooks/useScanViewController.ts @@ -0,0 +1,233 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { requiresAdminForAutomation } from "../../../lib/automationPlanner"; +import { + countScannerLearningRules, + type ScannerLearningRules, +} from "../../../lib/scannerLearning"; +import { type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser"; +import { analyzeReviewSamples } from "../../../lib/reviewSampleAnalysis"; +import { + parseLearnedArtifact as parseLearnedArtifactHelper, +} from "./scanViewReviewHelpers"; +import { useScanRuntimeInfo } from "./useScanRuntimeInfo"; +import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher"; +import { useScanViewActions } from "./useScanViewActions"; +import { useScanViewStateSync } from "./useScanViewStateSync"; +import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession"; +import type { ScanViewProps, ScanViewControllerResult } from "../types"; +import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global"; +import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories"; + +export function useScanViewController({ + snapshot, + isScanning, + captureSources, + selectedSourceId, + latestCapture, + captureStatus, + captureSelectedSource, + bridgeReady, + onStoredArtifactsChanged, +}: ScanViewProps): ScanViewControllerResult { + const repositories = useMemo(() => createRendererRepositories(), []); + const artifactRepo = repositories?.artifacts; + const runtimeRepo = repositories?.runtime; + const reviewSamplesRepo = repositories?.reviewSamples; + const learningRepo = repositories?.learning; + const snapshotRepo = repositories?.snapshot; + const automationRepo = repositories?.automation; + const captureRepo = repositories?.capture; + + const [detailsOpen, setDetailsOpen] = useState(false); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [reviewQueueOpen, setReviewQueueOpen] = useState(false); + const [reviewSamples, setReviewSamples] = useState([]); + const [reviewSampleTotal, setReviewSampleTotal] = useState(0); + const [reviewStatus, setReviewStatus] = useState(""); + const [autoScanRunning, setAutoScanRunning] = useState(false); + const stopVisibleScanRef = useRef(false); + const [autoScanStats, setAutoScanStats] = useState(emptyAutoScanStats); + const [scanSummary, setScanSummary] = useState(null); + const [scanLimit, setScanLimit] = useState(16); + const [scanLimitTouched, setScanLimitTouched] = useState(false); + const [skipRows, setSkipRows] = useState(0); + const [automationLog, setAutomationLog] = useState([]); + const [storedTotal, setStoredTotal] = useState(null); + const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1"); + const [scannerLearningRules, setScannerLearningRules] = useState({ textReplacements: {} }); + const [learningRulesLoaded, setLearningRulesLoaded] = useState(false); + const runtimeInfo = useScanRuntimeInfo(runtimeRepo); + + const parsedArtifact = useMemo( + () => parseLearnedArtifactHelper(latestCapture, scannerLearningRules), + [latestCapture, scannerLearningRules], + ); + + const selectedSource = captureSources.find((source) => source.id === selectedSourceId); + const genshinSource = captureSources.find((source) => source.isGenshinCandidate); + const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo); + const hasSourceSelected = selectedSourceId.length > 0; + const canCaptureSource = bridgeReady && Boolean(captureRepo?.captureSource); + const canReadReviewQueue = bridgeReady && Boolean(reviewSamplesRepo?.loadSamples); + const canSaveReviewSample = bridgeReady && Boolean(reviewSamplesRepo?.saveSample); + const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen); + const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]); + const learningRuleCount = countScannerLearningRules(scannerLearningRules); + const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0; + const activeTargetCount = autoScanRunning + ? resolveScanTargetCount(scanLimit, detectedInventoryCount) + : scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount); + const scanProgressBase = Math.max(autoScanStats.attempted, autoScanStats.parsed, autoScanStats.verified); + const scanProgressPercent = Math.min(100, Math.round((scanProgressBase / Math.max(1, activeTargetCount)) * 100)); + const scannerModeLabel = autoScanRunning ? "Scan laeuft" : runtimeInfo?.isElevated ? "Admin bereit" : "Bereit"; + const sourceLabel = selectedSource?.name ?? "Keine Quelle"; + const gridLabel = latestCapture?.inventoryGrid ? `${latestCapture.inventoryGrid.cols} x ${latestCapture.inventoryGrid.rows}` : "warte auf Capture"; + const inventoryLabel = latestCapture?.inventoryCount?.current ? `${latestCapture.inventoryCount.current}/${latestCapture.inventoryCount.total || "?"}` : "nicht erkannt"; + + const appendAutomationLog = useCallback((line: string) => { + setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]); + }, []); + + const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => { + const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`; + const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`; + const blocked = result.inputBlocked ? " input:blocked" : ""; + appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`); + }, [appendAutomationLog]); + + const toggleDevMode = useCallback(() => { + setDevMode((previous) => { + const next = !previous; + localStorage.setItem("gaa-dev-mode", next ? "1" : "0"); + return next; + }); + }, []); + + const parseArtifact = useCallback( + (capture: CaptureResult | null) => parseLearnedArtifactHelper(capture, scannerLearningRules), + [scannerLearningRules], + ); + + const { + requestScanStop, + saveReviewSample: handleSaveReviewSample, + loadReviewQueue: loadReviewQueueAction, + openReviewQueue: openReviewQueueModal, + runAutoReviewScan, + runVisibleGridScan, + } = useScanViewActions({ + autoScanRunning, + setAutoScanRunning, + isScanning, + stopVisibleScanRef, + selectedSourceId, + bridgeReady, + automationRepo, + runtimeInfo, + runtimeRepo, + scanLimit, + skipRows, + detectedInventoryCount, + setScanSummary, + setAutoScanStats, + setReviewStatus, + appendAutomationLog, + appendClickDiagnostics, + parseArtifact, + artifactRepo, + reviewSamplesRepo, + learningRepo, + onStoredArtifactsChanged, + setReviewSampleTotal, + setReviewSamples, + setLearningRulesLoaded, + setScannerLearningRules, + setStoredTotal, + scannerLearningRules, + captureSelectedSource, + latestCapture, + parsedArtifact, + canCaptureSource, + setReviewQueueOpen, + }); + + useScanViewStateSync({ + artifactRepo, + latestCapture, + scanLimitTouched, + setScanLimit, + setStoredTotal, + }); + + useScanSnapshotPublisher({ + autoScanRunning, + reviewStatus, + captureStatus, + selectedSourceName: selectedSource?.name ?? null, + autoScanStats, + scanSummary, + snapshot, + latestInventoryGrid: latestCapture?.inventoryGrid ?? null, + automationLog, + runtimeInfo, + storedTotal, + learningRuleCount, + snapshotRepo, + }); + + return { + detailsOpen, + diagnosticsOpen, + settingsOpen, + reviewQueueOpen, + reviewSamples, + reviewSampleTotal, + reviewStatus, + autoScanRunning, + autoScanStats, + scanSummary, + scanLimit, + scanLimitTouched, + skipRows, + automationLog, + storedTotal, + devMode, + scannerLearningRules, + learningRulesLoaded, + runtimeInfo, + parsedArtifact, + reviewAnalysis, + learningRuleCount, + detectedInventoryCount, + scanProgressPercent, + activeTargetCount, + requiresAdminForAutoScan, + hasSourceSelected, + canCaptureSource, + canReadReviewQueue, + canSaveReviewSample, + canAutoScan, + scannerModeLabel, + sourceLabel, + gridLabel, + inventoryLabel, + selectedSource, + genshinSource, + setDetailsOpen, + setDiagnosticsOpen, + setSettingsOpen, + setReviewQueueOpen, + setScanSummary, + setScanLimit, + setScanLimitTouched, + setSkipRows, + toggleDevMode, + requestScanStop, + saveReviewSample: handleSaveReviewSample, + loadReviewQueue: loadReviewQueueAction, + openReviewQueue: openReviewQueueModal, + runAutoReviewScan, + runVisibleGridScan, + }; +} diff --git a/src/features/scan/hooks/useScanViewStateSync.ts b/src/features/scan/hooks/useScanViewStateSync.ts new file mode 100644 index 0000000..179a506 --- /dev/null +++ b/src/features/scan/hooks/useScanViewStateSync.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import type { CaptureResult } from "../../../types/global"; +import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes"; +import { clampScanLimit } from "../../../lib/scannerSession"; + +interface ScanViewStateSyncInput { + artifactRepo?: ArtifactRepositoryPort; + latestCapture: CaptureResult | null; + scanLimitTouched: boolean; + setScanLimit: Dispatch>; + setStoredTotal: Dispatch>; +} + +export function useScanViewStateSync({ + artifactRepo, + latestCapture, + scanLimitTouched, + setScanLimit, + setStoredTotal, +}: ScanViewStateSyncInput) { + useEffect(() => { + const detectedCount = latestCapture?.inventoryCount?.current ?? 0; + if (!scanLimitTouched && detectedCount > 0) { + setScanLimit(clampScanLimit(detectedCount)); + } + }, [latestCapture?.inventoryCount?.current, scanLimitTouched, setScanLimit]); + + useEffect(() => { + artifactRepo?.loadAll().then((result) => { + if (result?.ok) setStoredTotal(result.total); + }).catch(() => undefined); + }, [artifactRepo, setStoredTotal]); +} + diff --git a/src/features/scan/types.ts b/src/features/scan/types.ts new file mode 100644 index 0000000..3ca9860 --- /dev/null +++ b/src/features/scan/types.ts @@ -0,0 +1,82 @@ +import type { AppSnapshot } from "../../types/domain"; +import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global"; +import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession"; +import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser"; +import type { ScannerLearningRules } from "../../lib/scannerLearning"; +import type { Dispatch, SetStateAction } from "react"; +import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis"; + +export interface ScanViewProps { + snapshot: AppSnapshot; + isScanning: boolean; + captureSources: CaptureSourceInfo[]; + selectedSourceId: string; + setSelectedSourceId: (value: string) => void; + latestCapture: CaptureResult | null; + captureStatus: string; + refreshCaptureSources: () => Promise; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + bridgeReady: boolean; + onStoredArtifactsChanged?: () => Promise; +} + +export interface ScanViewControllerResult { + detailsOpen: boolean; + diagnosticsOpen: boolean; + settingsOpen: boolean; + reviewQueueOpen: boolean; + reviewSamples: ReviewSampleRecord[]; + reviewSampleTotal: number; + reviewStatus: string; + autoScanRunning: boolean; + autoScanStats: AutoScanStats; + scanSummary: ScanSummary | null; + scanLimit: number; + scanLimitTouched: boolean; + skipRows: number; + automationLog: string[]; + storedTotal: number | null; + devMode: boolean; + scannerLearningRules: ScannerLearningRules; + learningRulesLoaded: boolean; + runtimeInfo: RuntimeInfo | null; + parsedArtifact: ParsedArtifactCandidate | null; + reviewAnalysis: ReturnType; + learningRuleCount: number; + detectedInventoryCount: number; + scanProgressPercent: number; + activeTargetCount: number; + requiresAdminForAutoScan: boolean; + hasSourceSelected: boolean; + canCaptureSource: boolean; + canReadReviewQueue: boolean; + canSaveReviewSample: boolean; + canAutoScan: boolean; + scannerModeLabel: string; + sourceLabel: string; + gridLabel: string; + inventoryLabel: string; + selectedSource: CaptureSourceInfo | undefined; + genshinSource: CaptureSourceInfo | undefined; + + setDetailsOpen: Dispatch>; + setDiagnosticsOpen: Dispatch>; + setSettingsOpen: Dispatch>; + setReviewQueueOpen: Dispatch>; + setScanSummary: Dispatch>; + setScanLimit: Dispatch>; + setScanLimitTouched: Dispatch>; + setSkipRows: Dispatch>; + + toggleDevMode: () => void; + requestScanStop: (reason?: string) => void; + saveReviewSample: ( + capture?: CaptureResult | null, + parsed?: ParsedArtifactCandidate | null, + reason?: string, + ) => Promise; + loadReviewQueue: () => Promise; + openReviewQueue: () => Promise; + runAutoReviewScan: () => Promise; + runVisibleGridScan: () => Promise; +} diff --git a/src/features/triage/TriageView.tsx b/src/features/triage/TriageView.tsx new file mode 100644 index 0000000..2686f7c --- /dev/null +++ b/src/features/triage/TriageView.tsx @@ -0,0 +1,61 @@ +import type { ArtifactRowProps, TriageViewProps } from "./types"; +import { useArtifactRowModel } from "./hooks/useArtifactRowModel"; +import { useTriageViewModel } from "./hooks/useTriageViewModel"; + +function ArtifactRow({ + artifact, + recommendation, + characters, +}: ArtifactRowProps) { + const { metaLabel, metaClassName, metaIcon, score, characterNames, artifactSummary, substatRows } = useArtifactRowModel({ + recommendation, + artifact, + characters, + characterIds: recommendation?.bestCharacters, + }); + + return ( +
+
+ {artifact.setName} + {artifactSummary} +
+
+ {substatRows.map((substat) => ( + {substat} + ))} +
+
{metaIcon}{metaLabel}
+
+ {score} + {characterNames} +
+
+ ); +} + +export function TriageView({ snapshot }: TriageViewProps) { + const { panelEyebrow, panelTitle, artifactCountLabel, recommendationByArtifact } = useTriageViewModel({ snapshot }); + + return ( +
+
+
+

{panelEyebrow}

+

{panelTitle}

+
+ {artifactCountLabel} +
+
+ {snapshot.artifacts.map((artifact) => ( + + ))} +
+
+ ); +} diff --git a/src/features/triage/hooks/useArtifactRowModel.ts b/src/features/triage/hooks/useArtifactRowModel.ts new file mode 100644 index 0000000..6c1ce3a --- /dev/null +++ b/src/features/triage/hooks/useArtifactRowModel.ts @@ -0,0 +1,43 @@ +import { verdictMeta } from "../../common/verdictMeta"; +import type { Artifact, Character, Recommendation } from "../../../types/domain"; +import type { ReactNode } from "react"; + +export interface ArtifactRowModel { + metaLabel: string; + metaClassName: string; + metaIcon: ReactNode; + score: number; + characterNames: string; + artifactSummary: string; + substatRows: string[]; +} + +interface UseArtifactRowModelInput { + artifact: Artifact; + recommendation?: Recommendation; + characters: Character[]; + characterIds?: string[]; +} + +export function useArtifactRowModel({ + recommendation, + characters, + artifact, + characterIds, +}: UseArtifactRowModelInput): ArtifactRowModel { + const meta = verdictMeta[recommendation?.verdict ?? "needs_review"]; + const characterNames = !characterIds || characterIds.length === 0 + ? "Review first" + : characterIds.map((id) => characters.find((character) => character.id === id)?.name ?? id).join(", "); + const score = recommendation ? Math.round(recommendation.score) : 0; + + return { + metaLabel: meta.label, + metaClassName: meta.className, + metaIcon: meta.icon, + score, + characterNames, + artifactSummary: `${artifact.slot} - +${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`, + substatRows: artifact.substats.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`), + }; +} diff --git a/src/features/triage/hooks/useTriageViewModel.ts b/src/features/triage/hooks/useTriageViewModel.ts new file mode 100644 index 0000000..f3de858 --- /dev/null +++ b/src/features/triage/hooks/useTriageViewModel.ts @@ -0,0 +1,27 @@ +import type { AppSnapshot, Recommendation } from "../../../types/domain"; +import type { Artifact, Character } from "../../../types/domain"; + +export interface TriageViewModel { + artifactCount: number; + panelEyebrow: string; + panelTitle: string; + artifactCountLabel: string; + recommendationByArtifact: Map; +} + +interface UseTriageViewModelInput { + snapshot: AppSnapshot; +} + +export function useTriageViewModel({ snapshot }: UseTriageViewModelInput): TriageViewModel { + const recommendationByArtifact = new Map(snapshot.recommendations.map((entry) => [entry.artifactId, entry])); + const artifactCount = snapshot.artifacts.length; + + return { + artifactCount, + panelEyebrow: "Artifact triage", + panelTitle: "No-brainer decisions", + artifactCountLabel: `${artifactCount} scanned pieces`, + recommendationByArtifact, + }; +} diff --git a/src/features/triage/types.ts b/src/features/triage/types.ts new file mode 100644 index 0000000..633a764 --- /dev/null +++ b/src/features/triage/types.ts @@ -0,0 +1,11 @@ +import type { AppSnapshot, Artifact, Character, Recommendation } from "../../types/domain"; + +export interface TriageViewProps { + snapshot: AppSnapshot; +} + +export interface ArtifactRowProps { + artifact: Artifact; + recommendation?: Recommendation; + characters: Character[]; +} diff --git a/src/infrastructure/repositories/rendererBridgeRepositories.ts b/src/infrastructure/repositories/rendererBridgeRepositories.ts new file mode 100644 index 0000000..de606dd --- /dev/null +++ b/src/infrastructure/repositories/rendererBridgeRepositories.ts @@ -0,0 +1,2 @@ +export * from "./rendererBridgeRepositoryTypes"; +export { createRendererRepositories } from "./rendererBridgeRepositoryFactory"; diff --git a/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts b/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts new file mode 100644 index 0000000..4027367 --- /dev/null +++ b/src/infrastructure/repositories/rendererBridgeRepositoryFactory.ts @@ -0,0 +1,218 @@ +import { getAssistantBridge } from "../../services/assistantBridge"; +import { + type ArtifactRepositoryPort, + type AutomationRepositoryPort, + type CaptureRepositoryPort, + type RendererRepositories, + type RuntimeRepositoryPort, + type ReviewSampleRepositoryPort, + type LearningRepositoryPort, + type SnapshotRepositoryPort, + type OverlayRepositoryPort, + type ScanExportPort, +} from "./rendererBridgeRepositoryTypes"; +import type { AppSnapshot } from "../../types/domain"; +import type { + ArtifactStoreLoadResult, + ArtifactStoreSaveResult, + LoadScannerLearningRulesResult, + BooleanResult, + CaptureSourceInfo, + FocusGenshinResult, + RuntimeInfo, + SaveResultWithPath, + ScrollResult, + AutomationGuard, + ClickResult, + ReviewSampleListResult, + SaveScannerLearningRulesResult, +} from "../../types/global"; + +const EMPTY_SNAPSHOT: AppSnapshot | null = null; +const EMPTY_CAPTURE_SOURCE_LIST: CaptureSourceInfo[] = []; +const EMPTY_SAVE_RESULT: SaveResultWithPath = { ok: false, path: "" }; +const EMPTY_RUNTIME_INFO: RuntimeInfo = { + ok: false, + isElevated: false, + platform: "unknown", +}; +const EMPTY_ARTIFACT_STORE_LOAD_RESULT: ArtifactStoreLoadResult = { + ok: false, + artifacts: [], + total: 0, + path: "", +}; +const EMPTY_ARTIFACT_STORE_SAVE_RESULT: ArtifactStoreSaveResult = { + ok: false, + added: 0, + updated: 0, + total: 0, + path: "", +}; +const EMPTY_REVIEW_SAMPLES_RESULT: ReviewSampleListResult = { + ok: false, + samples: [], + total: 0, + path: "", +}; +const EMPTY_SCANNER_LEARNING_RULES_RESULT: LoadScannerLearningRulesResult = { + ok: false, + path: "", + rules: {}, +}; +const EMPTY_SCAN_STATUS_RESULT: SaveResultWithPath = { ok: false, path: "" }; +const EMPTY_SAVE_RULES_RESULT: SaveScannerLearningRulesResult = { + ok: false, + path: "", + rules: {}, + total: 0, +}; + +async function createBridgeSafeCall( + callback: () => Promise | TResult | null | undefined, + fallback: TResult, +): Promise { + try { + const result = await callback(); + return result == null ? fallback : result; + } catch { + return fallback; + } +} + +function emptyAutomationGuard(): AutomationGuard { + return { ok: false, escapePressed: false }; +} + +function emptyFocusGenshinResult(): FocusGenshinResult { + return { focused: false, alreadyForeground: false }; +} + +function emptyClickResult(): ClickResult { + return { + ok: false, + x: 0, + y: 0, + clicked: false, + moved: false, + focused: false, + inputBlocked: false, + }; +} + +function emptyScrollResult(): ScrollResult { + return { ok: false, notchesSent: 0, inputBlocked: false }; +} + +function emptyBooleanResult(): BooleanResult { + return { ok: false }; +} + +export function createRendererRepositories(): RendererRepositories | null { + const bridge = getAssistantBridge(); + if (!bridge) return null; + + const artifactRepo: ArtifactRepositoryPort = { + loadAll: () => + createBridgeSafeCall( + () => bridge.loadArtifacts(), + EMPTY_ARTIFACT_STORE_LOAD_RESULT, + ), + saveMany: (records) => + createBridgeSafeCall( + () => bridge.saveArtifacts(records), + EMPTY_ARTIFACT_STORE_SAVE_RESULT, + ), + }; + + const captureRepo: CaptureRepositoryPort = { + listSources: () => createBridgeSafeCall(() => bridge.listCaptureSources(), EMPTY_CAPTURE_SOURCE_LIST), + captureSource: (sourceId, delayMs, focusGenshin, options) => + bridge.captureSource(sourceId, delayMs, focusGenshin, options), + }; + + const runtimeRepo: RuntimeRepositoryPort = { + getRuntimeInfo: () => + createBridgeSafeCall( + () => bridge.getRuntimeInfo(), + EMPTY_RUNTIME_INFO, + ), + }; + + const reviewSamplesRepo: ReviewSampleRepositoryPort = { + loadSamples: (limit = 50) => + createBridgeSafeCall( + () => bridge.loadReviewSamples(limit), + EMPTY_REVIEW_SAMPLES_RESULT, + ), + saveSample: (sample) => + createBridgeSafeCall( + () => bridge.saveReviewSample(sample), + EMPTY_SCAN_STATUS_RESULT, + ), + }; + + const learningRepo: LearningRepositoryPort = { + loadRules: () => + createBridgeSafeCall( + () => bridge.loadScannerLearningRules(), + EMPTY_SCANNER_LEARNING_RULES_RESULT, + ), + saveRules: (rules) => + createBridgeSafeCall( + () => bridge.saveScannerLearningRules(rules), + EMPTY_SAVE_RULES_RESULT, + ), + }; + + const snapshotRepo: SnapshotRepositoryPort = { + load: () => createBridgeSafeCall(() => bridge.loadSnapshot(), EMPTY_SNAPSHOT), + save: (snapshot) => createBridgeSafeCall(() => bridge.saveSnapshot(snapshot), EMPTY_SAVE_RESULT), + runMockScan: () => createBridgeSafeCall(() => bridge.runMockScan(), EMPTY_SNAPSHOT), + publishScannerStatus: (status) => + createBridgeSafeCall(() => bridge.publishScannerStatus(status), EMPTY_SCAN_STATUS_RESULT), + }; + + const automationRepo: AutomationRepositoryPort = { + getAutomationGuard: () => + createBridgeSafeCall( + () => bridge.getAutomationGuard(), + emptyAutomationGuard(), + ), + focusGenshin: () => + createBridgeSafeCall( + () => bridge.focusGenshin(), + emptyFocusGenshinResult(), + ), + focusMainWindow: () => createBridgeSafeCall(() => bridge.focusMainWindow(), emptyBooleanResult()), + clickScreen: (x, y) => createBridgeSafeCall(() => bridge.clickScreen(x, y), emptyClickResult()), + scrollScreen: (notches, anchorX, anchorY) => + createBridgeSafeCall(() => bridge.scrollScreen(notches, anchorX, anchorY), emptyScrollResult()), + onCommand: bridge.onScannerCommand, + }; + + const overlayRepo: OverlayRepositoryPort = { + show: () => createBridgeSafeCall(() => bridge.showOverlay(), emptyBooleanResult()), + }; + + const exportRepo: ScanExportPort = { + exportGood: (payload) => createBridgeSafeCall(() => bridge.exportGood(payload), EMPTY_SAVE_RESULT), + }; + + return { + artifacts: artifactRepo, + capture: captureRepo, + runtime: runtimeRepo, + reviewSamples: reviewSamplesRepo, + learning: learningRepo, + snapshot: snapshotRepo, + automation: automationRepo, + overlay: overlayRepo, + export: exportRepo, + canExportGood: bridge.canExportGood, + canShowOverlay: bridge.canShowOverlay, + canAutoScan: bridge.canAutoScan, + canReviewSamples: bridge.canReviewSamples, + isAvailable: true, + }; +} diff --git a/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts b/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts new file mode 100644 index 0000000..5e8e536 --- /dev/null +++ b/src/infrastructure/repositories/rendererBridgeRepositoryTypes.ts @@ -0,0 +1,90 @@ +import type { + AutomationGuard, + CaptureOptions, + CaptureResult, + CaptureSourceInfo, + ClickResult, + ReviewSampleListResult, + ScannerStatusPayload, + ReviewSamplePayload, + GoodDatabase, + FocusGenshinResult, + RuntimeInfo, + LoadScannerLearningRulesResult, + SaveScannerLearningRulesResult, + ArtifactStoreLoadResult, + ArtifactStoreSaveResult, + BooleanResult, + SaveResultWithPath, + ScrollResult, + ScannerLearningRulePayload, +} from "../../types/global"; +import type { AppSnapshot } from "../../types/domain"; +import type { StoredArtifactRecord } from "../../types/storage"; + +export interface ArtifactRepositoryPort { + loadAll(): Promise; + saveMany(records: StoredArtifactRecord[]): Promise; +} + +export interface CaptureRepositoryPort { + listSources(): Promise; + captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise; +} + +export interface RuntimeRepositoryPort { + getRuntimeInfo(): Promise; +} + +export interface ReviewSampleRepositoryPort { + loadSamples(limit?: number): Promise; + saveSample(sample: ReviewSamplePayload): Promise; +} + +export interface LearningRepositoryPort { + loadRules(): Promise; + saveRules( + rules: ScannerLearningRulePayload, + ): Promise; +} + +export interface SnapshotRepositoryPort { + load(): Promise; + save(snapshot: AppSnapshot): Promise; + runMockScan(): Promise; + publishScannerStatus(status: ScannerStatusPayload): Promise; +} + +export interface AutomationRepositoryPort { + getAutomationGuard(): Promise; + focusGenshin(): Promise; + focusMainWindow(): Promise; + clickScreen(x: number, y: number): Promise; + scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; + onCommand(callback: (command: "start-auto" | "stop") => void): () => void; +} + +export interface OverlayRepositoryPort { + show(): Promise; +} + +export interface ScanExportPort { + exportGood(payload: GoodDatabase): Promise; +} + +export interface RendererRepositories { + artifacts: ArtifactRepositoryPort; + capture: CaptureRepositoryPort; + runtime: RuntimeRepositoryPort; + reviewSamples: ReviewSampleRepositoryPort; + learning: LearningRepositoryPort; + snapshot: SnapshotRepositoryPort; + automation: AutomationRepositoryPort; + overlay: OverlayRepositoryPort; + export: ScanExportPort; + canExportGood: boolean; + canShowOverlay: boolean; + canAutoScan: boolean; + canReviewSamples: boolean; + isAvailable: boolean; +} diff --git a/src/lib/artifactOcrParser.test.ts b/src/lib/artifactOcrParser.test.ts new file mode 100644 index 0000000..0a8f483 --- /dev/null +++ b/src/lib/artifactOcrParser.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "vitest"; +import { parseArtifactCandidate } from "./artifactOcrParser"; +import type { CaptureResult } from "../types/global"; + +function captureFromOcr(textById: Record): CaptureResult { + return { + id: "test", + name: "test capture", + width: 1920, + height: 1080, + dataUrl: "", + capturedAt: new Date(0).toISOString(), + ocr: Object.entries(textById).map(([id, text]) => ({ + id, + label: id, + text, + confidence: 80, + })), + }; +} + +describe("parseArtifactCandidate", () => { + it("matches artifact data from the generated Genshin data package", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "A Note in Spring's Lei\n| Sands of Eon Vi", + "artifact-main-stat": "Elemental Mastery\n187", + "artifact-substats": "+20\n+ ATK+29\n+ CRIT DMG+15.5%\n+ CRIT Rate+2.7%\nATK + 15", + "artifact-set-effects": "A Day Carved From Rising Winds\n2-Piece Set: ATK +18%.", + "artifact-footer": "WV Equipped: Citlali\naR 0", + })); + + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.level).toBe(20); + expect(parsed?.mainStat).toBe("Elemental Mastery"); + expect(parsed?.setName).toBe("A Day Carved From Rising Winds"); + expect(parsed?.equipped).toBe("Citlali"); + }); + + it("recognizes newer characters and artifact sets without hardcoded one-offs", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Moonlit Offering's Opulent Dr\n| Flower of Life 2", + "artifact-main-stat": "4,780\nAhhh", + "artifact-substats": "+ Elemental Mastery+54\n+ CRIT Rate+7.4%\n+ ATK+4.7%", + "artifact-set-effects": "Aubade of Morningstar and Moor\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "? Equipped: Ineffa\naR 0", + })); + + expect(parsed?.slot).toBe("Flower of Life"); + expect(parsed?.mainStat).toBe("HP"); + expect(parsed?.mainValue).toBe("4,780"); + expect(parsed?.setName).toBe("Aubade of Morningstar and Moon"); + expect(parsed?.equipped).toBe("Ineffa"); + }); + + it("normalizes noisy slot aliases from title OCR before matching", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Viridescent Venerer's Determination\nSands of Eon Vi", + "artifact-main-stat": "Energy Recharge\n51.8%", + "artifact-substats": "+ ATK+5.8%\n+ Elemental Mastery+37\n+ HP+11.7%\n+ ATK+54", + "artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%", + "artifact-footer": "Equipped: Sucrose", + })); + + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.mainStat).toBe("Energy Recharge"); + }); + + it("uses slot rules and fuzzy set matching for plume artifacts", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Pristine Plume of the Bles\n| Plume of Death", + "artifact-main-stat": "31 !\npr", + "artifact-substats": "+20\n+ CRIT DMG+7.0%\n+ DEF+30.6%\n+ Elemental Mastery+40\nATK +5.8%", + "artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.", + "artifact-footer": "Equipped: Aino\nBR", + })); + + expect(parsed?.slot).toBe("Plume of Death"); + expect(parsed?.mainStat).toBe("ATK"); + expect(parsed?.mainValue).toBe("311"); + expect(parsed?.setName).toBe("Silken Moon's Serenade"); + expect(parsed?.equipped).toBe("Aino"); + }); + + it("keeps goblet elemental damage main stats separate from crit substats", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Deep Gallery's Bestowed Banquet\nGoblet of Eonothem", + "artifact-main-stat": "Cryo DMG Bonus\n46.6%", + "artifact-substats": "+ CRIT Rate+6.6%\n+ CRIT DMG+12.4%\n+ HP+269\n+ ATK+16.3%", + "artifact-set-effects": "Finale of the Deep Galleries:\n2-Piece Set: Cryo DMG Bonus +15%", + "artifact-footer": "Equipped: Skirk", + })); + + expect(parsed?.slot).toBe("Goblet of Eonothem"); + expect(parsed?.mainStat).toBe("Cryo DMG Bonus"); + expect(parsed?.mainValue).toBe("46.6%"); + }); + + it("does not let substats override circlet crit main stats", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Holy Crown of the Believer\nCirclet of Logos", + "artifact-main-stat": "CRIT Rate\n31.1%", + "artifact-substats": "+ ATK+4.7%\n+ Elemental Mastery+56\n+ DEF+37\n+ Energy Recharge+11.7%", + "artifact-set-effects": "Silken Moon's Serenade:\n2-Piece Set: Energy Recharge +20%.", + "artifact-footer": "Equipped: Chongyun", + })); + + expect(parsed?.slot).toBe("Circlet of Logos"); + expect(parsed?.mainStat).toBe("CRIT Rate"); + expect(parsed?.mainValue).toBe("31.1%"); + }); + + it("keeps percent substats distinct from flat ATK HP and DEF", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Beast Tamer's Talisman\nFlower of Life", + "artifact-main-stat": "HP\n4,780", + "artifact-substats": "+ HP+16.3%\n+ CRIT DMG+7.0%\n+ Energy Recharge+6.5%\n+ Elemental Mastery+68", + "artifact-set-effects": "Scroll of the Hero of Cinder City:\n2-Piece Set: When a nearby party member triggers a Nightsoul Burst", + "artifact-footer": "Equipped: Citlali", + })); + + expect(parsed?.substats).toContain("HP%+16.3%"); + expect(parsed?.setName).toBe("Scroll of the Hero of Cinder City"); + }); + + it("falls back from artifact piece name to the owning set", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Holy Crown of the Believer\nCirclet of Logos", + "artifact-main-stat": "CRIT Rate\n31.1%", + "artifact-substats": "+ ATK+4.7%\n+ Elemental Mastery+56\n+ DEF+37", + "artifact-set-effects": "unreadable noisy set text", + "artifact-footer": "Equipped: Chongyun", + })); + + expect(parsed?.setName).toBe("Silken Moon's Serenade"); + }); + + it("falls back from artifact piece name to the owning slot", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Viridescent Venerer's Determination", + "artifact-main-stat": "Energy Recharge\n51.8%", + "artifact-substats": "+ ATK+5.8%\n+ Elemental Mastery+37\n+ HP+11.7%\n+ ATK+54", + "artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%", + "artifact-footer": "Equipped: Sucrose", + })); + + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.setName).toBe("Viridescent Venerer"); + }); + + it("promotes ATK HP and DEF main stats to percent variants when the value is percent", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Hourglass of Thunder\nSands of Eon", + "artifact-main-stat": "ATK\n40.7%", + "artifact-substats": "+17\n+ CRIT DMG+14.8%\n+ Elemental Mastery+21\n+ ATK+53\n+ DEF+19", + "artifact-set-effects": "Thundering Fury:\n2-Piece Set: Electro DMG Bonus +15%", + "artifact-footer": "Equipped: Fischl", + })); + + expect(parsed?.level).toBe(17); + expect(parsed?.slot).toBe("Sands of Eon"); + expect(parsed?.mainStat).toBe("ATK%"); + expect(parsed?.mainValue).toBe("40.7%"); + }); + + it("keeps the main value even when level helps but the stat family is still ambiguous", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Myths of the Night Realm\nSands of Eon", + "artifact-main-stat": "30.8%", + "artifact-substats": "+12\n+ Elemental Mastery+16\n+ CRIT DMG+6.2%\n+ DEF+42\n+ HP+9.9%", + "artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing", + "artifact-footer": "Equipped: Sandrone", + })); + + expect(parsed?.level).toBe(12); + expect(parsed?.mainValue).toBe("30.8%"); + expect(parsed?.mainStat).toBe("Unknown main stat"); + }); + + it("cleans equipped footer noise before fuzzy-matching the character", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Gladiator's Nostalgia\nFlower of Life", + "artifact-main-stat": "HP\n4,780", + "artifact-substats": "+ Energy Recharge+11.0%\n+ ATK+9.9%\n+ HP+14.6%\n+ CRIT DMG+12.4%", + "artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%", + "artifact-footer": "1 gv 0RY If tha anuninnina\nJl Equipped: Bennett\nCEE", + })); + + expect(parsed?.equipped).toBe("Bennett"); + }); + + it("recognizes ATK percent main stats from OCR text on non-fixed slots", () => { + const sands = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Myths of the Night Realm\nSands of Eon", + "artifact-main-stat": "ATK\n30.8%", + "artifact-substats": "+ Elemental Mastery+16\n+ CRIT DMG+6.2%\n+ DEF+42\n+ HP+9.9%", + "artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing", + "artifact-footer": "Equipped: Sandrone", + })); + const circlet = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Maiden's Fading Beauty\nCirclet of Logos", + "artifact-main-stat": "ATK\n46.6%", + "artifact-substats": "+ ATK+29\n+ CRIT DMG+10.9%\n+ CRIT Rate+7.0%", + "artifact-set-effects": "Maiden Beloved:\n2-Piece Set: Character Healing Effectiveness +15%", + "artifact-footer": "Equipped: Qiqi", + })); + const goblet = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Viridescent Venerer's Vessel\nGoblet of Eonothem", + "artifact-main-stat": "ATK\n46.6%", + "artifact-substats": "+ HP+209\n+ CRIT DMG+25.6%\n+ Elemental Mastery+37", + "artifact-set-effects": "Viridescent Venerer:\n2-Piece Set: Anemo DMG Bonus +15%", + "artifact-footer": "Equipped: Ganyu", + })); + + expect(sands?.mainStat).toBe("ATK%"); + expect(sands?.mainValue).toBe("30.8%"); + expect(circlet?.mainStat).toBe("ATK%"); + expect(circlet?.mainValue).toBe("46.6%"); + expect(goblet?.mainStat).toBe("ATK%"); + expect(goblet?.mainValue).toBe("46.6%"); + }); + it("normalizes garbled percent punctuation in stat values", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Pristine Circlet of the Bles\n| Circlet of Logos", + "artifact-main-stat": "ATK\n46", + "artifact-substats": "+ Elemental Mastery+20" + String.fromCharCode(0x00b7) + "5%\n+ CRIT DMG+6.3%\n+ ATK+19\n+ DEF+12", + "artifact-set-effects": "Gladiator's Finale:\n2-Piece Set: ATK +18%", + "artifact-footer": "Equipped: Aino", + })); + + expect(parsed?.slot).toBe("Circlet of Logos"); + expect(parsed?.substats).toContain("Elemental Mastery+20.5%"); + expect(parsed?.substats).toContain("CRIT DMG+6.3%"); + }); + + it("handles garbled quotation marks in copied text without failing slot and set parsing", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Aloy's " + String.fromCharCode(0x201c) + "Gift" + String.fromCharCode(0x201d) + "\n| Circlet of Logos", + "artifact-main-stat": "Elemental Mastery\n46.6%", + "artifact-substats": "+ ATK+4.7\n+ CRIT DMG+12.4%\n+ Energy Recharge+8.1%\n+ HP+11", + "artifact-set-effects": "Maiden" + String.fromCharCode(0x2019) + "s Beloved:\n2-Piece Set: Energy Recharge +16%", + "artifact-footer": "Equipped: Shenhe", + })); + + expect(parsed?.slot).toBe("Circlet of Logos"); + expect(parsed?.mainStat).toBe("Elemental Mastery"); + expect(parsed?.setName).toBe("Maiden Beloved"); + }); + + it("recovers substats that overflow into the set effects OCR crop", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Moonlit Offering's Opulent Dr\nFlower of Life", + "artifact-main-stat": "HP\n4,780", + "artifact-substats": "+ CRIT DMG+13.2%", + "artifact-set-effects": "+ HP+15.7%\n+ DEF+12.4%\n+ Energy Recharge+5.2%\nAubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "Equipped: Venti", + })); + + expect(parsed?.substats).toEqual([ + "CRIT DMG+13.2%", + "HP%+15.7%", + "DEF%+12.4%", + "Energy Recharge+5.2%", + ]); + expect(parsed?.fields.substats.confidence).toBe(96); + }); + + it("keeps a percent main value even when OCR misses the main stat label", () => { + const parsed = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Moonlit Offering's Final\nSands of Eon", + "artifact-main-stat": "46.6%\n1S 2.5.8 J\nSe", + "artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%", + "artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "", + })); + + expect(parsed?.mainStat).toBe("Unknown main stat"); + expect(parsed?.mainValue).toBe("46.6%"); + expect(parsed?.fields.mainValue.confidence).toBeGreaterThanOrEqual(80); + }); + + it("derives unique main stats from slot and value when the OCR label is missing", () => { + const sands = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Revelation's Toll\nSands of Eon", + "artifact-main-stat": "58.3%\n1S 2.5.8 J\nSe", + "artifact-substats": "+ CRIT Rate+14.0%\n+ Elemental Mastery+33\n+ Energy Recharge+6.5%", + "artifact-set-effects": "Night of the Sky's Unveiling:\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "Equipped: Zibai", + })); + const circlet = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Crown of the Saints\nCirclet of Logos", + "artifact-main-stat": "62.2%", + "artifact-substats": "+ ATK+18\n+ ATK+10.5%\n+ DEF+17.5%", + "artifact-set-effects": "Obsidian Codex:\n2-Piece Set: While the equipping character is in Nightsoul's Blessing", + "artifact-footer": "Equipped: Aino", + })); + + expect(sands?.mainValue).toBe("58.3%"); + expect(sands?.mainStat).toBe("DEF%"); + expect(circlet?.mainValue).toBe("62.2%"); + expect(circlet?.mainStat).toBe("CRIT DMG"); + }); + + it("recovers unique max-value mains from noisy digit fragments but keeps ambiguous 46-values conservative", () => { + const circlet = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Moonlit Offering's Silver Crown\nCirclet of Logos", + "artifact-main-stat": "6\n2 D", + "artifact-substats": "+ ATK+29\n+ ATK+5.8%\n+ Elemental Mastery+77\n- DEF+5.1%", + "artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "", + })); + const sands = parseArtifactCandidate(captureFromOcr({ + "artifact-title": "Moonlit Offering's Final Hour\nSands of Eon", + "artifact-main-stat": "46.6% |\nPEE", + "artifact-substats": "+ ATK+19\n+ Energy Recharge+6.5%\n+ CRIT DMG+18.7%\n- DEF+53", + "artifact-set-effects": "Aubade of Morningstar and Moon\n2-Piece Set: Increases Elemental Mastery by 80.", + "artifact-footer": "", + })); + + expect(circlet?.mainValue).toBe("62.2%"); + expect(circlet?.mainStat).toBe("CRIT DMG"); + expect(sands?.mainValue).toBe("46.6%"); + expect(sands?.mainStat).toBe("Unknown main stat"); + }); +}); diff --git a/src/lib/artifactOcrParser.ts b/src/lib/artifactOcrParser.ts new file mode 100644 index 0000000..79ff8cf --- /dev/null +++ b/src/lib/artifactOcrParser.ts @@ -0,0 +1,584 @@ +import type { CaptureResult } from "../types/global.js"; +import { + allowedMainStatsForSlot, + canonicalStatName, + fixedMainStatBySlot, + globalMainStats, + globalSubstats, + knownCharacters, + knownPieceNames, + knownSets, + mainStatValueReferences, + normalizeCharacterAlias, + normalizePieceAlias, + normalizeSetAlias, + normalizeSlotAlias, + pieceToSet, + pieceToSlot, + slotNames, + textReplacements, +} from "./genshinData.js"; +import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js"; + +type MainStatValueReference = { stat: string; base: number; max: number }; + +export interface ParsedField { + value: string; + confidence: number; + source: "ocr" | "database" | "derived" | "fallback" | "missing"; +} + +export interface ParsedArtifactCandidate { + name: string; + slot: string; + level: number; + mainStat: string; + mainValue: string; + substats: string[]; + setName: string; + equipped: string; + confidence: number; + notes: string[]; + fields: { + name: ParsedField; + slot: ParsedField; + level?: ParsedField; + mainStat: ParsedField; + mainValue: ParsedField; + setName: ParsedField; + equipped: ParsedField; + substats: ParsedField; + }; +} + +const mainStatNames = sortLongestFirst(globalMainStats); +const substatNames = sortLongestFirst(globalSubstats); + +export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArtifactCandidate | null { + if (!capture?.ocr?.length) return null; + + const byId = new Map( + (capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => [entry.id, normalizeText(entry.text)]), + ); + const allText = normalizeText((capture.ocr ?? []).map((entry: (typeof capture.ocr)[number]) => entry.text).join("\n")); + const titleText = byId.get("artifact-title") ?? ""; + const mainText = byId.get("artifact-main-stat") ?? ""; + const substatText = byId.get("artifact-substats") ?? ""; + const setText = byId.get("artifact-set-effects") ?? ""; + const footerText = byId.get("artifact-footer") ?? ""; + + const nameField = parseArtifactName(titleText); + const slotField = parseSlot(titleText + "\n" + allText, nameField.value); + const levelField = parseArtifactLevel(substatText + "\n" + mainText + "\n" + allText); + const parsedLevel = levelField.value ? Number.parseInt(levelField.value, 10) : null; + const level = parsedLevel ?? 0; + let mainStatField = inferMainStat(slotField.value, mainText); + let mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel); + if (!mainStatField.value && mainValueField.value) { + const inferredFromValue = inferMainStatFromValue(slotField.value, mainValueField.value, mainText, parsedLevel); + if (inferredFromValue.value) { + mainStatField = inferredFromValue; + mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel); + } + } + if (!mainStatField.value && mainValueField.value) { + const exactReferenceMatch = deriveMainStatFromExactReferenceValue(slotField.value, mainValueField.value, mainText, parsedLevel); + if (exactReferenceMatch.value) { + mainStatField = exactReferenceMatch; + mainValueField = findMainValue(mainText, mainStatField.value, slotField.value, parsedLevel); + } + } + if ((!mainStatField.value || !mainValueField.value) && slotField.value) { + const noisyReferenceMatch = deriveMainStatAndValueFromNoisyReference(slotField.value, mainText, parsedLevel); + if (!mainStatField.value && noisyReferenceMatch.mainStat.value) { + mainStatField = noisyReferenceMatch.mainStat; + } + if (!mainValueField.value && noisyReferenceMatch.mainValue.value) { + mainValueField = noisyReferenceMatch.mainValue; + } + } + const substats = parseSubstats([substatText, leadingSetEffectText(setText)].filter(Boolean).join("\n")); + const substatsField = field(substats.join(", "), substats.length >= 4 ? 96 : substats.length >= 3 ? 82 : substats.length > 0 ? 55 : 0, substats.length ? "ocr" : "missing"); + const setField = parseSetName(setText, nameField.value); + const equippedField = parseEquippedCharacter(footerText + "\n" + allText); + const notes: string[] = []; + + if (!nameField.value) notes.push("Artifact name not confidently parsed."); + if (nameField.value && nameField.confidence < 84) notes.push("Artifact name was fuzzy-matched; review if this piece matters."); + if (!slotField.value) notes.push("Slot not confidently parsed."); + if (!levelField.value) notes.push("Artifact level not confidently parsed."); + if (!mainStatField.value) notes.push("Main stat not confidently parsed."); + if (!mainValueField.value) notes.push("Main stat value not confidently parsed."); + if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning."); + if (!setField.value) notes.push("Set name not confidently parsed."); + + for (const [label, parsedField] of Object.entries({ + name: nameField, + slot: slotField, + level: levelField, + mainStat: mainStatField, + mainValue: mainValueField, + set: setField, + equipped: equippedField, + }) as Array<[string, ParsedField]>) { + if (parsedField.value && parsedField.confidence < 70) notes.push(`${label} confidence is low; review before trusting it.`); + } + + const fields = { + name: nameField, + slot: slotField, + level: levelField, + mainStat: mainStatField, + mainValue: mainValueField, + setName: setField, + equipped: equippedField, + substats: substatsField, + }; + const confidence = Math.round(Object.values(fields).reduce((sum, parsedField) => sum + parsedField.confidence, 0) / Object.values(fields).length); + + return { + name: nameField.value || "Unknown artifact", + slot: slotField.value || "Unknown slot", + level, + mainStat: mainStatField.value || "Unknown main stat", + mainValue: mainValueField.value || "?", + substats, + setName: setField.value || "Unknown set", + equipped: equippedField.value || "Not detected", + confidence, + notes: [...new Set(notes)], + fields, + }; +} + +function parseArtifactName(titleText: string): ParsedField { + const titleLines = titleText + .split("\n") + .map((line) => cleanupOcrLabel(line)) + .filter(Boolean); + + for (const line of titleLines) { + const alias = normalizePieceAlias(line); + if (alias) return field(alias, 96, "database"); + } + + const knownPiece = fuzzyFindKnown(titleText, knownPieceNames, 0.72); + if (knownPiece) return field(knownPiece.value, Math.round(knownPiece.score * 100), knownPiece.score >= 0.98 ? "database" : "fallback"); + + const fallback = firstUsefulLine(titleText, slotNames); + return fallback ? field(fallback, 50, "fallback") : field("", 0, "missing"); +} + +function parseSlot(text: string, artifactName: string): ParsedField { + const slotLines = text + .split("\n") + .map((line) => cleanupOcrLabel(line)) + .filter(Boolean); + + for (const line of slotLines) { + const alias = normalizeSlotAlias(line); + if (alias) return field(alias, 96, "ocr"); + } + + const directSlot = fuzzyFindKnown(text, slotNames, 0.68); + if (directSlot) return field(directSlot.value, Math.round(directSlot.score * 100), directSlot.score >= 0.95 ? "ocr" : "fallback"); + + const derivedSlot = artifactName ? pieceToSlot.get(artifactName) ?? "" : ""; + return derivedSlot ? field(derivedSlot, 94, "derived") : field("", 0, "missing"); +} + +function parseSetName(setText: string, artifactName: string): ParsedField { + const setFromPiece = artifactName ? pieceToSet.get(artifactName) : undefined; + const candidateLines = setText + .split("\n") + .map((line) => line.trim().replace(/:$/, "")) + .filter((line) => line.length > 3 && !/^\d/.test(line) && !/piece set/i.test(line)); + + for (const line of candidateLines) { + const alias = normalizeSetAlias(line); + if (alias) return field(alias, 96, "database"); + } + + const directLine = candidateLines.find((line) => line.length > 8); + + const setFromText = fuzzyFindKnown(`${directLine ?? ""}\n${setText}`, knownSets, 0.64); + if (setFromText && (!setFromPiece || setFromText.score >= 0.78)) return field(setFromText.value, Math.round(setFromText.score * 100), setFromText.score >= 0.95 ? "ocr" : "fallback"); + if (setFromPiece) return field(setFromPiece, 92, "derived"); + return setFromText ? field(setFromText.value, Math.round(setFromText.score * 100), "fallback") : field("", 0, "missing"); +} + +function parseArtifactLevel(text: string): ParsedField { + const lines = normalizeText(text) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + const match = line.match(/^\+\s*(20|1[0-9]|[0-9])\b/); + if (match?.[1]) return field(match[1], 96, "ocr"); + } + + const anywhere = normalizeText(text).match(/(?:^|\s)\+\s*(20|1[0-9]|[0-9])\b/); + return anywhere?.[1] ? field(anywhere[1], 84, "ocr") : field("", 0, "missing"); +} + +function normalizeText(text: string) { + return applyTextReplacements(text) + .replace(/[\u201c\u201d]/g, '"') + .replace(/[\u2019]/g, "'") + .replace(/[\u00B7]/g, ".") + .replace(/\r/g, "") + .replace(/[|]/g, "I") + .replace(/\s+\n/g, "\n") + .trim(); +} + +function applyTextReplacements(text: string) { + return Object.entries(textReplacements as Record).reduce( + (current, [from, to]) => current.replace(new RegExp(escapeRegex(from), "gi"), to), + text, + ); +} + +function firstUsefulLine(text: string, rejectIncludes: string[]) { + return text + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 5 && !rejectIncludes.some((reject) => simplifyForMatch(line).includes(simplifyForMatch(reject)))) ?? ""; +} + +function findMainValue(text: string, mainStat: string, slot: string, level: number | null): ParsedField { + const cleaned = text.replace(/\b20\b/g, " ").replace(/[Oo]/g, "0"); + const percentValue = extractPercentValue(cleaned); + let ocrField = field("", 0, "missing"); + if (percentValue && !mainStat) ocrField = field(percentValue, 84, "ocr"); + if (percentValue && isPercentMainStat(mainStat)) ocrField = field(percentValue, 96, "ocr"); + const flat = /\b([0-9]{1,2},[0-9]{3}|[0-9]{2,4})\b/.exec(cleaned); + if (!ocrField.value && flat) ocrField = field(flat[1], 92, "ocr"); + + const derivedField = deriveMainValueFromLevel(slot, mainStat, level); + if (!ocrField.value) return derivedField; + if (!derivedField.value) return ocrField; + + if (["HP", "ATK", "DEF", "Elemental Mastery"].includes(mainStat)) { + const ocrNumeric = parseNumericValue(ocrField.value); + const derivedNumeric = parseNumericValue(derivedField.value); + const derivedIntDigits = String(Math.round(derivedNumeric)).length; + const ocrIntDigits = String(Math.round(ocrNumeric)).length; + if (!Number.isFinite(ocrNumeric) || ocrNumeric < derivedNumeric * 0.5 || ocrIntDigits + 1 < derivedIntDigits) return derivedField; + if (Math.abs(ocrNumeric - derivedNumeric) >= Math.max(4, derivedNumeric * 0.22)) return derivedField; + } + + return ocrField; +} + +function extractPercentValue(text: string) { + const lineMatches = text + .split("\n") + .map((line) => line.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/)) + .filter((match): match is RegExpMatchArray => Boolean(match)); + + const preferred = lineMatches[0] ?? text.match(/([0-9]{1,3}(?:[.,:\u00B7][0-9])?)\s*%/); + if (!preferred?.[1]) return ""; + return `${preferred[1].replace(/[:,\u00B7]/g, ".")}%`; +} + +function inferMainStat(slot: string, text: string): ParsedField { + if (fixedMainStatBySlot[slot]) return field(fixedMainStatBySlot[slot], 100, "derived"); + + const direct = findDirectMainStat(text); + if (direct) return field(promotePercentVariant(direct, text), 94, "ocr"); + + const allowedForSlot = sortLongestFirst(allowedMainStatsForSlot(slot)); + const fuzzyAllowed = fuzzyFindKnown(text, allowedForSlot, 0.68); + if (fuzzyAllowed) return field(promotePercentVariant(fuzzyAllowed.value, text), Math.round(fuzzyAllowed.score * 100), "fallback"); + + const fuzzy = fuzzyFindKnown(text, mainStatNames, 0.72); + return fuzzy ? field(promotePercentVariant(fuzzy.value, text), Math.round(fuzzy.score * 100), "fallback") : field("", 0, "missing"); +} + +function findDirectMainStat(text: string) { + const compact = simplifyForMatch(text); + const hasPercentValue = /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text); + const priority = [ + "Physical DMG Bonus", + "Elemental Mastery", + "Energy Recharge", + "Healing Bonus", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "CRIT Rate", + "CRIT DMG", + "ATK%", + "HP%", + "DEF%", + "ATK", + "HP", + "DEF", + ]; + + const direct = priority.find((stat) => compact.includes(simplifyForMatch(stat))) ?? ""; + if (direct) return direct; + + if (hasPercentValue && /(^|\s)atk(\s|$)/i.test(text)) return "ATK%"; + if (hasPercentValue && /(^|\s)hp(\s|$)/i.test(text)) return "HP%"; + if (hasPercentValue && /(^|\s)def(\s|$)/i.test(text)) return "DEF%"; + + return ""; +} + +function inferMainStatFromValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField { + const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", "")); + if (!Number.isFinite(numeric)) return field("", 0, "missing"); + + const candidates = getSlotMainStatValueReferences(slot) + .map((candidate) => { + const expected = expectedMainStatValue(candidate, level); + return { + ...candidate, + expected, + delta: Math.abs(expected - numeric), + }; + }) + .sort((left, right) => left.delta - right.delta); + + const best = candidates[0]; + const tolerance = toleranceForMainStatValue(best?.stat ?? "", mainValue); + const competing = candidates.filter((candidate) => candidate.delta <= tolerance); + if (best && competing.length === 1) { + return field(promotePercentVariant(best.stat, text), Math.max(72, Math.round(92 - best.delta * 24)), "derived"); + } + + return field("", 0, "missing"); +} + +function deriveMainStatFromExactReferenceValue(slot: string, mainValue: string, text: string, level: number | null): ParsedField { + const numeric = Number.parseFloat(normalizeMainValue(mainValue).replace("%", "")); + if (!Number.isFinite(numeric)) return field("", 0, "missing"); + + const matches = getSlotMainStatValueReferences(slot) + .filter((candidate) => Math.abs(expectedMainStatValue(candidate, level) - numeric) <= toleranceForMainStatValue(candidate.stat, mainValue)) + .sort((left, right) => Math.abs(expectedMainStatValue(left, level) - numeric) - Math.abs(expectedMainStatValue(right, level) - numeric)); + + if (matches.length !== 1) return field("", 0, "missing"); + return field(promotePercentVariant(matches[0].stat, text || mainValue), 88, "derived"); +} + +function deriveMainStatAndValueFromNoisyReference(slot: string, text: string, level: number | null) { + const fragment = text.replace(/[^\d]/g, ""); + if (fragment.length < 2) { + return { + mainStat: field("", 0, "missing"), + mainValue: field("", 0, "missing"), + }; + } + + const candidates = getSlotMainStatValueReferences(slot) + .map((candidate) => { + const formattedValue = formatExpectedValue(candidate, level); + const digits = formattedValue.replace(/[^\d]/g, ""); + return { + candidate, + formattedValue, + score: digitReferenceScore(fragment, digits), + }; + }) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score); + + const best = candidates[0]; + const second = candidates[1]; + if (!best) { + return { + mainStat: field("", 0, "missing"), + mainValue: field("", 0, "missing"), + }; + } + if (second && best.score - second.score < 0.2) { + return { + mainStat: field("", 0, "missing"), + mainValue: field("", 0, "missing"), + }; + } + + return { + mainStat: field(promotePercentVariant(best.candidate.stat, text), Math.round(72 + best.score * 18), "derived"), + mainValue: field(best.formattedValue, Math.round(78 + best.score * 14), "derived"), + }; +} + +function parseSubstats(text: string) { + const normalized = normalizeText(text) + .replace(/CRIT\s*DMG/gi, "CRIT DMG") + .replace(/CRIT\s*Rate/gi, "CRIT Rate") + .replace(/Energy\s*Recharge/gi, "Energy Recharge") + .replace(/Elemental\s*Mastery/gi, "Elemental Mastery") + .replace(/([A-Z]{2,4})\s*\+/g, "$1+"); + + const statPattern = new RegExp(`(${substatNames.map(escapeRegex).join("|")})\\s*\\+\\s*([0-9]+(?:\\.[0-9])?%?)`, "gi"); + const results: string[] = []; + let match: RegExpExecArray | null; + while ((match = statPattern.exec(normalized))) { + const stat = canonicalSubstatName(match[1], match[2]); + results.push(`${stat}+${match[2]}`); + } + return [...new Set(results)].slice(0, 4); +} + +function leadingSetEffectText(text: string) { + const lines = normalizeText(text).split("\n"); + const result: string[] = []; + for (const line of lines) { + if (/^\s*\d+\s*-\s*Piece Set/i.test(line) || /piece set/i.test(line)) break; + result.push(line); + } + return result.join("\n"); +} + +function canonicalSubstatName(rawStat: string, rawValue: string) { + const cleaned = rawStat.replace(/\s+/g, " ").trim(); + const known = canonicalStatName(cleaned) || fuzzyFindKnown(cleaned, substatNames, 0.8)?.value || cleaned; + const canonical = canonicalStatName(known); + if (["ATK", "HP", "DEF"].includes(canonical) && rawValue.includes("%")) return `${canonical}%`; + return canonical; +} + +function parseEquippedCharacter(text: string): ParsedField { + const equippedLine = text + .split("\n") + .map((line) => line.trim()) + .find((line) => /equipped/i.test(line)); + + if (!equippedLine) return field("Not detected", 45, "missing"); + + const afterLabel = cleanupCharacterNoise(equippedLine); + const alias = afterLabel ? normalizeCharacterAlias(afterLabel) : ""; + if (alias) return field(alias, 96, "database"); + const known = afterLabel ? fuzzyFindKnown(afterLabel, knownCharacters, 0.6) : null; + if (known) return field(known.value, Math.round(known.score * 100), known.score >= 0.95 ? "ocr" : "fallback"); + + const fallbackSearch = cleanupCharacterNoise(text); + const wholeTextMatch = fallbackSearch ? fuzzyFindKnown(fallbackSearch, knownCharacters, 0.88) : null; + if (wholeTextMatch) return field(wholeTextMatch.value, Math.round(wholeTextMatch.score * 100), "fallback"); + + return afterLabel ? field(afterLabel, 50, "fallback") : field("Not detected", 45, "missing"); +} + +function field(value: string, confidence: number, source: ParsedField["source"]): ParsedField { + return { value, confidence: Math.max(0, Math.min(100, confidence)), source }; +} + +function isPercentMainStat(stat: string) { + return /%|Rate|DMG|Bonus|Recharge/i.test(stat); +} + +function promotePercentVariant(stat: string, text: string) { + if (["ATK", "HP", "DEF"].includes(stat) && /[0-9]{1,3}(?:\.[0-9])?\s*%/.test(text)) return `${stat}%`; + return stat; +} + +function getSlotMainStatValueReferences(slot: string): MainStatValueReference[] { + const valueReferences = mainStatValueReferences[slot]; + return Array.isArray(valueReferences) ? valueReferences as MainStatValueReference[] : []; +} + +function normalizeMainValue(value: string) { + return value.replace(/[:,\u00B7]/g, ".").replace(/\s+/g, ""); +} + +function deriveMainValueFromLevel(slot: string, mainStat: string, level: number | null): ParsedField { + if (!mainStat) return field("", 0, "missing"); + const reference = getSlotMainStatValueReferences(slot).find((candidate) => candidate.stat === mainStat); + if (!reference) return field("", 0, "missing"); + if (level === null) { + if (slot === "Flower of Life" || slot === "Plume of Death") return field(formatExpectedValue(reference, null), 72, "derived"); + return field("", 0, "missing"); + } + if (level < 0 || level > 20) return field("", 0, "missing"); + return field(formatExpectedValue(reference, level), 88, "derived"); +} + +function expectedMainStatValue(reference: { base: number; max: number }, level: number | null) { + if (level === null || !Number.isFinite(level)) return reference.max; + const clampedLevel = Math.max(0, Math.min(20, level)); + return reference.base + (reference.max - reference.base) * (clampedLevel / 20); +} + +function formatExpectedValue(reference: { stat: string; base: number; max: number }, level: number | null) { + const numeric = expectedMainStatValue(reference, level); + const rounded = isPercentMainStat(reference.stat) + ? roundTo(numeric, 1) + : Math.round(numeric); + return isPercentMainStat(reference.stat) ? `${rounded.toFixed(1)}%` : rounded.toLocaleString("en-US"); +} + +function toleranceForMainStatValue(stat: string, mainValue: string) { + if (mainValue.includes("%") || isPercentMainStat(stat)) return 0.45; + if (stat === "Elemental Mastery") return 2.5; + return 6; +} + +function parseNumericValue(value: string) { + return Number.parseFloat( + value + .replace(/,/g, "") + .replace("%", "") + .trim(), + ); +} + +function roundTo(value: number, digits: number) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function digitReferenceScore(fragment: string, referenceDigits: string) { + if (!fragment || !referenceDigits) return 0; + if (fragment === referenceDigits) return 1; + if (referenceDigits.startsWith(fragment)) { + return Math.max(0, 0.95 - (referenceDigits.length - fragment.length) * 0.08); + } + if (fragment.length >= 3 && isDigitSubsequence(fragment, referenceDigits)) { + return 0.72; + } + return 0; +} + +function isDigitSubsequence(fragment: string, referenceDigits: string) { + let index = 0; + for (const char of referenceDigits) { + if (char === fragment[index]) index++; + if (index >= fragment.length) return true; + } + return false; +} + +function cleanupOcrLabel(line: string) { + return line + .replace(/^[^A-Za-z]+/, "") + .replace(/[^A-Za-z'\s]+$/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function cleanupCharacterNoise(text: string) { + return text + .replace(/^.*?equipped\s*:?\s*/i, "") + .replace(/[^A-Za-z'\-\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function sortLongestFirst(values: string[]) { + return [...values].sort((a, b) => b.length - a.length); +} + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + diff --git a/src/lib/artifactStore.test.ts b/src/lib/artifactStore.test.ts new file mode 100644 index 0000000..a66b556 --- /dev/null +++ b/src/lib/artifactStore.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import { + hashId, + isReviewOnlyArtifactSource, + resolveStoredArtifactSource, + sessionSignature, + storeSignature, + storedArtifactStrength, + toStoredArtifact, +} from "./artifactStore"; + +function candidate(overrides: Partial = {}): ParsedArtifactCandidate { + const field = { value: "", confidence: 90, source: "ocr" as const }; + return { + name: "Gladiator's Nostalgia", + slot: "Flower of Life", + level: 20, + mainStat: "HP", + mainValue: "4,780", + substats: ["CRIT Rate+3.9%", "ATK%+5.8%", "Energy Recharge+5.2%", "ATK+19"], + setName: "Gladiator's Finale", + equipped: "Hu Tao", + confidence: 91, + notes: [], + fields: { + name: field, + slot: field, + level: field, + mainStat: field, + mainValue: field, + setName: field, + equipped: field, + substats: field, + }, + ...overrides, + }; +} + +describe("artifactStore", () => { + it("keeps the session signature sensitive to the equipped character", () => { + const a = sessionSignature(candidate()); + const b = sessionSignature(candidate({ equipped: "Xiangling" })); + expect(a).not.toBe(b); + }); + + it("ignores the equipped character in the store signature", () => { + const a = storeSignature(candidate()); + const b = storeSignature(candidate({ equipped: "Xiangling" })); + expect(a).toBe(b); + }); + + it("changes the store signature when substats change", () => { + const a = storeSignature(candidate()); + const b = storeSignature(candidate({ substats: ["CRIT Rate+7.8%", "ATK%+5.8%", "Energy Recharge+5.2%", "ATK+19"] })); + expect(a).not.toBe(b); + }); + + it("changes the store signature when the level changes", () => { + const a = storeSignature(candidate({ level: 20 })); + const b = storeSignature(candidate({ level: 16 })); + expect(a).not.toBe(b); + }); + + it("produces a stable id for the same artifact", () => { + const recordA = toStoredArtifact(candidate(), "auto-scan", false); + const recordB = toStoredArtifact(candidate({ equipped: "Xiangling" }), "manual-scan", true); + expect(recordA.id).toBe(recordB.id); + expect(recordA.id).toBe(hashId(storeSignature(candidate()))); + }); + + it("copies parsed values into the stored record", () => { + const record = toStoredArtifact(candidate(), "auto-scan", true); + expect(record.name).toBe("Gladiator's Nostalgia"); + expect(record.slot).toBe("Flower of Life"); + expect(record.level).toBe(20); + expect(record.setName).toBe("Gladiator's Finale"); + expect(record.substats).toHaveLength(4); + expect(record.needsReview).toBe(true); + expect(record.source).toBe("auto-scan"); + }); + + it("treats review recovery sources as lower priority than verified scan sources", () => { + expect(isReviewOnlyArtifactSource("review-reprocess")).toBe(true); + expect(resolveStoredArtifactSource("auto-scan", "review-reprocess")).toBe("auto-scan"); + expect(resolveStoredArtifactSource("review-recovered", "manual-scan")).toBe("manual-scan"); + }); + + it("scores confirmed artifacts above uncertain review-only records", () => { + const review = toStoredArtifact(candidate({ confidence: 74 }), "review-reprocess", true); + const confirmed = toStoredArtifact(candidate({ confidence: 92 }), "auto-scan", false); + expect(storedArtifactStrength(confirmed)).toBeGreaterThan(storedArtifactStrength(review)); + }); +}); diff --git a/src/lib/artifactStore.ts b/src/lib/artifactStore.ts new file mode 100644 index 0000000..2fbb81a --- /dev/null +++ b/src/lib/artifactStore.ts @@ -0,0 +1,86 @@ +import type { ParsedArtifactCandidate } from "./artifactOcrParser.js"; +import type { StoredArtifactRecord } from "../types/storage.js"; + +/** + * Signature used inside one scan session to detect that the detail panel + * actually changed after a click. Includes the equipped character so two + * otherwise identical pieces on different characters still count as new. + */ +export function sessionSignature(parsed: ParsedArtifactCandidate) { + return [ + parsed.name, + parsed.slot, + parsed.level, + parsed.mainStat, + parsed.mainValue, + parsed.setName, + parsed.equipped, + parsed.substats.join("|"), + ].join("::"); +} + +/** + * Signature used for the persistent store. Excludes the equipped character, + * so re-equipping an artifact updates the existing record instead of + * duplicating it. Level is part of the identity because the local DB now + * persists partially leveled pieces as distinct scan states. + */ +export function storeSignature(parsed: ParsedArtifactCandidate) { + return [ + parsed.name, + parsed.slot, + parsed.level, + parsed.mainStat, + parsed.mainValue, + parsed.setName, + parsed.substats.join("|"), + ].join("::"); +} + +export function hashId(value: string) { + let hash = 5381; + for (let index = 0; index < value.length; index++) { + hash = ((hash << 5) + hash + value.charCodeAt(index)) >>> 0; + } + return `${hash.toString(16).padStart(8, "0")}-${value.length.toString(16)}`; +} + +export function toStoredArtifact(parsed: ParsedArtifactCandidate, source: string, needsReview: boolean): StoredArtifactRecord { + return { + id: hashId(storeSignature(parsed)), + name: parsed.name, + slot: parsed.slot, + level: parsed.level, + setName: parsed.setName, + mainStat: parsed.mainStat, + mainValue: parsed.mainValue, + substats: [...parsed.substats], + equipped: parsed.equipped, + confidence: parsed.confidence, + needsReview, + source, + }; +} + +export function isReviewOnlyArtifactSource(source: string | null | undefined) { + return /^review-/i.test(source ?? ""); +} + +export function storedArtifactStrength(record: StoredArtifactRecord | null | undefined) { + if (!record) return 0; + return ( + (record.confidence ?? 0) + + Math.min(16, (record.substats?.length ?? 0) * 4) + + (record.needsReview ? -10 : 8) + + (record.equipped && !/not detected/i.test(record.equipped) ? 3 : 0) + ); +} + +export function resolveStoredArtifactSource(existingSource: string | null | undefined, incomingSource: string | null | undefined) { + const existing = existingSource ?? ""; + const incoming = incomingSource ?? ""; + if (!incoming) return existing; + if (!existing) return incoming; + if (isReviewOnlyArtifactSource(incoming) && !isReviewOnlyArtifactSource(existing)) return existing; + return incoming; +} diff --git a/src/lib/autoScanController.test.ts b/src/lib/autoScanController.test.ts new file mode 100644 index 0000000..ba1f7bc --- /dev/null +++ b/src/lib/autoScanController.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController"; + +describe("autoScanController", () => { + it("does not count empty signatures as scanned artifacts", () => { + expect(classifyAutoScanCapture({ signature: "", lastDetailSignature: "", seen: new Set() })).toMatchObject({ kind: "unreadable" }); + }); + + it("separates stuck detail views from duplicates", () => { + const seen = new Set(["same"]); + + expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "same", seen })).toMatchObject({ kind: "stuck" }); + expect(classifyAutoScanCapture({ signature: "same", lastDetailSignature: "other", seen })).toMatchObject({ kind: "duplicate" }); + }); + + it("accepts a new signature as a readable artifact", () => { + expect(classifyAutoScanCapture({ signature: "new", lastDetailSignature: "old", seen: new Set(["old"]) })).toMatchObject({ kind: "new" }); + }); + + it("uses a conservative miss threshold", () => { + expect(shouldAbortAfterConsecutiveMisses(2)).toBe(false); + expect(shouldAbortAfterConsecutiveMisses(3)).toBe(true); + }); +}); diff --git a/src/lib/autoScanController.ts b/src/lib/autoScanController.ts new file mode 100644 index 0000000..4f7a89d --- /dev/null +++ b/src/lib/autoScanController.ts @@ -0,0 +1,24 @@ +export type AutoScanCaptureDecision = + | { kind: "unreadable"; countAsMiss: true } + | { kind: "stuck"; countAsMiss: true; signature: string } + | { kind: "duplicate"; countAsDuplicate: true; signature: string } + | { kind: "new"; countAsParsed: true; signature: string }; + +export function classifyAutoScanCapture({ + signature, + lastDetailSignature, + seen, +}: { + signature: string; + lastDetailSignature: string; + seen: ReadonlySet; +}): AutoScanCaptureDecision { + if (!signature) return { kind: "unreadable", countAsMiss: true }; + if (signature === lastDetailSignature && seen.has(signature)) return { kind: "stuck", countAsMiss: true, signature }; + if (seen.has(signature)) return { kind: "duplicate", countAsDuplicate: true, signature }; + return { kind: "new", countAsParsed: true, signature }; +} + +export function shouldAbortAfterConsecutiveMisses(consecutiveMisses: number, threshold = 3) { + return consecutiveMisses >= threshold; +} diff --git a/src/lib/autoScanLoop.test.ts b/src/lib/autoScanLoop.test.ts new file mode 100644 index 0000000..42f6248 --- /dev/null +++ b/src/lib/autoScanLoop.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { detailFingerprint, fingerprintDataUrl, isRepeatedProcessedPageFingerprint, screenFingerprint } from "./autoScanLoop"; + +describe("autoScanLoop fingerprints", () => { + it("distinguishes captures that share the same prefix but differ later", () => { + const sharedPrefix = "data:image/png;base64," + "A".repeat(180); + const left = sharedPrefix + "LEFT-" + "B".repeat(600); + const right = sharedPrefix + "RIGHT-" + "C".repeat(600); + + expect(fingerprintDataUrl(left)).not.toBe(fingerprintDataUrl(right)); + }); + + it("stays stable for the same capture string", () => { + const dataUrl = "data:image/png;base64," + "Q".repeat(2048); + expect(fingerprintDataUrl(dataUrl)).toBe(fingerprintDataUrl(dataUrl)); + }); + + it("uses the detail preview for detail-change verification instead of the full frame", () => { + const captureA = { + detailDataUrl: "data:image/png;base64," + "DETAIL-A".repeat(64), + dataUrl: "data:image/png;base64," + "FULL-A".repeat(256), + } as const; + const captureB = { + detailDataUrl: captureA.detailDataUrl, + dataUrl: "data:image/png;base64," + "FULL-B".repeat(256), + } as const; + + expect(detailFingerprint(captureA as never)).toBe(detailFingerprint(captureB as never)); + }); + + it("uses the inventory preview for scroll verification when available", () => { + const captureA = { + inventoryDataUrl: "data:image/png;base64," + "GRID-A".repeat(64), + dataUrl: "data:image/png;base64," + "FRAME-A".repeat(256), + } as const; + const captureB = { + inventoryDataUrl: "data:image/png;base64," + "GRID-B".repeat(64), + dataUrl: captureA.dataUrl, + } as const; + + expect(screenFingerprint(captureA as never)).not.toBe(screenFingerprint(captureB as never)); + }); + + it("treats repeated page fingerprints as a loop only after page one", () => { + const seen = new Set(["abc"]); + expect(isRepeatedProcessedPageFingerprint("abc", seen, 1)).toBe(false); + expect(isRepeatedProcessedPageFingerprint("abc", seen, 2)).toBe(true); + expect(isRepeatedProcessedPageFingerprint("", seen, 3)).toBe(false); + }); +}); diff --git a/src/lib/autoScanLoop.ts b/src/lib/autoScanLoop.ts new file mode 100644 index 0000000..30ebfdf --- /dev/null +++ b/src/lib/autoScanLoop.ts @@ -0,0 +1,487 @@ +import type { BooleanResult, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global"; +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import { sessionSignature } from "./artifactStore"; +import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner"; +import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController"; +import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality"; +import type { AutoScanStats, ScanSummary } from "./scannerSession"; +import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession"; + +// Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md +// ADR-007): one click per tile, a fixed settle delay, one retry if the detail +// view did not change, then move on. No click-profile matrix, no offset +// retries, no double-read conflict resolution - those never fixed a single +// click and only made failures harder to diagnose. + +type AutoScanApi = { + clickScreen: (x: number, y: number) => Promise; + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + getAutomationGuard?: () => Promise; +}; + +export type AutoScanLoopDependencies = { + api: AutoScanApi; + captureSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise; + parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; + persistParsedArtifact: ( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate, + source: string, + needsReview: boolean, + ) => Promise; + saveReviewSample: ( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate | null, + reason: string, + ) => Promise; + getAutoReviewReason: (capture: CaptureResult, parsed: ParsedArtifactCandidate) => string; + shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean; + appendAutomationLog: (line: string) => void; + appendClickDiagnostics: (result: ClickResult, prefix?: string) => void; + setReviewStatus: (value: string) => void; + setAutoScanStats: (stats: AutoScanStats) => void; + shouldStop: () => boolean; +}; + +export type AutoScanLoopOptions = { + scanLimit: number; + skipRows: number; + detectedInventoryCount?: number | null; +}; + +export type AutoScanLoopResult = { + status: ScanSummary["status"]; + stats: AutoScanStats; + blockedReason: string; + pageCount: number; + gridLabel: string; + targetCount: number; +}; + +const CLICK_SETTLE_MS = 280; +const MISS_ABORT_THRESHOLD = 3; +const UNREADABLE_ABORT_THRESHOLD = 5; + +export async function runAutoScanLoop( + deps: AutoScanLoopDependencies, + options: AutoScanLoopOptions, +): Promise { + const { + api, + captureSelectedSource, + captureFastSelectedSource, + parseArtifact, + persistParsedArtifact, + saveReviewSample, + getAutoReviewReason, + shouldFlagArtifactForReview, + appendAutomationLog, + appendClickDiagnostics, + setReviewStatus, + setAutoScanStats, + shouldStop, + } = deps; + + const stats: AutoScanStats = { ...emptyAutoScanStats }; + const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount); + const rowsToSkip = clampSkipRows(options.skipRows); + const seen = new Set(); + const seenPageFingerprints = new Set(); + let page = 0; + let blockedReason = ""; + let aborted = false; + let consecutiveMisses = 0; + let rowsQueued = 0; + + function updateStats() { + setAutoScanStats({ ...stats }); + } + + async function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) { + const saved = await saveReviewSample(capture, parsed, reason); + if (saved?.ok) { + stats.review++; + updateStats(); + } + } + + async function checkGuard() { + if (shouldStop()) return "Stop-Button gedrueckt."; + if (!api.getAutomationGuard) return ""; + try { + const guard = await api.getAutomationGuard(); + if (guard.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt."; + if (guard.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt."; + if (guard.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt."; + return ""; + } catch { + return ""; + } + } + + function inputStopReason(result: ClickResult) { + if (result.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt."; + if (result.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt."; + if (result.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt."; + return ""; + } + + async function waitDuringScan(ms: number) { + const startedAt = Date.now(); + while (Date.now() - startedAt < ms) { + const guardReason = await checkGuard(); + if (guardReason) return guardReason; + await wait(Math.min(120, ms - (Date.now() - startedAt))); + } + return ""; + } + + async function clickTarget(target: GridTarget, label: string) { + appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`); + const clickResult = await api.clickScreen(target.x, target.y); + appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`); + stats.clicked++; + stats.attempted = stats.clicked; + updateStats(); + return clickResult; + } + + let currentCapture = await captureSelectedSource(0, true); + const initialCaptureRejection = captureSourceRejectionReason(currentCapture); + let gridModel = buildGridModel(currentCapture?.inventoryGrid); + + if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) { + const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren."; + setReviewStatus(reason); + return { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets }; + } + + let lastDetailSignature = ""; + const initialParsed = parseArtifact(currentCapture); + if (initialParsed) lastDetailSignature = sessionSignature(initialParsed); + let lastDetailViewFingerprint = detailFingerprint(currentCapture); + + try { + while (!blockedReason && !shouldStop() && stats.clicked < maxTargets) { + page++; + stats.pages = page; + updateStats(); + + const currentPageFingerprint = screenFingerprint(currentCapture); + if (isRepeatedProcessedPageFingerprint(currentPageFingerprint, seenPageFingerprints, page)) { + blockedReason = `Inventarseite ${page} wurde bereits zuvor gesehen. Scrollen hat wahrscheinlich keine neue Seite geliefert; Scan gestoppt, um keine Duplikat-Schleife zu erzeugen.`; + break; + } + if (currentPageFingerprint) seenPageFingerprints.add(currentPageFingerprint); + + const pageSkipRows = page === 1 ? Math.min(rowsToSkip, Math.max(0, gridModel.rows - 1)) : 0; + const baseTargets = gridModel.targets.filter((target) => target.row >= pageSkipRows); + const pagePlan = buildInventoryPagePlan({ + targets: baseTargets, + cols: gridModel.cols, + rows: Math.max(1, gridModel.rows - pageSkipRows), + totalTargetCount: maxTargets, + processedTargets: stats.clicked, + rowsQueued, + }); + const targets = pagePlan.pageTargets; + + if (targets.length === 0) { + blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`; + break; + } + + setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.clicked}/${maxTargets} geklickt.`); + let newArtifactsOnPage = 0; + + for (const target of targets) { + if (shouldStop() || stats.clicked >= maxTargets) break; + + const guardReason = await checkGuard(); + if (guardReason) { + blockedReason = guardReason; + aborted = true; + break; + } + + let clickResult = await clickTarget(target, "click"); + let stopReason = inputStopReason(clickResult); + if (stopReason) { + blockedReason = stopReason; + aborted = true; + break; + } + + if (clickResult.moved === false || clickResult.clicked === false) { + // A structural failure (cursor could not be placed, or SendInput + // was rejected outright) means clicks are not reaching Genshin at + // all - almost always an elevation mismatch. Abort immediately + // instead of clicking blindly through the rest of the inventory. + blockedReason = clickResult.inputBlocked + ? "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten')." + : `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`; + break; + } + + let waitStop = await waitDuringScan(CLICK_SETTLE_MS); + if (waitStop) { + blockedReason = waitStop; + aborted = true; + break; + } + + let previewCapture = await captureFastSelectedSource(0, true); + let previewFingerprint = detailFingerprint(previewCapture); + let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint); + + if (!changedDetail) { + appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`); + clickResult = await clickTarget(target, "retry"); + stopReason = inputStopReason(clickResult); + if (stopReason) { + blockedReason = stopReason; + aborted = true; + break; + } + waitStop = await waitDuringScan(CLICK_SETTLE_MS); + if (waitStop) { + blockedReason = waitStop; + aborted = true; + break; + } + previewCapture = await captureFastSelectedSource(0, true); + previewFingerprint = detailFingerprint(previewCapture); + changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint); + } + + if (!changedDetail) { + stats.misses++; + consecutiveMisses++; + updateStats(); + appendAutomationLog(`miss r${target.row} c${target.col}: Detailansicht unveraendert`); + if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) { + blockedReason = "Mehrere Klicks hintereinander haben die Detailansicht nicht veraendert. Auto-Scan gestoppt: Klicks landen wahrscheinlich nicht auf neuen Artifacts."; + break; + } + continue; + } + + stats.verified++; + + const capture = await captureSelectedSource(0, true); + if (capture?.ocrTimedOut) { + stats.misses++; + consecutiveMisses++; + lastDetailViewFingerprint = detailFingerprint(capture); + updateStats(); + appendAutomationLog(`miss r${target.row} c${target.col}: OCR timeout`); + if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) { + blockedReason = "Mehrere OCR-Timeouts hintereinander. Auto-Scan gestoppt, damit die Session nicht haengen bleibt."; + break; + } + continue; + } + + const parsed = parseArtifact(capture); + const rejection = captureRejectionReason(capture, parsed); + + if (rejection) { + await saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`); + if (parsed && shouldPersistParsedArtifact(parsed, true)) { + consecutiveMisses = 0; + const signature = sessionSignature(parsed); + stats.parsed++; + lastDetailSignature = signature; + lastDetailViewFingerprint = detailFingerprint(capture); + seen.add(signature); + newArtifactsOnPage++; + if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++; + updateStats(); + continue; + } + stats.misses++; + consecutiveMisses++; + updateStats(); + appendAutomationLog(`miss r${target.row} c${target.col}: ${rejection}`); + if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) { + blockedReason = "Mehrere unlesbare Artifact-Captures hintereinander. Auto-Scan gestoppt, damit nicht blind weitergeklickt wird."; + break; + } + continue; + } + + const signature = parsed ? sessionSignature(parsed) : ""; + const decision = classifyAutoScanCapture({ signature, lastDetailSignature, seen }); + + if (!capture || !parsed || decision.kind === "unreadable") { + stats.misses++; + consecutiveMisses++; + updateStats(); + appendAutomationLog(`miss r${target.row} c${target.col}: kein Artifact lesbar`); + if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) { + blockedReason = "Mehrere unlesbare Artifacts hintereinander. Auto-Scan gestoppt: Klicks treffen wahrscheinlich nicht das Artifact-Raster."; + break; + } + continue; + } + + if (decision.kind === "stuck") { + stats.misses++; + consecutiveMisses++; + updateStats(); + appendAutomationLog(`miss r${target.row} c${target.col}: Detail zeigt weiterhin "${parsed.name}"`); + if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) { + blockedReason = `Mehrere Klicks blieben auf "${parsed.name}". Auto-Scan gestoppt.`; + break; + } + continue; + } + + consecutiveMisses = 0; + stats.parsed++; + lastDetailSignature = signature; + lastDetailViewFingerprint = detailFingerprint(capture); + + if (decision.kind === "duplicate") { + stats.duplicates++; + updateStats(); + continue; + } + + seen.add(signature); + newArtifactsOnPage++; + + const reason = getAutoReviewReason(capture, parsed); + const needsReview = reason ? true : shouldFlagArtifactForReview(parsed); + if (reason) { + await saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`); + } + if (await persistParsedArtifact(capture, parsed, "auto-scan", needsReview)) stats.stored++; + updateStats(); + } + + const endOfPagePlan = buildInventoryPagePlan({ + targets: gridModel.targets.filter((target) => target.row >= pageSkipRows), + cols: gridModel.cols, + rows: Math.max(1, gridModel.rows - pageSkipRows), + totalTargetCount: maxTargets, + processedTargets: stats.clicked, + rowsQueued, + }); + rowsQueued = endOfPagePlan.rowsQueuedAfterPage; + + if (aborted || stats.clicked >= maxTargets || shouldStop() || blockedReason) break; + + if (newArtifactsOnPage === 0 && page > 1) { + blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`; + break; + } + + const guardReason = await checkGuard(); + if (guardReason) { + blockedReason = guardReason; + aborted = true; + break; + } + + const rowsToScroll = endOfPagePlan.scrollRowsAfterPage; + if (rowsToScroll <= 0) break; + const scrollNotches = Math.min(60, Math.max(1, rowsToScroll * 10 - 1)); + setReviewStatus(`Automatischer Scan Seite ${page} fertig. Scrolle zur naechsten Inventory-Seite...`); + appendAutomationLog(`scroll ${scrollNotches} (${rowsToScroll} row(s)) @ ${gridModel.anchorX},${gridModel.anchorY}`); + const scrollResult = await api.scrollScreen(-scrollNotches, gridModel.anchorX, gridModel.anchorY); + if (scrollResult.inputBlocked) { + blockedReason = "Scroll-Input wurde von Windows blockiert. Starte die App als Administrator."; + break; + } + + if (page % 12 === 0) { + appendAutomationLog(`scroll correction p${page}: +1 notch @ ${gridModel.anchorX},${gridModel.anchorY}`); + const correctionResult = await api.scrollScreen(1, gridModel.anchorX, gridModel.anchorY); + if (correctionResult.inputBlocked) { + blockedReason = "Scroll-Korrektur wurde von Windows blockiert."; + break; + } + } + + const scrollWaitStop = await waitDuringScan(760); + if (scrollWaitStop) { + blockedReason = scrollWaitStop; + aborted = true; + break; + } + + const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture); + currentCapture = await captureFastSelectedSource(0, true); + const afterScrollFingerprint = screenFingerprint(currentCapture); + + if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) { + blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert."; + break; + } + + if (afterScrollFingerprint && seenPageFingerprints.has(afterScrollFingerprint)) { + blockedReason = "Scrollen hat erneut eine bereits verarbeitete Inventarseite gezeigt."; + break; + } + + const refreshedModel = buildGridModel(currentCapture?.inventoryGrid); + if (!refreshedModel) { + blockedReason = "Kachel-Grid nach dem Scrollen verloren."; + break; + } + if (refreshedModel.source === "detected" && refreshedModel.confidence >= 72) { + gridModel = refreshedModel; + } + } + } catch (error) { + blockedReason = `Fehler waehrend des Scans: ${error instanceof Error ? error.message : String(error)}`; + appendAutomationLog(blockedReason); + } + + const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done"; + return { + status, + stats, + blockedReason, + pageCount: page, + gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`, + targetCount: maxTargets, + }; +} + +export function detailFingerprint(capture: CaptureResult | null) { + if (!capture) return ""; + if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl); + if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl); + return ""; +} + +export function screenFingerprint(capture: CaptureResult | null) { + if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl); + if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl); + return ""; +} + +export function isRepeatedProcessedPageFingerprint( + fingerprint: string, + seenPageFingerprints: ReadonlySet, + page: number, +) { + return page > 1 && Boolean(fingerprint) && seenPageFingerprints.has(fingerprint); +} + +export function fingerprintDataUrl(dataUrl: string) { + let hash = 2166136261; + const stride = Math.max(1, Math.floor(dataUrl.length / 4096)); + for (let index = 0; index < dataUrl.length; index += stride) { + hash ^= dataUrl.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`; +} + +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} diff --git a/src/lib/automationPlanner.test.ts b/src/lib/automationPlanner.test.ts new file mode 100644 index 0000000..51481d5 --- /dev/null +++ b/src/lib/automationPlanner.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + automationBlockReason, + buildInventoryPagePlan, + buildGridModel, + requiresAdminForAutomation, +} from "./automationPlanner"; + +describe("automationPlanner", () => { + it("requires admin whenever this app's own process is not elevated", () => { + expect(requiresAdminForAutomation({ ok: true, isElevated: false, platform: "win32" })).toBe(true); + expect(requiresAdminForAutomation({ ok: true, isElevated: true, platform: "win32" })).toBe(false); + expect(requiresAdminForAutomation({ ok: false, isElevated: false, platform: "win32" })).toBe(false); + expect(requiresAdminForAutomation(null)).toBe(false); + }); + + it("returns a user-facing admin block reason when not elevated", () => { + expect(automationBlockReason({ ok: true, isElevated: false, platform: "win32" })).toContain("Administrator"); + expect(automationBlockReason({ ok: true, isElevated: true, platform: "win32" })).toBe(""); + }); + + it("builds a dense click grid from partial detected centers", () => { + const grid = buildGridModel({ + rows: 3, + cols: 4, + confidence: 96, + source: "detected", + centers: [ + { row: 0, col: 0, x: 100, y: 200 }, + { row: 0, col: 1, x: 220, y: 200 }, + { row: 0, col: 3, x: 460, y: 200 }, + { row: 2, col: 0, x: 100, y: 500 }, + { row: 2, col: 3, x: 460, y: 500 }, + ], + }); + + expect(grid).not.toBeNull(); + expect(grid?.targets).toHaveLength(12); + expect(grid?.stepX).toBe(120); + expect(grid?.stepY).toBe(150); + expect(grid?.targets.find((target) => target.row === 1 && target.col === 2)).toMatchObject({ x: 340, y: 350 }); + }); + + it("plans overlapping inventory pages like Inventory Kamera for a partial final page", () => { + const targets = Array.from({ length: 40 }, (_, index) => ({ + row: Math.floor(index / 8), + col: index % 8, + x: index * 10, + y: index * 10, + })); + + const firstPage = buildInventoryPagePlan({ + targets, + cols: 8, + rows: 5, + totalTargetCount: 50, + processedTargets: 0, + rowsQueued: 0, + }); + expect(firstPage.pageTargets).toHaveLength(40); + expect(firstPage.startIndex).toBe(0); + expect(firstPage.scrollRowsAfterPage).toBe(2); + + const finalPage = buildInventoryPagePlan({ + targets, + cols: 8, + rows: 5, + totalTargetCount: 50, + processedTargets: 40, + rowsQueued: 5, + }); + expect(finalPage.pageTargets).toHaveLength(10); + expect(finalPage.startIndex).toBe(24); + expect(finalPage.pageTargets[0]).toMatchObject({ row: 3, col: 0 }); + expect(finalPage.scrollRowsAfterPage).toBe(0); + }); + + it("keeps the first page top-aligned when the whole inventory fits inside one visible page", () => { + const targets = Array.from({ length: 40 }, (_, index) => ({ + row: Math.floor(index / 8), + col: index % 8, + x: index * 10, + y: index * 10, + })); + + const singlePage = buildInventoryPagePlan({ + targets, + cols: 8, + rows: 5, + totalTargetCount: 16, + processedTargets: 0, + rowsQueued: 0, + }); + + expect(singlePage.pageTargets).toHaveLength(16); + expect(singlePage.startIndex).toBe(0); + expect(singlePage.pageTargets[0]).toMatchObject({ row: 0, col: 0 }); + expect(singlePage.pageTargets[15]).toMatchObject({ row: 1, col: 7 }); + expect(singlePage.scrollRowsAfterPage).toBe(0); + }); +}); diff --git a/src/lib/automationPlanner.ts b/src/lib/automationPlanner.ts new file mode 100644 index 0000000..8b64849 --- /dev/null +++ b/src/lib/automationPlanner.ts @@ -0,0 +1,193 @@ +import type { CaptureResult, RuntimeInfo } from "../types/global"; + +export type GridTarget = { x: number; y: number; row: number; col: number }; + +export type GridModel = { + targets: GridTarget[]; + cols: number; + rows: number; + startX: number; + startY: number; + stepX: number; + stepY: number; + anchorX: number; + anchorY: number; + confidence: number; + source: "detected" | "fallback" | "missing"; +}; + +export type InventoryPagePlan = { + pageTargets: GridTarget[]; + remainingTargets: number; + totalRows: number; + remainingRows: number; + startIndex: number; + rowsQueuedAfterPage: number; + scrollRowsAfterPage: number; +}; + +// Matches Inventory Kamera's model: the whole app simply always runs +// elevated (see docs/DECISIONS.md ADR-007), so auto-scan only needs to check +// our own process elevation - no per-process target-elevation probing or +// separate broker process required. +export function requiresAdminForAutomation(runtime: RuntimeInfo | null | undefined) { + return runtime?.ok === true && runtime.isElevated !== true; +} + +export function automationBlockReason(runtime: RuntimeInfo | null | undefined) { + if (!requiresAdminForAutomation(runtime)) return ""; + return "Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert."; +} + +export function buildGridModel(grid: CaptureResult["inventoryGrid"] | undefined): GridModel | null { + if (!grid || grid.source === "missing" || grid.centers.length === 0) return null; + + const centers = grid.centers + .filter((center) => Number.isFinite(center.x) && Number.isFinite(center.y)) + .map((center) => ({ + x: Math.round(center.x), + y: Math.round(center.y), + row: Math.max(0, Math.round(center.row)), + col: Math.max(0, Math.round(center.col)), + })); + + if (centers.length === 0) return null; + + const cols = Math.max(1, grid.cols || Math.max(...centers.map((center) => center.col)) + 1); + const rows = Math.max(1, grid.rows || Math.max(...centers.map((center) => center.row)) + 1); + const xCoords = buildAxisCoordinates(centers, "col", "x", cols); + const yCoords = buildAxisCoordinates(centers, "row", "y", rows); + + if (xCoords.length !== cols || yCoords.length !== rows) return null; + + const targets = yCoords.flatMap((y, row) => + xCoords.map((x, col) => ({ + x: Math.round(x), + y: Math.round(y), + row, + col, + })), + ); + + const stepX = Math.max(1, Math.round(medianDiff(xCoords) || 1)); + const stepY = Math.max(1, Math.round(medianDiff(yCoords) || 1)); + + return { + targets, + cols, + rows, + startX: targets[0]?.x ?? Math.round(xCoords[0]), + startY: targets[0]?.y ?? Math.round(yCoords[0]), + stepX, + stepY, + anchorX: Math.round(xCoords[Math.min(1, xCoords.length - 1)] ?? xCoords[0]), + anchorY: Math.round(yCoords[Math.floor(yCoords.length / 2)] ?? yCoords[0]), + confidence: grid.confidence, + source: grid.source, + }; +} + +export function buildInventoryPagePlan({ + targets, + cols, + rows, + totalTargetCount, + processedTargets, + rowsQueued, +}: { + targets: GridTarget[]; + cols: number; + rows: number; + totalTargetCount: number; + processedTargets: number; + rowsQueued: number; +}): InventoryPagePlan { + const safeCols = Math.max(1, cols); + const safeRows = Math.max(1, rows); + const safeTotal = Math.max(0, totalTargetCount); + const safeProcessed = Math.max(0, Math.min(safeTotal, processedTargets)); + const remainingTargets = Math.max(0, safeTotal - safeProcessed); + const totalRows = Math.max(0, Math.ceil(safeTotal / safeCols)); + const remainingRows = Math.max(0, totalRows - rowsQueued); + const fullPage = safeCols * safeRows; + const pageTargetCount = Math.min(remainingTargets, fullPage); + const partialPage = pageTargetCount > 0 && pageTargetCount < fullPage; + // Only bottom-align a partial page after we have already consumed at least + // one full visible page. The first page of a small inventory starts at the + // top of the grid, while the final scrolled page is bottom-aligned because + // it overlaps previous rows. + const alignPartialPageToBottom = rowsQueued > 0 && partialPage && remainingRows > 0 && remainingRows < safeRows; + const startIndex = alignPartialPageToBottom ? Math.max(0, (safeRows - remainingRows) * safeCols) : 0; + const pageTargets = targets.slice(startIndex, Math.min(targets.length, startIndex + pageTargetCount)); + const rowsQueuedAfterPage = Math.min(totalRows, rowsQueued + safeRows); + const remainingRowsAfterPage = Math.max(0, totalRows - rowsQueuedAfterPage); + const scrollRowsAfterPage = remainingRowsAfterPage > 0 ? Math.min(safeRows, remainingRowsAfterPage) : 0; + + return { + pageTargets, + remainingTargets, + totalRows, + remainingRows, + startIndex, + rowsQueuedAfterPage, + scrollRowsAfterPage, + }; +} + +function buildAxisCoordinates( + centers: GridTarget[], + groupKey: "row" | "col", + valueKey: "x" | "y", + expectedCount: number, +) { + const known = Array.from({ length: expectedCount }, (_, index) => { + const values = centers + .filter((center) => center[groupKey] === index) + .map((center) => center[valueKey]); + return values.length > 0 ? median(values) : null; + }); + + const knownPairs = known + .map((value, index) => ({ index, value })) + .filter((entry): entry is { index: number; value: number } => typeof entry.value === "number"); + + if (knownPairs.length === expectedCount) return known as number[]; + if (knownPairs.length === 0) return []; + + const indexedSteps = knownPairs + .slice(1) + .map((entry, index) => { + const previous = knownPairs[index]; + const indexDiff = entry.index - previous.index; + return indexDiff > 0 ? Math.abs(entry.value - previous.value) / indexDiff : 0; + }) + .filter((step) => step > 3); + const step = median(indexedSteps) || estimateStepFromAllCenters(centers.map((center) => center[valueKey])); + if (!step || !Number.isFinite(step)) return []; + + const first = knownPairs[0]; + const start = first.value - step * first.index; + return Array.from({ length: expectedCount }, (_, index) => Math.round(start + step * index)); +} + +function estimateStepFromAllCenters(values: number[]) { + const sorted = [...new Set(values.map((value) => Math.round(value)))].sort((a, b) => a - b); + return medianDiff(sorted); +} + +function median(values: number[]) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +function medianDiff(values: number[]) { + if (values.length < 2) return 0; + const sorted = [...values].sort((a, b) => a - b); + const diffs = sorted + .slice(1) + .map((value, index) => Math.abs(value - sorted[index])) + .filter((diff) => diff > 3); + return diffs.length > 0 ? median(diffs) : 0; +} diff --git a/src/lib/demoData.ts b/src/lib/demoData.ts new file mode 100644 index 0000000..e54b157 --- /dev/null +++ b/src/lib/demoData.ts @@ -0,0 +1,268 @@ +import presetsJson from "../../data/presets.json"; +import { recommendArtifacts, suggestBuilds } from "./scoring"; +import type { AppSnapshot, Artifact, Character, CharacterPreset, ScanEvent } from "../types/domain"; + +const presets = presetsJson.characters.map((entry) => ({ + ...entry, + characterId: entry.id, +})) as unknown as CharacterPreset[]; + +export function createDemoArtifacts(): Artifact[] { + const now = new Date().toISOString(); + return [ + { + id: "art-001", + setKey: "marechaussee_hunter", + setName: "Marechaussee Hunter", + slot: "sands", + rarity: 5, + level: 20, + mainStat: "HP%", + substats: [ + { key: "CRIT Rate", value: 10.1, unit: "%" }, + { key: "CRIT DMG", value: 14.0, unit: "%" }, + { key: "Energy Recharge", value: 5.8, unit: "%" }, + { key: "ATK%", value: 4.1, unit: "%" }, + ], + locked: true, + source: "mock", + confidence: 0.97, + lastSeenAt: now, + }, + { + id: "art-002", + setKey: "golden_troupe", + setName: "Golden Troupe", + slot: "goblet", + rarity: 5, + level: 16, + mainStat: "HP%", + substats: [ + { key: "Energy Recharge", value: 11.0, unit: "%" }, + { key: "CRIT Rate", value: 6.6, unit: "%" }, + { key: "CRIT DMG", value: 13.2, unit: "%" }, + { key: "Elemental Mastery", value: 19, unit: "flat" }, + ], + locked: false, + source: "mock", + confidence: 0.94, + lastSeenAt: now, + }, + { + id: "art-003", + setKey: "emblem_of_severed_fate", + setName: "Emblem of Severed Fate", + slot: "circlet", + rarity: 5, + level: 20, + mainStat: "CRIT Rate", + substats: [ + { key: "Energy Recharge", value: 17.5, unit: "%" }, + { key: "CRIT DMG", value: 19.4, unit: "%" }, + { key: "ATK%", value: 5.3, unit: "%" }, + { key: "HP%", value: 4.7, unit: "%" }, + ], + locked: true, + source: "mock", + confidence: 0.99, + lastSeenAt: now, + }, + { + id: "art-004", + setKey: "deepwood_memories", + setName: "Deepwood Memories", + slot: "goblet", + rarity: 5, + level: 4, + mainStat: "Elemental Mastery", + substats: [ + { key: "CRIT Rate", value: 3.9, unit: "%" }, + { key: "Energy Recharge", value: 6.5, unit: "%" }, + { key: "ATK%", value: 4.7, unit: "%" }, + ], + locked: false, + source: "mock", + confidence: 0.91, + lastSeenAt: now, + }, + { + id: "art-005", + setKey: "viridescent_venerer", + setName: "Viridescent Venerer", + slot: "circlet", + rarity: 5, + level: 0, + mainStat: "Elemental Mastery", + substats: [ + { key: "Energy Recharge", value: 5.2, unit: "%" }, + { key: "DEF%", value: 7.3, unit: "%" }, + { key: "HP", value: 269, unit: "flat" }, + ], + locked: false, + source: "mock", + confidence: 0.86, + lastSeenAt: now, + }, + { + id: "art-006", + setKey: "noblesse_oblige", + setName: "Noblesse Oblige", + slot: "sands", + rarity: 5, + level: 0, + mainStat: "DEF%", + substats: [ + { key: "DEF", value: 23, unit: "flat" }, + { key: "HP", value: 209, unit: "flat" }, + { key: "ATK", value: 19, unit: "flat" }, + ], + locked: false, + source: "mock", + confidence: 0.96, + lastSeenAt: now, + }, + { + id: "art-007", + setKey: "heart_of_depth", + setName: "Heart of Depth", + slot: "plume", + rarity: 5, + level: 12, + mainStat: "ATK", + substats: [ + { key: "CRIT Rate", value: 6.2, unit: "%" }, + { key: "CRIT DMG", value: 12.4, unit: "%" }, + { key: "HP%", value: 9.9, unit: "%" }, + { key: "Energy Recharge", value: 4.5, unit: "%" }, + ], + locked: false, + source: "mock", + confidence: 0.8, + lastSeenAt: now, + }, + { + id: "art-008", + setKey: "viridescent_venerer", + setName: "Viridescent Venerer", + slot: "sands", + rarity: 5, + level: 20, + mainStat: "Elemental Mastery", + substats: [ + { key: "Energy Recharge", value: 18.1, unit: "%" }, + { key: "CRIT Rate", value: 3.1, unit: "%" }, + { key: "HP%", value: 8.7, unit: "%" }, + { key: "ATK%", value: 4.7, unit: "%" }, + ], + locked: true, + source: "mock", + confidence: 0.97, + lastSeenAt: now, + }, + { + id: "art-009", + setKey: "viridescent_venerer", + setName: "Viridescent Venerer", + slot: "flower", + rarity: 5, + level: 16, + mainStat: "HP", + substats: [ + { key: "Elemental Mastery", value: 63, unit: "flat" }, + { key: "Energy Recharge", value: 11.7, unit: "%" }, + { key: "CRIT Rate", value: 3.5, unit: "%" }, + { key: "DEF%", value: 5.8, unit: "%" }, + ], + locked: true, + source: "mock", + confidence: 0.95, + lastSeenAt: now, + }, + { + id: "art-010", + setKey: "viridescent_venerer", + setName: "Viridescent Venerer", + slot: "plume", + rarity: 5, + level: 16, + mainStat: "ATK", + substats: [ + { key: "Elemental Mastery", value: 82, unit: "flat" }, + { key: "Energy Recharge", value: 10.4, unit: "%" }, + { key: "HP%", value: 5.3, unit: "%" }, + { key: "DEF", value: 21, unit: "flat" }, + ], + locked: true, + source: "mock", + confidence: 0.94, + lastSeenAt: now, + }, + ]; +} + +export function createDemoCharacters(): Character[] { + return [ + { id: "neuvillette", name: "Neuvillette", owned: true, level: 90, constellation: 0, rolePreference: "main_dps", confidence: 0.93 }, + { id: "furina", name: "Furina", owned: true, level: 90, constellation: 0, rolePreference: "sub_dps", confidence: 0.91 }, + { id: "raiden_shogun", name: "Raiden Shogun", owned: true, level: 80, constellation: 0, rolePreference: "main_dps", confidence: 0.88 }, + { id: "nahida", name: "Nahida", owned: true, level: 90, constellation: 0, rolePreference: "support", confidence: 0.92 }, + { id: "kazuha", name: "Kaedehara Kazuha", owned: true, level: 80, constellation: 0, rolePreference: "support", confidence: 0.87 }, + ]; +} + +export function createDemoScanEvents(): ScanEvent[] { + return [ + { + id: "scan-001", + type: "environment", + label: "Environment check", + detail: "Borderless Windowed, 2560x1440 profile, English UI expected.", + confidence: 0.92, + }, + { + id: "scan-002", + type: "character", + label: "Character pass", + detail: "5 owned characters detected from the prepared scanner adapter.", + confidence: 0.89, + }, + { + id: "scan-003", + type: "artifact", + label: "Artifact pass", + detail: "7 artifacts parsed. 1 needs review because confidence is under threshold.", + confidence: 0.9, + }, + { + id: "scan-004", + type: "complete", + label: "Account snapshot ready", + detail: "Recommendations and build candidates are available.", + confidence: 0.95, + }, + ]; +} + +export function createDemoSnapshot(): AppSnapshot { + const artifacts = createDemoArtifacts(); + const characters = createDemoCharacters(); + const recommendations = recommendArtifacts(artifacts, characters, presets); + const builds = suggestBuilds(artifacts, characters, presets); + + return { + artifacts, + characters, + recommendations, + builds, + scanEvents: createDemoScanEvents(), + }; +} + +export async function runMockScan() { + await new Promise((resolve) => window.setTimeout(resolve, 900)); + return createDemoSnapshot(); +} + +export function getPresets() { + return presets; +} diff --git a/src/lib/fuzzyMatch.ts b/src/lib/fuzzyMatch.ts new file mode 100644 index 0000000..3a8b90c --- /dev/null +++ b/src/lib/fuzzyMatch.ts @@ -0,0 +1,65 @@ +export interface FuzzyMatchResult { + value: string; + score: number; +} + +export function simplifyForMatch(text: string) { + return text.toLowerCase().replace(/[^a-z0-9%]+/g, ""); +} + +export function fuzzyFindKnown(text: string, values: string[], minimumScore = 0.72): FuzzyMatchResult | null { + const haystack = simplifyForMatch(text); + if (!haystack) return null; + + let best: FuzzyMatchResult | null = null; + for (const value of values) { + const needle = simplifyForMatch(value); + if (!needle) continue; + + const score = scoreCandidate(haystack, needle); + if (!best || score > best.score) best = { value, score }; + } + + return best && best.score >= minimumScore ? best : null; +} + +function scoreCandidate(haystack: string, needle: string) { + if (haystack.includes(needle)) return 1; + if (needle.includes(haystack) && haystack.length >= Math.min(8, needle.length)) return haystack.length / needle.length; + + const windows = slidingWindows(haystack, needle.length); + const bestDistance = Math.min(...windows.map((window) => levenshtein(window, needle))); + const normalized = 1 - bestDistance / Math.max(needle.length, 1); + + const prefixBonus = needle.startsWith(haystack.slice(0, Math.min(haystack.length, needle.length))) ? 0.08 : 0; + return Math.max(0, Math.min(1, normalized + prefixBonus)); +} + +function slidingWindows(text: string, size: number) { + if (text.length <= size) return [text]; + const windows: string[] = []; + const minSize = Math.max(4, Math.floor(size * 0.7)); + for (let start = 0; start <= text.length - minSize; start++) { + windows.push(text.slice(start, Math.min(text.length, start + size))); + } + return windows; +} + +function levenshtein(a: string, b: string) { + const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + for (let i = 0; i <= a.length; i++) dp[i][0] = i; + for (let j = 0; j <= b.length; j++) dp[0][j] = j; + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost, + ); + } + } + + return dp[a.length][b.length]; +} diff --git a/src/lib/genshinData.ts b/src/lib/genshinData.ts new file mode 100644 index 0000000..f2029f9 --- /dev/null +++ b/src/lib/genshinData.ts @@ -0,0 +1,164 @@ +import gameData from "../data/genshinGameData.json" with { type: "json" }; +import { simplifyForMatch } from "./fuzzyMatch.js"; + +type ArtifactPiece = { + name: string; + setName: string; + slot: string; + relicType: string; +}; + +type Character = { + name: string; +}; + +type GenshinGameDataContract = typeof gameData & { + artifactPieces?: ArtifactPiece[]; + stats?: { + main?: string[]; + mainBySlot?: Record; + sub?: string[]; + }; + aliases?: { + stats?: Record; + textReplacements?: Record; + slotAliases?: Record; + setAliases?: Record; + pieceAliases?: Record; + characterAliases?: Record; + }; + mainStatsBySlot?: Record; + mainStatValueReferences?: Record>; + characters?: Character[]; +}; + +export const genshinGameData = gameData as GenshinGameDataContract; + +export const slotNames = genshinGameData.slots; +export const statAliases = genshinGameData.aliases?.stats ?? {}; +export const textReplacements = genshinGameData.aliases?.textReplacements ?? {}; +export const slotAliases = genshinGameData.aliases?.slotAliases ?? {}; +export const setAliases = genshinGameData.aliases?.setAliases ?? {}; +export const pieceAliases = genshinGameData.aliases?.pieceAliases ?? {}; +export const characterAliases = genshinGameData.aliases?.characterAliases ?? {}; +export const knownSets = genshinGameData.artifactSets.map((set) => set.name); +export const knownCharacters = (genshinGameData.characters ?? []).map((character) => character.name); +export const sourceVersion = genshinGameData.sourceVersion ?? "unknown"; + +export const fixedMainStatBySlot: Record = { + "Flower of Life": "HP", + "Plume of Death": "ATK", +}; + +const bundledMainStats = genshinGameData.stats?.main ?? genshinGameData.mainStats; +const bundledSubstats = genshinGameData.stats?.sub ?? genshinGameData.substats; + +export const mainStatsBySlot: Record = genshinGameData.stats?.mainBySlot ?? genshinGameData.mainStatsBySlot ?? { + "Flower of Life": ["HP"], + "Plume of Death": ["ATK"], + "Sands of Eon": ["HP%", "ATK%", "DEF%", "Energy Recharge", "Elemental Mastery"], + "Goblet of Eonothem": [ + "HP%", + "ATK%", + "DEF%", + "Elemental Mastery", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Physical DMG Bonus", + ], + "Circlet of Logos": ["HP%", "ATK%", "DEF%", "Elemental Mastery", "CRIT Rate", "CRIT DMG", "Healing Bonus"], +}; + +const fallbackArtifactPieces = genshinGameData.artifactSets.flatMap((artifactSet) => + artifactSet.pieces.map((piece) => ({ + name: piece.name, + setName: artifactSet.name, + slot: slotFromRelicType(piece.relicType), + relicType: piece.relicType, + })), +); + +export const artifactPieces: ArtifactPiece[] = (genshinGameData.artifactPieces ?? fallbackArtifactPieces) + .filter((piece) => piece.name && piece.setName && piece.slot); + +export const pieceToSet = new Map(artifactPieces.map((piece) => [piece.name, piece.setName])); +export const pieceToSlot = new Map(artifactPieces.map((piece) => [piece.name, piece.slot])); +export const knownPieceNames = artifactPieces.map((piece) => piece.name); + +export const globalMainStats = unique([ + ...bundledMainStats, + ...Object.keys(statAliases), +]); + +export const globalSubstats = unique([ + ...bundledSubstats, + ...Object.keys(statAliases), +]); + +export const mainStatValueReferences = genshinGameData.mainStatValueReferences ?? {}; + +export function allowedMainStatsForSlot(slot: string) { + return mainStatsBySlot[slot] ?? globalMainStats; +} + +export function canonicalStatName(raw: string) { + return statAliases[raw] ?? raw; +} + +export function normalizeSlotAlias(raw: string) { + const simplified = simplifyForMatch(raw); + const aliasMatch = Object.entries(slotAliases).find(([alias]) => simplifyForMatch(alias) === simplified); + if (aliasMatch) return aliasMatch[1]; + const direct = slotNames.find((slot) => simplifyForMatch(slot) === simplified); + return direct ?? ""; +} + +export function normalizeSetAlias(raw: string) { + const simplified = simplifyForMatch(raw); + const aliasMatch = Object.entries(setAliases).find(([alias]) => simplifyForMatch(alias) === simplified); + if (aliasMatch) return aliasMatch[1]; + const direct = knownSets.find((set) => simplifyForMatch(set) === simplified); + return direct ?? ""; +} + +export function normalizePieceAlias(raw: string) { + const simplified = simplifyForMatch(raw); + const aliasMatch = Object.entries(pieceAliases).find(([alias]) => simplifyForMatch(alias) === simplified); + if (aliasMatch) return aliasMatch[1]; + const direct = knownPieceNames.find((piece) => simplifyForMatch(piece) === simplified); + return direct ?? ""; +} + +export function normalizeCharacterAlias(raw: string) { + const simplified = simplifyForMatch(raw); + const aliasMatch = Object.entries(characterAliases).find(([alias]) => simplifyForMatch(alias) === simplified); + if (aliasMatch) return aliasMatch[1]; + const direct = knownCharacters.find((character) => simplifyForMatch(character) === simplified); + return direct ?? ""; +} + +function unique(values: string[]) { + return [...new Set(values)]; +} + +function slotFromRelicType(relicType: string) { + switch (relicType) { + case "EQUIP_BRACER": + return "Flower of Life"; + case "EQUIP_NECKLACE": + return "Plume of Death"; + case "EQUIP_SHOES": + return "Sands of Eon"; + case "EQUIP_RING": + return "Goblet of Eonothem"; + case "EQUIP_DRESS": + return "Circlet of Logos"; + default: + return ""; + } +} diff --git a/src/lib/goodFormat.ts b/src/lib/goodFormat.ts new file mode 100644 index 0000000..46f5534 --- /dev/null +++ b/src/lib/goodFormat.ts @@ -0,0 +1,30 @@ +import type { Artifact } from "../types/domain"; +import type { GoodDatabase } from "../types/global"; + +const slotToGood: Record = { + flower: "flower", + plume: "plume", + sands: "sands", + goblet: "goblet", + circlet: "circlet", +}; + +export function exportGood(artifacts: Artifact[]): GoodDatabase { + return { + format: "GOOD", + version: 2, + source: "Genshin Artifact Assistant", + artifacts: artifacts.map((artifact) => ({ + setKey: artifact.setKey, + slotKey: slotToGood[artifact.slot], + rarity: artifact.rarity, + level: artifact.level, + mainStatKey: artifact.mainStat, + substats: artifact.substats.map((substat) => ({ + key: substat.key, + value: substat.value, + })), + lock: artifact.locked, + })), + }; +} diff --git a/src/lib/localAccountSnapshot.test.ts b/src/lib/localAccountSnapshot.test.ts new file mode 100644 index 0000000..d8ebd82 --- /dev/null +++ b/src/lib/localAccountSnapshot.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { getPresets } from "./demoData"; +import { createLocalAccountSnapshot } from "./localAccountSnapshot"; +import type { StoredArtifactRecord } from "../types/storage"; + +function artifact(id: string, equipped: string, slot: string, setName = "Viridescent Venerer"): StoredArtifactRecord { + return { + id, + name: `${slot} Piece`, + slot, + setName, + mainStat: slot === "Flower of Life" ? "HP" : slot === "Plume of Death" ? "ATK" : "Elemental Mastery", + mainValue: slot === "Flower of Life" ? "4,780" : slot === "Plume of Death" ? "311" : "187", + substats: ["CRIT Rate+7.0%", "CRIT DMG+14.0%", "Energy Recharge+11.0%", "ATK+29"], + equipped, + confidence: 94, + needsReview: false, + source: "manual-scan", + }; +} + +describe("localAccountSnapshot", () => { + it("infers owned characters from equipped artifact records", () => { + const snapshot = createLocalAccountSnapshot([ + artifact("a", "Sucrose", "Flower of Life"), + artifact("b", "Sucrose", "Plume of Death"), + ], getPresets()); + + expect(snapshot?.characters.find((character) => character.name === "Sucrose")?.owned).toBe(true); + expect(snapshot?.recommendations.length).toBeGreaterThan(0); + }); + + it("creates partial build suggestions for locally inferred characters", () => { + const snapshot = createLocalAccountSnapshot([ + artifact("a", "Aino", "Flower of Life", "Silken Moon's Serenade"), + artifact("b", "Aino", "Plume of Death", "Silken Moon's Serenade"), + artifact("c", "Aino", "Sands of Eon", "Silken Moon's Serenade"), + ], getPresets()); + + const aino = snapshot?.characters.find((character) => character.name === "Aino"); + const ainoBuilds = snapshot?.builds.filter((build) => build.characterId === aino?.id) ?? []; + + expect(aino?.owned).toBe(true); + expect(ainoBuilds.length).toBeGreaterThan(0); + expect(ainoBuilds[0].warnings.join(" ")).toContain("Missing"); + }); +}); diff --git a/src/lib/localAccountSnapshot.ts b/src/lib/localAccountSnapshot.ts new file mode 100644 index 0000000..50de926 --- /dev/null +++ b/src/lib/localAccountSnapshot.ts @@ -0,0 +1,151 @@ +import type { StoredArtifactRecord } from "../types/storage"; +import { storedArtifactsToDomain } from "./storedArtifactAdapter"; +import { recommendArtifacts, suggestBuilds } from "./scoring"; +import type { AppSnapshot, Artifact, ArtifactSlot, Character, CharacterPreset, CharacterRole, ScanEvent } from "../types/domain"; + +const slotOrder: ArtifactSlot[] = ["flower", "plume", "sands", "goblet", "circlet"]; + +export function createLocalAccountSnapshot(records: StoredArtifactRecord[], basePresets: CharacterPreset[]): AppSnapshot | null { + const artifacts = storedArtifactsToDomain(records); + if (artifacts.length === 0) return null; + + const characters = inferCharacters(records, basePresets); + const generatedPresets = createGeneratedPresets(characters, artifacts, basePresets); + const presets = mergePresets(basePresets, generatedPresets); + const recommendations = recommendArtifacts(artifacts, characters, presets); + const builds = suggestBuilds(artifacts, characters, presets); + const events: ScanEvent[] = [ + { + id: "local-db-loaded", + type: "complete", + label: "Lokale Artifact-DB geladen", + detail: `${artifacts.length} gespeicherte Artifacts, ${characters.filter((character) => character.owned).length} lokale Charaktere, ${builds.length} Build-Vorschlaege.`, + confidence: 0.95, + }, + ]; + + return { artifacts, characters, recommendations, builds, scanEvents: events }; +} + +function inferCharacters(records: StoredArtifactRecord[], basePresets: CharacterPreset[]): Character[] { + const equippedNames = [...new Set(records.map((record) => record.equipped.trim()).filter(isUsefulCharacterName))]; + const presetByName = new Map(basePresets.map((preset) => [simplify(preset.characterId), preset])); + const characters: Character[] = []; + + for (const name of equippedNames) { + const id = slug(name); + const preset = presetByName.get(simplify(name)) ?? basePresets.find((entry) => simplify(entry.characterId).includes(simplify(name))); + characters.push({ + id: preset?.characterId ?? id, + name, + owned: true, + level: 90, + constellation: 0, + rolePreference: preset?.role ?? inferRoleFromRecords(records.filter((record) => record.equipped === name)), + confidence: 0.82, + }); + } + + for (const preset of basePresets) { + if (characters.some((character) => character.id === preset.characterId)) continue; + characters.push({ + id: preset.characterId, + name: titleCase(preset.characterId.replaceAll("_", " ")), + owned: false, + level: 1, + constellation: 0, + rolePreference: preset.role, + confidence: 0.5, + }); + } + + return characters; +} + +function createGeneratedPresets(characters: Character[], artifacts: Artifact[], basePresets: CharacterPreset[]): CharacterPreset[] { + return characters + .filter((character) => character.owned && !basePresets.some((preset) => preset.characterId === character.id)) + .map((character) => { + const currentSets = mostCommonSetsForCharacter(character.name, artifacts); + return { + characterId: character.id, + role: character.rolePreference, + recommendedSets: currentSets.slice(0, 1), + alternativeSets: currentSets.slice(1, 3), + mainStats: inferMainStatsForRole(character.rolePreference), + substatWeights: inferWeightsForRole(character.rolePreference), + explanation: "Generated from locally scanned equipped artifacts. Review once curated character presets are added.", + }; + }); +} + +function mostCommonSetsForCharacter(characterName: string, artifacts: Artifact[]) { + const counts = new Map(); + const equippedArtifacts = artifacts.filter((artifact) => artifact.equipped === characterName); + const sourceArtifacts = equippedArtifacts.length ? equippedArtifacts : artifacts; + for (const artifact of sourceArtifacts) { + counts.set(artifact.setKey, (counts.get(artifact.setKey) ?? 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([setKey]) => setKey); + return ranked.length ? ranked : [`${slug(characterName)}_current_set`]; +} + +function mergePresets(basePresets: CharacterPreset[], generatedPresets: CharacterPreset[]) { + const byId = new Map(); + for (const preset of basePresets) byId.set(preset.characterId, preset); + for (const preset of generatedPresets) if (!byId.has(preset.characterId)) byId.set(preset.characterId, preset); + return [...byId.values()]; +} + +function inferRoleFromRecords(records: StoredArtifactRecord[]): CharacterRole { + const text = records.flatMap((record) => [record.setName, record.mainStat, ...record.substats]).join(" "); + if (/healing|maiden|clam/i.test(text)) return "healer"; + if (/viridescent|noblesse|scroll|cinder/i.test(text)) return "support"; + if (/elemental mastery|reaction|gilded|deepwood/i.test(text)) return "reaction"; + return "sub_dps"; +} + +function inferMainStatsForRole(role: CharacterRole): Partial> { + if (role === "support" || role === "reaction") { + return { + sands: ["Elemental Mastery", "Energy Recharge", "ATK%", "HP%"], + goblet: ["Elemental Mastery", "Elemental DMG Bonus", "ATK%", "HP%"], + circlet: ["Elemental Mastery", "CRIT Rate", "CRIT DMG", "Healing Bonus"], + }; + } + if (role === "healer") { + return { + sands: ["HP%", "Energy Recharge", "ATK%"], + goblet: ["HP%", "Healing Bonus", "ATK%"], + circlet: ["Healing Bonus", "HP%", "CRIT Rate"], + }; + } + return { + sands: ["ATK%", "Energy Recharge", "Elemental Mastery", "HP%"], + goblet: ["ATK%", "Physical DMG Bonus", "Hydro DMG Bonus", "Pyro DMG Bonus", "Electro DMG Bonus", "Cryo DMG Bonus", "Dendro DMG Bonus", "Anemo DMG Bonus", "Geo DMG Bonus"], + circlet: ["CRIT Rate", "CRIT DMG", "ATK%", "Elemental Mastery"], + }; +} + +function inferWeightsForRole(role: CharacterRole): Record { + if (role === "support") return { "Energy Recharge": 1.2, "CRIT Rate": 0.35, "CRIT DMG": 0.3, "ATK%": 0.25, "HP%": 0.25, "Elemental Mastery": 0.75 }; + if (role === "reaction") return { "Elemental Mastery": 1.25, "Energy Recharge": 0.8, "CRIT Rate": 0.5, "CRIT DMG": 0.45, "ATK%": 0.35 }; + if (role === "healer") return { "HP%": 1.1, "ATK%": 0.7, "Energy Recharge": 0.85, "CRIT Rate": 0.25, "CRIT DMG": 0.2 }; + return { "CRIT Rate": 1.15, "CRIT DMG": 1.1, "ATK%": 0.9, "Energy Recharge": 0.7, "Elemental Mastery": 0.45, "HP%": 0.3 }; +} + +function isUsefulCharacterName(value: string) { + return Boolean(value && value !== "Not detected" && !/unknown|missing|detected/i.test(value)); +} + +function simplify(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function slug(value: string) { + return simplify(value).replace(/\s+/g, "_") || "unknown_character"; +} + +function titleCase(value: string) { + return value.replace(/\b\w/g, (match) => match.toUpperCase()); +} diff --git a/src/lib/reviewSampleAnalysis.test.ts b/src/lib/reviewSampleAnalysis.test.ts new file mode 100644 index 0000000..c1fac03 --- /dev/null +++ b/src/lib/reviewSampleAnalysis.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { analyzeReviewSamples } from "./reviewSampleAnalysis"; +import type { ReviewSampleRecord } from "../types/global"; + +describe("analyzeReviewSamples", () => { + it("summarizes weak fields, reasons, and average confidence", () => { + const samples: ReviewSampleRecord[] = [ + sample("2026-01-01T00:00:00.000Z", "automatic:low-total-confidence-72:p1:r0c5", 72, { + name: 78, + substats: 0, + equipped: 45, + }), + sample("2026-01-02T00:00:00.000Z", "manual:low-field-mainStat-substats", 86, { + mainStat: 62, + substats: 55, + }), + { savedAt: "2026-01-03T00:00:00.000Z", sample: { reason: "probe:no-detail-change:r1c1" } }, + ]; + + const analysis = analyzeReviewSamples(samples); + + expect(analysis.total).toBe(3); + expect(analysis.withParsed).toBe(2); + expect(analysis.averageConfidence).toBe(79); + expect(analysis.latestSavedAt).toBe("2026-01-03T00:00:00.000Z"); + expect(analysis.reasons.map((entry) => entry.reason)).toEqual([ + "low-field-mainStat-substats", + "low-total-confidence-72", + "no-detail-change:r1c1", + ]); + expect(analysis.weakFields[0]).toMatchObject({ field: "substats", count: 2, average: 28 }); + expect(analysis.weakFields).toContainEqual({ field: "equipped", count: 1, average: 45 }); + }); +}); + +function sample(savedAt: string, reason: string, confidence: number, fields: Record): ReviewSampleRecord { + return { + savedAt, + sample: { + reason, + parsed: { + confidence, + fields: Object.fromEntries( + Object.entries(fields).map(([key, fieldConfidence]) => [key, { confidence: fieldConfidence }]), + ), + }, + }, + }; +} diff --git a/src/lib/reviewSampleAnalysis.ts b/src/lib/reviewSampleAnalysis.ts new file mode 100644 index 0000000..d27becd --- /dev/null +++ b/src/lib/reviewSampleAnalysis.ts @@ -0,0 +1,72 @@ +import type { ReviewSampleRecord } from "../types/global"; + +export type ReviewSampleAnalysis = { + total: number; + withParsed: number; + averageConfidence: number; + weakFields: Array<{ field: string; count: number; average: number }>; + reasons: Array<{ reason: string; count: number }>; + latestSavedAt?: string; +}; + +type ParsedLike = { + confidence?: unknown; + fields?: Record; +}; + +export function analyzeReviewSamples(samples: ReviewSampleRecord[]): ReviewSampleAnalysis { + const reasonCounts = new Map(); + const fieldCounts = new Map(); + let confidenceSum = 0; + let parsedCount = 0; + let latestSavedAt = ""; + + for (const entry of samples) { + if (entry.savedAt && (!latestSavedAt || entry.savedAt > latestSavedAt)) latestSavedAt = entry.savedAt; + + const reason = normalizeReason(entry.sample?.reason); + reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); + + const parsed = entry.sample?.parsed as ParsedLike | undefined; + if (!parsed || typeof parsed !== "object") continue; + + const confidence = typeof parsed.confidence === "number" ? parsed.confidence : null; + if (confidence !== null) { + confidenceSum += confidence; + parsedCount++; + } + + for (const [field, value] of Object.entries(parsed.fields ?? {})) { + const fieldConfidence = value && typeof value.confidence === "number" ? value.confidence : null; + if (fieldConfidence === null || fieldConfidence >= 70) continue; + const current = fieldCounts.get(field) ?? { count: 0, sum: 0 }; + current.count++; + current.sum += fieldConfidence; + fieldCounts.set(field, current); + } + } + + return { + total: samples.length, + withParsed: parsedCount, + averageConfidence: parsedCount > 0 ? Math.round(confidenceSum / parsedCount) : 0, + weakFields: [...fieldCounts.entries()] + .map(([field, value]) => ({ field, count: value.count, average: Math.round(value.sum / value.count) })) + .sort((a, b) => b.count - a.count || a.average - b.average || a.field.localeCompare(b.field)), + reasons: [...reasonCounts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)), + latestSavedAt: latestSavedAt || undefined, + }; +} + +function normalizeReason(reason: string | undefined) { + if (!reason) return "unknown"; + return reason + .replace(/^automatic:/, "") + .replace(/^manual:/, "") + .replace(/^probe:/, "") + .replace(/:p\d+.*$/, "") + .replace(/:preflight.*$/, "") + .trim() || "unknown"; +} diff --git a/src/lib/scanReviewUtils.ts b/src/lib/scanReviewUtils.ts new file mode 100644 index 0000000..b91124c --- /dev/null +++ b/src/lib/scanReviewUtils.ts @@ -0,0 +1,24 @@ +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import { shouldSaveReviewSample } from "./scannerLearning"; +import type { CaptureResult } from "../types/global"; + +export interface ReviewReasonInput { + capture: CaptureResult; + parsed: ParsedArtifactCandidate; +} + +export function getAutoReviewReason(capture: ReviewReasonInput["capture"], parsed: ReviewReasonInput["parsed"]) { + if (!capture.detailDataUrl || !capture.crops?.length || !capture.ocr?.length) return "missing-crops-or-ocr"; + if (!shouldSaveReviewSample(parsed)) return ""; + const lowFields = Object.entries(parsed.fields) + .filter(([, field]) => field.confidence < 70) + .map(([key]) => key); + if (parsed.confidence < 82) return `low-total-confidence-${parsed.confidence}`; + if (lowFields.length > 0) return `low-field-${lowFields.join("-")}`; + if (parsed.notes.some((note) => /not confidently|incomplete|low/i.test(note))) return "parser-notes"; + return ""; +} + +export function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..7ae35ea --- /dev/null +++ b/src/lib/scanner.ts @@ -0,0 +1,82 @@ +import type { AppSnapshot, Artifact, Character, ScanEvent } from "../types/domain"; +import { createDemoArtifacts, createDemoCharacters, createDemoScanEvents } from "./demoData"; +import { recommendArtifacts, suggestBuilds } from "./scoring"; +import { getPresets } from "./demoData"; + +export interface ScannerEnvironment { + borderlessWindowed: boolean; + language: "en" | "unknown"; + resolution: string; + hdrWarning: boolean; +} + +export interface ScannerAdapter { + checkEnvironment(): Promise; + scanCharacters(): Promise<{ characters: Character[]; events: ScanEvent[] }>; + scanArtifacts(): Promise<{ artifacts: Artifact[]; events: ScanEvent[] }>; +} + +export class MockScannerAdapter implements ScannerAdapter { + async checkEnvironment(): Promise { + return { + borderlessWindowed: true, + language: "en", + resolution: "2560x1440", + hdrWarning: false, + }; + } + + async scanCharacters() { + await delay(180); + return { + characters: createDemoCharacters(), + events: createDemoScanEvents().filter((event) => event.type === "character"), + }; + } + + async scanArtifacts() { + await delay(260); + return { + artifacts: createDemoArtifacts(), + events: createDemoScanEvents().filter((event) => event.type === "artifact"), + }; + } +} + +export async function runAccountScan(adapter: ScannerAdapter = new MockScannerAdapter()): Promise { + const environment = await adapter.checkEnvironment(); + const characterPass = await adapter.scanCharacters(); + const artifactPass = await adapter.scanArtifacts(); + const presets = getPresets(); + const recommendations = recommendArtifacts(artifactPass.artifacts, characterPass.characters, presets); + const builds = suggestBuilds(artifactPass.artifacts, characterPass.characters, presets); + + return { + artifacts: artifactPass.artifacts, + characters: characterPass.characters, + recommendations, + builds, + scanEvents: [ + { + id: "environment-live", + type: "environment", + label: "Environment check", + detail: `${environment.borderlessWindowed ? "Borderless ready" : "Borderless required"} · ${environment.resolution} · ${environment.language.toUpperCase()}`, + confidence: environment.hdrWarning ? 0.78 : 0.94, + }, + ...characterPass.events, + ...artifactPass.events, + { + id: "complete-live", + type: "complete", + label: "Account snapshot ready", + detail: "Local recommendation engine updated with the latest scan.", + confidence: 0.95, + }, + ], + }; +} + +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} diff --git a/src/lib/scannerCaptureQuality.test.ts b/src/lib/scannerCaptureQuality.test.ts new file mode 100644 index 0000000..429a26b --- /dev/null +++ b/src/lib/scannerCaptureQuality.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality"; +import type { CaptureResult } from "../types/global"; +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; + +function capture(overrides: Partial = {}): CaptureResult { + return { + id: "test", + name: "test", + width: 1920, + height: 1080, + dataUrl: "", + capturedAt: new Date(0).toISOString(), + captureTarget: "genshin-client", + ocr: [], + ...overrides, + }; +} + +function parsed(overrides: Partial = {}): ParsedArtifactCandidate { + return { + name: "A Note in Spring's Leich", + slot: "Sands of Eon", + level: 20, + mainStat: "Elemental Mastery", + mainValue: "187", + substats: ["ATK+29", "CRIT DMG+15.5%", "CRIT Rate+2.7%"], + setName: "A Day Carved From Rising Winds", + equipped: "Citlali", + confidence: 92, + notes: [], + fields: { + name: { value: "A Note in Spring's Leich", confidence: 92, source: "ocr" }, + slot: { value: "Sands of Eon", confidence: 92, source: "ocr" }, + level: { value: "20", confidence: 96, source: "ocr" }, + mainStat: { value: "Elemental Mastery", confidence: 92, source: "ocr" }, + mainValue: { value: "187", confidence: 92, source: "ocr" }, + setName: { value: "A Day Carved From Rising Winds", confidence: 92, source: "ocr" }, + equipped: { value: "Citlali", confidence: 92, source: "ocr" }, + substats: { value: "ATK+29, CRIT DMG+15.5%, CRIT Rate+2.7%", confidence: 92, source: "ocr" }, + }, + ...overrides, + }; +} + +describe("scannerCaptureQuality", () => { + it("rejects primary-screen fallback captures", () => { + expect(captureRejectionReason(capture({ captureTarget: "primary-screen" }), parsed())).toContain("Primary Screen"); + expect(captureSourceRejectionReason(capture({ captureTarget: "primary-screen" }))).toContain("Primary Screen"); + }); + + it("accepts a direct desktop/window capture when the OCR content looks like Genshin", () => { + expect(captureSourceRejectionReason(capture({ + captureTarget: "desktop-source", + ocr: [{ id: "artifact-title", label: "title", text: "Gladiator's Nostalgia\nFlower of Life", confidence: 90 }], + }))).toBe(""); + }); + + it("rejects captures that clearly contain the app UI", () => { + expect( + captureRejectionReason( + capture({ + ocr: [{ id: "artifact-title", label: "title", text: "Artifacts scannen\nScanner Diagnose", confidence: 90 }], + }), + parsed(), + ), + ).toContain("App"); + }); + + it("does not persist obviously incomplete review parses", () => { + expect(shouldPersistParsedArtifact(parsed({ mainStat: "Unknown main stat" }), true)).toBe(false); + expect(shouldPersistParsedArtifact(parsed({ substats: [] }), true)).toBe(false); + expect(shouldPersistParsedArtifact(parsed({ confidence: 59 }), true)).toBe(false); + expect(shouldPersistParsedArtifact(parsed({ confidence: 80 }), true)).toBe(true); + }); + + it("persists complete artifacts even if they do not need review", () => { + expect(shouldPersistParsedArtifact(parsed(), false)).toBe(true); + }); +}); diff --git a/src/lib/scannerCaptureQuality.ts b/src/lib/scannerCaptureQuality.ts new file mode 100644 index 0000000..6d36aae --- /dev/null +++ b/src/lib/scannerCaptureQuality.ts @@ -0,0 +1,55 @@ +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import type { CaptureResult } from "../types/global"; + +const assistantUiNeedles = [ + "artifacts scannen", + "scanner diagnose", + "review queue", + "build-optionen", + "build options", + "input-broker", + "auto-scan", + "manuell mitlesen", + "einzelnes artifact lesen", +]; + +export function captureRejectionReason(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null) { + if (!capture) return "Keine Capture-Daten vorhanden."; + const sourceRejection = captureSourceRejectionReason(capture); + if (sourceRejection) return sourceRejection; + if (!parsed) return "Artifact konnte aus dem Capture nicht geparst werden."; + if (parsed.name === "Unknown artifact") return "Artifact-Name ist unbekannt."; + if (parsed.slot === "Unknown slot") return "Artifact-Slot ist unbekannt."; + if (parsed.setName === "Unknown set") return "Artifact-Set ist unbekannt."; + if (parsed.mainStat === "Unknown main stat" || parsed.mainValue === "?") return "Main Stat ist unvollstaendig."; + return ""; +} + +export function captureSourceRejectionReason(capture: CaptureResult | null) { + if (!capture) return "Keine Capture-Daten vorhanden."; + if (capture.captureTarget === "primary-screen") { + return "Capture stammt nur vom Primary Screen statt vom Genshin-Client."; + } + if (looksLikeAssistantUi(capture)) return "Capture enthaelt UI-Text der App statt eines Genshin-Artifacts."; + return ""; +} + +export function shouldPersistParsedArtifact(parsed: ParsedArtifactCandidate, needsReview: boolean) { + if (parsed.name === "Unknown artifact") return false; + if (parsed.slot === "Unknown slot") return false; + if (parsed.setName === "Unknown set") return false; + if (parsed.mainStat === "Unknown main stat") return false; + if (parsed.mainValue === "?") return false; + if (parsed.substats.length === 0) return false; + if (!needsReview && parsed.confidence < 68) return false; + if (needsReview && parsed.confidence < 60) return false; + return true; +} + +function looksLikeAssistantUi(capture: CaptureResult) { + const joined = (capture.ocr ?? []) + .map((entry) => entry.text ?? "") + .join("\n") + .toLowerCase(); + return assistantUiNeedles.some((needle) => joined.includes(needle)); +} diff --git a/src/lib/scannerLearning.test.ts b/src/lib/scannerLearning.test.ts new file mode 100644 index 0000000..ec58876 --- /dev/null +++ b/src/lib/scannerLearning.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { applyScannerLearningRules, countScannerLearningRules, deriveScannerLearningRules, deriveScannerLearningRulesFromReviewSamples, shouldFlagArtifactForReview, shouldSaveReviewSample } from "./scannerLearning"; +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import type { CaptureResult } from "../types/global"; + +function capture(text: string): CaptureResult { + return { + id: "test", + name: "test", + width: 1920, + height: 1080, + dataUrl: "", + capturedAt: new Date(0).toISOString(), + ocr: [{ id: "artifact-substats", label: "Substats", text, confidence: 72 }], + }; +} + +describe("scannerLearning", () => { + it("applies deterministic OCR text replacements before parsing", () => { + const learned = applyScannerLearningRules(capture("CIT DMG+7.0%\nEnergv Recharge+6.5%")); + + expect(learned?.ocr?.[0]?.text).toContain("CRIT DMG+7.0%"); + expect(learned?.ocr?.[0]?.text).toContain("Energy Recharge+6.5%"); + }); + + it("marks low confidence or noted parses for review", () => { + expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 95 } } })).toBe(false); + expect(shouldSaveReviewSample({ confidence: 96, notes: ["Artifact name was fuzzy-matched"], fields: { name: { confidence: 95 } } })).toBe(false); + expect(shouldSaveReviewSample({ confidence: 96, notes: [], fields: { name: { confidence: 62 } } })).toBe(true); + expect(shouldSaveReviewSample({ confidence: 90, notes: ["Main stat not confidently parsed."], fields: { mainStat: { confidence: 0 } } })).toBe(true); + }); + + it("derives conservative replacements from OCR review samples", () => { + const reviewCapture: CaptureResult = { + id: "review", + name: "review", + width: 1920, + height: 1080, + dataUrl: "", + capturedAt: new Date(0).toISOString(), + ocr: [ + { id: "artifact-main-stat", label: "Main", text: "Elemental Masterv\n187", confidence: 62 }, + { id: "artifact-set-effects", label: "Set", text: "Aubade of Morningstar and Moor", confidence: 68 }, + ], + }; + + const learned = deriveScannerLearningRules(reviewCapture, parsedArtifact({ + mainStat: "Elemental Mastery", + setName: "Aubade of Morningstar and Moon", + })); + + expect(learned?.textReplacements?.["Elemental Masterv"]).toBe("Elemental Mastery"); + expect(learned?.textReplacements?.Moor).toBe("Moon"); + }); + + it("counts learned rules", () => { + expect(countScannerLearningRules({ textReplacements: { one: "1", two: "2" } })).toBe(2); + }); + + it("does not flag DB review when only non-critical fields are weak", () => { + expect(shouldFlagArtifactForReview(parsedArtifact({ + confidence: 92, + notes: [], + substats: ["CRIT DMG+13.2%", "HP%+15.7%", "DEF%+12.4%", "Energy Recharge+5.2%"], + fields: { + ...parsedArtifact().fields, + mainStat: { value: "HP", confidence: 100, source: "derived" }, + mainValue: { value: "4,780", confidence: 100, source: "derived" }, + name: { value: "Moonlit Offering's Opulent Dream", confidence: 88, source: "fallback" }, + setName: { value: "Aubade of Morningstar and Moon", confidence: 88, source: "fallback" }, + equipped: { value: "Not detected", confidence: 45, source: "missing" }, + substats: { value: "CRIT DMG+13.2%, HP%+15.7%, DEF%+12.4%, Energy Recharge+5.2%", confidence: 82, source: "ocr" }, + }, + }))).toBe(false); + }); + + it("still flags DB review when critical parsing is incomplete", () => { + expect(shouldFlagArtifactForReview(parsedArtifact({ + confidence: 82, + notes: ["Main stat not confidently parsed."], + mainStat: "Unknown main stat", + fields: { + ...parsedArtifact().fields, + mainStat: { value: "", confidence: 0, source: "missing" }, + }, + }))).toBe(true); + }); + + it("can derive startup learning rules from persisted review samples", () => { + const rules = deriveScannerLearningRulesFromReviewSamples([ + { + savedAt: new Date(0).toISOString(), + sample: { + capture: { + name: "sample", + width: 1920, + height: 1080, + ocr: [{ id: "artifact-set-effects", label: "Set", text: "Aubade of Morningstar and Moor", confidence: 60 }], + }, + parsed: parsedArtifact({ + setName: "Aubade of Morningstar and Moon", + }), + }, + }, + ]); + + expect(rules.textReplacements?.Moor).toBe("Moon"); + }); +}); + +function parsedArtifact(overrides: Partial = {}): ParsedArtifactCandidate { + return { + name: "Sample Artifact", + slot: "Sands of Eon", + level: 20, + mainStat: "ATK%", + mainValue: "46.6%", + substats: ["CRIT Rate+3.9%"], + setName: "Aubade of Morningstar and Moon", + equipped: "Citlali", + confidence: 84, + notes: ["Main stat not confidently parsed."], + fields: { + name: { value: "Sample Artifact", confidence: 82, source: "fallback" }, + slot: { value: "Sands of Eon", confidence: 96, source: "ocr" }, + level: { value: "20", confidence: 96, source: "ocr" }, + mainStat: { value: "ATK%", confidence: 44, source: "fallback" }, + mainValue: { value: "46.6%", confidence: 94, source: "ocr" }, + setName: { value: "Aubade of Morningstar and Moon", confidence: 92, source: "derived" }, + equipped: { value: "Citlali", confidence: 78, source: "fallback" }, + substats: { value: "CRIT Rate+3.9%", confidence: 76, source: "ocr" }, + }, + ...overrides, + }; +} diff --git a/src/lib/scannerLearning.ts b/src/lib/scannerLearning.ts new file mode 100644 index 0000000..31e15c1 --- /dev/null +++ b/src/lib/scannerLearning.ts @@ -0,0 +1,242 @@ +import type { ParsedArtifactCandidate } from "./artifactOcrParser"; +import { simplifyForMatch } from "./fuzzyMatch"; +import type { CaptureResult, ReviewSampleRecord } from "../types/global"; +import type { ScannerLearningRulePayload } from "../types/global"; + +export type ScannerLearningRules = ScannerLearningRulePayload; + +export const DEFAULT_SCANNER_LEARNING_RULES: ScannerLearningRules = { + textReplacements: { + "CIT DMG": "CRIT DMG", + "CRIT DMC": "CRIT DMG", + "CRIT Rate+Z": "CRIT Rate+2", + "Energv Recharge": "Energy Recharge", + "Elemental Masterv": "Elemental Mastery", + "Equipped;": "Equipped:", + }, +}; + +export function mergeScannerLearningRules(...rules: Array | null | undefined>): ScannerLearningRules { + return rules.reduce( + (merged, rule) => ({ + textReplacements: { ...merged.textReplacements, ...(rule?.textReplacements ?? {}) }, + }), + { textReplacements: { ...DEFAULT_SCANNER_LEARNING_RULES.textReplacements } }, + ); +} + +export function applyScannerLearningRules(capture: CaptureResult | null, rules?: Partial | null) { + if (!capture?.ocr?.length) return capture; + const merged = mergeScannerLearningRules(rules); + const replacements = Object.entries(merged.textReplacements ?? {}).filter(([from]) => from.length > 0); + if (replacements.length === 0) return capture; + + return { + ...capture, + ocr: capture.ocr.map((entry) => ({ + ...entry, + text: applyTextReplacements(entry.text, replacements), + })), + }; +} + +export function shouldSaveReviewSample(parsed: { confidence: number; notes: string[]; fields: Record } | null) { + if (!parsed) return true; + if (parsed.confidence < 82) return true; + const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; + if (criticalFields.some((fieldName) => { + const field = parsed.fields[fieldName]; + return field ? field.confidence < 70 : false; + })) return true; + if (parsed.notes.some((note) => /main stat not confidently parsed|main stat value not confidently parsed|set name not confidently parsed|slot not confidently parsed|artifact name not confidently parsed/i.test(note))) { + return true; + } + if (/Substats look incomplete/i.test(parsed.notes.join(" ")) && (parsed.fields.substats?.confidence ?? 0) < 70) return true; + return false; +} + +export function shouldFlagArtifactForReview( + parsed: { confidence: number; notes: string[]; fields: Record; substats?: string[] } | null, +) { + if (!parsed) return true; + if (parsed.confidence < 78) return true; + + const criticalFields = ["name", "slot", "mainStat", "mainValue", "setName"]; + if (criticalFields.some((fieldName) => (parsed.fields[fieldName]?.confidence ?? 0) < 70)) return true; + + if (parsed.notes.some((note) => /main stat not confidently parsed|main stat value not confidently parsed|set name not confidently parsed|slot not confidently parsed|artifact name not confidently parsed/i.test(note))) { + return true; + } + + const substatCount = parsed.substats?.length ?? 0; + const substatConfidence = parsed.fields.substats?.confidence ?? 100; + if (substatCount === 0) return true; + if (substatCount < 3 && substatConfidence < 70) return true; + + return false; +} + +export function countScannerLearningRules(rules?: Partial | null) { + return Object.keys(rules?.textReplacements ?? {}).length; +} + +export function deriveScannerLearningRules( + capture: CaptureResult | null, + parsed: ParsedArtifactCandidate | null, +): Partial | null { + if (!capture?.ocr?.length || !parsed) return null; + + const replacements: Record = {}; + for (const entry of capture.ocr) { + const expectedValues = expectedValuesForOcrEntry(entry.id, parsed); + for (const expected of expectedValues) { + for (const [from, to] of deriveReplacementPairs(entry.text, expected)) { + if (!from || !to || simplifyForMatch(from) === simplifyForMatch(to)) continue; + replacements[from] = to; + } + } + } + + return Object.keys(replacements).length > 0 ? { textReplacements: replacements } : null; +} + +export function deriveScannerLearningRulesFromReviewSamples(samples: ReviewSampleRecord[] | null | undefined) { + const merged: Partial[] = []; + for (const entry of samples ?? []) { + const capture = normalizeReviewCapture(entry); + const parsed = normalizeReviewParsed(entry); + const learned = deriveScannerLearningRules(capture, parsed); + if (learned) merged.push(learned); + } + return mergeScannerLearningRules(...merged); +} + +function expectedValuesForOcrEntry(id: string, parsed: ParsedArtifactCandidate) { + switch (id) { + case "artifact-title": + return [parsed.name, parsed.slot]; + case "artifact-main-stat": + return [parsed.mainStat, parsed.mainValue]; + case "artifact-substats": + return parsed.substats; + case "artifact-set-effects": + return [parsed.setName]; + case "artifact-footer": + return parsed.equipped && parsed.equipped !== "Not detected" ? [`Equipped: ${parsed.equipped}`, parsed.equipped] : []; + default: + return []; + } +} + +function deriveReplacementPairs(rawText: string, expected: string) { + if (!expected || expected.startsWith("Unknown")) return []; + const normalizedExpected = normalizeLearningText(expected); + if (normalizedExpected.length < 4) return []; + + const pairs = new Map(); + const lines = rawText + .split("\n") + .map((line) => normalizeLearningText(line)) + .filter((line) => line.length >= 3); + + for (const line of lines) { + const score = similarityScore(line, normalizedExpected); + if (score >= 0.76 && score < 0.995 && Math.abs(line.length - normalizedExpected.length) <= Math.max(12, Math.round(normalizedExpected.length * 0.45))) { + pairs.set(line, normalizedExpected); + } + + const lineWords = tokenizeLearningWords(line); + const expectedWords = tokenizeLearningWords(normalizedExpected); + if (lineWords.length === expectedWords.length && lineWords.length > 0 && lineWords.length <= 7) { + for (let index = 0; index < lineWords.length; index++) { + const rawWord = lineWords[index]; + const expectedWord = expectedWords[index]; + if (rawWord.length < 4 || expectedWord.length < 4) continue; + const wordScore = similarityScore(rawWord, expectedWord); + if (wordScore >= 0.7 && wordScore < 0.995) pairs.set(rawWord, expectedWord); + } + } + } + + return [...pairs.entries()]; +} + +function applyTextReplacements(text: string, replacements: Array<[string, string]>) { + return replacements.reduce((current, [from, to]) => current.replace(new RegExp(escapeRegex(from), "gi"), to), text); +} + +function normalizeReviewCapture(entry: ReviewSampleRecord): CaptureResult | null { + const capture = entry.sample?.capture; + if (!capture?.ocr) return null; + return { + id: "review-sample", + name: capture.name ?? "review-sample", + width: capture.width ?? 0, + height: capture.height ?? 0, + dataUrl: capture.dataUrl ?? "", + capturedAt: capture.capturedAt ?? entry.savedAt, + captureTarget: capture.captureTarget, + detailDataUrl: capture.detailDataUrl, + inventoryDataUrl: capture.inventoryDataUrl, + ocr: capture.ocr, + crops: (capture.crops ?? []).map((crop) => ({ + id: crop.id, + label: crop.label, + rect: crop.rect, + dataUrl: crop.dataUrl ?? "", + })), + inventoryGrid: capture.inventoryGrid, + inventoryCount: capture.inventoryCount, + }; +} + +function normalizeReviewParsed(entry: ReviewSampleRecord): ParsedArtifactCandidate | null { + const parsed = entry.sample?.parsed; + if (!parsed || typeof parsed !== "object") return null; + const candidate = parsed as Partial; + if (!candidate.fields || typeof candidate.fields !== "object") return null; + return candidate as ParsedArtifactCandidate; +} + +function normalizeLearningText(text: string) { + return text.replace(/\s+/g, " ").trim(); +} + +function tokenizeLearningWords(text: string) { + return text + .split(/\s+/) + .map((part) => part.replace(/^[^A-Za-z0-9%+.-]+|[^A-Za-z0-9%+.-]+$/g, "")) + .filter(Boolean); +} + +function similarityScore(left: string, right: string) { + const a = simplifyForMatch(left); + const b = simplifyForMatch(right); + if (!a || !b) return 0; + if (a === b) return 1; + const distance = levenshtein(a, b); + return Math.max(0, 1 - distance / Math.max(a.length, b.length, 1)); +} + +function levenshtein(a: string, b: string) { + const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + for (let i = 0; i <= a.length; i++) dp[i][0] = i; + for (let j = 0; j <= b.length; j++) dp[0][j] = j; + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost, + ); + } + } + + return dp[a.length][b.length]; +} + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/lib/scannerSession.test.ts b/src/lib/scannerSession.test.ts new file mode 100644 index 0000000..72dcfb1 --- /dev/null +++ b/src/lib/scannerSession.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { clampScanLimit, clampSkipRows, resolveScanTargetCount } from "./scannerSession"; + +describe("scannerSession helpers", () => { + it("clamps scan target counts into a sane range", () => { + expect(clampScanLimit(0)).toBe(1); + expect(clampScanLimit(2500)).toBe(1800); + }); + + it("respects the manual scan limit and only caps it against the detected inventory count", () => { + expect(resolveScanTargetCount(16, 124)).toBe(16); + expect(resolveScanTargetCount(200, 124)).toBe(124); + expect(resolveScanTargetCount(16, 0)).toBe(16); + expect(resolveScanTargetCount(16, null)).toBe(16); + }); + + it("keeps skipped rows within the visible grid range", () => { + expect(clampSkipRows(-5)).toBe(0); + expect(clampSkipRows(99)).toBe(8); + }); +}); diff --git a/src/lib/scannerSession.ts b/src/lib/scannerSession.ts new file mode 100644 index 0000000..c045da1 --- /dev/null +++ b/src/lib/scannerSession.ts @@ -0,0 +1,45 @@ +export type AutoScanStats = { + clicked: number; + attempted: number; + verified: number; + parsed: number; + stored: number; + review: number; + duplicates: number; + misses: number; + pages: number; +}; + +export type ScanSummary = AutoScanStats & { + mode: string; + status: "done" | "stopped" | "blocked"; + targetCount?: number; + gridLabel?: string; +}; + +export const emptyAutoScanStats: AutoScanStats = { + clicked: 0, + attempted: 0, + verified: 0, + parsed: 0, + stored: 0, + review: 0, + duplicates: 0, + misses: 0, + pages: 0, +}; + +export function clampScanLimit(value: number) { + return Math.max(1, Math.min(1800, Math.round(value || 1))); +} + +export function clampSkipRows(value: number) { + return Math.max(0, Math.min(8, Math.round(value || 0))); +} + +export function resolveScanTargetCount(scanLimit: number, detectedInventoryCount?: number | null) { + const detected = Number.isFinite(detectedInventoryCount) ? Number(detectedInventoryCount) : 0; + const requested = clampScanLimit(scanLimit); + if (detected > 0) return clampScanLimit(Math.min(requested, detected)); + return requested; +} diff --git a/src/lib/scoring.test.ts b/src/lib/scoring.test.ts new file mode 100644 index 0000000..8a410df --- /dev/null +++ b/src/lib/scoring.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { createDemoArtifacts, createDemoCharacters, getPresets } from "./demoData"; +import { recommendArtifacts, suggestBuilds } from "./scoring"; +import type { Artifact, ArtifactSlot, Character, CharacterPreset } from "../types/domain"; + +describe("recommendation engine", () => { + it("flags low-confidence artifacts for review", () => { + const artifacts = createDemoArtifacts(); + const recommendations = recommendArtifacts(artifacts, createDemoCharacters(), getPresets()); + const reviewed = recommendations.find((entry) => entry.artifactId === "art-007"); + + expect(reviewed?.verdict).toBe("needs_review"); + }); + + it("suggests build candidates for owned characters", () => { + const builds = suggestBuilds(createDemoArtifacts(), createDemoCharacters(), getPresets()); + + expect(builds.length).toBeGreaterThan(0); + expect(builds[0].artifactIds.length).toBe(5); + }); + + it("suggests partial builds when the scanned inventory has missing slots", () => { + const partialArtifacts = createDemoArtifacts().filter((artifact) => artifact.slot === "sands" || artifact.slot === "goblet"); + const builds = suggestBuilds(partialArtifacts, createDemoCharacters(), getPresets()); + + expect(builds.length).toBeGreaterThan(0); + expect(builds[0].artifactIds.length).toBeLessThan(5); + expect(builds[0].warnings.join(" ")).toContain("Missing"); + }); + + it("warns when a suggested build wants an artifact equipped by another character", () => { + const artifacts = createDemoArtifacts().map((artifact) => + artifact.slot === "flower" ? { ...artifact, equipped: "Furina" } : artifact, + ); + const characters = createDemoCharacters().map((character) => + character.id === "furina" ? { ...character, owned: false } : character, + ); + const builds = suggestBuilds(artifacts, characters, getPresets()); + const conflictBuild = builds.find((build) => build.warnings.some((warning) => warning.includes("Conflicts"))); + + expect(conflictBuild?.warnings.join(" ")).toContain("Furina"); + }); + + it("allows a preferred 4-piece build with one off-piece", () => { + const preset = testPreset(["preferred"], ["fallback"]); + const builds = suggestBuilds([ + artifact("a", "flower", "preferred", "HP"), + artifact("b", "plume", "preferred", "ATK"), + artifact("c", "sands", "preferred", "ATK%"), + artifact("d", "goblet", "preferred", "ATK%"), + artifact("e", "circlet", "off_piece", "CRIT Rate"), + ], [testCharacter()], [preset]); + + const recommended = builds.find((build) => build.quality === "recommended_set"); + expect(recommended?.artifactIds).toEqual(expect.arrayContaining(["a", "b", "c", "d", "e"])); + }); + + it("creates a practical 2pc+2pc fallback build", () => { + const preset = testPreset(["preferred"], ["fallback"]); + const builds = suggestBuilds([ + artifact("a", "flower", "preferred", "HP"), + artifact("b", "plume", "preferred", "ATK"), + artifact("c", "sands", "fallback", "ATK%"), + artifact("d", "goblet", "fallback", "ATK%"), + artifact("e", "circlet", "off_piece", "CRIT Rate"), + ], [testCharacter()], [preset]); + + const fallback = builds.find((build) => build.quality === "alternative_set"); + expect(fallback?.explanation).toContain("2pc+2pc"); + }); +}); + +function testCharacter(): Character { + return { id: "tester", name: "Tester", owned: true, level: 90, constellation: 0, rolePreference: "main_dps", confidence: 1 }; +} + +function testPreset(recommendedSets: string[], alternativeSets: string[]): CharacterPreset { + return { + characterId: "tester", + role: "main_dps", + recommendedSets, + alternativeSets, + mainStats: { + sands: ["ATK%"], + goblet: ["ATK%"], + circlet: ["CRIT Rate"], + }, + substatWeights: { "CRIT Rate": 1, "CRIT DMG": 1, "ATK%": 0.8 }, + explanation: "Test preset", + }; +} + +function artifact(id: string, slot: ArtifactSlot, setKey: string, mainStat: string): Artifact { + return { + id, + setKey, + setName: setKey, + slot, + rarity: 5, + level: 20, + mainStat, + substats: [ + { key: "CRIT Rate", value: 7, unit: "%" }, + { key: "CRIT DMG", value: 14, unit: "%" }, + ], + locked: false, + source: "mock", + confidence: 0.99, + lastSeenAt: "2026-07-04T00:00:00.000Z", + }; +} diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts new file mode 100644 index 0000000..2990f94 --- /dev/null +++ b/src/lib/scoring.ts @@ -0,0 +1,304 @@ +import type { + Artifact, + ArtifactSlot, + BuildSuggestion, + Character, + CharacterPreset, + Recommendation, +} from "../types/domain"; + +const freeSlots: ArtifactSlot[] = ["flower", "plume"]; +const slots: ArtifactSlot[] = ["flower", "plume", "sands", "goblet", "circlet"]; + +function statValueScore(stat: string, value: number) { + if (stat.includes("CRIT")) return value / 7; + if (stat === "Energy Recharge") return value / 6; + if (stat === "Elemental Mastery") return value / 30; + if (stat.endsWith("%")) return value / 7; + return value / 50; +} + +export function scoreArtifactForPreset(artifact: Artifact, preset: CharacterPreset) { + const allowedMainStats = preset.mainStats[artifact.slot]; + const mainStatFits = + freeSlots.includes(artifact.slot) || + !allowedMainStats || + allowedMainStats.includes(artifact.mainStat); + + if (!mainStatFits) return 0; + + const setBonus = + preset.recommendedSets.includes(artifact.setKey) ? 22 : + preset.alternativeSets.includes(artifact.setKey) ? 12 : + 0; + + const mainStatBonus = freeSlots.includes(artifact.slot) ? 8 : 24; + const substatScore = artifact.substats.reduce((total, substat) => { + const weight = preset.substatWeights[substat.key] ?? 0; + return total + weight * statValueScore(substat.key, substat.value); + }, 0); + + return Math.round((setBonus + mainStatBonus + substatScore + artifact.level * 0.35) * 10) / 10; +} + +export function recommendArtifacts( + artifacts: Artifact[], + characters: Character[], + presets: CharacterPreset[], +): Recommendation[] { + const ownedPresets = presets.filter((preset) => + characters.some((character) => character.id === preset.characterId && character.owned), + ); + + return artifacts.map((artifact) => { + if (artifact.confidence < 0.82) { + return { + artifactId: artifact.id, + verdict: "needs_review", + score: 0, + bestCharacters: [], + reason: "Scanner confidence is low. Review this piece before trusting any recommendation.", + }; + } + + const ranked = ownedPresets + .map((preset) => ({ + preset, + score: scoreArtifactForPreset(artifact, preset), + })) + .sort((a, b) => b.score - a.score); + + const best = ranked[0]; + const bestCharacters = ranked + .filter((entry) => entry.score >= Math.max(22, best?.score * 0.78)) + .slice(0, 3) + .map((entry) => entry.preset.characterId); + + if (!best || best.score < 12) { + return { + artifactId: artifact.id, + verdict: "trash_candidate", + score: best?.score ?? 0, + bestCharacters: [], + reason: "No owned character preset strongly wants this main stat, set, or substat mix.", + }; + } + + const verdict = + best.score >= 48 ? "keep" : + best.score >= 34 ? "character_specific" : + best.score >= 24 ? "maybe_level" : + "trash_candidate"; + + return { + artifactId: artifact.id, + verdict, + score: best.score, + bestCharacters, + reason: `Best fit is ${best.preset.characterId.replaceAll("_", " ")}: ${best.preset.explanation}`, + }; + }); +} + +export function suggestBuilds( + artifacts: Artifact[], + characters: Character[], + presets: CharacterPreset[], +): BuildSuggestion[] { + const suggestions: BuildSuggestion[] = []; + + for (const character of characters.filter((entry) => entry.owned)) { + const preset = presets.find((entry) => entry.characterId === character.id); + if (!preset) continue; + + const bySlot = new Map(); + for (const slot of slots) { + bySlot.set( + slot, + artifacts + .filter((artifact) => artifact.slot === slot && artifact.confidence >= 0.82) + .map((artifact) => ({ + artifact, + score: scoreArtifactForPreset(artifact, preset), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 8) + .map((entry) => entry.artifact), + ); + } + + const candidates = ["recommended_set", "alternative_set", "rainbow"] as const; + let addedForCharacter = 0; + const usedBuilds = new Set(); + for (const quality of candidates) { + const build = pickBuild(quality, preset, bySlot); + if (!build) continue; + const buildKey = build.map((artifact) => artifact.id).sort().join("|"); + if (usedBuilds.has(buildKey)) continue; + usedBuilds.add(buildKey); + + const score = build.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0); + const warnings = buildWarnings(build, preset, character); + suggestions.push({ + id: `${character.id}-${quality}`, + characterId: character.id, + label: + quality === "recommended_set" ? "Best recommended set" : + quality === "alternative_set" ? "Best fallback set" : + "Best stat-stick build", + quality, + artifactIds: build.map((artifact) => artifact.id), + score: Math.round(score * 10) / 10, + warnings, + explanation: + quality === "recommended_set" + ? "Uses the preferred 4-piece path with the best available off-piece." + : quality === "alternative_set" + ? describeFallbackBuild(build, preset) + : "Ignores set perfection and takes the strongest available stats.", + }); + addedForCharacter++; + } + + if (addedForCharacter === 0) { + const partial = pickPartialBuild(bySlot); + if (partial.length > 0) { + const score = partial.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0); + const missingSlots = slots.filter((slot) => !partial.some((artifact) => artifact.slot === slot)); + suggestions.push({ + id: `${character.id}-partial`, + characterId: character.id, + label: "Best partial build", + quality: "rainbow", + artifactIds: partial.map((artifact) => artifact.id), + score: Math.round(score * 10) / 10, + warnings: [ + `Missing ${missingSlots.join(", ")} before this becomes a full build.`, + ...equippedConflictWarnings(partial, character), + ], + explanation: "Uses the strongest currently scanned pieces and shows what is still missing.", + }); + } + } + } + + return suggestions.sort((a, b) => b.score - a.score); +} + +function pickBuild( + quality: BuildSuggestion["quality"], + preset: CharacterPreset, + bySlot: Map, +) { + const combinations = enumerateBuilds(bySlot); + if (combinations.length === 0) return null; + + return combinations + .filter((build) => buildMatchesQuality(build, quality, preset)) + .sort((a, b) => buildScore(b, preset) - buildScore(a, preset))[0] ?? null; +} + +function pickPartialBuild(bySlot: Map) { + const build: Artifact[] = []; + for (const artifacts of bySlot.values()) { + if (artifacts[0]) build.push(artifacts[0]); + } + return build; +} + +function enumerateBuilds(bySlot: Map) { + const candidates = slots.map((slot) => bySlot.get(slot)?.slice(0, 6) ?? []); + if (candidates.some((artifacts) => artifacts.length === 0)) return []; + + const builds: Artifact[][] = []; + for (const flower of candidates[0]) { + for (const plume of candidates[1]) { + for (const sands of candidates[2]) { + for (const goblet of candidates[3]) { + for (const circlet of candidates[4]) { + builds.push([flower, plume, sands, goblet, circlet]); + } + } + } + } + } + return builds; +} + +function buildMatchesQuality(build: Artifact[], quality: BuildSuggestion["quality"], preset: CharacterPreset) { + if (quality === "rainbow") return true; + const setCounts = countSets(build); + + if (quality === "recommended_set") { + return preset.recommendedSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4); + } + + if (preset.alternativeSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return true; + const twoPieceSets = Object.entries(setCounts) + .filter(([setKey, count]) => count >= 2 && [...preset.recommendedSets, ...preset.alternativeSets].includes(setKey)) + .map(([setKey]) => setKey); + return twoPieceSets.length >= 2; +} + +function buildScore(build: Artifact[], preset: CharacterPreset) { + return build.reduce((total, artifact) => total + scoreArtifactForPreset(artifact, preset), 0) + setShapeBonus(build, preset); +} + +function setShapeBonus(build: Artifact[], preset: CharacterPreset) { + const setCounts = countSets(build); + if (preset.recommendedSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return 30; + if (preset.alternativeSets.some((setKey) => (setCounts[setKey] ?? 0) >= 4)) return 22; + const twoPieceSets = Object.entries(setCounts).filter(([, count]) => count >= 2).length; + return twoPieceSets >= 2 ? 14 : 0; +} + +function countSets(build: Artifact[]) { + return build.reduce>((counts, artifact) => { + counts[artifact.setKey] = (counts[artifact.setKey] ?? 0) + 1; + return counts; + }, {}); +} + +function describeFallbackBuild(build: Artifact[], preset: CharacterPreset) { + const setCounts = countSets(build); + const alternative4p = preset.alternativeSets.find((setKey) => (setCounts[setKey] ?? 0) >= 4); + if (alternative4p) return "Uses the best available alternative 4-piece path because the ideal set is incomplete."; + + const twoPieceSets = Object.entries(setCounts) + .filter(([, count]) => count >= 2) + .map(([setKey]) => setKey.replaceAll("_", " ")) + .slice(0, 2); + if (twoPieceSets.length >= 2) return `Uses a practical 2pc+2pc fallback: ${twoPieceSets.join(" + ")}.`; + + return "Uses the next best set path because the ideal pieces are incomplete."; +} + +function buildWarnings(build: Artifact[], preset: CharacterPreset, character: Character) { + const warnings: string[] = []; + const setCounts = countSets(build); + + if (!preset.recommendedSets.some((setKey) => setCounts[setKey] >= 4)) { + warnings.push("No full preferred 4-piece set yet."); + } + + const hasEr = build.some((artifact) => + artifact.mainStat === "Energy Recharge" || + artifact.substats.some((substat) => substat.key === "Energy Recharge"), + ); + if (preset.erTarget && !hasEr) warnings.push(`ER target around ${preset.erTarget}% may be hard to reach.`); + warnings.push(...equippedConflictWarnings(build, character)); + + return warnings; +} + +function equippedConflictWarnings(build: Artifact[], character: Character) { + const conflicts = build + .filter((artifact) => artifact.equipped && simplify(artifact.equipped) !== simplify(character.name)) + .map((artifact) => `${artifact.slot} from ${artifact.equipped}`); + return conflicts.length ? [`Conflicts: ${conflicts.slice(0, 3).join(", ")}${conflicts.length > 3 ? "..." : ""}.`] : []; +} + +function simplify(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} diff --git a/src/lib/snapshotSummary.ts b/src/lib/snapshotSummary.ts new file mode 100644 index 0000000..c5fd3e6 --- /dev/null +++ b/src/lib/snapshotSummary.ts @@ -0,0 +1,17 @@ +import type { AppSnapshot, ArtifactVerdict } from "../types/domain"; + +export function summarizeSnapshot(snapshot: AppSnapshot) { + const useful = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) => + ["keep", "character_specific", "maybe_level"].includes(entry.verdict), + ).length; + const trash = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length; + const review = snapshot.recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length; + + return { + artifacts: snapshot.artifacts.length, + useful, + trash, + review, + builds: snapshot.builds.length, + }; +} diff --git a/src/lib/storedArtifactAdapter.test.ts b/src/lib/storedArtifactAdapter.test.ts new file mode 100644 index 0000000..2023f97 --- /dev/null +++ b/src/lib/storedArtifactAdapter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { storedArtifactsToDomain } from "./storedArtifactAdapter"; +import type { StoredArtifactRecord } from "../types/storage"; + +function record(overrides: Partial = {}): StoredArtifactRecord { + return { + id: "stored-1", + name: "A Note in Spring's Leich", + slot: "Sands of Eon", + level: 16, + setName: "Viridescent Venerer", + mainStat: "ATK", + mainValue: "46.6%", + substats: ["ATK+29", "ATK+15.2%", "CRIT DMG+15.5%", "Energy Recharge+11.7%"], + equipped: "Sucrose", + confidence: 96, + needsReview: false, + source: "auto-scan", + lastSeenAt: "2026-07-04T00:00:00.000Z", + ...overrides, + }; +} + +describe("storedArtifactAdapter", () => { + it("converts stored OCR artifacts into recommendation-domain artifacts", () => { + const [artifact] = storedArtifactsToDomain([record()]); + + expect(artifact.slot).toBe("sands"); + expect(artifact.setKey).toBe("viridescent_venerer"); + expect(artifact.mainStat).toBe("ATK%"); + expect(artifact.level).toBe(16); + expect(artifact.equipped).toBe("Sucrose"); + expect(artifact.confidence).toBe(0.96); + expect(artifact.source).toBe("screen"); + }); + + it("keeps flat and percent ATK substats distinct", () => { + const [artifact] = storedArtifactsToDomain([record()]); + + expect(artifact.substats).toEqual( + expect.arrayContaining([ + { key: "ATK", value: 29, unit: "flat" }, + { key: "ATK%", value: 15.2, unit: "%" }, + ]), + ); + }); +}); diff --git a/src/lib/storedArtifactAdapter.ts b/src/lib/storedArtifactAdapter.ts new file mode 100644 index 0000000..f595709 --- /dev/null +++ b/src/lib/storedArtifactAdapter.ts @@ -0,0 +1,139 @@ +import presetsJson from "../../data/presets.json"; +import type { StoredArtifactRecord } from "../types/storage"; +import type { Artifact, ArtifactSlot, ScanSource, ArtifactSubstat } from "../types/domain"; + +const setNameToKey = new Map( + Object.entries(presetsJson.sets).map(([key, name]) => [simplify(String(name)), key]), +); + +const slotMap: Record = { + [simplify("Flower of Life")]: "flower", + [simplify("Plume of Death")]: "plume", + [simplify("Sands of Eon")]: "sands", + [simplify("Goblet of Eonothem")]: "goblet", + [simplify("Circlet of Logos")]: "circlet", +}; + +const statNames = [ + "CRIT Rate", + "CRIT DMG", + "Energy Recharge", + "Elemental Mastery", + "Physical DMG Bonus", + "Hydro DMG Bonus", + "Pyro DMG Bonus", + "Electro DMG Bonus", + "Cryo DMG Bonus", + "Dendro DMG Bonus", + "Anemo DMG Bonus", + "Geo DMG Bonus", + "Healing Bonus", + "ATK", + "HP", + "DEF", + "ATK%", + "HP%", + "DEF%", +]; + +export function storedArtifactsToDomain(records: StoredArtifactRecord[]): Artifact[] { + const now = new Date().toISOString(); + + return records + .map((record): Artifact | null => { + const slot = toSlot(record.slot); + if (!slot) return null; + + return { + id: record.id, + setKey: toSetKey(record.setName), + setName: record.setName || "Unknown set", + slot, + rarity: 5, + level: typeof record.level === "number" ? record.level : inferLevel(record), + mainStat: normalizeMainStat(record.mainStat, record.mainValue), + substats: record.substats.map(parseStoredSubstat).filter(Boolean) as ArtifactSubstat[], + equipped: isUsefulEquippedName(record.equipped) ? record.equipped.trim() : undefined, + locked: !record.needsReview && record.confidence >= 90, + source: toSource(record.source), + confidence: Math.max(0, Math.min(1, record.confidence / 100)), + lastSeenAt: record.lastSeenAt ?? record.firstSeenAt ?? now, + }; + }) + .filter(Boolean) as Artifact[]; +} + +function isUsefulEquippedName(value: string) { + return Boolean(value?.trim() && !/unknown|missing|not detected/i.test(value)); +} + +function toSlot(value: string): ArtifactSlot | null { + return slotMap[simplify(value)] ?? null; +} + +function toSetKey(value: string) { + const simplified = simplify(value); + return setNameToKey.get(simplified) ?? slug(value || "unknown_set"); +} + +function normalizeMainStat(stat: string, value: string) { + const cleanStat = stat.replace(/\s+/g, " ").trim(); + const cleanValue = value.trim(); + if (/^(ATK|HP|DEF)$/i.test(cleanStat) && cleanValue.includes("%")) return `${cleanStat.toUpperCase()}%`; + return canonicalStatName(cleanStat, cleanValue) || cleanStat || "Unknown main stat"; +} + +function parseStoredSubstat(raw: string): ArtifactSubstat | null { + const text = raw.replace(/[•+]/g, " ").replace(/\s+/g, " ").trim(); + if (!text) return null; + + const stat = statNames.find((name) => simplify(text).includes(simplify(name.replace("%", "")))); + const valueMatch = /([0-9]+(?:\.[0-9])?)\s*%?/.exec(text); + if (!stat || !valueMatch) return null; + + const value = Number(valueMatch[1]); + if (!Number.isFinite(value)) return null; + + const unit: ArtifactSubstat["unit"] = text.includes("%") ? "%" : "flat"; + return { + key: canonicalStatName(stat, unit === "%" ? `${value}%` : `${value}`) || stat, + value, + unit, + }; +} + +function canonicalStatName(stat: string, value: string) { + const simple = simplify(stat); + if (simple === "crit rate") return "CRIT Rate"; + if (simple === "crit dmg" || simple === "crit damage") return "CRIT DMG"; + if (simple === "energy recharge") return "Energy Recharge"; + if (simple === "elemental mastery") return "Elemental Mastery"; + if (simple === "atk" && value.includes("%")) return "ATK%"; + if (simple === "hp" && value.includes("%")) return "HP%"; + if (simple === "def" && value.includes("%")) return "DEF%"; + if (simple === "atk") return "ATK"; + if (simple === "hp") return "HP"; + if (simple === "def") return "DEF"; + return statNames.find((name) => simplify(name) === simple) ?? ""; +} + +function inferLevel(record: StoredArtifactRecord) { + // Backward compatibility for older OCR records written before level + // persistence landed in the local store. + return record.source === "manual-scan" || record.source === "auto-scan" ? 20 : 0; +} + +function toSource(value: string): ScanSource { + if (value === "manual-scan") return "manual"; + if (value === "overlay") return "overlay"; + if (value === "good_import") return "good_import"; + return "screen"; +} + +function simplify(value: string) { + return value.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim(); +} + +function slug(value: string) { + return simplify(value).replace(/%/g, "percent").replace(/\s+/g, "_") || "unknown_set"; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..c1be548 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles/global.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/pages/AppPage.tsx b/src/pages/AppPage.tsx new file mode 100644 index 0000000..42bfb6e --- /dev/null +++ b/src/pages/AppPage.tsx @@ -0,0 +1,8 @@ +import { AppPageLayout } from "./app/AppPageLayout"; +import { useAppController } from "../features/app/useAppController"; + +export function AppPage() { + const controller = useAppController(); + + return ; +} diff --git a/src/pages/app/AppPageLayout.tsx b/src/pages/app/AppPageLayout.tsx new file mode 100644 index 0000000..e9648bd --- /dev/null +++ b/src/pages/app/AppPageLayout.tsx @@ -0,0 +1,79 @@ +import { Eye, Play, Save } from "lucide-react"; +import { BuildsView } from "../../features/builds/BuildsView"; +import { OverlayPreview, OverlaySettings } from "../../features/overlay/OverlayViews"; +import { ScanView } from "../../features/scan/ScanView"; +import { TriageView } from "../../features/triage/TriageView"; +import { AppMetrics, AppShell, AppSidebar, AppTopbar } from "../../features/layout"; +import type { AppPageLayoutProps } from "./types"; +import { useAppPageLayoutModel } from "./hooks/useAppPageLayoutModel"; + +export function AppPageLayout({ controller }: AppPageLayoutProps) { + const { + activeView, + setActiveView, + isOverlay, + isScanning, + snapshot, + captureSources, + selectedSourceId, + setSelectedSourceId, + latestCapture, + topbarStatus, + bridgeReady, + canExportGood, + canShowOverlay, + metricCards, + refreshCaptureSources, + captureSelectedSource, + exportCurrentGood, + loadStoredArtifactSnapshot, + showOverlay, + } = controller; + const { renderNavigation, handleDemoScan } = useAppPageLayoutModel({ controller }); + + if (isOverlay) { + return ; + } + + return ( + } + children={ + <> + } + overlayIcon={} + demoIcon={} + /> + + {activeView === "scan" && ( + + )} + {activeView === "triage" && } + {activeView === "builds" && } + {activeView === "overlay" && } + + } + /> + ); +} diff --git a/src/pages/app/hooks/useAppPageLayoutModel.ts b/src/pages/app/hooks/useAppPageLayoutModel.ts new file mode 100644 index 0000000..5c35564 --- /dev/null +++ b/src/pages/app/hooks/useAppPageLayoutModel.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo } from "react"; +import { appNavigationItems } from "../../../features/layout/navigation"; +import type { AppPageLayoutProps } from "../types"; + +export function useAppPageLayoutModel({ controller }: AppPageLayoutProps) { + const { + runDemoScan, + canShowOverlay, + } = controller; + + const renderNavigation = useMemo( + () => + appNavigationItems.map((item) => ({ + ...item, + disabled: item.id === "overlay" && !canShowOverlay, + disabledReason: item.id === "overlay" && !canShowOverlay ? "Overlay benoetigt die Electron-Bridge." : undefined, + })), + [canShowOverlay], + ); + + const handleDemoScan = useCallback(() => { + void runDemoScan(); + }, [runDemoScan]); + + return { + renderNavigation, + handleDemoScan, + }; +} diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts new file mode 100644 index 0000000..1efd1a6 --- /dev/null +++ b/src/pages/app/index.ts @@ -0,0 +1 @@ +export { AppPageLayout } from "./AppPageLayout"; diff --git a/src/pages/app/types.ts b/src/pages/app/types.ts new file mode 100644 index 0000000..39ea6ca --- /dev/null +++ b/src/pages/app/types.ts @@ -0,0 +1,5 @@ +import type { AppControllerResult } from "../../features/app/types"; + +export interface AppPageLayoutProps { + controller: AppControllerResult; +} diff --git a/src/services/assistantBridge.ts b/src/services/assistantBridge.ts new file mode 100644 index 0000000..5ddedeb --- /dev/null +++ b/src/services/assistantBridge.ts @@ -0,0 +1,98 @@ +import type { + CaptureOptions, + CaptureResult, + CaptureSourceInfo, + ArtifactStoreLoadResult, + ArtifactStoreSaveResult, + BooleanResult, + ReviewSampleListResult, + ReviewSamplePayload, + SaveResultWithPath, + FocusGenshinResult, + LoadScannerLearningRulesResult, + ScrollResult, + RuntimeInfo, + SaveScannerLearningRulesResult, + SaveSnapshotResult, + GoodDatabase, + ScannerStatusPayload, + ScannerLearningRulePayload, +} from "../types/global"; +import type { AppSnapshot } from "../types/domain"; +import type { StoredArtifactRecord } from "../types/storage"; +import type { AutomationGuard, ClickResult } from "../types/global"; + +export interface AssistantBridge { + isAvailable: boolean; + canExportGood: boolean; + canShowOverlay: boolean; + canAutoScan: boolean; + canReviewSamples: boolean; + loadSnapshot: () => Promise; + saveSnapshot: (snapshot: AppSnapshot) => Promise; + runMockScan: () => Promise; + listCaptureSources: () => Promise; + captureSource: ( + sourceId: string, + delayMs?: number, + focusGenshin?: boolean, + options?: CaptureOptions, + ) => Promise; + exportGood: (payload: GoodDatabase) => Promise; + showOverlay: () => Promise; + loadArtifacts: () => Promise; + saveArtifacts: (records: StoredArtifactRecord[]) => Promise; + loadReviewSamples: (limit?: number) => Promise; + saveReviewSample: (sample: ReviewSamplePayload) => Promise; + loadScannerLearningRules: () => Promise; + saveScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise; + getRuntimeInfo: () => Promise; + getAutomationGuard: () => Promise; + publishScannerStatus: (status: ScannerStatusPayload) => Promise; + focusMainWindow: () => Promise; + focusGenshin: () => Promise; + clickScreen: (x: number, y: number) => Promise; + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void; +} + +function hasFunction(api: Record, key: string): boolean { + return typeof api[key] === "function"; +} + +export function getAssistantBridge(): AssistantBridge | null { + const api = window.assistantApi; + if (!api) return null; + const apiRecord = api as unknown as Record; + const canAutoScan = hasFunction(apiRecord, "clickScreen") && hasFunction(apiRecord, "scrollScreen"); + const canReviewSamples = hasFunction(apiRecord, "loadReviewSamples") && hasFunction(apiRecord, "saveReviewSample"); + + return { + isAvailable: true, + canExportGood: Boolean(api.exportGood), + canShowOverlay: Boolean(api.showOverlay), + canAutoScan, + canReviewSamples, + loadSnapshot: () => api.loadSnapshot(), + saveSnapshot: (snapshot: AppSnapshot) => api.saveSnapshot(snapshot), + runMockScan: () => api.runMockScan(), + listCaptureSources: () => api.listCaptureSources(), + captureSource: (sourceId, delayMs, focusGenshin, options) => api.captureSource(sourceId, delayMs, focusGenshin, options), + exportGood: (payload) => api.exportGood(payload), + loadArtifacts: () => api.loadArtifacts(), + saveArtifacts: (records) => api.saveArtifacts(records), + loadReviewSamples: (limit = 50) => api.loadReviewSamples(limit), + saveReviewSample: (sample) => api.saveReviewSample(sample), + loadScannerLearningRules: () => api.loadScannerLearningRules(), + saveScannerLearningRules: (rules) => api.saveScannerLearningRules(rules), + getRuntimeInfo: () => api.getRuntimeInfo(), + getAutomationGuard: () => api.getAutomationGuard(), + publishScannerStatus: (status) => api.publishScannerStatus(status), + focusMainWindow: () => api.focusMainWindow(), + focusGenshin: () => api.focusGenshin(), + clickScreen: (x: number, y: number) => api.clickScreen(x, y), + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => api.scrollScreen(notches, anchorX, anchorY), + showOverlay: () => api.showOverlay(), + onScannerCommand: (callback) => api.onScannerCommand(callback), + }; +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..96fbc0c --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,2184 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #090711; + color: #f4efff; + --surface: rgba(24, 18, 43, 0.68); + --surface-soft: rgba(18, 12, 35, 0.58); + --line: rgba(188, 154, 255, 0.18); + --line-strong: rgba(210, 184, 255, 0.32); + --text-muted: #b6aeca; + --text-soft: #8f86a8; + --cyan: #7ee7f2; + --gold: #f0c878; + --danger: #ff8c9d; + --button-hover-shift: -0.6px; + --button-hover-scale: 1.005; + --button-hover-shadow: 0 3px 9px rgba(184, 140, 255, 0.14); + --button-hover-brightness: 1.025; + --glass-shadow: 0 24px 70px rgba(0, 0, 0, 0.36); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 1100px; + min-height: 720px; + background: + radial-gradient(1200px 760px at 18% -12%, rgba(102, 73, 176, 0.28), transparent 58%), + linear-gradient(145deg, #080610 0%, #130b24 42%, #080811 100%); +} + +button { + font: inherit; + position: relative; + overflow: hidden; + cursor: pointer; + transform: translateY(0); + will-change: transform; + transition: + transform 140ms ease, + box-shadow 140ms ease, + filter 140ms ease, + border-color 140ms ease, + background 140ms ease; + border-color: rgba(184, 140, 255, 0.28); +} + +button:enabled svg, +[role="button"]:enabled svg { + transition: transform 140ms ease; +} + +[role="button"], +button, +button.clickable, +button[data-clickable="true"] { + cursor: pointer; +} + +button:hover:not(:disabled):not([aria-disabled="true"]), +button:focus-visible:not(:disabled):not([aria-disabled="true"]), +[role="button"]:hover:not(:disabled):not([aria-disabled="true"]), +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), +button.clickable:hover:not(:disabled):not([aria-disabled="true"]), +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(var(--button-hover-brightness)) saturate(1.02); + box-shadow: var(--button-hover-shadow); + border-color: rgba(214, 183, 255, 0.58); + background-color: rgba(255, 255, 255, 0.03); +} + +button:hover:not(:disabled):not([aria-disabled="true"])::before, +button:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:hover:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::before { + opacity: 0.35; + transform: scaleX(1); +} + +button:not(:disabled):not([aria-disabled="true"])::before, +[role="button"]:not(:disabled):not([aria-disabled="true"])::before, +button.clickable:not(:disabled):not([aria-disabled="true"])::before, +button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + bottom: 8px; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(214, 183, 255, 0.38), transparent); + opacity: 0; + transform: scaleX(0); + transform-origin: center; + transition: + transform 160ms ease, + opacity 160ms ease; + pointer-events: none; +} + +.scan-cta:hover:not(:disabled):not([aria-disabled="true"]), +.scan-cta:focus-visible:not(:disabled):not([aria-disabled="true"]), +.review-button:hover:not(:disabled):not([aria-disabled="true"]), +.review-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(var(--button-hover-brightness)); + box-shadow: var(--button-hover-shadow); +} + +button:hover:not(:disabled):not([aria-disabled="true"]) svg, +button:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"]) svg, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +button.clickable:hover:not(:disabled):not([aria-disabled="true"]) svg, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]) svg, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"]) svg, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) svg { + transform: translateX(0.6px); +} + +button:active:not(:disabled):not([aria-disabled="true"]), +[role="button"]:active:not(:disabled):not([aria-disabled="true"]), +button.clickable:active:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:active:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(0px) scale(0.999); + filter: brightness(0.985); + box-shadow: 0 2px 4px rgba(184, 140, 255, 0.12); +} + +button:hover:not(:disabled):not([aria-disabled="true"])::after, +button:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:hover:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:hover:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:hover:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"])::after { + opacity: 1; + transform: translateX(120%); +} + +button:not(:disabled):not([aria-disabled="true"])::after, +[role="button"]:not(:disabled):not([aria-disabled="true"])::after, +button.clickable:not(:disabled):not([aria-disabled="true"])::after, +button[data-clickable="true"]:not(:disabled):not([aria-disabled="true"])::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-120%); + background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.12), transparent); + opacity: 0; + pointer-events: none; + transition: + transform 260ms ease, + opacity 260ms ease; +} + +button:focus-visible:not(:disabled):not([aria-disabled="true"]), +[role="button"]:focus-visible:not(:disabled):not([aria-disabled="true"]), +button.clickable:focus-visible:not(:disabled):not([aria-disabled="true"]), +button[data-clickable="true"]:focus-visible:not(:disabled):not([aria-disabled="true"]) { + outline: 0; + border-color: rgba(214, 183, 255, 0.6); +} + +.ghost-button, +.primary-button, +.scan-cta, +.stop-button, +.review-button, +.nav-item, +.mode-option { + transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.app-shell { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 100vh; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 24%), + linear-gradient(180deg, rgba(184, 140, 255, 0.06), transparent 42%); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + border-right: 1px solid var(--line); + background: rgba(13, 9, 25, 0.72); + box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(22px); + padding: 24px 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 1px solid rgba(216, 190, 255, 0.32); + border-radius: 8px; + background: + linear-gradient(145deg, rgba(222, 197, 255, 0.18), rgba(126, 231, 242, 0.08)), + rgba(23, 16, 43, 0.82); + color: #f5eaff; + box-shadow: 0 16px 38px rgba(68, 43, 142, 0.32); + font-weight: 800; +} + +.brand-title { + font-size: 15px; + font-weight: 800; +} + +.brand-subtitle, +.muted { + color: var(--text-soft); + font-size: 12px; +} + +.nav-list { + display: grid; + gap: 8px; +} + +.nav-item, +.ghost-button, +.primary-button, +.mode-option { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid transparent; + border-radius: 8px; + color: #eee8ff; + cursor: pointer; +} + +.nav-item { + width: 100%; + justify-content: flex-start; + padding: 11px 12px; + background: transparent; +} + +.nav-item:hover:not(:disabled), +.nav-item.active { + border-color: rgba(214, 183, 255, 0.28); + background: linear-gradient(135deg, rgba(184, 140, 255, 0.18), rgba(126, 231, 242, 0.05)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.nav-item:hover:not(:disabled), +.nav-item:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(1.002); +} + +.nav-item:disabled { + opacity: 0.45; + cursor: not-allowed; + color: #7f8ca0; +} + +.safety-card { + display: flex; + gap: 10px; + margin-top: auto; + border: 1px solid rgba(126, 231, 242, 0.24); + border-radius: 8px; + background: rgba(22, 28, 48, 0.62); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(18px); + padding: 14px; + color: var(--cyan); +} + +.safety-card span { + display: block; + margin-top: 4px; + color: #9eb6c3; + font-size: 12px; + line-height: 1.4; +} + +.main-panel { + padding: 26px; + min-width: 0; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.topbar h1 { + max-width: 780px; + margin: 4px 0 0; + color: #fbf8ff; + font-size: 30px; + letter-spacing: 0; + text-shadow: 0 18px 48px rgba(184, 140, 255, 0.25); +} + +.eyebrow { + margin: 0; + color: var(--cyan); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.topbar-status { + max-width: 320px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ghost-button, +.primary-button { + height: 40px; + padding: 0 14px; +} + +.ghost-button { + border-color: var(--line); + background: rgba(28, 20, 51, 0.66); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + backdrop-filter: blur(14px); +} + +.ghost-button:hover:not(:disabled), +.ghost-button:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + border-color: rgba(214, 183, 255, 0.45); + box-shadow: + 0 2px 6px rgba(137, 91, 255, 0.09), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + background: rgba(56, 38, 103, 0.84); +} + +.ghost-button.success { + border-color: rgba(126, 242, 207, 0.54); + background: rgba(29, 88, 79, 0.42); + color: #bfffee; + box-shadow: + 0 10px 28px rgba(53, 220, 187, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.primary-button { + border-color: rgba(216, 183, 255, 0.62); + background: linear-gradient(135deg, #d9c0ff 0%, #a983ff 48%, #7ee7f2 100%); + box-shadow: + 0 16px 42px rgba(137, 91, 255, 0.34), + inset 0 1px 0 rgba(255, 255, 255, 0.42); + color: #10091d; + font-weight: 800; +} + +.primary-button:hover:not(:disabled), +.primary-button:focus-visible:not(:disabled) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + filter: brightness(1.05); + box-shadow: + 0 3px 8px rgba(137, 91, 255, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.52); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 24px 0; +} + +.metric, +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + var(--surface); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(22px); +} + +.metric { + position: relative; + overflow: hidden; + padding: 16px; +} + +.metric::before { + content: ""; + position: absolute; + inset: 0; + border-top: 1px solid rgba(255, 255, 255, 0.12); + pointer-events: none; +} + +.metric span, +.metric small { + display: block; + color: var(--text-soft); + font-size: 12px; +} + +.metric strong { + display: block; + margin: 8px 0 4px; + color: #ffffff; + font-size: 28px; +} + +.content-grid { + display: grid; + gap: 14px; +} + +.scan-layout { + grid-template-columns: 1.3fr 1fr; +} + +.panel { + padding: 18px; +} + +.panel.wide { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 3px 0 0; + font-size: 18px; +} + +.scan-badge { + border: 1px solid var(--line-strong); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + padding: 6px 10px; + color: var(--text-muted); + font-size: 12px; +} + +.scan-badge.live { + border-color: var(--cyan); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); + color: var(--cyan); +} + +.timeline { + display: grid; + gap: 14px; +} + +.timeline-item { + display: grid; + grid-template-columns: 18px 1fr; + gap: 12px; +} + +.timeline-dot { + width: 10px; + height: 10px; + margin-top: 5px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 22px rgba(126, 231, 242, 0.72); +} + +.timeline-item p { + margin: 4px 0; + color: var(--text-muted); +} + +.timeline-item span { + color: var(--text-soft); + font-size: 12px; +} + +.mode-list { + display: grid; + gap: 10px; +} + +.mode-option { + align-items: flex-start; + flex-direction: column; + padding: 13px; + background: rgba(21, 15, 40, 0.66); + text-align: left; +} + +.mode-option:hover:not(.selected):not(:disabled), +.mode-option:focus-visible:not(.selected):not(:disabled) { + border-color: rgba(188, 154, 255, 0.32); + transform: translateY(var(--button-hover-shift)) scale(1.002); +} + +.mode-option.selected { + border-color: rgba(126, 231, 242, 0.5); + background: linear-gradient(135deg, rgba(126, 231, 242, 0.16), rgba(184, 140, 255, 0.16)); +} + +.mode-option span { + color: var(--text-muted); + font-size: 13px; + line-height: 1.4; +} + +.character-row, +.build-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} + +.character-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + padding: 14px; +} + +.character-card span, +.character-card small { + display: block; + margin-top: 6px; + color: var(--text-soft); +} + +.artifact-table { + display: grid; + gap: 8px; +} + +.artifact-row { + display: grid; + grid-template-columns: 230px 1fr 160px 120px; + gap: 12px; + align-items: center; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(13, 9, 26, 0.56); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 12px; +} + +.artifact-row span, +.reason span { + display: block; + color: var(--text-soft); + font-size: 12px; +} + +.substats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.substats span { + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 999px; + background: rgba(37, 27, 66, 0.68); + padding: 5px 8px; +} + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 30px; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 800; +} + +.keep, +.specific { + border: 1px solid rgba(157, 240, 212, 0.22); + background: rgba(53, 205, 156, 0.14); + color: #9df0d4; +} + +.maybe { + border: 1px solid rgba(240, 200, 120, 0.24); + background: rgba(240, 200, 120, 0.14); + color: var(--gold); +} + +.trash { + border: 1px solid rgba(255, 140, 157, 0.22); + background: rgba(255, 140, 157, 0.12); + color: var(--danger); +} + +.review { + border: 1px solid rgba(255, 184, 112, 0.24); + background: rgba(255, 184, 112, 0.13); + color: #ffbf82; +} + +.score { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border: 1px solid rgba(214, 183, 255, 0.42); + border-radius: 8px; + background: linear-gradient(145deg, rgba(184, 140, 255, 0.22), rgba(126, 231, 242, 0.08)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); + color: #f1e7ff; +} + +.build-card { + min-height: 330px; +} + +.build-copy, +.overlay-settings p, +.overlay-card p { + color: var(--text-muted); + line-height: 1.5; +} + +.build-pieces { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.build-pieces div { + display: grid; + grid-template-columns: 72px 1fr; + gap: 4px 8px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.46); + padding: 9px; +} + +.build-pieces small { + grid-column: 2; + color: var(--text-soft); +} + +.warning-list { + display: grid; + gap: 6px; + margin-top: 14px; +} + +.warning-list span { + display: flex; + align-items: center; + gap: 6px; + color: #ffbf82; + font-size: 13px; +} + +.overlay-settings { + display: grid; + max-width: 720px; + gap: 14px; +} + +.overlay-settings-copy { + display: grid; + gap: 4px; + border: 1px solid rgba(126, 231, 242, 0.16); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 12px; +} + +.overlay-settings-copy strong { + color: var(--text); +} + +.overlay-settings-copy span { + color: var(--muted); + font-size: 13px; +} + +.overlay-root { + display: flex; + justify-content: flex-end; + align-items: flex-start; + min-height: 100vh; + padding: 80px 48px; + background: transparent; +} + +.overlay-card { + width: 360px; + border: 1px solid rgba(214, 183, 255, 0.34); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), + rgba(17, 10, 34, 0.82); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.45); + padding: 18px; + backdrop-filter: blur(12px); +} + +.overlay-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.overlay-card-head h2 { + margin: 2px 0 0; + font-size: 19px; +} + +.overlay-card-head > strong { + display: grid; + min-width: 50px; + height: 50px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.34); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); + font-size: 18px; +} + +.overlay-artifact-mini, +.overlay-character-list { + display: grid; + gap: 4px; + margin-top: 12px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.38); + padding: 10px; +} + +.overlay-artifact-mini span, +.overlay-artifact-mini small, +.overlay-character-list span, +.overlay-card p { + color: var(--muted); + font-size: 12px; +} + +.overlay-artifact-mini strong { + color: var(--text); +} + +.overlay-character-list { + display: flex; + flex-wrap: wrap; +} + +.overlay-character-list span { + border: 1px solid rgba(126, 231, 242, 0.22); + border-radius: 999px; + background: rgba(126, 231, 242, 0.08); + padding: 5px 8px; +} + +@media (max-width: 1180px) { + .app-shell { + grid-template-columns: 220px 1fr; + } + + .artifact-row { + grid-template-columns: 1fr; + } +} +.capture-controls { + display: grid; + gap: 12px; +} + +.capture-controls select { + width: 100%; + min-height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 12px; + outline: none; +} + +.capture-controls select:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.capture-actions { + display: flex; + gap: 10px; +} + +.capture-controls p { + margin: 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.45; +} + +.capture-preview { + display: grid; + min-height: 158px; + place-items: center; + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(10, 7, 19, 0.64); +} + +.capture-preview img { + display: block; + width: 100%; + height: 100%; + max-height: 260px; + object-fit: contain; +} + +.capture-preview span { + color: var(--text-soft); + font-size: 13px; +} + +.bridge-status { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 800; +} + +.bridge-status.connected { + border: 1px solid rgba(157, 240, 212, 0.24); + background: rgba(53, 205, 156, 0.14); + color: #9df0d4; +} + +.bridge-status.missing { + border: 1px solid rgba(255, 140, 157, 0.24); + background: rgba(255, 140, 157, 0.12); + color: var(--danger); +} + +.crop-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.crop-card { + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.52); +} + +.crop-card img { + display: block; + width: 100%; + height: 82px; + object-fit: contain; + background: rgba(4, 3, 10, 0.74); + border-bottom: 1px solid rgba(188, 154, 255, 0.12); +} + +.crop-card div { + padding: 8px; +} + +.crop-card strong, +.crop-card span { + display: block; +} + +.crop-card strong { + font-size: 12px; +} + +.crop-card span { + margin-top: 3px; + color: var(--text-soft); + font-size: 11px; +} + +.capture-hint { + border-left: 2px solid rgba(240, 200, 120, 0.42); + padding-left: 10px; + color: #f0c878 !important; +} + +.ocr-panel { + display: grid; + gap: 8px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(8, 6, 18, 0.56); + padding: 12px; +} + +.ocr-row { + display: grid; + grid-template-columns: 132px 1fr; + gap: 10px; + border-top: 1px solid rgba(188, 154, 255, 0.12); + padding-top: 8px; +} + +.ocr-row span, +.ocr-row small { + display: block; +} + +.ocr-row span { + color: #f4efff; + font-size: 12px; + font-weight: 800; +} + +.ocr-row small { + margin-top: 3px; + color: var(--text-soft); + font-size: 11px; +} + +.ocr-row pre { + margin: 0; + white-space: pre-wrap; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + line-height: 1.35; +} + +.capture-debug { + color: var(--text-soft) !important; + font-size: 12px !important; +} + +.parsed-panel { + display: grid; + gap: 10px; + border: 1px solid rgba(126, 231, 242, 0.24); + border-radius: 8px; + background: rgba(126, 231, 242, 0.08); + padding: 12px; +} + +.parsed-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.parsed-heading span { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.parsed-grid { + display: grid; + grid-template-columns: 92px 1fr; + gap: 7px 12px; +} + +.parsed-grid span { + color: var(--text-soft); + font-size: 12px; +} + +.parsed-grid strong { + color: #f4efff; + font-size: 12px; +} + +.parsed-notes { + display: grid; + gap: 4px; + border-top: 1px solid rgba(188, 154, 255, 0.14); + padding-top: 8px; +} + +.parsed-notes span { + color: #f0c878; + font-size: 12px; +} + +.scanner-workbench { + display: grid; + gap: 14px; + width: 100%; + justify-self: stretch; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.018)), + rgba(18, 12, 35, 0.7); + box-shadow: var(--glass-shadow); + backdrop-filter: blur(22px); + padding: 18px; +} + +.scanner-header, +.scanner-toolbar, +.scanner-status-row, +.result-heading, +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.scanner-header h2, +.modal-header h2 { + margin: 3px 0 0; + font-size: 22px; +} + +.scanner-subcopy { + margin: 8px 0 0; + max-width: 620px; + color: var(--text-soft); + font-size: 13px; + line-height: 1.5; +} + +.scanner-header-pills { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.scanner-toolbar { + align-items: flex-end; + display: grid; + grid-template-columns: minmax(320px, 1fr) auto; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.42); + padding: 12px; +} + +.source-select { + display: grid; + min-width: 0; + gap: 6px; +} + +.source-select span { + color: var(--text-soft); + font-size: 12px; + font-weight: 800; +} + +.source-select select { + width: 100%; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 12px; + outline: none; +} + +.source-select select:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.scanner-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.scanner-status-row { + align-items: flex-start; + color: var(--text-muted); + font-size: 12px; +} + +.scanner-status-row span:first-child { + color: var(--cyan); + font-weight: 800; +} + +.bridge-banner { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(255, 140, 157, 0.3); + border-radius: 10px; + background: rgba(255, 140, 157, 0.08); + color: var(--danger); + font-size: 13px; + font-weight: 700; + padding: 12px 14px; +} + +.dev-toggle { + opacity: 0.65; +} + +.dev-toggle.active { + opacity: 1; + border-color: rgba(126, 231, 242, 0.5); + color: var(--cyan); +} + +.player-scan-card { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(15, 10, 29, 0.6); + padding: 16px; +} + +.player-scan-row { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 10px; +} + +.player-scan-row .source-select { + min-width: 260px; + flex: 1; +} + +.mini-config { + display: grid; + gap: 6px; + width: 150px; +} + +.mini-config span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.mini-config input { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 10px; + outline: none; +} + +.mini-config input:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.player-scan-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.learning-strip { + display: grid; + gap: 8px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 10px 12px; +} + +.learning-strip span { + display: grid; + gap: 3px; + min-width: 0; + color: var(--text-soft); + font-size: 11px; + font-weight: 700; +} + +.learning-strip strong { + color: #f4efff; + font-size: 13px; +} + +.learning-strip { + grid-template-columns: auto auto 1fr; + align-items: center; + border-color: rgba(126, 231, 242, 0.18); + background: rgba(126, 231, 242, 0.06); +} + +.learning-strip small { + color: var(--text-muted); + font-size: 11px; +} + +.runtime-pill { + min-height: 34px; + display: inline-flex; + align-items: center; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(188, 154, 255, 0.22); + background: rgba(15, 10, 29, 0.55); + color: var(--text-soft); + font-size: 12px; + font-weight: 900; +} + +.runtime-pill.elevated { + border-color: rgba(109, 244, 197, 0.36); + background: rgba(33, 214, 155, 0.13); + color: var(--mint); +} + +.runtime-pill.standard { + border-color: rgba(255, 207, 109, 0.3); + background: rgba(255, 207, 109, 0.11); + color: var(--warning); +} + +.scan-cta { + min-height: 44px; + padding: 0 22px; + font-size: 14px; +} + +.stop-button { + min-height: 44px; + padding: 0 20px; + border: 1px solid rgba(255, 140, 157, 0.5); + border-radius: 10px; + background: rgba(255, 140, 157, 0.14); + color: var(--danger); + font-size: 14px; + font-weight: 800; + cursor: pointer; +} + +.stop-button:hover:not(:disabled):not([aria-disabled="true"]), +.stop-button:focus-visible:not(:disabled):not([aria-disabled="true"]) { + transform: translateY(var(--button-hover-shift)) scale(var(--button-hover-scale)); + box-shadow: 0 3px 8px rgba(255, 140, 157, 0.12); +} + +.player-status { + margin: 0; + color: var(--text-soft); + font-size: 13px; + line-height: 1.5; +} + +.scanner-preflight { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.scanner-preflight div { + min-height: 52px; + display: grid; + align-content: center; + gap: 3px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(15, 10, 29, 0.34); + padding: 9px 11px; +} + +.scanner-preflight span { + color: var(--text-muted); + font-size: 11px; + font-weight: 800; +} + +.scanner-preflight strong { + color: var(--text); + font-size: 13px; +} + +.scanner-preflight .ok { + border-color: rgba(126, 242, 207, 0.28); + background: rgba(33, 214, 155, 0.09); +} + +.scanner-preflight .ok strong { + color: var(--mint); +} + +.scanner-preflight .blocked { + border-color: rgba(255, 207, 109, 0.3); + background: rgba(255, 207, 109, 0.1); +} + +.scanner-preflight .blocked strong { + color: var(--warning); +} + +.player-progress { + display: grid; + gap: 8px; +} + +.player-progress-bar { + height: 8px; + border-radius: 999px; + background: rgba(188, 154, 255, 0.12); + overflow: hidden; +} + +.player-progress-bar div { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(126, 231, 242, 0.85), rgba(188, 154, 255, 0.9)); + transition: width 0.35s ease; +} + +.player-progress-stats { + display: flex; + flex-wrap: wrap; + gap: 14px; + color: var(--text-muted); + font-size: 12px; +} + +.player-progress-stats strong { + color: #f4efff; +} + +.player-progress-stats .collection { + margin-left: auto; +} + +.dev-section { + display: grid; + gap: 10px; + border: 1px dashed rgba(126, 231, 242, 0.25); + border-radius: 12px; + padding: 12px; +} + +.dev-section-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.scanner-diagnostics-modal { + width: min(1040px, 94vw); +} + +.scanner-settings-modal { + width: min(760px, 92vw); +} + +.diagnostics-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.diagnostics-preflight { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.diagnostics-status { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 10px 12px; +} + +.artifact-result-card { + display: grid; + gap: 10px; +} + +.artifact-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.artifact-result-head .artifact-set { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.quality-chip { + border-radius: 999px; + font-size: 11px; + font-weight: 800; + padding: 4px 10px; + white-space: nowrap; +} + +.quality-chip.good { + background: rgba(157, 240, 212, 0.14); + color: #9df0d4; +} + +.quality-chip.mid { + background: rgba(255, 214, 140, 0.14); + color: #ffd68c; +} + +.quality-chip.low { + background: rgba(255, 140, 157, 0.14); + color: var(--danger); +} + +.artifact-slot { + color: var(--text-muted); + font-size: 12px; +} + +.artifact-mainstat { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 10px; + background: rgba(8, 6, 18, 0.42); + padding: 10px 12px; +} + +.artifact-mainstat span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.artifact-mainstat strong { + color: #f4efff; + font-size: 15px; +} + +.artifact-mainstat em { + margin-left: auto; + color: var(--cyan); + font-size: 18px; + font-style: normal; + font-weight: 800; +} + +.artifact-substats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.artifact-substats span { + border: 1px solid rgba(188, 154, 255, 0.2); + border-radius: 999px; + background: rgba(188, 154, 255, 0.08); + color: #e8defc; + font-size: 12px; + padding: 4px 10px; +} + +.artifact-substats span.none { + border-style: dashed; + color: var(--text-muted); +} + +.artifact-equipped { + color: var(--text-muted); + font-size: 12px; +} + +.scan-summary-dev { + margin: 0; + color: var(--text-muted); + font-size: 11px; +} + +.auto-scan-strip { + display: grid; + grid-template-columns: 1fr repeat(8, auto auto); + gap: 6px 8px; + align-items: center; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.07); + padding: 10px 12px; +} + +.auto-scan-strip span { + color: var(--cyan); + font-size: 12px; + font-weight: 800; +} + +.auto-scan-strip strong { + color: #f4efff; + font-size: 13px; +} + +.auto-scan-strip small { + color: var(--text-soft); + font-size: 11px; +} + +.scan-config-strip { + display: grid; + grid-template-columns: 170px 170px minmax(0, 1fr); + gap: 10px; + align-items: end; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(10, 7, 20, 0.36); + padding: 12px; +} + +.scan-config-strip label { + display: grid; + gap: 6px; +} + +.scan-config-strip span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.scan-config-strip input { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(15, 10, 29, 0.78); + color: #f4efff; + padding: 0 10px; + outline: none; +} + +.scan-config-strip input:focus { + border-color: rgba(126, 231, 242, 0.52); + box-shadow: 0 0 0 3px rgba(126, 231, 242, 0.08); +} + +.scan-config-strip p { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} + +.grid-detection-strip { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + align-items: center; + border: 1px solid rgba(157, 240, 212, 0.2); + border-radius: 8px; + background: rgba(157, 240, 212, 0.07); + padding: 9px 12px; + box-shadow: 0 0 0 1px rgba(126, 231, 242, 0.035) inset; +} + +.grid-detection-strip span { + color: var(--text-soft); + font-size: 12px; + font-weight: 800; +} + +.grid-detection-strip strong { + color: #9df0d4; + font-size: 13px; +} + +.grid-detection-strip small { + color: var(--text-muted); + font-size: 11px; +} + +.grid-detection-strip.missing { + border-color: rgba(255, 140, 157, 0.24); + background: rgba(255, 140, 157, 0.06); +} + +.grid-detection-strip.missing strong { + color: var(--danger); +} + +.automation-log { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: start; + border: 1px solid rgba(126, 231, 242, 0.2); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 8px 12px; +} + +.automation-log-lines { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 118px; + overflow-y: auto; +} + +.automation-log span { + color: var(--cyan); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.automation-log strong { + color: #f4efff; + font-size: 12px; + overflow-wrap: anywhere; +} + +.scanner-main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); + gap: 14px; + align-items: stretch; +} + +.capture-stage-shell { + display: grid; + gap: 10px; + min-width: 0; +} + +.capture-stage { + display: grid; + min-height: clamp(440px, 58vh, 700px); + max-height: 720px; + place-items: center; + overflow: hidden; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(5, 4, 12, 0.72); +} + +.capture-stage img { + display: block; + width: auto; + height: auto; + max-width: min(100%, 980px); + max-height: min(100%, 680px); + object-fit: contain; +} + +.capture-stage-meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.capture-stage-meta div { + display: grid; + gap: 3px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.34); + padding: 10px 12px; +} + +.capture-stage-meta span { + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.capture-stage-meta strong { + color: #f4efff; + font-size: 12px; + overflow-wrap: anywhere; +} + +.empty-stage { + display: grid; + place-items: center; + gap: 8px; + color: var(--text-soft); + text-align: center; +} + +.empty-stage strong { + color: #f4efff; +} + +.scanner-result-panel { + display: grid; + align-content: start; + max-width: 360px; + gap: 12px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.07); + padding: 14px; +} + +.result-heading h3 { + margin: 3px 0 0; + font-size: 18px; +} + +.result-score { + display: grid; + width: 64px; + height: 64px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.4); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); + font-size: 20px; + font-weight: 900; +} + +.result-empty { + margin: 0; + color: var(--text-muted); + line-height: 1.5; +} + +.scanner-result-brief { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.scanner-result-brief span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 999px; + background: rgba(8, 6, 18, 0.38); + color: var(--text-soft); + font-size: 11px; + font-weight: 800; +} + +.scanner-result-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.scanner-result-caption { + margin: 0; + color: var(--text-soft); + font-size: 11px; + line-height: 1.45; +} + +.parsed-grid.compact { + grid-template-columns: 74px 1fr; +} + +.parsed-notes.compact { + border-top: 0; + padding-top: 0; +} + +.field-confidence-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.field-confidence { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 6px; + align-items: center; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(8, 6, 18, 0.42); + padding: 7px 8px; +} + +.field-confidence span, +.field-confidence small { + color: var(--text-soft); + font-size: 10px; +} + +.field-confidence strong { + font-size: 12px; +} + +.field-confidence small { + grid-column: 1 / -1; + text-transform: uppercase; +} + +.field-confidence.high strong { + color: #9df0d4; +} + +.field-confidence.medium strong { + color: #f0c878; +} + +.field-confidence.low { + border-color: rgba(255, 140, 157, 0.28); +} + +.field-confidence.low strong { + color: var(--danger); +} + +.review-button { + justify-content: center; + width: 100%; +} + +.review-status { + margin: 0; + color: var(--text-soft); + font-size: 11px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + background: rgba(4, 3, 10, 0.72); + padding: 28px; +} + +.modal-panel { + display: grid; + width: min(980px, 94vw); + max-height: 88vh; + overflow: hidden; + border: 1px solid rgba(214, 183, 255, 0.3); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)), + rgba(14, 9, 29, 0.96); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.52); +} + +.modal-header { + border-bottom: 1px solid rgba(188, 154, 255, 0.14); + padding: 16px; +} + +.modal-body { + display: grid; + gap: 14px; + overflow: auto; + padding: 16px; +} + +.details-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.details-grid .crop-card img { + height: 132px; + object-fit: contain; +} + +.review-queue-modal { + width: min(860px, calc(100vw - 72px)); +} + +.review-queue-summary { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + border: 1px solid rgba(126, 231, 242, 0.18); + border-radius: 8px; + background: rgba(126, 231, 242, 0.06); + padding: 10px 12px; +} + +.review-queue-summary strong { + color: var(--cyan); + font-size: 22px; +} + +.review-analysis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.review-analysis div { + display: grid; + gap: 4px; + border: 1px solid rgba(188, 154, 255, 0.14); + border-radius: 8px; + background: rgba(15, 10, 29, 0.42); + padding: 10px; +} + +.review-analysis span { + color: var(--text-muted); + font-size: 11px; + font-weight: 800; +} + +.review-analysis strong { + min-width: 0; + color: var(--text); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-sample-list { + display: grid; + gap: 10px; +} + +.review-sample-card { + display: grid; + gap: 10px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(12, 8, 25, 0.72); + padding: 12px; +} + +.review-sample-head, +.review-sample-meta, +.review-sample-parsed, +.review-sample-ocr { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.review-sample-head { + justify-content: space-between; +} + +.review-sample-head div { + display: grid; + gap: 3px; +} + +.review-sample-head strong { + color: var(--text); +} + +.review-sample-head span, +.review-sample-head time, +.review-sample-meta span, +.review-sample-parsed span, +.review-sample-ocr span { + color: var(--muted); + font-size: 12px; +} + +.review-sample-parsed strong { + color: var(--text); + font-size: 13px; +} + +.empty-stage.compact { + min-height: 180px; +} + +@media (max-width: 1024px) { + .scanner-main-grid { + grid-template-columns: 1fr; + } + + .scanner-toolbar { + align-items: stretch; + flex-direction: column; + } + + .scanner-actions { + justify-content: flex-start; + } + + .scan-config-strip { + grid-template-columns: 1fr 1fr; + } + + .scan-config-strip p { + grid-column: 1 / -1; + } + + .capture-stage-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.capture-stage img { + max-height: 640px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.modal-backdrop { + align-items: center; + justify-items: center; + overflow: hidden; +} + +.modal-panel { + width: min(900px, calc(100vw - 72px)); + max-height: min(760px, calc(100vh - 72px)); + grid-template-rows: auto minmax(0, 1fr); +} + +.scan-summary-backdrop { + position: fixed !important; + inset: 0 !important; + z-index: 999; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + padding: 24px; +} + +.scan-summary-modal { + display: grid; + gap: 16px; + width: min(520px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; + border: 1px solid rgba(214, 183, 255, 0.32); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), + rgba(15, 10, 30, 0.97); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); + padding: 22px; +} + +.scan-summary-icon { + display: grid; + width: 52px; + height: 52px; + place-items: center; + border: 1px solid rgba(126, 231, 242, 0.34); + border-radius: 8px; + background: rgba(126, 231, 242, 0.1); + color: var(--cyan); +} + +.scan-summary-modal h2 { + margin: 4px 0 0; + color: #f4efff; + font-size: 24px; +} + +.scan-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.scan-summary-grid div { + display: grid; + gap: 4px; + border: 1px solid rgba(188, 154, 255, 0.16); + border-radius: 8px; + background: rgba(8, 6, 18, 0.42); + padding: 10px; +} + +.scan-summary-grid strong { + color: var(--cyan); + font-size: 22px; +} + +.scan-summary-grid span, +.scan-summary-copy { + color: var(--text-soft); + font-size: 12px; + line-height: 1.45; +} + +.scan-summary-copy { + margin: 0; +} + +.modal-body { + min-height: 0; + overscroll-behavior: contain; +} + +.details-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 900px) { + .modal-panel { + width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + } + + .details-grid { + grid-template-columns: 1fr; + } +} + diff --git a/src/types/domain.ts b/src/types/domain.ts new file mode 100644 index 0000000..9770939 --- /dev/null +++ b/src/types/domain.ts @@ -0,0 +1,95 @@ +export type ArtifactSlot = "flower" | "plume" | "sands" | "goblet" | "circlet"; + +export type ArtifactVerdict = + | "keep" + | "maybe_level" + | "character_specific" + | "trash_candidate" + | "needs_review"; + +export type ScanSource = "mock" | "screen" | "overlay" | "good_import" | "manual"; + +export interface ArtifactSubstat { + key: string; + value: number; + unit: "%" | "flat"; +} + +export interface Artifact { + id: string; + setKey: string; + setName: string; + slot: ArtifactSlot; + rarity: 4 | 5; + level: number; + mainStat: string; + substats: ArtifactSubstat[]; + equipped?: string; + locked: boolean; + source: ScanSource; + confidence: number; + lastSeenAt: string; +} + +export interface Character { + id: string; + name: string; + owned: boolean; + level: number; + constellation: number; + rolePreference: CharacterRole; + confidence: number; +} + +export type CharacterRole = + | "main_dps" + | "sub_dps" + | "support" + | "healer" + | "reaction"; + +export interface CharacterPreset { + characterId: string; + role: CharacterRole; + recommendedSets: string[]; + alternativeSets: string[]; + mainStats: Partial>; + substatWeights: Record; + erTarget?: number; + explanation: string; +} + +export interface Recommendation { + artifactId: string; + verdict: ArtifactVerdict; + score: number; + bestCharacters: string[]; + reason: string; +} + +export interface BuildSuggestion { + id: string; + characterId: string; + label: string; + quality: "recommended_set" | "alternative_set" | "rainbow"; + artifactIds: string[]; + score: number; + warnings: string[]; + explanation: string; +} + +export interface ScanEvent { + id: string; + type: "environment" | "character" | "artifact" | "review" | "complete"; + label: string; + detail: string; + confidence?: number; +} + +export interface AppSnapshot { + artifacts: Artifact[]; + characters: Character[]; + recommendations: Recommendation[]; + builds: BuildSuggestion[]; + scanEvents: ScanEvent[]; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..0c8437e --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,305 @@ +import type { AppSnapshot } from "./domain"; +import type { StoredArtifactRecord } from "./storage"; + +export interface CaptureOptions { + skipOcr?: boolean; +} + +export interface CaptureSourceInfo { + id: string; + name: string; + isGenshinCandidate: boolean; + thumbnailDataUrl: string; +} + +export interface CaptureCrop { + id: string; + label: string; + rect: { x: number; y: number; width: number; height: number }; + dataUrl: string; +} + +export interface OcrResult { + id: string; + label: string; + text: string; + confidence: number; +} + +export type CaptureTarget = "genshin-client" | "primary-screen" | "desktop-source"; + +export interface CaptureResult { + id: string; + name: string; + width: number; + height: number; + dataUrl: string; + capturedAt: string; + captureTarget?: CaptureTarget; + detailDataUrl?: string; + inventoryDataUrl?: string; + ocrSkipped?: boolean; + ocrTimedOut?: boolean; + crops?: CaptureCrop[]; + ocr?: OcrResult[]; + inventoryGrid?: { + centers: Array<{ x: number; y: number; row: number; col: number }>; + rows: number; + cols: number; + confidence: number; + source: "detected" | "fallback" | "missing"; + }; + inventoryCount?: { + current: number; + total: number; + confidence: number; + source: "ocr" | "missing"; + text: string; + }; +} + +export interface WindowBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface ClickResult { + ok: boolean; + x: number; + y: number; + cursorX?: number; + cursorY?: number; + escapePressed?: boolean; + enterPressed?: boolean; + f9Pressed?: boolean; + /** Cursor verifiably reached the target position before clicking. */ + moved?: boolean; + /** Mouse down+up were injected (only attempted when moved is true). */ + clicked?: boolean; + /** SendInput injected zero events, e.g. blocked by UIPI (elevated game). */ + inputBlocked?: boolean; + focused?: boolean; + alreadyForeground?: boolean; + foregroundProcess?: string; + targetProcess?: string; + isElevated?: boolean; +} + +export interface RuntimeInfo { + ok: boolean; + isElevated: boolean; + platform: string; + hotkeys?: Record; + genshinFound?: boolean; + genshinHwnd?: number; + targetProcess?: string; + foregroundProcess?: string; + foregroundHwnd?: number; + helperPid?: number; +} + +export interface FocusGenshinResult { + focused: boolean; + alreadyForeground: boolean; + foregroundProcess?: string; + targetProcess?: string; + genshinFound?: boolean; + setForegroundResult?: boolean; +} + +export interface BooleanResult { + ok: boolean; +} + +export interface SaveResultWithPath { + ok: boolean; + path: string; +} + +export type SaveSnapshotResult = SaveResultWithPath; + +export interface ScannerLearningRulePayload { + textReplacements?: Record; +} + +export interface LoadScannerLearningRulesResult { + ok: boolean; + path: string; + rules: ScannerLearningRulePayload; +} + +export interface SaveScannerLearningRulesResult { + ok: boolean; + path: string; + rules: ScannerLearningRulePayload; + total: number; +} + +export interface AutomationGuard { + ok: boolean; + cursorX?: number; + cursorY?: number; + escapePressed: boolean; + enterPressed?: boolean; + f9Pressed?: boolean; + isElevated?: boolean; +} + +export interface GdiCaptureResult { + dataUrl: string; + width: number; + height: number; + originX: number; + originY: number; + captureTarget: Extract; +} + +export interface HelperOperationResponse { + id?: string; + ok: boolean; + error?: string; + [key: string]: unknown; +} + +export interface ScrollResult { + ok: boolean; + notchesSent: number; + inputBlocked?: boolean; + isElevated?: boolean; +} + +export type ArtifactSaveResult = ArtifactStoreSaveResult; + +export interface ArtifactStoreLoadResult { + ok: boolean; + artifacts: StoredArtifactRecord[]; + total: number; + path: string; +} + +export interface ArtifactStoreSaveResult { + ok: boolean; + added: number; + updated: number; + total: number; + path: string; +} + +export interface ReviewSampleRecord { + savedAt: string; + sample?: { + reason?: string; + parsed?: unknown; + capture?: { + id?: string; + name?: string; + width?: number; + height?: number; + dataUrl?: string; + detailDataUrl?: string; + inventoryDataUrl?: string; + captureTarget?: CaptureResult["captureTarget"]; + capturedAt?: string; + crops?: Array<{ id: string; label: string; rect: { x: number; y: number; width: number; height: number }; dataUrl?: string }>; + ocr?: OcrResult[]; + inventoryGrid?: CaptureResult["inventoryGrid"]; + inventoryCount?: CaptureResult["inventoryCount"]; + }; + }; +} + +export interface ReviewSampleListResult { + ok: boolean; + samples: ReviewSampleRecord[]; + total: number; + path: string; +} + +export interface ReviewSamplePayload { + reason?: string; + parsed?: unknown; + capture?: { + id?: string; + name?: string; + width?: number; + height?: number; + dataUrl?: string; + detailDataUrl?: string; + inventoryDataUrl?: string; + captureTarget?: CaptureResult["captureTarget"]; + capturedAt?: string; + crops?: Array<{ id: string; label: string; rect: { x: number; y: number; width: number; height: number }; dataUrl?: string }>; + ocr?: OcrResult[]; + inventoryGrid?: CaptureResult["inventoryGrid"]; + inventoryCount?: CaptureResult["inventoryCount"]; + }; + [key: string]: unknown; +} + +export interface ScannerStatusPayload { + running: boolean; + reviewStatus: string; + captureStatus: string; + selectedSource: string | null; + stats: Record; + summary: unknown; + snapshotArtifacts: number; + snapshotCharacters: number; + snapshotRecommendations: number; + snapshotBuilds: number; + grid: CaptureResult["inventoryGrid"] | null; + automationLog: string[]; + runtimeInfo: RuntimeInfo | null; + storedTotal: number | null; + learningRuleCount: number; + updatedAt: string | null; + [key: string]: unknown; +} + +export interface GoodExportArtifact { + setKey: string; + slotKey: string; + rarity: number; + level: number; + mainStatKey: string; + substats: Array<{ key: string; value: number }>; + lock: boolean; +} + +export interface GoodDatabase { + format: "GOOD"; + version: number; + source: string; + artifacts: GoodExportArtifact[]; +} + +declare global { + interface Window { + assistantApi?: { + loadSnapshot: () => Promise; + saveSnapshot: (snapshot: AppSnapshot) => Promise; + runMockScan: () => Promise; + listCaptureSources: () => Promise; + captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; + clickScreen: (x: number, y: number) => Promise; + scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; + getAutomationGuard: () => Promise; + focusMainWindow: () => Promise; + focusGenshin: () => Promise; + getRuntimeInfo: () => Promise; + saveReviewSample: (sample: ReviewSamplePayload) => Promise; + loadReviewSamples: (limit?: number) => Promise; + loadScannerLearningRules: () => Promise; + saveScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise; + loadArtifacts: () => Promise; + saveArtifacts: (records: StoredArtifactRecord[]) => Promise; + exportGood: (payload: GoodDatabase) => Promise; + publishScannerStatus: (status: ScannerStatusPayload) => Promise; + showOverlay: () => Promise; + hideOverlay: () => Promise; + onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => () => void; + }; + } +} diff --git a/src/types/storage.ts b/src/types/storage.ts new file mode 100644 index 0000000..353c220 --- /dev/null +++ b/src/types/storage.ts @@ -0,0 +1,17 @@ +export interface StoredArtifactRecord { + id: string; + name: string; + slot: string; + level?: number; + setName: string; + mainStat: string; + mainValue: string; + substats: string[]; + equipped: string; + confidence: number; + needsReview: boolean; + source: string; + firstSeenAt?: string; + lastSeenAt?: string; + timesSeen?: number; +} diff --git a/tsconfig.electron.json b/tsconfig.electron.json new file mode 100644 index 0000000..0e2a407 --- /dev/null +++ b/tsconfig.electron.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist-electron", + "types": ["node", "electron"] + }, + "include": ["electron/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c0ea4e1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..21bb926 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: true, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +});